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:
@@ -0,0 +1,80 @@
|
||||
import { upsertPushDevice, getPushDevices, deletePushDevice } from 'officerdb';
|
||||
|
||||
// Device registration, reached through the platform's authenticated proxy.
|
||||
//
|
||||
// The owner comes from X-Officer-User, injected by createSidecarProxy and trusted because this server
|
||||
// binds loopback only. Nothing else can reach it.
|
||||
|
||||
const PLATFORMS = new Set(['ios', 'android']);
|
||||
const ENVIRONMENTS = new Set(['production', 'sandbox']);
|
||||
|
||||
type RegisterBody = {
|
||||
token?: string;
|
||||
platform?: string;
|
||||
environment?: string;
|
||||
bundleId?: string;
|
||||
appSlug?: string;
|
||||
};
|
||||
|
||||
function ownerId(req: Request): number | null {
|
||||
const id = Number(req.headers.get('X-Officer-User'));
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
export async function handleDeviceRoute(req: Request, url: URL): Promise<Response | null> {
|
||||
const userId = ownerId(req);
|
||||
if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
||||
|
||||
if (url.pathname === '/_officer/devices' && req.method === 'GET') {
|
||||
const devices = await getPushDevices(userId, url.searchParams.get('appSlug') ?? undefined);
|
||||
// Never echo tokens back in full: they are the address of a device, and this response goes to a
|
||||
// client. Enough to identify a row, not enough to push to it.
|
||||
return Response.json({
|
||||
devices: devices.map((d) => ({
|
||||
id: d.id,
|
||||
platform: d.platform,
|
||||
environment: d.environment,
|
||||
bundleId: d.bundleId,
|
||||
appSlug: d.appSlug,
|
||||
tokenSuffix: d.token.slice(-8),
|
||||
lastSeenAt: d.lastSeenAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/_officer/devices' && req.method === 'POST') {
|
||||
let body: RegisterBody;
|
||||
try {
|
||||
body = (await req.json()) as RegisterBody;
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid json' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { token, platform, bundleId, appSlug } = body;
|
||||
const environment = body.environment ?? 'production';
|
||||
|
||||
if (!token || !platform || !bundleId || !appSlug) {
|
||||
return Response.json({ error: 'token, platform, bundleId and appSlug are required' }, { status: 400 });
|
||||
}
|
||||
if (!PLATFORMS.has(platform)) {
|
||||
return Response.json({ error: "platform must be 'ios' or 'android'" }, { status: 400 });
|
||||
}
|
||||
// Rejected rather than defaulted: an iOS token from a debug build fails against production APNs with
|
||||
// a silent BadDeviceToken, so a wrong value here is a device that never receives anything and never
|
||||
// reports why.
|
||||
if (!ENVIRONMENTS.has(environment)) {
|
||||
return Response.json({ error: "environment must be 'production' or 'sandbox'" }, { status: 400 });
|
||||
}
|
||||
|
||||
const device = await upsertPushDevice({ userId, token, platform, environment, bundleId, appSlug });
|
||||
return Response.json({ ok: true, id: device.id });
|
||||
}
|
||||
|
||||
const match = url.pathname.match(/^\/_officer\/devices\/(.+)$/);
|
||||
if (match && req.method === 'DELETE') {
|
||||
await deletePushDevice(decodeURIComponent(match[1]!));
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user