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> };