From b94a5e90b8f67b421d691a031c391e16d974341d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 02:32:09 +0000 Subject: [PATCH] =?UTF-8?q?SendOff=20=E2=80=94=20a=20file=20lands=20in=20a?= =?UTF-8?q?=20real=20directory=20in=20the=20recipient's=20own=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//` for something you sent yourself, and `Documents/SendOff//` 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 --- CLAUDE.md | 2 +- src/databases/officer_db/src/index.ts | 1 + src/databases/officer_db/src/schema.ts | 1 + src/databases/officer_db/src/sendoff/index.ts | 16 ++ .../officer_db/src/sendoff/queries.ts | 122 +++++++++ .../officer_db/src/sendoff/schema.ts | 55 ++++ src/servers/api/sendoff/paths.ts | 51 ++++ src/servers/api/sendoff/router.ts | 236 ++++++++++++++++++ src/servers/hono.ts | 2 + src/servers/notifications/catalogue.ts | 16 ++ src/servers/permissions/registry.ts | 21 ++ 11 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 src/databases/officer_db/src/sendoff/index.ts create mode 100644 src/databases/officer_db/src/sendoff/queries.ts create mode 100644 src/databases/officer_db/src/sendoff/schema.ts create mode 100644 src/servers/api/sendoff/paths.ts create mode 100644 src/servers/api/sendoff/router.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4bebed32..309facb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha - `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds: `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is - never grantable at any level). **19 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days. + never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days. - `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, and `confined` stripped for an account with no `osUser`. diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 53275efb..4cd5916a 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -40,6 +40,7 @@ export * from './ai-provider-usage'; export * from './anthropic-accounts'; export * from './theme-favorites'; export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup'; +export * from './sendoff'; export * from './command-hq'; export * from './dashboards'; export * from './integrations'; diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 4a0f79c9..9f714397 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -37,6 +37,7 @@ export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags +export * from './sendoff/schema'; // sendoff_devices, sendoff_senders export * from './agent-panels/schema'; // agent_panels // Core because the tailnet is the perimeter — a security model resting on it cannot treat administering diff --git a/src/databases/officer_db/src/sendoff/index.ts b/src/databases/officer_db/src/sendoff/index.ts new file mode 100644 index 00000000..d7c7fbd8 --- /dev/null +++ b/src/databases/officer_db/src/sendoff/index.ts @@ -0,0 +1,16 @@ +export { sendoffDevices, sendoffSenders } from './schema'; +export { + upsertSendoffDevice, + listSendoffDevices, + renameSendoffDevice, + deleteSendoffDevice, + listAllowedSenders, + listMyRecipients, + listOtherAccounts, + allowSender, + revokeSender, + maySendTo, + accountsByIds, + type SendoffDevice, + type SendoffPerson, +} from './queries'; diff --git a/src/databases/officer_db/src/sendoff/queries.ts b/src/databases/officer_db/src/sendoff/queries.ts new file mode 100644 index 00000000..92223bfb --- /dev/null +++ b/src/databases/officer_db/src/sendoff/queries.ts @@ -0,0 +1,122 @@ +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '../db'; +import { users } from '../auth/schema'; +import { sendoffDevices, sendoffSenders } from './schema'; + +export type SendoffDevice = { id: string; name: string; platform: string | null; lastSeenAt: Date }; + +/** + * Register or refresh a device. Called on every app start, so it must be idempotent on the client's own id. + * + * `lastSeenAt` is refreshed on conflict and the name is overwritten, because the client is the authority on + * what it calls itself — renaming on the device should show up in the picker without a second endpoint. + */ +export async function upsertSendoffDevice(params: { + id: string; + userId: number; + name: string; + platform?: string | null; +}): Promise { + await db + .insert(sendoffDevices) + .values({ + id: params.id, + userId: params.userId, + name: params.name, + platform: params.platform ?? null, + }) + .onConflictDoUpdate({ + target: sendoffDevices.id, + set: { name: params.name, platform: params.platform ?? null, lastSeenAt: new Date() }, + }); +} + +export async function listSendoffDevices(userId: number): Promise { + const rows = await db + .select({ + id: sendoffDevices.id, + name: sendoffDevices.name, + platform: sendoffDevices.platform, + lastSeenAt: sendoffDevices.lastSeenAt, + }) + .from(sendoffDevices) + .where(eq(sendoffDevices.userId, userId)); + return rows; +} + +/** Scoped to the owner of the row: a device id is a client-chosen uuid and must not be a way to reach another account's. */ +export async function renameSendoffDevice(userId: number, id: string, name: string): Promise { + const rows = await db + .update(sendoffDevices) + .set({ name }) + .where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId))) + .returning({ id: sendoffDevices.id }); + return rows.length > 0; +} + +export async function deleteSendoffDevice(userId: number, id: string): Promise { + const rows = await db + .delete(sendoffDevices) + .where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId))) + .returning({ id: sendoffDevices.id }); + return rows.length > 0; +} + +export type SendoffPerson = { id: number; email: string; name: string | null }; + +/** The accounts I have allowed to send to me. */ +export async function listAllowedSenders(recipientUserId: number): Promise { + const rows = await db + .select({ id: users.id, email: users.email, name: users.name }) + .from(sendoffSenders) + .innerJoin(users, eq(users.id, sendoffSenders.senderUserId)) + .where(eq(sendoffSenders.recipientUserId, recipientUserId)); + return rows; +} + +/** + * The accounts I may send to — the inverse question, and the one the picker asks. + * + * Absence denies, so this returns only recipients who have named me. A sender never learns that an account + * exists and refused them; it simply is not in the list. + */ +export async function listMyRecipients(senderUserId: number): Promise { + const rows = await db + .select({ id: users.id, email: users.email, name: users.name }) + .from(sendoffSenders) + .innerJoin(users, eq(users.id, sendoffSenders.recipientUserId)) + .where(eq(sendoffSenders.senderUserId, senderUserId)); + return rows; +} + +/** Every account except me — what the owner's picker shows, since the owner may send to anyone. */ +export async function listOtherAccounts(exceptUserId: number): Promise { + const rows = await db.select({ id: users.id, email: users.email, name: users.name }).from(users); + return rows.filter((r) => r.id !== exceptUserId); +} + +export async function allowSender(recipientUserId: number, senderUserId: number): Promise { + await db.insert(sendoffSenders).values({ recipientUserId, senderUserId }).onConflictDoNothing(); +} + +export async function revokeSender(recipientUserId: number, senderUserId: number): Promise { + await db + .delete(sendoffSenders) + .where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId))); +} + +/** May this sender write into this recipient's tree? The owner check is the CALLER's job — see the schema. */ +export async function maySendTo(senderUserId: number, recipientUserId: number): Promise { + const rows = await db + .select({ id: sendoffSenders.id }) + .from(sendoffSenders) + .where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId))) + .limit(1); + return rows.length > 0; +} + +/** Resolve accounts by id, for naming a recipient in a response without a second round trip. */ +export async function accountsByIds(ids: number[]): Promise { + if (!ids.length) return []; + return db.select({ id: users.id, email: users.email, name: users.name }).from(users).where(inArray(users.id, ids)); +} diff --git a/src/databases/officer_db/src/sendoff/schema.ts b/src/databases/officer_db/src/sendoff/schema.ts new file mode 100644 index 00000000..9cb6a432 --- /dev/null +++ b/src/databases/officer_db/src/sendoff/schema.ts @@ -0,0 +1,55 @@ +import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { users } from '../auth/schema'; + +/** + * A named machine, so "send it to my laptop" has something to pick from. + * + * Separate from `push_devices` deliberately, and the two are not the same population. A push device is a + * token: iOS or Android only, no human name, and it exists to be pushed AT. This is a name a person chose, + * for a machine that may never receive a push at all — a desktop, a browser, a box with no app on it. The + * name's only job is to become a folder. + * + * The id is the client's own uuid rather than a serial, because registration happens on every app start and + * has to be idempotent without the client remembering a number we gave it. + */ +export const sendoffDevices = pgTable('sendoff_devices', { + id: text('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** What the owner calls it. Becomes a directory name, so it is sanitised before it touches the disk. */ + name: text('name').notNull(), + platform: text('platform'), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(), +}); + +/** + * Who may send files to whom — held by the RECIPIENT, one row per sender they have allowed. + * + * This is the whole cross-user permission model and it is deliberately not a role grant. A grant answers + * "may this role send files", which is the wrong question: the person who should decide whether Fedra can + * put a file in your home directory is you, not whoever administers roles. So consent is data, it is + * per-pair, and absence denies — a sender who has not been allowed does not see the recipient as a target + * at all, rather than seeing one and being refused. + * + * The owner is exempt as a SENDER and only as a sender: they bypass every other check on this platform and + * there is no version of this where they cannot reach a home directory they already own. Receiving still + * needs their say-so, so nobody lands files in the owner's tree uninvited. + */ +export const sendoffSenders = pgTable( + 'sendoff_senders', + { + id: serial('id').primaryKey(), + /** The account granting permission — the one whose tree gets written to. */ + recipientUserId: integer('recipient_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** The account allowed to send to them. */ + senderUserId: integer('sender_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + // uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push. + (table) => [uniqueIndex('uq_sendoff_senders').on(table.recipientUserId, table.senderUserId)], +); diff --git a/src/servers/api/sendoff/paths.ts b/src/servers/api/sendoff/paths.ts new file mode 100644 index 00000000..882dcf07 --- /dev/null +++ b/src/servers/api/sendoff/paths.ts @@ -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//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'; +} diff --git a/src/servers/api/sendoff/router.ts b/src/servers/api/sendoff/router.ts new file mode 100644 index 00000000..56239902 --- /dev/null +++ b/src/servers/api/sendoff/router.ts @@ -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//`, and `` 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 { + 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//`. `machine` is a name + * of my own and writes into MY tree under `SendOff//`. Exactly one of the two. + */ +sendoffRouter.post('/send', async (ctx) => { + const me = ctx.get('user'); + const body = ctx.get('body') as Record; + + // `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 }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 080b3278..fe315a69 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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 ['/file-browser', fileBrowserRouter], ['/terminal', terminalRouter], ['/minesweeper', minesweeperRouter], + ['/sendoff', sendoffRouter], // ['/memos', memosRouter], // plugin — switched off 2026-08-13 ['/app-store', appStoreRouter], ['/plugins', pluginsRouter], diff --git a/src/servers/notifications/catalogue.ts b/src/servers/notifications/catalogue.ts index 82b89237..acc30e0d 100644 --- a/src/servers/notifications/catalogue.ts +++ b/src/servers/notifications/catalogue.ts @@ -99,6 +99,22 @@ const CORE_ENTRIES: Record = { 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', diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index f8e1280b..2ec5c9b2 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -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//`, 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',