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
+51
View File
@@ -0,0 +1,51 @@
import { join } from 'node:path';
import { getHomeDir, getOwnerHomeDir } from '@@/data-path';
/** `Documents/SendOff` under whichever home this account actually browses. */
export const SENDOFF_SUBPATH = join('Documents', 'SendOff');
/**
* The SendOff directory for an account, in the home its own file browser shows.
*
* The two homes are not interchangeable and picking the wrong one makes a delivery invisible: the owner
* browses their real login home (`/home/pastilhas`), while another account browses the managed home under
* `DATA_PATH/<email>/home`. `getOwnerHomeDir` ignores the email it is passed and always answers the owner,
* so it can only be used once the role has been checked — which is why this takes the role rather than
* working it out from the email.
*/
export function sendoffDirFor(account: { email: string; isOwner: boolean }): string {
const home = account.isOwner ? getOwnerHomeDir(account.email) : getHomeDir(account.email);
return join(home, SENDOFF_SUBPATH);
}
/**
* A folder name safe to create, from a name a person or a device chose.
*
* Anything that is not a letter, digit, dash, underscore or dot becomes a dash, and leading dots go — so a
* name cannot produce `..`, a hidden directory, or a path with a separator in it. Empty after that is
* refused by the caller rather than silently becoming a directory called `-`.
*/
export function safeSegment(name: string): string {
return name
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^\.+/, '')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
/**
* A filename safe to write, preserving the extension.
*
* Same rules as a segment, with the basename taken first: a browser will happily send `../../etc/passwd`
* as a filename and the multipart spec does not forbid it.
*/
export function safeFilename(name: string): string {
const base = name.split(/[/\\]/).pop() ?? '';
const cleaned = base
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^\.+/, '')
.slice(0, 200);
return cleaned || 'file';
}
+236
View File
@@ -0,0 +1,236 @@
import { mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { extname, join } from 'node:path';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import {
accountsByIds,
allowSender,
deleteSendoffDevice,
listAllowedSenders,
listMyRecipients,
listOtherAccounts,
listSendoffDevices,
maySendTo,
renameSendoffDevice,
revokeSender,
upsertSendoffDevice,
} from 'officerdb';
import { getNotifyServerUrl } from '../notify/router';
import { safeFilename, safeSegment, sendoffDirFor } from './paths';
/*
SendOff — files land in a real directory in the recipient's own tree.
There is no staging area, no transfer record and 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: visible in
every UI that exists, and managed like anything else its owner owns.
Two things are worth knowing before changing anything here.
**This is the only sanctioned door into another account's filesystem.** `/file-browser` and `/upload`
write exclusively into the caller's own tree and that must stay true. So this route writes ONLY under
`Documents/SendOff/<sender>/`, and `<sender>` comes from the token — never from the payload — so a folder
name cannot lie about who put a file there.
**Consent belongs to the recipient.** A role grant would answer "may this role send files", which is the
wrong question; the person who decides whether somebody may write into your home is you. So it is a row,
per pair, held by the recipient, and absence denies. A sender who has not been allowed does not see the
recipient in their picker at all rather than seeing one and being refused — which also means this endpoint
never confirms that an account exists.
*/
export const sendoffRouter = createRouter();
const MAX_FILE_SIZE = 512 * 1024 * 1024; // 512 MB — this is a file transfer, not an avatar upload
const isOwnerRole = (role: string | null | undefined): boolean => role === 'Super Admin';
/**
* A path that does not exist yet, suffixing ` (1)`, ` (2)`, … before the extension.
*
* Never overwrite. A second send of the same name is a second file: the sender cannot see the recipient's
* directory, so they have no way to know they are about to destroy something, and silently replacing a file
* somebody was sent is the one outcome with no recovery.
*/
function freePath(dir: string, filename: string): string {
const first = join(dir, filename);
if (!existsSync(first)) return first;
const ext = extname(filename);
const stem = filename.slice(0, filename.length - ext.length);
for (let n = 1; n < 1000; n++) {
const candidate = join(dir, `${stem} (${n})${ext}`);
if (!existsSync(candidate)) return candidate;
}
return join(dir, `${stem} (${Date.now()})${ext}`);
}
/*
The push, and why it carries what it does.
`id` is the FOLDER NAME — a username or a machine name — and `n` is a file count. Both are interpolated by
the catalogue on the device ("3 file(s) from andre") and both are what the route needs to open the right
directory. No filename and no path travels: the folder name plus the count is what the owner accepted as
the line, and it is enough for the tap to land somewhere useful.
Never fatal. A delivery that succeeded must not be reported as a failure because a notification did not
go out — the file is already in their tree, and the file browser shows it either way.
*/
async function pushLanded(recipientUserId: number, folder: string, count: number): Promise<void> {
try {
const base = getNotifyServerUrl();
if (!base) return;
await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId) },
body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
console.error('[sendoff] could not push:', err instanceof Error ? err.message : err);
}
}
/**
* POST /sendoff/send — multipart: any number of `file` parts, plus `toUser` or `machine`.
*
* `toUser` is an account id and writes into THEIR tree under `SendOff/<my-username>/`. `machine` is a name
* of my own and writes into MY tree under `SendOff/<machine>/`. Exactly one of the two.
*/
sendoffRouter.post('/send', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as Record<string, unknown>;
// `parseBody({ all: true })` gives an array when a part repeats and a single value when it does not, so
// both shapes have to be accepted — a one-file send is the common case and must not need a special part.
const raw = body.file;
const files = (Array.isArray(raw) ? raw : [raw]).filter((f): f is File => f instanceof File);
if (!files.length) throw errors.BAD_REQUEST('at least one file is required');
const tooBig = files.find((f) => f.size > MAX_FILE_SIZE);
if (tooBig) throw errors.BAD_REQUEST(`${tooBig.name} exceeds the ${MAX_FILE_SIZE / 1024 / 1024} MB limit`);
const toUser = body.toUser === undefined || body.toUser === '' ? null : Number(body.toUser);
const machine = typeof body.machine === 'string' && body.machine.trim() ? body.machine.trim() : null;
if ((toUser === null) === (machine === null)) {
throw errors.BAD_REQUEST('send to exactly one of toUser or machine');
}
let targetUserId: number;
let targetAccount: { email: string; isOwner: boolean };
let folder: string;
if (machine) {
// My own machine, my own tree. The name is mine to choose and is not checked against the device
// registry: the registry exists to POPULATE a picker, not to be the authority on what I may call a
// folder in my own home.
targetUserId = me.id;
targetAccount = { email: me.email, isOwner: isOwnerRole(me.role) };
folder = safeSegment(machine);
} else {
if (toUser === me.id) throw errors.BAD_REQUEST('use machine to send to yourself');
if (!Number.isInteger(toUser) || toUser === null || toUser <= 0)
throw errors.BAD_REQUEST('toUser must be an account id');
const [recipient] = await accountsByIds([toUser]);
// 404, never 403, and the same answer for "no such account" as for "did not allow you". A sender must
// not be able to enumerate accounts, nor learn that one refused them.
if (!recipient) throw errors.NOT_FOUND('no such recipient');
const allowed = isOwnerRole(me.role) || (await maySendTo(me.id, toUser));
if (!allowed) throw errors.NOT_FOUND('no such recipient');
const [full] = await accountsByIds([toUser]);
targetUserId = toUser;
targetAccount = { email: recipient.email, isOwner: full?.id === 1 };
// From the TOKEN. This is the whole reason the folder name can be trusted.
folder = safeSegment(me.name || me.email.split('@')[0] || `user-${me.id}`);
}
if (!folder) throw errors.BAD_REQUEST('that name has no usable characters');
const dir = join(sendoffDirFor(targetAccount), folder);
await mkdir(dir, { recursive: true });
const written: { name: string; size: number }[] = [];
for (const file of files) {
const path = freePath(dir, safeFilename(file.name));
await Bun.write(path, await file.arrayBuffer());
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
}
void pushLanded(targetUserId, folder, written.length);
return ctx.json({ path: join('Documents', 'SendOff', folder), files: written });
});
/**
* GET /sendoff/targets — everything the send form needs: my machines, and who I may send to.
*
* The owner sees every account because they may send to anyone; everybody else sees only the accounts that
* have named them. One request rather than two, because a picker with half its options is not a picker.
*/
sendoffRouter.get('/targets', async (ctx) => {
const me = ctx.get('user');
const [devices, people] = await Promise.all([
listSendoffDevices(me.id),
isOwnerRole(me.role) ? listOtherAccounts(me.id) : listMyRecipients(me.id),
]);
return ctx.json({ devices, people });
});
/** GET /sendoff/senders — who I have allowed to send to me. */
sendoffRouter.get('/senders', async (ctx) => {
const me = ctx.get('user');
const [allowed, everyone] = await Promise.all([listAllowedSenders(me.id), listOtherAccounts(me.id)]);
const allowedIds = new Set(allowed.map((a) => a.id));
// Both lists, so the UI can render a set of toggles rather than an "add" flow over an account picker.
return ctx.json({ allowed, candidates: everyone.map((p) => ({ ...p, allowed: allowedIds.has(p.id) })) });
});
sendoffRouter.post('/senders', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { userId?: number };
const id = Number(body?.userId);
if (!Number.isInteger(id) || id <= 0) throw errors.BAD_REQUEST('userId is required');
if (id === me.id) throw errors.BAD_REQUEST('you can always send to yourself');
const [account] = await accountsByIds([id]);
if (!account) throw errors.NOT_FOUND('no such account');
await allowSender(me.id, id);
return ctx.json({ ok: true });
});
sendoffRouter.delete('/senders/:userId', async (ctx) => {
const me = ctx.get('user');
const id = Number(ctx.req.param('userId'));
if (!Number.isInteger(id) || id <= 0) throw errors.BAD_REQUEST('userId is required');
// Revoking stops future sends. Files already delivered are the recipient's own files now, and deleting
// somebody's files as a side effect of a permission change would be its own kind of surprise.
await revokeSender(me.id, id);
return ctx.json({ ok: true });
});
/** POST /sendoff/devices — register or refresh. Idempotent on the client's own uuid; called every launch. */
sendoffRouter.post('/devices', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { id?: string; name?: string; platform?: string };
const id = typeof body?.id === 'string' ? body.id.trim() : '';
const name = typeof body?.name === 'string' ? body.name.trim() : '';
if (!id || !name) throw errors.BAD_REQUEST('id and name are required');
if (!safeSegment(name)) throw errors.BAD_REQUEST('that name has no usable characters');
await upsertSendoffDevice({ id, userId: me.id, name, platform: body?.platform ?? null });
return ctx.json({ ok: true });
});
sendoffRouter.get('/devices', async (ctx) => ctx.json({ devices: await listSendoffDevices(ctx.get('user').id) }));
sendoffRouter.patch('/devices/:id', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { name?: string };
const name = typeof body?.name === 'string' ? body.name.trim() : '';
if (!name || !safeSegment(name)) throw errors.BAD_REQUEST('name is required');
if (!(await renameSendoffDevice(me.id, ctx.req.param('id'), name))) throw errors.NOT_FOUND('no such device');
return ctx.json({ ok: true });
});
sendoffRouter.delete('/devices/:id', async (ctx) => {
const me = ctx.get('user');
if (!(await deleteSendoffDevice(me.id, ctx.req.param('id')))) throw errors.NOT_FOUND('no such device');
return ctx.json({ ok: true });
});
+2
View File
@@ -27,6 +27,7 @@ import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-p
import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { minesweeperRouter } from './api/minesweeper/router';
import { sendoffRouter } from './api/sendoff/router';
import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router';
@@ -141,6 +142,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter],
['/sendoff', sendoffRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
+16
View File
@@ -99,6 +99,22 @@ const CORE_ENTRIES: Record<string, NotificationEntry> = {
route: 'officer://jobs/{id}',
thread: 'officer/core:job',
},
/*
SendOff. `id` is the FOLDER — a username, or one of your own machine names — and `n` is how many
files landed, so the device renders "3 file(s) from andre" and the route opens that directory.
A folder name and a count travel, and nothing else: no filename, no path, no size. That is a
deliberate relaxation of "content does not travel" rather than an oversight — the owner's call, 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.
*/
'officer/core:sendoff.landed': {
title: 'SendOff',
body: '{n} file(s) from {id}',
target: 'mobile',
route: 'officer://files?path=Documents/SendOff/{id}',
thread: 'sendoff:{id}',
},
'officer/core:job-failed': {
title: 'Job failed',
target: 'mobile',
+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',