Files
platform/src/servers/notifications/catalogue.ts
T
pastilhas 46d621c91b chat notifications reach the phone, on the same rule as the bell
the browser bell and phone push are now two subscribers to one signal. both
ask shouldNotify(); neither owns the rule.

deliberately a second subscriber rather than a branch inside notify-delivery.
the two channels share only the question "does this account want to hear about
this" — the browser needs an open tab and sends the chat's real title over a
loopback socket on the owner's own tailnet, the phone needs no tab and must
send NO title, because that hop goes through apple. one function doing both
means an `if (isPush)` at every step, and the first thing to go wrong is a
title leaking because the branch was missed in one place.

two codes, officer/core:chat.turn-ended and chat.input-needed. the route is
written out with its scheme rather than derived from the target: the hub has
no chat screen — chat is Butler, a sibling app — so these deep-link OUT over
officer-chat://, and two of the suite's twenty-one schemes already break the
officer-<app> pattern (officer-videos, officer-readaloud). deriving that works
until it silently does not.

`thread` interpolates now, so `chat:{id}` groups per conversation. a static
thread forces a choice between "three pings from one chat collapse" and "two
busy chats stay apart", and the wrong answer is that every chat is the same
chat.

dropped officer/core:agent: chat.turn-ended supersedes it and its route
pointed at officer://chat/{id}, a hub screen that does not exist.

the banner cannot name which conversation. a chat's title is content and
content does not travel; it says what happened, and tapping opens the chat,
which is where the name lives.
2026-08-19 22:00:17 +00:00

201 lines
7.7 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',
},
'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 };
}