From e867fe72afb38e4edd1149e4b87b673aafc0b103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 27 Aug 2026 12:15:16 +0000 Subject: [PATCH] =?UTF-8?q?notify=20one=20named=20account=20=E2=80=94=20an?= =?UTF-8?q?d=20unshadow=20/broadcast,=20which=20never=20worked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "can you send a notification to George" had no answer. an agent key reaches its own holder and nothing else, and the only route that spoke about somebody else sent to EVERYONE. so the choice was the whole household or nobody. POST /api/notify/user, ownerOnly, same authority and same reasoning as /broadcast: everything else under this permission acts on the account asking, and reaching another person's phone is administration however few phones it is. it returns the target's email so the caller can see who was reached rather than trusting an id they typed, and 404s an unknown account rather than letting the sidecar's silence read as delivery. AND THE PART THAT MATTERS MORE. /broadcast has answered 404 since the day it landed. `createSidecarProxy` mounts `router.all('/*')` when it is CREATED, and hono matches in registration order — so every route added to the proxy's own router afterwards was forwarded to the sidecar, which has never heard of it. three days, on a route whose entire purpose was to stop people POSTing to the sidecar's ephemeral port by hand. a shadowed route does not fail like a missing one; it fails like a sidecar that has not implemented it, so the 404 reads as somebody else's problem. caldav's router learned this on 2026-08-21 and wrote it down. this file now does the same thing in the same words: the platform's own routes first, the catch-all proxy mounted LAST. verified: /broadcast now answers 400 to an empty body where it answered 404 before, /user delivers and reports the address, /_officer/devices still forwards. found while trying to reach around the notify sidecar's producer secret to do this by hand — which is exactly what that secret was added to stop, and the comment above it says so. it stopped me. the route is the way. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/notify/router.ts | 71 ++++++++++++++++++++++++++++- src/servers/permissions/registry.ts | 2 +- 2 files changed, 70 insertions(+), 3 deletions(-) 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