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>
217 lines
8.5 KiB
TypeScript
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 };
|
|
}
|