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
+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 };
}