diff --git a/src/servers/api/notify/router.ts b/src/servers/api/notify/router.ts index 0d7ea1f2..33eb2505 100644 --- a/src/servers/api/notify/router.ts +++ b/src/servers/api/notify/router.ts @@ -1,4 +1,5 @@ import { createSidecarProxy } from '../../sidecar/create-proxy'; +import { createRouter } from '../../create-router'; import { getUsers } from 'officerdb'; import * as errors from '../../custom-errors'; @@ -9,6 +10,23 @@ import * as errors from '../../custom-errors'; 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. @@ -19,7 +37,7 @@ const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' }); Teams will narrow this later. Today the choice is everyone or one person, and everyone is owner-only. */ -proxy.router.post('/broadcast', async (ctx) => { +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(); @@ -54,7 +72,56 @@ proxy.router.post('/broadcast', async (ctx) => { return ctx.json({ sent: results.filter((r) => r.ok).length, total: results.length, results }); }); -export const notifyRouter = proxy.router; +/* + 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; diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index cff6ab5e..7494b88c 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -216,7 +216,7 @@ const CORE_REGISTRY: Permission[] = [ made `core` right. Sending to EVERY account is administration, so it needs the owner even though the permission around it is not deniable. */ - ownerOnly: ['/broadcast'], + ownerOnly: ['/broadcast', '/user'], }, // `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale, // minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone