Files
platform/src/servers/notifications/publish.ts
T
pastilhas 55d6eba511 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.
2026-08-19 19:07:49 +00:00

59 lines
2.6 KiB
TypeScript

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