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