#!/usr/bin/env bun /** * Fire one notification at a registered device, to prove the pipe. * * bun run notify:test # officer/core:test to user 1 * bun run notify:test --user 2 # someone else * bun run notify:test --code officer/core:job --id 412 * * 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}`); return i === -1 ? undefined : process.argv[i + 1]; }; const code = arg('code') ?? 'officer/core:test'; const userId = Number(arg('user') ?? 1); const id = arg('id'); const n = arg('n'); const addressFile = join(DATA_PATH, 'notify-server.url'); let base: string; try { base = readFileSync(addressFile, 'utf8').trim(); } catch { console.error(`[notify:test] no address at ${addressFile}`); console.error(' officer-notify is not running — `pm2 start ecosystem.config.cjs --only officer-notify`'); process.exit(1); } // The file is only a hint: a crash leaves it behind, so prove the listener is there before believing it. let health: { ok: boolean; channels: string[] }; try { const res = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(5000) }); health = (await res.json()) as typeof health; } catch { console.error(`[notify:test] ${base} is not answering — stale address file, or the sidecar is down`); process.exit(1); } console.log(`[notify:test] sidecar ${base}`); console.log(`[notify:test] channels configured: ${health.channels.length ? health.channels.join(', ') : 'NONE'}`); if (health.channels.length === 0) { // Said before sending rather than after, because the send below WILL return ok and deliver nothing. console.error(''); console.error('[notify:test] nothing is configured, so this cannot reach a device.'); console.error(' APNs needs apns-p8, apns-key-id and apns-team-id:'); console.error(' cat AuthKey_XXXXXXXXXX.p8 | bun run secret:set apns-p8'); console.error(' echo -n XXXXXXXXXX | bun run secret:set apns-key-id'); console.error(' echo -n RGHC4NJC77 | bun run secret:set apns-team-id'); console.error(' then: pm2 restart officer-notify'); process.exit(1); } const body = { code, userId, ...(id === undefined ? {} : { id }), ...(n === undefined ? {} : { n: Number(n) }), }; console.log(`[notify:test] sending ${JSON.stringify(body)}`); // 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> }; if (!res.ok || out.error) { console.error(`[notify:test] refused (${res.status}): ${out.error ?? 'unknown'}`); process.exit(1); } const results = out.results ?? []; if (results.length === 0) { console.error('[notify:test] no channel took it — nothing configured, or no device registered for this user'); process.exit(1); } let delivered = 0; for (const r of results) { const sent = Number(r.sent ?? 0); delivered += sent; const dead = Array.isArray(r.dead) && r.dead.length ? `, ${r.dead.length} dead token(s) deleted` : ''; console.log(`[notify:test] ${r.channel}: sent ${sent}, failed ${r.failed}${dead}`); } // `sent: 0, failed: 0` is the quiet one: the channel is configured but the user has no device of that // platform registered. Worth naming, because it looks like success. if (delivered === 0) { console.error('[notify:test] nothing was delivered — is a device registered for this user?'); console.error(' check: GET /api/notify/_officer/devices'); process.exit(1); } console.log(`[notify:test] delivered to ${delivered} device(s) — look at your phone`);