import { createSidecarProxy } from '../../sidecar/create-proxy'; import { getUsers } from 'officerdb'; import * as errors from '../../custom-errors'; // /api/notify/* — auth, then forward to officer-notify. The apps register their push tokens through here // and nothing else uses it; producers inside the tailnet POST to the sidecar over loopback directly. // // No notification knowledge lives in the platform: not a device token, not an APNs key, not a payload. const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' }); /* POST /api/notify/broadcast — one message to every account, owner only. It exists because the alternative was what actually happened on 2026-08-24: to reach four accounts at once, somebody found the sidecar's ephemeral port and POSTed to it directly, once per user, with no authentication at all. That worked, which was the problem — the same door was open to anything else on the machine, including an agent. Teams will narrow this later. Today the choice is everyone or one person, and everyone is owner-only. */ proxy.router.post('/broadcast', async (ctx) => { type BroadcastBody = { title?: string; body?: string; code?: string }; const body: BroadcastBody = await ctx.req.json().catch(() => ({}) as BroadcastBody); const title = body.title?.trim(); const message = body.body?.trim(); if (!title || !message) throw errors.BAD_REQUEST('title and body are required'); const base = proxy.getHttpUrl(); const producer = notifyProducerHeaders(); if (!base || !producer) throw errors.createError(503, 'notify sidecar not available'); // Sequential, and deliberately so: this is a handful of accounts on a personal server, and one slow // APNs call must not turn into a hundred concurrent ones against the same host. const results: { userId: number; ok: boolean }[] = []; for (const user of await getUsers()) { try { const res = await fetch(`${base}/_officer/notify`, { method: 'POST', headers: { 'content-type': 'application/json', ...producer }, // `userId` in the BODY, with no `X-Officer-User`: this is the platform speaking about someone // else, which is exactly the distinction `sidecar/notify/resolve-user.ts` draws. body: JSON.stringify({ code: body.code ?? 'officer/core:message', userId: user.id, title, body: message }), signal: AbortSignal.timeout(15_000), }); results.push({ userId: user.id, ok: res.ok }); } catch { // One account's failure must not stop the rest — a broadcast that gives up halfway is worse than // one that reports which half landed. results.push({ userId: user.id, ok: false }); } } return ctx.json({ sent: results.filter((r) => r.ok).length, total: results.length, results }); }); export const notifyRouter = proxy.router; /** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ export const getNotifyServerUrl = proxy.getHttpUrl; /** * The header an in-process producer must send when it calls the sidecar directly. * * Producers inside the platform — sendoff, chat — bypass this proxy and POST over loopback, because they * are notifying somebody other than the caller and the proxy pins the target to whoever is signed in. * That path used to accept anyone who could reach the port, which on a machine running commanders is * anyone with a shell. * * Null before the sidecar registers, in which case the producer is right to give up rather than send * unauthenticated. */ export function notifyProducerHeaders(): Record | null { const secret = proxy.getProducerSecret(); return secret ? { 'X-Officer-Producer': secret } : null; }