SendOff — a file lands in a real directory in the recipient's own tree

Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push.
No staging area, no transfer table, no ack, nothing to expire. Every account
already has a home, a file browser over it and a Files app on the phone, so a
delivered file is an ordinary file from the moment it arrives.

`Documents/SendOff/<machine>/` for something you sent yourself, and
`Documents/SendOff/<sender>/` for another account's files. The home is the one
that account actually browses — the owner's real login home, a member's managed
home — because the two are different directories and writing to the wrong one
makes a delivery invisible rather than wrong.

CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant
answers "may this role send files", and the person who should decide whether
somebody may write into your home is you. Absence denies, and a sender who was
not allowed does not see the recipient in their picker at all — the endpoint
answers 404 for "no such account" and for "did not allow you" alike, so it cannot
be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving
still needs their say-so.

The sender's folder name comes from the token and never from the payload, so it
cannot lie about who put a file there. Names are sanitised to one path segment and
filenames to their basename — verified: `../../../../etc` becomes a literal
folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside
the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the
sender cannot see the directory and would have no way to know.

The push carries the folder name and a file count, and the catalogue renders
"3 file(s) from andre" on the device. That relaxes "content does not travel" by a
folder name and an integer — the owner's call, on the grounds that the file 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.

`kind: 'app'` rather than `confined` even though it writes into a home: the path
is one fixed shape chosen by the server from the caller's token, so there is
nothing to traverse and nothing to read back. Requiring a Linux user would mean an
account without one could not be sent a file, which is most of the point.

Backend and verified end to end. The web send form is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 02:32:09 +00:00
co-authored by Claude Opus 5
parent 510fa3f524
commit b94a5e90b8
11 changed files with 522 additions and 1 deletions
+21
View File
@@ -347,6 +347,27 @@ const CORE_REGISTRY: Permission[] = [
ws: ['minesweeper'],
routes: ['/minesweeper'],
},
/*
SendOff is `app`, not `confined`, and the distinction is the point.
It writes into a home directory, which normally means `confined` — but it writes exactly one shape of
path, `Documents/SendOff/<sender>/`, chosen by the server from the caller's token. There is no path in
the request to contain, nothing to traverse out of, and no way to read anything back. So the Linux-user
requirement that `confined` exists to enforce buys nothing here, and requiring it would mean an account
with no OS user could not be sent a file — which is most of the point of the feature.
WHO may send to whom is not decided here. It is a row per pair held by the recipient, because the
person who should decide whether somebody may put a file in your home is you, not whoever administers
roles. This grant only says the feature is reachable at all.
*/
{
key: 'sendoff',
label: 'SendOff',
description: 'Send files to your own machines, and to accounts that have allowed you',
kind: 'app',
api: ['/sendoff'],
routes: ['/sendoff'],
},
{
key: 'items',
label: 'Permission authoring',