Skip to content

Routes

The dashboard’s server only relays. The dashboard’s logic lives in the browser (public/); all the server does is provide a way to the ledger and the daemon, and serve the dashboard’s files. The moment logic lived on the server, there would be a copy of what the ledger knows, so it does not.

Route Answered by
GET /healthz the server itself (liveness, and the settings the dashboard needs)
POST /api/crawls the ledger (the one and only route that writes)
/api/* (the rest) passed through to the ledger (GET only)
/wacz/:id/… signed by the ledger, then answered by the daemon
anything else the files in public/

The routing is in src/server.mjs; parsing and rewriting the /wacz/ routes is in src/wacz.mjs.

Every route refuses with a 403 unless the request’s Host is 127.0.0.1:<port> or localhost:<port>. That stops a page from another site from reading the dashboard as if it were its own origin, by pointing its domain name at 127.0.0.1 (DNS rebinding).

Returns liveness and the settings the dashboard needs.

{
"ok": true,
"subject": "alice",
"organizations": ["acme"],
"replayOrigin": "http://127.0.0.1:8899",
"windmillOrigin": "http://127.0.0.1:8000",
"windmillWorkspace": "crawler",
"writeLimit": 1048576
}

The dashboard reads it when it opens, to learn who it is looking as and where the deep links go. writeLimit is the largest body, in bytes, the route that writes accepts; the new crawl view stops before sending a bigger one. It does not live under /api/: that is where the ledger is passed through, and mixing this server’s answers in would make it unclear who answered.

The ledger does not return CORS (Cross-Origin Resource Sharing, which lets pages from another origin call it) headers (OPTIONS /api/archives is a 404). Since the browser cannot call it directly, the server relays it to make it same-origin.

  • The path and query go through unchanged, as a GET. Any other method is a 405 (the only route that writes is POST /api/crawls below).
  • The access token is attached as Authorization: Bearer. If the server does not have one yet, it first gets one from the development issuer.
  • On a 401 it gets a new token and retries once. It does not track the token’s lifetime: restarting the issuer changes its keys, so a clock that says “still valid” cannot be trusted. A 401 says both “expired” and “the keys changed” in the same way.
  • The ledger’s status, content-type and body are returned as they are.

Which ledger routes are read is decided by the dashboard (public/app.js); the server does not choose. It keeps no list of allowed routes. So the ledger’s GET /api/crawl-rules/check (the new crawl view checking its rules as you write them) and GET /api/crawls/:id/decisions (what an opened crawl’s rules decided) need nothing here: they are reads, and pass through like the lists.

POST /api/crawls — the route that starts a crawl

Section titled “POST /api/crawls — the route that starts a crawl”

This is the one route of the dashboard’s server that writes. The form on the crawls view (Screens) uses it. The server does not check the contents: it hands the body that passed the checks below to the ledger’s POST /api/crawls as it is, and returns the answer as it is. The ledger is the one that checks and decides the seed URLs, the policy (depth, page limit, which scripts run) and the per-crawl variables (variables: the spelling and number of the names, the length of the values, and whether a ticked script declared each name). Whether a declared secret exists in Windmill is also the ledger’s check; the dashboard never touches a secret’s value (it lives in a Windmill variable, and is neither in the form nor in the body).

  • Only JSON from the dashboard’s own page gets through. If the Origin is not the dashboard it is a 403, if the content-type is not JSON it is a 415, and a body over 1 MiB (seed URLs and any script written in the form, together; the same limit as the ledger’s POST /api/crawls) is a 413. This stops a page from another site from making your browser send the POST (CSRF). It does not rely on Chrome’s Private Network Access.
  • The access token is attached, and a 401 retried, the same way as for /api/* above.
The ledger’s answer When
202 {"crawlId": …} accepted (it does not wait for the crawl to finish)
400 {"error": …} a seed URL that cannot be read, no registered scripts (with a hint), an unknown script id or the same id twice (with ids), a required variable missing or a variable no script declared (with names), a declared secret that is missing from Windmill or is not a secret there (with secrets: name and path), and so on
404 the identity may not start crawls ({"error":"not found"}), or the ledger has nowhere to send crawls configured and does not serve the route at all
409 {"error": …, "crawlId": …} a crawl is already running (one at a time, overall); crawlId is the one in the way
503 {"error": "windmill unreachable"} the ledger could not ask Windmill whether a declared secret exists (the flow would not run either, so it does not start the crawl)

The checks are writeRefusal in src/lib.mjs, which the tests call directly.

These routes validate a WACZ (the archive format that packs captured pages into one ZIP) and read inside it. The WARC files in it (which record the captured requests and responses in order) and its indexes are read one window at a time.

The dashboard calls Sent to the daemon Returns
POST /wacz/:id/validate POST /validate the validation report
GET /wacz/:id/lines?path=&from=&count= POST /lines a window of lines
GET /wacz/:id/line?path=&n= POST /line one whole line, and its fields
GET /wacz/:id/records?path=&from=&count= POST /records the records, walking the WARC from the start
GET /wacz/:id/record?path=&offset=&length= POST /record one record (WARC headers, HTTP, body)
GET /wacz/:id/record/body?path=&offset=&length= POST /record/body the image itself; raster only, with nosniff and sandbox, 415 otherwise

This table is checked against OPS in src/wacz.mjs (npm run site:check). To add a route, change OPS, paramsFor and this table.

  • Every route takes the same two steps. Get a signature from the ledger’s POST /api/archives/:id/url, then hand that URL to the daemon (why in that order is in Inspecting a WACZ).
  • The dashboard calls GET; the daemon gets POST. <img src> is a GET too, so the image itself can be fetched with a GET. Only validation is a POST from the dashboard as well (it runs only when you press it).
  • The body sent to the daemon is { "source": { "kind": "uri", "uri": "<signed URL>" }, …arguments }. Validation’s only argument is locale.

Which headers of the upstream answer (the ledger’s or the daemon’s) pass through is an allowlist, PASS_HEADERS in src/server.mjs. The daemon’s date and connection are not mixed into the dashboard’s response. The image itself passes as bytes, together with the marks the daemon put on it (nosniff, sandbox, no-store).

A failure says what is missing. “You may not see it” and “it is not running” are never mixed.

Status When Body
400 an argument of a /wacz/ route is missing or not a number; the ledger is not asked {"error":"from is required"}
403 the Host is not the dashboard’s (any route); on the route that writes, also when the Origin is not the dashboard {"error":"dashboard の Host ではない"} and so on
405 wrong method: on a /wacz/ route (validation is POST, the rest GET), or on /api/* (GET only, except the route that writes) POST だけ, GET だけ and so on, with an Allow header
413 the body sent to the route that writes is over 1 MiB {"error":"本文が 1048576 バイトを超えた"}
415 the content-type sent to the route that writes is not JSON {"error":"JSON だけを受ける"}
as the ledger said the ledger refused (such as a 404 for an archive you may not view) the ledger’s body
502 the issuer, the ledger or the daemon cannot be reached, or the issuer refused the identity {"error":"… に届かない (…)。…"}, with one sentence on how to fix it
404 a file that is not in public/, or an extension that is not served not found

If the upstream breaks off while the answer is being streamed, the headers have already been written, so the server closes the connection.