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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user