import { createSidecarProxy } from '../../sidecar/create-proxy'; import { createRouter } from '../../create-router'; 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' }); /* ORDER IS LOAD-BEARING, and getting it wrong is silent. `createSidecarProxy` mounts `router.all('/*')` the moment it is created — a catch-all. Hono matches in registration order, so anything added to the proxy's OWN router afterwards is shadowed: the request is forwarded to the sidecar, which has never heard of it and answers a plain `not found`. That is what happened to `/broadcast`. It was written on 2026-08-24 to replace somebody POSTing to the sidecar's ephemeral port by hand, it typechecked, it was reviewed, and it answered 404 from the day it landed — for three days, to a route whose whole purpose was to stop people going around the platform. A shadowed route does not fail like a missing one; it fails like a sidecar that does not implement it. So: the platform's own routes on this router first, the catch-all proxy mounted LAST. Same rule, and the same wording, as `plugins/caldav/api/router.ts`, which learned it the same way. */ export const notifyRouter = createRouter(); /* 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. */ notifyRouter.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 }); }); /* POST /api/notify/user — one message to ONE named account, owner only. The gap this fills, in the words of the route above: the choice was everyone or yourself. Notifying a SINGLE OTHER PERSON — the thing an owner actually wants when somebody's device finishes registering, or when one member needs telling and the other five do not — had no door, so the honest answer to "notify him" was a broadcast to the whole household or nothing. Same authority as `/broadcast` and for the same reason: everything else under this permission acts on the account asking, and reaching somebody else's phone is administration however few phones it is. Deliberately NOT reachable by an agent's own reasoning about who deserves a notification — it is `ownerOnly`, so a member's session key cannot call it at all, and the owner's carries the owner's authority because it IS the owner. The producer secret stays where it is; this route is the platform speaking, which is the only thing that ever holds it. */ notifyRouter.post('/user', async (ctx) => { type UserBody = { userId?: number; title?: string; body?: string; code?: string }; const body: UserBody = await ctx.req.json().catch(() => ({}) as UserBody); const title = body.title?.trim(); const message = body.body?.trim(); const userId = Number(body.userId); if (!Number.isInteger(userId) || userId <= 0) throw errors.BAD_REQUEST('userId is required'); if (!title || !message) throw errors.BAD_REQUEST('title and body are required'); // Refuse a stranger rather than hand the sidecar an id that resolves to nobody: a 404 here says the // account does not exist, where the sidecar's silence would read as a delivered notification. const target = (await getUsers()).find((u) => u.id === userId); if (!target) throw errors.createError(404, `no account with id ${userId}`); const base = proxy.getHttpUrl(); const producer = notifyProducerHeaders(); if (!base || !producer) throw errors.createError(503, 'notify sidecar not available'); const res = await fetch(`${base}/_officer/notify`, { method: 'POST', headers: { 'content-type': 'application/json', ...producer }, // `userId` in the BODY and no `X-Officer-User` — the platform speaking about somebody else, which is // the distinction `sidecar/notify/resolve-user.ts` draws. body: JSON.stringify({ code: body.code ?? 'officer/core:message', userId, title, body: message }), signal: AbortSignal.timeout(15_000), }); if (!res.ok) throw errors.createError(502, `notify sidecar answered ${res.status}`); // The email, so the caller can see WHO was reached rather than trusting an id they typed. return ctx.json({ sent: 1, userId, email: target.email }); }); // LAST. Everything above is the platform's; everything else belongs to the sidecar. notifyRouter.route('/', 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; }