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 } } }
```
_(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`.
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.
+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 |
| `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 `<publisher>/<appName>/`. 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/<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
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**
+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
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
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 { 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 };
}
+14
View File
@@ -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<Notification>;
try {