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:
@@ -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`.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user