Files
platform/src/servers/notifications/catalogue.ts
T
pastilhasandClaude Opus 5 b94a5e90b8 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>
2026-08-23 02:32:09 +00:00

217 lines
8.5 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { OFFICER_ITEMS_DIR } from '../data-path';
// The notification catalogue: what a code MEANS, assembled here and rendered on the device.
//
// ── Why the meaning lives on the server ──
//
// A push carries a code and some integers — `officerdev/music:12.3`, `n: 3` — and never content. The
// device turns that into the notification you actually see. If the code-to-text mapping lived in the app,
// every new kind of notification would be an App Store release; because it lives here and is synced, a
// new kind is a server change and nothing else. That single property is what the whole design is for.
//
// See docs/push-notifications.md for the design and docs/push-notifications-app.md for the client contract.
//
// ── The namespace is DERIVED, never declared ──
//
// A code is `<publisher>/<appName>:<key>`, and the publisher and appName come from WHERE THE FILE IS —
// `notifications/<publisher>/<appName>/*.yaml` — not from anything inside it. Same rule as `mountPrefix`.
// A plugin therefore cannot claim `officer/core:` or another publisher's namespace by saying so; it would
// have to be installed somewhere it cannot install itself.
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
export const NOTIFICATIONS_DIR = join(OFFICER_ITEMS_DIR, 'notifications');
/** How one code renders, and where tapping it goes. */
export type NotificationEntry = {
/** Template. `{n}` and `{id}` are substituted on the device. */
title: string;
/** Template, optional — most notifications are a title alone. */
body?: string;
/** Which app the deep link opens. */
target?: string;
/** The deep link, `{id}` interpolated. */
route?: string;
/**
* Grouping key, so like-notifications collapse rather than stack. Defaults to the code's namespace.
*
* A TEMPLATE, like the others — `chat:{id}` groups per conversation, so two busy chats do not collapse
* into one banner while three pings from the SAME chat still do. A static thread would force a choice
* between those, and the wrong one is "every chat is the same chat".
*/
thread?: string;
};
export type Catalogue = {
/**
* Changes whenever the aggregate does. A CONTENT HASH, not a counter.
*
* A counter would need somewhere to live, and would then be wrong after a restore, a rollback, or two
* servers built from the same install. The hash needs no storage and is right in all three cases —
* which is why the client compares `mv !== version` rather than `mv > version`.
*/
version: number;
entries: Record<string, NotificationEntry>;
};
/**
* The platform's own, shipped in code rather than on disk.
*
* Plugin catalogues arrive through `capabilities/notifications/`, which is installed rather than
* committed. The platform's must not depend on that having happened — `test` in particular has to exist
* on a machine that has never installed anything, because it is what first light is proved with.
*/
const CORE_ENTRIES: Record<string, NotificationEntry> = {
// ── Chat ──
//
// The hub has no chat screen; chat is Butler, a sibling app. So these route OUT of the hub over
// `officer-chat://`, and the scheme is written here rather than derived from the target: two of the
// suite's twenty-one schemes already break the `officer-<app>` pattern (`officer-videos`,
// `officer-readaloud`), so deriving it works until it silently does not.
//
// The banner cannot name WHICH conversation — a chat's title is content, and content does not travel.
// It says what happened; tapping opens the chat, which is where the name lives.
'officer/core:chat.turn-ended': {
title: 'Chat',
body: 'Finished its turn',
target: 'chat',
route: 'officer-chat://session/{id}',
thread: 'chat:{id}',
},
'officer/core:chat.input-needed': {
title: 'Chat',
body: 'Needs your input',
target: 'chat',
route: 'officer-chat://session/{id}',
thread: 'chat:{id}',
},
'officer/core:test': {
title: 'Officer',
body: 'Test notification',
target: 'mobile',
route: 'officer://',
},
'officer/core:job': {
title: 'Job finished',
target: 'mobile',
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',
route: 'officer://jobs/{id}',
thread: 'officer/core:job',
},
};
/** FNV-1a, 32-bit. Small, stable across processes, and not a security boundary. */
function hash(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** Keep only the fields we publish, so a stray key in a plugin's YAML cannot reach the device. */
function coerce(raw: unknown): NotificationEntry | null {
if (raw === null || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
// A title is the one thing a notification cannot do without — the OS needs something to draw.
if (typeof r.title !== 'string' || !r.title.trim()) return null;
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v : undefined);
return {
title: r.title,
...(str(r.body) ? { body: str(r.body)! } : {}),
...(str(r.target) ? { target: str(r.target)! } : {}),
...(str(r.route) ? { route: str(r.route)! } : {}),
...(str(r.thread) ? { thread: str(r.thread)! } : {}),
};
}
/**
* Every plugin-published entry under `root`.
*
* A file that will not parse is skipped rather than fatal: one plugin shipping broken YAML must not take
* out every other plugin's notifications, and a push whose code is missing already has a defined
* behaviour on the device (the unknown-code fallback). Failing loudly here would trade a degraded
* notification for none at all.
*/
export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<string, NotificationEntry> {
let files: string[];
try {
files = (readdirSync(root, { recursive: true }) as string[]).filter((f) => /\.ya?ml$/.test(f));
} catch {
return {}; // no directory yet is the ordinary state of a fresh install
}
const entries: Record<string, NotificationEntry> = {};
for (const rel of files.sort()) {
// `<publisher>/<appName>/<file>.yaml` — anything shallower has no namespace to derive and is ignored.
const parts = rel.split('/');
if (parts.length < 3) continue;
const [publisher, appName] = parts;
let doc: unknown;
try {
doc = YAML.parse(readFileSync(join(root, rel), 'utf8'));
} catch {
continue;
}
if (doc === null || typeof doc !== 'object') continue;
for (const [key, value] of Object.entries(doc as Record<string, unknown>)) {
const entry = coerce(value);
if (!entry) continue;
entries[`${publisher}/${appName}:${key}`] = {
...entry,
thread: entry.thread ?? `${publisher}/${appName}`,
};
}
}
return entries;
}
/**
* The whole catalogue: the platform's own plus everything installed plugins publish.
*
* Core wins on a collision. A plugin cannot land on a core code by accident — the namespace is derived
* from its install path — so this only matters if the store is edited by hand, and the platform's own
* notifications are the ones that must keep working when it is.
*/
export function loadCatalogue(root: string = NOTIFICATIONS_DIR): Catalogue {
const entries = { ...loadPublishedEntries(root), ...CORE_ENTRIES };
// Sorted so the hash depends on content, not on the order the filesystem happened to return.
const canonical = JSON.stringify(
Object.keys(entries)
.sort()
.map((k) => [k, entries[k]]),
);
return { version: hash(canonical), entries };
}