diff --git a/COMMS/sendoff/platform.md b/COMMS/sendoff/platform.md new file mode 100644 index 00000000..63c8e002 --- /dev/null +++ b/COMMS/sendoff/platform.md @@ -0,0 +1,141 @@ +# SendOff — the backend is in. What mobile needs to change. + +**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design +draft; this file is what actually shipped, and it differs in two places. + +The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine +folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all +exercised against the live server before this note was written. The web send form is **not** built yet; +that is on this side and does not block anything below. + +## The convention on disk + +``` +/Documents/SendOff// — something you sent yourself, aimed at a named device +/Documents/SendOff// — files another account sent you +``` + +**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is +`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital. + +`` is the home that account actually browses: the owner's real login home, a member's managed home +under `DATA_PATH//home`. The app never needs to know which — every path it receives is already +relative to the right one. + +There is no `from-` prefix. The doc had `from-`; the owner asked for the folder to be the +sender's name alone. + +## Two changes from the draft spec + +**1. Device registration moved under the feature.** + +``` +was: POST /api/devices/register { id, name, platform } +now: POST /api/sendoff/devices { id, name, platform } → { ok: true } +``` + +Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also +`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`, +`DELETE /api/sendoff/devices/:id`. + +Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a +top-level noun collides with the **push token** registry that already exists at +`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with +no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may +never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody +an afternoon. + +**2. `toUser` is an account id, not a username.** + +``` +was: meta { "toUser": "" } +now: form field toUser= +``` + +Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still +gets a name — the sender's display name, falling back to the local part of their email — so nothing about +the on-disk convention changes. If the app would rather send a username, say so and this side will accept +either; it is a small change here and a smaller one than guessing. + +## The send endpoint + +``` +POST /api/sendoff/send + multipart/form-data: + file — repeatable, any number of parts + machine — a name of your own } exactly one of these + toUser — an account id } + → { path: "Documents/SendOff/", files: [{ name, size }] } +``` + +Notes that matter for a client: + +- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`. + A file over the limit is a `400` naming the file. +- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is + ever overwritten, because the sender cannot see the recipient's directory and would have no way to know + they were about to destroy something. Show the returned name, not the local one. +- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash. + Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target. +- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.** + That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that + one refused you. Do not present the two differently, because they are not distinguishable. + +## Who may send to whom + +Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept +handshake: I add you, and only then do you see me as a target. + +``` +GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows +GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] } +POST /api/sendoff/senders { userId } +DELETE /api/sendoff/senders/:userId +``` + +`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single +request. For everyone except the owner, `people` contains only accounts that have named them. **The owner +may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs +their say-so, so nobody lands files in the owner's tree uninvited. + +Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by +then, and deleting somebody's files as a side effect of a permission change would be its own surprise. + +## The push + +``` +code: officer/core:sendoff.landed +id: the folder name — a username, or one of your own machine names +n: how many files landed +``` + +The catalogue entry renders `{n} file(s) from {id}` and its route is: + +``` +officer://files?path=Documents/SendOff/{id} +``` + +**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive +from the server — which is the whole point of that design. What the app does need is to keep parsing +`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does. + +A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate +relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file +itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would +be content; whose folder it is, is addressing. If a future notification wants more than that, it is a +conversation rather than a patch. + +## What is NOT built + +- **The web send form.** This side, in progress. It changes no API. +- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here + blocks it — `POST /sendoff/send` takes multipart from any client. +- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came + with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a + month. + +## If something here is wrong for mobile + +Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and +expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client +ends up carrying a translation layer nobody meant to write.