From 55d6eba51189f45a592e8513fb6163aa11d21e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 19 Aug 2026 19:07:49 +0000 Subject: [PATCH] notification catalogue: GET /api/notify/mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/// 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 `/: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. --- docs/push-notifications-app.md | 10 +- docs/push-notifications.md | 17 +- plugins/CLAUDE.md | 31 ++++ src/servers/notifications/catalogue.test.ts | 153 +++++++++++++++++ src/servers/notifications/catalogue.ts | 177 ++++++++++++++++++++ src/servers/notifications/publish.ts | 58 +++++++ src/servers/plugins/install.ts | 17 +- src/servers/sidecar/notify/index.ts | 14 ++ 8 files changed, 470 insertions(+), 7 deletions(-) create mode 100644 src/servers/notifications/catalogue.test.ts create mode 100644 src/servers/notifications/catalogue.ts create mode 100644 src/servers/notifications/publish.ts diff --git a/docs/push-notifications-app.md b/docs/push-notifications-app.md index c7a04f1a..1b251239 100644 --- a/docs/push-notifications-app.md +++ b/docs/push-notifications-app.md @@ -98,13 +98,19 @@ credential → APNs → device → banner. GET /api/notify/mapping → { version: 47, entries: { "": { title, body?, target, route, thread } } } ``` -_(Not built yet — I'm writing it now. The shape is fixed; assume it and I'll match it.)_ +**Built and live** as of 2026-08-19. `Cache-Control: no-store` — the client decides by `version`, so a +cached copy would defeat the sync it drives. The catalogue is re-read from disk per request, so installing +a plugin changes the response with no restart. Codes are namespaced `/:`, e.g. `officerdev/music:12.3`. The app **fetches this and caches it into a shared App Group container** — shared because the notification extension in Phase 3 must read it without the app running. Sync on launch, and whenever a push arrives -carrying an `mv` higher than the cached `version`. +whose `mv` **differs from** the cached `version`. + +**Compare with `!==`, not `>`.** `version` is a content hash, not a counter, so it does not increase +monotonically — removing a plugin returns it to the value it had before that plugin was installed. That is +deliberate: a counter would need persistent storage and would be wrong after a restore or a rollback. Entries are templates: `title: "{n} new emails"` rendered against the push's `n`. `route` carries the deep link with `{id}` interpolated. diff --git a/docs/push-notifications.md b/docs/push-notifications.md index cb57dbbc..f4c7f52c 100644 --- a/docs/push-notifications.md +++ b/docs/push-notifications.md @@ -130,18 +130,27 @@ An entry says how to render and where to go: | `route` | the deep link, with `{id}` interpolated | | `thread` | grouping key, so like-notifications collapse rather than stack | -**Plugins declare their own**, on the affordances pattern (`src/servers/affordances/publish.ts`): a -directory in the plugin repository, copied to a platform-managed store on install, removed on uninstall, -namespaced `//`. That mechanism already exists and is understood; a second one for +**Plugins declare their own**, on the affordances pattern (`src/servers/notifications/publish.ts`, which +deliberately mirrors `affordances/publish.ts`): a `notifications/` directory of YAML in the plugin +repository, copied to `capabilities/notifications///` on install and removed on +uninstall. + +The namespace is **derived from where the file lands, never from what it declares** — the same rule as +`mountPrefix`. A plugin therefore cannot claim `officer/core:` or another publisher's codes by writing +them into its own catalogue; it would have to install itself somewhere it cannot. This is pinned by test. That mechanism already exists and is understood; a second one for notifications would drift from it. The platform aggregates every installed plugin's catalogue plus its own into the response above, and `version` increments whenever that aggregate changes. ### Staying in sync, and the fallback Every push carries `mv`, the mapping version it was rendered against. The app compares it to its cached -version and **resyncs in the background when it is behind** — so the mapping normally catches up before an +version and **resyncs in the background when they differ** — so the mapping normally catches up before an unknown code can arrive. +`version` is a **content hash, not a counter**, so the test is `mv !== version` rather than `mv > version`. +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; a hash needs no storage and is right in all three. + When a push still outruns the sync, the fallback renders: > **Unknown notification** diff --git a/plugins/CLAUDE.md b/plugins/CLAUDE.md index 0c45c433..3d65139a 100644 --- a/plugins/CLAUDE.md +++ b/plugins/CLAUDE.md @@ -119,6 +119,37 @@ normalised into one. A plugin ships an `affordances/` directory in its repository; installing copies it in, uninstalling removes it — the same shape as `assets/` and `publishAssets`. +## Notifications — the same shape, for push + +A plugin can also declare what its push notifications MEAN. Same mechanism, different store: + +``` +capabilities/notifications/ + officerdev/music/ + catalogue.yaml +``` + +```yaml +nowPlaying: + title: 'Now playing' + route: 'officer://music/{id}' +``` + +That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content — +and the device renders the title and follows the route. The point is that **adding a notification is a +server change and never an app build**, because the mapping is synced to the device rather than compiled +into it. + +**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a +key in your own catalogue produces `/:officer/core:test`, not the core code — the same +rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`. + +An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your +namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's +catalogue is skipped without affecting anyone else's. + +See `docs/push-notifications.md`. + ### A plugin-contributed one, for reference This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the diff --git a/src/servers/notifications/catalogue.test.ts b/src/servers/notifications/catalogue.test.ts new file mode 100644 index 00000000..30029517 --- /dev/null +++ b/src/servers/notifications/catalogue.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadCatalogue, loadPublishedEntries } from './catalogue'; + +/** A throwaway `notifications/` store: `write('officerdev/music/a.yaml', '…')`. */ +function store(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'notif-')); + for (const [rel, body] of Object.entries(files)) { + const abs = join(root, rel); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, body); + } + return root; +} + +describe('notification catalogue', () => { + test('a code is namespaced by WHERE the file is, not by what it says', () => { + // The security property. A plugin declaring `officer/core:job` inside its own file must not be able + // to overwrite the platform's, because the namespace comes from its install path. + const root = store({ + 'officerdev/music/c.yaml': ['nowPlaying:', ' title: Now playing'].join('\n'), + }); + try { + const entries = loadPublishedEntries(root); + expect(Object.keys(entries)).toEqual(['officerdev/music:nowPlaying']); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('a plugin cannot claim a core code', () => { + // Even spelled out verbatim as a key, it lands under the plugin's own namespace... + const root = store({ + 'evil/plugin/c.yaml': ['"officer/core:test":', ' title: Pwned'].join('\n'), + }); + try { + const entries = loadPublishedEntries(root); + expect(entries['officer/core:test']).toBeUndefined(); + expect(entries['evil/plugin:officer/core:test']?.title).toBe('Pwned'); + + // ...and the real core entry is untouched in the assembled catalogue. + expect(loadCatalogue(root).entries['officer/core:test']?.body).toBe('Test notification'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('core entries survive a hand-edited store that collides with them', () => { + const root = store({ + 'officer/core/c.yaml': ['test:', ' title: Replaced'].join('\n'), + }); + try { + expect(loadCatalogue(root).entries['officer/core:test']?.title).toBe('Officer'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('one plugin shipping broken yaml does not take out the others', () => { + const root = store({ + 'officerdev/music/c.yaml': ['a:', ' title: Music'].join('\n'), + 'officerdev/photos/c.yaml': '{{{ not yaml at all', + 'officerdev/email/c.yaml': ['b:', ' title: Mail'].join('\n'), + }); + try { + const entries = loadPublishedEntries(root); + expect(entries['officerdev/music:a']?.title).toBe('Music'); + expect(entries['officerdev/email:b']?.title).toBe('Mail'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('unknown keys are dropped, and an entry with no title is skipped entirely', () => { + const root = store({ + 'officerdev/music/c.yaml': [ + 'ok:', + ' title: Fine', + ' sneaky: reaches-the-device', + 'noTitle:', + ' body: orphan', + ].join('\n'), + }); + try { + const entries = loadPublishedEntries(root); + expect(entries['officerdev/music:ok']).toEqual({ title: 'Fine', thread: 'officerdev/music' }); + expect(entries['officerdev/music:noTitle']).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('thread defaults to the namespace so like-notifications collapse', () => { + const root = store({ + 'officerdev/music/c.yaml': ['a:', ' title: A', 'b:', ' title: B', ' thread: own'].join('\n'), + }); + try { + const entries = loadPublishedEntries(root); + expect(entries['officerdev/music:a']?.thread).toBe('officerdev/music'); + expect(entries['officerdev/music:b']?.thread).toBe('own'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('a file too shallow to have a namespace is ignored', () => { + const root = store({ 'loose.yaml': ['x:', ' title: Nope'].join('\n') }); + try { + expect(loadPublishedEntries(root)).toEqual({}); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('a missing store is the ordinary fresh install, not an error', () => { + const catalogue = loadCatalogue(join(tmpdir(), 'definitely-not-here-notif')); + expect(catalogue.entries['officer/core:test']).toBeDefined(); + expect(catalogue.version).toBeGreaterThan(0); + }); + + test('version is a content hash: stable across reads, and it moves when content does', () => { + const a = store({ 'officerdev/music/c.yaml': ['a:', ' title: A'].join('\n') }); + const b = store({ 'officerdev/music/c.yaml': ['a:', ' title: B'].join('\n') }); + try { + // Stable — the client resyncs on `mv !== version`, so a version that drifted per call would make + // every push look like a mapping change. + expect(loadCatalogue(a).version).toBe(loadCatalogue(a).version); + expect(loadCatalogue(a).version).not.toBe(loadCatalogue(b).version); + } finally { + rmSync(a, { recursive: true, force: true }); + rmSync(b, { recursive: true, force: true }); + } + }); + + test('version does not depend on filesystem ordering', () => { + const a = store({ + 'officerdev/music/1.yaml': ['a:', ' title: A'].join('\n'), + 'officerdev/music/2.yaml': ['b:', ' title: B'].join('\n'), + }); + const b = store({ + 'officerdev/music/2.yaml': ['b:', ' title: B'].join('\n'), + 'officerdev/music/1.yaml': ['a:', ' title: A'].join('\n'), + }); + try { + expect(loadCatalogue(a).version).toBe(loadCatalogue(b).version); + } finally { + rmSync(a, { recursive: true, force: true }); + rmSync(b, { recursive: true, force: true }); + } + }); +}); diff --git a/src/servers/notifications/catalogue.ts b/src/servers/notifications/catalogue.ts new file mode 100644 index 00000000..72e86f57 --- /dev/null +++ b/src/servers/notifications/catalogue.ts @@ -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 `/:`, and the publisher and appName come from WHERE THE FILE IS — +// `notifications///*.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; +}; + +/** + * 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 = { + '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; + // 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 { + 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 = {}; + + for (const rel of files.sort()) { + // `//.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)) { + 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 }; +} diff --git a/src/servers/notifications/publish.ts b/src/servers/notifications/publish.ts new file mode 100644 index 00000000..e948b355 --- /dev/null +++ b/src/servers/notifications/publish.ts @@ -0,0 +1,58 @@ +import { cp, readdir, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { NOTIFICATIONS_DIR } from './catalogue'; +import type { DiscoveredPlugin } from '../plugins/manifest'; + +// Copying a plugin's notification catalogue into the store the sidecar reads. +// +// Deliberately the same shape as `publishAffordances` — a directory in the plugin repository, copied to a +// platform-managed place on install, removed on uninstall. Two mechanisms for "the plugin ships some +// files" would drift, and that one is already understood. +// +// The destination namespace is what makes codes safe: `//`, mirroring `mountPrefix`, +// so a plugin's codes are stamped with its provenance by WHERE IT LANDS rather than by anything it +// declares. See `catalogue.ts`. + +/** Where a plugin's catalogue lands. `officerdev/music/`, beside `officer/core`'s built-in entries. */ +export const notificationDirFor = (plugin: DiscoveredPlugin): string => + join(NOTIFICATIONS_DIR, plugin.manifest.publisher, plugin.appName); + +/** What the plugin ships, if anything. */ +export const notificationSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'notifications'); + +/** + * Publish a plugin's catalogue. Returns the number of files, or 0 when it ships none — the ordinary case. + * + * Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT remove + * files the plugin has since dropped; that is uninstall's job, and deleting on every install would make a + * half-finished copy destructive. + */ +export async function publishNotifications(plugin: DiscoveredPlugin): Promise { + const source = notificationSourceFor(plugin); + if (!existsSync(source)) return 0; + + const dest = notificationDirFor(plugin); + await cp(source, dest, { recursive: true, force: true }); + + try { + return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => /\.ya?ml$/.test(f)).length; + } catch { + return 0; + } +} + +/** + * Drop a plugin's catalogue. + * + * Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party + * plugin's, and taking it out because one was uninstalled would strip the rest. + * + * The codes then stop resolving, which is a defined state rather than a broken one — a device holding a + * queued push for a departed plugin renders the unknown-code fallback. See docs/push-notifications.md. + * + * Safe to call when nothing was ever published. + */ +export async function unpublishNotifications(plugin: DiscoveredPlugin): Promise { + await rm(notificationDirFor(plugin), { recursive: true, force: true }); +} diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index b2f996dd..d031f5a2 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -17,6 +17,7 @@ import { fetchPlugin } from './fetch'; import { revokeGrantsForPermissions } from 'officerdb'; import { publishAssets, unpublishAssets } from '../app-store/assets'; import { publishAffordances, unpublishAffordances } from '../affordances/publish'; +import { publishNotifications, unpublishNotifications } from '../notifications/publish'; import { refreshPluginMounts, snapshotPlugins } from './mount'; import { generatePluginSchemas, pushPluginSchema } from './schema'; import { declaredTables, dropTables, existingTables } from './tables'; @@ -361,6 +362,17 @@ export async function installPlugin( await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`); } + // Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs, + // and its pushes render the unknown-code fallback on the device rather than nothing at all. + try { + const published = await publishNotifications(plugin); + if (published) { + await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`); + } + } catch (err) { + await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`); + } + // The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only // offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin. if (plugin.web && forUserId !== undefined) { @@ -450,7 +462,10 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?: // being told about a surface that is no longer there — an affordance for an uninstalled plugin is // worse than none, because it reads as true. if (plugin) await unpublishAffordances(plugin).catch(() => {}); - await step(steps, onStep, 'tables and data: untouched (published assets and affordances removed)'); + // And its notification codes, which stop resolving rather than resolving to something stale. A device + // still holding a queued push renders the unknown-code fallback, which is a defined state. + if (plugin) await unpublishNotifications(plugin).catch(() => {}); + await step(steps, onStep, 'tables and data: untouched (published assets, affordances and notifications removed)'); return { ok: true, appName, steps }; } diff --git a/src/servers/sidecar/notify/index.ts b/src/servers/sidecar/notify/index.ts index a62845dd..cf10a8b6 100644 --- a/src/servers/sidecar/notify/index.ts +++ b/src/servers/sidecar/notify/index.ts @@ -2,6 +2,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { dispatch, configuredChannels } from './dispatch'; import { handleDeviceRoute } from './devices'; +import { loadCatalogue } from '../../notifications/catalogue'; import { resolveNotifyUser } from './resolve-user'; import { closeApnsSessions } from './apns'; import type { Notification, NotifyType } from './types'; @@ -22,6 +23,7 @@ import { API_URL } from '../../officer-url.mjs'; // POST /_officer/devices register/refresh a device (idempotent; call every launch) // DELETE /_officer/devices/:token deregister, on sign-out // GET /_officer/devices the caller's registered devices +// GET /mapping the code-to-text catalogue the device caches // // The notify body is deliberately narrow: a category and an id, never content. See ./text.ts for why // the visible string is composed here rather than sent by the producer. @@ -50,6 +52,18 @@ const server = Bun.serve({ return Response.json({ ok: true, channels: configuredChannels() }); } + // The code-to-text mapping the device caches. Read from disk on every request rather than memoised: + // installing a plugin changes it, and a notifier that serves a stale catalogue until someone restarts + // it would present as "the new plugin's notifications don't work" — the hardest kind of bug to place. + // It is a few kilobytes off local disk against a request the app makes about once per launch. + if (url.pathname === '/mapping' && req.method === 'GET') { + const catalogue = loadCatalogue(); + return Response.json(catalogue, { + // The client decides by `version`, so a cached copy would defeat the sync it exists to drive. + headers: { 'Cache-Control': 'no-store' }, + }); + } + if (url.pathname === '/_officer/notify' && req.method === 'POST') { let body: Partial; try {