COMMS: SendOff backend is in, and it differs from the draft in two places
Opens the channel for mobile. The endpoints as built rather than as specced, both deviations with the reasoning, and the parts that need no app build at all. The two that matter: device registration moved to /api/sendoff/devices, because /api/devices collided with the push-token registry and those are different populations; and toUser is an account id rather than a username, because users.name is nullable and a username is not a reliable address. Both are cheap to change here and expensive to guess at, which the note says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
```
|
||||
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
|
||||
<home>/Documents/SendOff/<sender>/ — 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.
|
||||
|
||||
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
|
||||
under `DATA_PATH/<email>/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-<username>`; 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": "<username>" }
|
||||
now: form field toUser=<account id, integer>
|
||||
```
|
||||
|
||||
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/<folder>", 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.
|
||||
Reference in New Issue
Block a user