notify one named account — and unshadow /broadcast, which never worked

"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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 12:15:16 +00:00
co-authored by Claude Opus 5
parent e6da5d8c77
commit e867fe72af
2 changed files with 70 additions and 3 deletions
+69 -2
View File
@@ -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<BroadcastBody>().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<UserBody>().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;
+1 -1
View File
@@ -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