notification catalogue: GET /api/notify/mapping
the piece that makes a new notification kind a server change rather than an app store release. a push carries a code and integers; this is what the code means, synced to the device instead of compiled into it. deliberately the same shape as affordances rather than a second mechanism: a `notifications/` directory of yaml in the plugin repository, copied to capabilities/notifications/<publisher>/<appName>/ on install, removed on uninstall. publish.ts mirrors affordances/publish.ts closely enough to diff. the namespace is DERIVED from where the file lands, never from what it declares — same rule as mountPrefix. a plugin writing `officer/core:test` as a key gets `<publisher>/<plugin>:officer/core:test` and the real core entry is untouched. that is the security property of the whole scheme and it is pinned by test. version is a content hash, not a counter. a counter needs somewhere to live and is then wrong after a restore, a rollback, or two servers built from the same install; a hash needs no storage and is right in all three. so the client compares mv !== version, and both docs now say so — i had written "higher". core entries ship in code rather than on disk, because capabilities/ is installed rather than committed and `test` has to exist on a machine that has installed nothing — it is what first light is proved with. read from disk per request, not memoised: installing a plugin has to change the answer without a restart, or it presents as "the new plugin's notifications don't work", which is the hardest kind of bug to place. verified live: 401 through the platform, plugin catalogue appearing and disappearing with no restart, version returning to its prior value when the plugin is removed. 10 catalogue tests, 100% coverage of catalogue.ts.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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. */
|
||||
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> = {
|
||||
'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',
|
||||
},
|
||||
'officer/core:agent': {
|
||||
title: 'Agent finished',
|
||||
target: 'mobile',
|
||||
route: 'officer://chat/{id}',
|
||||
thread: 'officer/core:agent',
|
||||
},
|
||||
};
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user