Bee Box documentation · directory: https://beebox.run/docs/contracts/ · index: https://beebox.run/docs/contracts/index.md · root: https://beebox.run/llms.txt # Scan Upload Wire Contract The HTTP contract between the stand-alone `scan-uploader/` package (laptop client) and beebox's scan routes (server). The two sides share **no code** — this document is the single coordination point. Every implementation site that encodes part of this contract carries the breadcrumb comment: ``` // WIRE CONTRACT (scan-upload): must match docs/scan-upload-contract.md — change both sides together. ``` Breadcrumbed sites: the server routes (`src/webapp/routes/scan-upload*.ts`), the client's HTTP layer (`scan-uploader/src/`), and the server route doctests (`test/webapp/routes/scan-upload.doctest.md`) — the doctests exercise this contract exactly as the client sends it and are its executable form. Design history: [`implemented-plans/scanner-ingest.md`](implemented-plans/scanner-ingest.md). ## Auth Every request: `Authorization: Bearer `. Scan tokens are per-box credentials minted by the boxholder (`scanTokens` tRPC procedures), stored hashed in `.beebox/scan-tokens.secret.json`. They authorize **only** the two routes below — verified independently by the hub (which also requires the path to be exactly one of those two routes under `//`, hash form included; nothing else in the `/api/scan/` subtree is reachable with a scan token) and by the box child. They are not mobile device tokens; they cannot mint session cookies, open WebSockets, or reach any other surface. A full owner identity (browser session) is also accepted on these routes. Failures: `401` invalid/revoked token; `401` from the hub for a scan token on any non-scan path. ## Hashes SHA-256 of the file bytes, lowercase hex, 64 chars. Used as the PUT path segment, the check key, and the server's dedup/idempotency key. ## `POST //api/scan/check` Request body (JSON): `{ "hashes": ["", …] }` — batch, ≤500 entries. Response `200` (JSON): ```json { "states": { "": { "state": "unknown" }, "": { "state": "rejected", "reason": "…" } } } ``` Per-hash `state`: | state | meaning | client behavior | |---|---|---| | `unknown` | never seen | PUT it | | `pending` | validated, in quarantine, awaiting promote | confirmed — apply disposition | | `imported` | in the box's upload ledger | confirmed — apply disposition | | `rejected` | failed validation; `reason` says why | never disposition; report; re-PUT only with retry flag | `pending`/`imported` count as confirmed so a client that crashed between PUT and disposition converges on the next run. A server-side entry mid-promotion is reported as `pending` — the wire vocabulary stays at these four states. An empty `hashes` array is legal (`200`, empty `states`). A malformed body, a malformed hash (not 64 lowercase hex), or >500 hashes → `400`. A box that cannot receive scans at all answers **every** request on both routes — check included — with the retryable `503 { "status": "server-error", … }` below, so a client learns the box is unavailable on its first call rather than on its first upload. The one condition today is a box that has not been converted to git-annex: promotion stages raw asset bytes, which a manifest-scheme box gitignores, so an `accepted` there would be a lie the client acts on by deleting its only copy. ## `PUT //api/scan/files/` Body: the raw file bytes, `Content-Type: application/octet-stream`, streamed. `Content-Length` required (`411` without it). Malformed path hash or missing/unusable `X-Upload-Filename` → `400`. Headers: | header | required | meaning | |---|---|---| | `X-Upload-Filename` | yes | original filename (basename only; server sanitizes) | | `X-Scan-Profile` | no | free-text scanner profile name (≤200 chars), recorded as provenance | Server behavior: streams to quarantine while metering bytes (over-limit → `413`, partial file deleted), re-hashes, then validates (magic-byte sniff vs extension allowlist; `qpdf --check` for PDFs, image decode for images). Responses (JSON, `status` field is the vocabulary): | HTTP | body | meaning | |---|---|---| | `200` | `{ "status": "accepted" }` | stored + validated; now `pending` | | `200` | `{ "status": "duplicate" }` | hash already `pending`/`imported` | | `422` | `{ "status": "rejected", "reason": "…" }` | validation failed; recorded as `rejected` | | `422` | `{ "status": "hash-mismatch" }` | received bytes ≠ path hash; nothing recorded — retry | | `413` | — | over size limit (50 MB) | | `429` | — | rate limited; honor `Retry-After` | | `503` | `{ "status": "server-error", "reason": "…" }` | server temporarily unable to accept this file (e.g. qpdf missing, box not annex-converted); nothing recorded — report, never disposition, retry a later run | Re-PUT of a `rejected` hash re-runs validation (the retry path after a validator fix). Re-PUT of `pending`/`imported` is a no-op `duplicate`. PUT is idempotent throughout — retrying any response is safe. ## Server-side lifecycle (why `check` answers change on their own) Accepted files sit in the box's scan quarantine until the **promote worker** (`src/core/scan/promote.ts`) runs — debounced two minutes after the last PUT (accepted or rejected), once at box-serve startup, and on a slow GC sweep for boxes that stop scanning — under a per-box cross-process lock. A pass imports pending files through `bbx upload --as scan` (materialized under their original filenames, tagged `--source scan-upload/`), raises one question card per rejection, runs a supervised full `bbx wakeup` recorded by a durable marker until it succeeds, and garbage-collects quarantine. The GC is what makes the answers below stable rather than eventually-empty: | Entry | When it goes | What `check` says afterwards | |---|---|---| | imported | next pass, file + sidecar | `imported`, from the upload ledger | | rejected, question pending | never | `rejected` + reason | | rejected, question resolved | file now; sidecar becomes a tombstone | `rejected` + reason | | rejected, resolved 30+ days ago | everything | `unknown` — a re-upload re-validates | Client consequence: a `rejected` hash can eventually return to `unknown`. That is deliberate, and far enough out that re-sending deserves fresh validation. ## Limits 50 MB per file; 60 requests/min per token; 500 hashes per check call. ## Client obligations The uploader must: apply a settle gate (skip files modified <10 s ago), snapshot file identity (dev, inode, size, mtime-ns) before hashing, and **restat before any disposition** (archive/trash), skipping the disposition if identity changed. Disposition only after `accepted`/`duplicate` on PUT or `pending`/`imported` from check. `rejected` files are never moved or deleted. ## Change discipline Any change to routes, headers, states, statuses, limits, or hashing updates this doc, both breadcrumbed implementations, and the route doctests in the same change.