From 01cecdad606302176c7fbfe97b600b6e07cb1b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 09:00:56 +0000 Subject: [PATCH] notify:test goes through the platform again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit it POSTed to the sidecar directly on loopback, and yesterday the sidecar began requiring proof that the platform is the caller — so the CLI 403'd. my regression: i noted it in that commit and left it failing. it mints a key for the one send and revokes it in a finally, whatever happens. that is legitimate here and nowhere else: the script runs as the owner, on the owner's machine, with the database already open in front of it. anything that cannot do that has no business sending either. the key is named "notify:test (temporary)" so a leftover from a killed run is obvious in the key list rather than looking like something a person made. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- scripts/notify-test.ts | 55 +++++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/scripts/notify-test.ts b/scripts/notify-test.ts index d57309b3..265e0462 100644 --- a/scripts/notify-test.ts +++ b/scripts/notify-test.ts @@ -6,13 +6,25 @@ * bun run notify:test --user 2 # someone else * bun run notify:test --code officer/core:job --id 412 * - * Talks to the sidecar over loopback, which is how every other producer reaches it — so this exercises - * the real path rather than a special one. It reports what the channels actually did, because the thing - * that wastes a day here is that a send with nothing configured returns success and delivers nothing. + * Goes through the PLATFORM, with a key it mints and revokes for the one send — the same door an app or + * a commander uses, so this exercises the real path rather than a special one. It reports what the + * channels actually did, because the thing that wastes a day here is that a send with nothing configured + * returns success and delivers nothing. + * + * It used to POST to the sidecar directly on loopback. That stopped working on 2026-08-25, when the + * sidecar began requiring proof that the platform is the caller — anything on the machine could otherwise + * notify any user, which on a box running agents is anything with a shell. The secret lives only in the + * server's memory, so a CLI cannot present it and should not be able to. + * + * Minting a key is legitimate HERE and nowhere else: this runs as the owner, on the owner's machine, with + * the database open in front of it. Anything that cannot already do that has no business sending either. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { DATA_PATH } from '../src/servers/data-path'; +import { mintApiKey } from '../src/servers/auth-token'; +import { createApiKey, revokeApiKeysByName } from 'officerdb'; +import { PORT, OFFICER_API_URL } from '../src/servers/officer-url.mjs'; const arg = (name: string): string | undefined => { const i = process.argv.indexOf(`--${name}`); @@ -69,12 +81,37 @@ const body = { console.log(`[notify:test] sending ${JSON.stringify(body)}`); -const res = await fetch(`${base}/_officer/notify`, { - method: 'POST', - headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(20_000), -}); +// A key for this one send, revoked immediately afterwards whatever happens. Named so a leftover from a +// killed run is obvious in the key list rather than looking like something a person made. +const KEY_NAME = 'notify:test (temporary)'; +await revokeApiKeysByName(userId, KEY_NAME).catch(() => {}); +const { secret, keyHash, prefix } = mintApiKey(); +await createApiKey({ userId, name: KEY_NAME, keyHash, prefix }); + +const publicHost = (() => { + try { + return process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL.replace(/^"|"$/g, '')).host : undefined; + } catch { + return undefined; + } +})(); + +let res: Response; +try { + res = await fetch(`${OFFICER_API_URL}/api/notify/_officer/notify`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + Authorization: `Bearer ${secret}`, + // Origin validation matches Host against PUBLIC_URL, so a bare loopback request is refused. + Host: publicHost ?? `127.0.0.1:${PORT}`, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(20_000), + }); +} finally { + await revokeApiKeysByName(userId, KEY_NAME).catch(() => {}); +} const out = (await res.json()) as { ok?: boolean; error?: string; results?: Array> };