# HTTP API reference All consumer endpoints are mounted under `/pulsar/api/`. Auth is the same `req.user`-populating stack sgapps.io uses for every other service (session cookie or bearer token). Response envelope is the SGApps standard `{status: 'ok'|'error', ...}`. ## POST /pulsar/api/fetch Submit a fetch request. Returns immediately with a `jobId` the caller polls via `GET /pulsar/api/job/:id`. ### Request body ```json { "url": "", "method": "GET|HEAD|POST|PUT|DELETE|PATCH|OPTIONS", "headers": { "": "", ... }, "body": "", "bodyBase64": false, "timeoutMs": 10000, "maxResponseBytes": 2097152, "allowInsecureTLS": false, "allowPrivateIP": false, "rangeRequest": { "start": 0, "end": 65535 }, "followRedirects": true } ``` | Field | Type | Default | Notes | |---|---|---|---| | `url` | string | — | **Required.** Scheme ∈ {http, https}. SSRF classifier rejects forbidden hosts + instance-metadata IPs regardless of flags. Private IPs are rejected unless `allowPrivateIP: true`. | | `method` | string | `"GET"` | Must be in the allow-list above. | | `headers` | object | `{}` | Browser-forbidden names (Host, Content-Length, Connection, Cookie, Origin, Referer, …) are stripped. CRLF-injecting values are dropped. | | `body` | string / null | `null` | Only sent for non-GET/HEAD methods. ≤ `requestBodyMax` (default 1 MiB). | | `bodyBase64` | bool | `false` | Set to `true` if `body` is base64-encoded binary. | | `timeoutMs` | number | `10000` | Agent-side exec timeout. Capped at `timeoutMsMax` (default 30 s). | | `maxResponseBytes` | number | `2097152` (2 MiB) | Agent + server both crop at this. Capped at `maxResponseBytesHard` (default 16 MiB). | | `allowInsecureTLS` | bool | `false` | Lets the agent skip TLS cert validation. Only dispatched to agents whose capability is `accept`. | | `allowPrivateIP` | bool | `false` | Required to target `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, link-local, CGNAT. | | `rangeRequest` | object / null | `null` | `{start, end}` byte range. Mapped to `Range: bytes=-` on the outbound. | | `followRedirects` | bool | `true` | When `false`, redirect responses are passed through to the caller. | ### Response (202, synchronous "accepted") ```json { "status": "ok", "data": { "jobId": "", "state": "pending", "retryAfterMs": 500 } } ``` ### Error responses | HTTP | `error.code` | When | |---|---|---| | 400 | `bad-request` / `bad-url` / `private-ip-blocked` | malformed body, scheme not http/https, private IP without flag | | 401 | `auth-required` | no session / no bearer | | 413 | `payload-too-large` | `body` exceeds `requestBodyMax` | | 500 | `internal-error` | Mongo / server bug | | 503 | `no-active-agents` | caller has 0 approved + pinged-in-last-60s devices | ## GET /pulsar/api/job/:id Poll a job. The caller MUST own the job (same user as the submitter). ### Response — pending ```json {"status":"ok","data":{"state":"pending","retryAfterMs":500}} ``` ### Response — dispatched ```json {"status":"ok","data":{"state":"dispatched","retryAfterMs":1500}} ``` ### Response — done ```json { "status": "ok", "data": { "state": "done", "result": { "status": 200, "statusText": "OK", "headers": { "": "" }, "body": "", "bodyBase64": false, "cropped": false, "cropOriginalSize": null, "elapsedMs": 182, "bytesWritten": 12 } } } ``` `bodyBase64: true` when the agent detected non-UTF-8 bytes in the first 1 KiB of the response (binary heuristic). Decode as `atob()` or `Buffer.from(..., 'base64')`. ### Response — failed ```json { "status": "ok", "data": { "state": "failed", "error": { "code": "", "message": "" } } } ``` Error codes agents may emit: | Code | Meaning | |---|---| | `timeout` | Agent exec timeout (`timeoutMs`) hit before upstream responded | | `dns` | Could not resolve the hostname | | `tls` | TLS handshake failed (and `allowInsecureTLS: false`) | | `refused` | Connection refused | | `oversize` | Upstream body exceeded `maxResponseBytes` and the agent could not crop safely | | `fetch-error` | Generic browser `fetch()` failure (CORS blocked, network layer, …) | | `agent-refused-override` | A job with `allowInsecureTLS: true` or `allowPrivateIP: true` landed on an agent that refuses it. Pulsar may re-dispatch to another agent silently; the job is only marked failed if nobody accepts. | | `cancelled` | Caller called `DELETE /pulsar/api/job/:id` | ### Response — expired ```json {"status":"ok","data":{"state":"expired"}} ``` Returned once the row's `resultReadDeadlineAt` is in the past (default `requestTimeoutMs` = 60 s). The caller stops polling and either submits again or gives up. ## DELETE /pulsar/api/job/:id Cancel a pending or dispatched job. The row flips to `state: 'failed'` with `error: {code: 'cancelled'}`. ### Response ```json {"status":"ok","data":{"state":"failed"}} ``` 404 if the row is already settled or missing. ## Error codes ### Server-side (consumer API) | Code | HTTP | Context | |---|---|---| | `auth-required` | 401 | No `req.user` populated | | `bad-request` | 400 | malformed body | | `missing-url` | 400 | `url` not provided | | `bad-method` | 400 | `method` not in allow-list | | `body-must-be-string` | 400 | `body` is not a string | | `bad-url` | 400 | SSRF classifier flagged as unparseable / forbidden | | `private-ip-blocked` | 400 | SSRF: private IP without `allowPrivateIP` | | `bad-range` | 400 | `rangeRequest.start`/`end` invalid | | `payload-too-large` | 413 | request body exceeds `requestBodyMax` | | `no-active-agents` | 503 | caller has 0 approved + online devices | | `bad-job-id` | 400 | `:id` not a 24-hex ObjectId | | `not-found` | 404 | job unknown or not owned by caller | | `internal-error` | 500 | server bug — check logs | ### Agent-side (via `error.code` in `done`/`failed` response) See the table under **GET /pulsar/api/job/:id → failed**.