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:
2026-08-19 19:07:49 +00:00
parent 98960d2827
commit 55d6eba511
8 changed files with 470 additions and 7 deletions
+8 -2
View File
@@ -98,13 +98,19 @@ credential → APNs → device → banner.
GET /api/notify/mapping → { version: 47, entries: { "<code>": { title, body?, target, route, thread } } } GET /api/notify/mapping → { version: 47, entries: { "<code>": { 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 `<publisher>/<appName>:<code>`, e.g. `officerdev/music:12.3`. Codes are namespaced `<publisher>/<appName>:<code>`, e.g. `officerdev/music:12.3`.
The app **fetches this and caches it into a shared App Group container** — shared because the notification 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 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 Entries are templates: `title: "{n} new emails"` rendered against the push's `n`. `route` carries the deep
link with `{id}` interpolated. link with `{id}` interpolated.
+13 -4
View File
@@ -130,18 +130,27 @@ An entry says how to render and where to go:
| `route` | the deep link, with `{id}` interpolated | | `route` | the deep link, with `{id}` interpolated |
| `thread` | grouping key, so like-notifications collapse rather than stack | | `thread` | grouping key, so like-notifications collapse rather than stack |
**Plugins declare their own**, on the affordances pattern (`src/servers/affordances/publish.ts`): a **Plugins declare their own**, on the affordances pattern (`src/servers/notifications/publish.ts`, which
directory in the plugin repository, copied to a platform-managed store on install, removed on uninstall, deliberately mirrors `affordances/publish.ts`): a `notifications/` directory of YAML in the plugin
namespaced `<publisher>/<appName>/`. That mechanism already exists and is understood; a second one for repository, copied to `capabilities/notifications/<publisher>/<appName>/` 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 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. into the response above, and `version` increments whenever that aggregate changes.
### Staying in sync, and the fallback ### Staying in sync, and the fallback
Every push carries `mv`, the mapping version it was rendered against. The app compares it to its cached 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. 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: When a push still outruns the sync, the fallback renders:
> **Unknown notification** > **Unknown notification**
+31
View File
@@ -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 `affordances/` directory in its repository; installing copies it in, uninstalling removes it — the same
shape as `assets/` and `publishAssets`. 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 `<you>/<yourplugin>: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 ### 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 This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
+153
View File
@@ -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, string>): 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 });
}
});
});
+177
View File
@@ -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 };
}
+58
View File
@@ -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: `<publisher>/<appName>/`, 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<number> {
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<void> {
await rm(notificationDirFor(plugin), { recursive: true, force: true });
}
+16 -1
View File
@@ -17,6 +17,7 @@ import { fetchPlugin } from './fetch';
import { revokeGrantsForPermissions } from 'officerdb'; import { revokeGrantsForPermissions } from 'officerdb';
import { publishAssets, unpublishAssets } from '../app-store/assets'; import { publishAssets, unpublishAssets } from '../app-store/assets';
import { publishAffordances, unpublishAffordances } from '../affordances/publish'; import { publishAffordances, unpublishAffordances } from '../affordances/publish';
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
import { refreshPluginMounts, snapshotPlugins } from './mount'; import { refreshPluginMounts, snapshotPlugins } from './mount';
import { generatePluginSchemas, pushPluginSchema } from './schema'; import { generatePluginSchemas, pushPluginSchema } from './schema';
import { declaredTables, dropTables, existingTables } from './tables'; 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}`); 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 // 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. // offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
if (plugin.web && forUserId !== undefined) { 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 // 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. // worse than none, because it reads as true.
if (plugin) await unpublishAffordances(plugin).catch(() => {}); 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 }; return { ok: true, appName, steps };
} }
+14
View File
@@ -2,6 +2,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect'; import { createSidecarConnector } from '../connect';
import { dispatch, configuredChannels } from './dispatch'; import { dispatch, configuredChannels } from './dispatch';
import { handleDeviceRoute } from './devices'; import { handleDeviceRoute } from './devices';
import { loadCatalogue } from '../../notifications/catalogue';
import { resolveNotifyUser } from './resolve-user'; import { resolveNotifyUser } from './resolve-user';
import { closeApnsSessions } from './apns'; import { closeApnsSessions } from './apns';
import type { Notification, NotifyType } from './types'; 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) // POST /_officer/devices register/refresh a device (idempotent; call every launch)
// DELETE /_officer/devices/:token deregister, on sign-out // DELETE /_officer/devices/:token deregister, on sign-out
// GET /_officer/devices the caller's registered devices // 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 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. // 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() }); 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') { if (url.pathname === '/_officer/notify' && req.method === 'POST') {
let body: Partial<Notification>; let body: Partial<Notification>;
try { try {