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