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
+1 -1
View File
@@ -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`.
+1
View File
@@ -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';
+1
View File
@@ -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
@@ -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';
@@ -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<void> {
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<SendoffDevice[]> {
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<boolean> {
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<boolean> {
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<SendoffPerson[]> {
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<SendoffPerson[]> {
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<SendoffPerson[]> {
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<void> {
await db.insert(sendoffSenders).values({ recipientUserId, senderUserId }).onConflictDoNothing();
}
export async function revokeSender(recipientUserId: number, senderUserId: number): Promise<void> {
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<boolean> {
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<SendoffPerson[]> {
if (!ids.length) return [];
return db.select({ id: users.id, email: users.email, name: users.name }).from(users).where(inArray(users.id, ids));
}
@@ -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)],
);
+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',