notify: the sidecar shell, with Discord as the first channel

Step 2 of docs/push-notifications.md. One real channel working end to end before any Apple
or Google credential exists, so the pipe is proven before the hard part.

officer-notify is a PM2 peer with its own loopback listener, announced as notify:server and
proxied at /api/notify. It is a sidecar rather than platform code because the producers are
spread across sidecars — the queue, email, the agent — and a platform-owned notifier would
force every one of them to call back into the platform. That is the inversion just removed
from email; this avoids recreating it.

Channels sit behind one interface (types.ts) so APNs and FCM slot in beside Discord rather
than replacing anything. Each is awaited with its own error boundary and the dispatcher
always resolves: a job that finished has finished whether or not a banner appeared, so a
channel must never be able to break its producer.

text.ts is where the doorbell rule is actually enforced. APNs and FCM both need a title to
render a banner, so "send nothing" was never available — what we control is that the string
is composed HERE from the category alone. A producer sends { type: 'mail', count: 3 } and
the wire carries "3 new emails". It cannot carry a subject line because there is nowhere to
put one.

Device registration lives behind X-Officer-User, trusted because the listener binds loopback.
Platform and environment are validated rather than defaulted: an iOS token from a debug build
fails against production APNs with a silent BadDeviceToken, so a wrong value is a device that
never receives anything and never says why. GET /_officer/devices returns only the last 8
characters of a token — enough to identify a row, not enough to push to it.

Verified end to end against a fake webhook: /_health reports configured channels, a test
notification arrives as {"content":"Officer"}, { type: 'mail', count: 3 } arrives as
{"content":"3 new emails"}, and every validation path returns its own error.

Deletes src/servers/notify/discord.ts, which this supersedes and which had no other callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 21:36:17 +00:00
co-authored by Claude Opus 5
parent 15f262539f
commit 3f22a808d6
12 changed files with 380 additions and 34 deletions
+37
View File
@@ -0,0 +1,37 @@
import type { Channel, DeliveryResult, Notification } from './types';
import { discordChannel } from './discord';
// Fan a notification out to every configured channel.
//
// Channels are independent and none of them may break a producer: a job that finished has finished
// whether or not a banner appeared. So every channel is awaited with its own error boundary, and the
// dispatcher always resolves.
const channels: Channel[] = [discordChannel];
/** Registered here rather than imported at the top so channels can be added without touching producers. */
export function registerChannel(channel: Channel): void {
channels.push(channel);
}
export function configuredChannels(): string[] {
return channels.filter((c) => c.isConfigured()).map((c) => c.name);
}
export async function dispatch(n: Notification): Promise<DeliveryResult[]> {
const active = channels.filter((c) => c.isConfigured());
if (active.length === 0) return [];
return Promise.all(
active.map(async (c) => {
try {
return await c.send(n);
} catch (err) {
// A channel that throws instead of returning is a bug in that channel, not a reason to fail
// the notification or the producer behind it.
console.error(`[notify] channel ${c.name} threw:`, err instanceof Error ? err.message : err);
return { channel: c.name, sent: 0, failed: 1 };
}
}),
);
}