Files
platform/src/servers/sidecar/notify/text.ts
T
pastilhasandClaude Opus 5 3f22a808d6 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>
2026-07-31 21:36:17 +00:00

33 lines
1.3 KiB
TypeScript

import type { Notification } from './types';
// The visible text, composed HERE from the category — never sent by a producer and never carrying
// content. This is what stops "Re: your invoice from Acme" reaching Apple or Google.
//
// It has to exist somewhere: APNs and FCM both need a title/body to render a banner, so "send nothing"
// is not an option. What we control is that the string is generic and derived from `type` alone, so the
// most either service learns is the category and the timing. The app can render its own copy from the
// same data payload if it wants better wording; this is the fallback that goes over the wire.
export function renderTitle(n: Notification): string {
switch (n.type) {
case 'job':
return n.ok === false ? 'Job failed' : 'Job finished';
case 'mail':
return n.count && n.count > 1 ? `${n.count} new emails` : 'New email';
case 'agent':
return 'Agent finished';
case 'download':
return 'Download complete';
case 'test':
return 'Officer';
}
}
/**
* Deliberately empty for most categories. A banner with a title and no body is the least we can send
* while still being a usable notification, and every word added is a word Apple and Google get to read.
*/
export function renderBody(n: Notification): string {
return n.type === 'test' ? 'Test notification' : '';
}