secret store holds foreign secrets, and notify reads from it

putSecret/getSecret, `bun run secret:set`, and notify taking its credentials
from the store with env as the fallback. this was described two messages ago
as a later thing; it is about fifteen lines and it was blocking the honest
answer to "where does the .p8 go", so it is done.

getKey was the wrong door for anything supplied from outside — it MINTS a
random 32-byte value when the purpose is absent, which for a foreign
credential means silently authenticating to apple with a fake. getSecret
returns null instead, which is what lets the env fallback work.

putSecret retires the previous row rather than deleting it: a mistyped paste
over a working key should be recoverable, and retiredKeys already exists for
that shape. one transaction, because the partial unique index means the retire
has to land before the insert.

the CLI reads stdin and has deliberately no --value flag: a secret in argv is
in shell history, in ps output, and in any audit log that records command
lines. offering the flag would make the convenient path the leaky one. it
prints a length and the last four characters, never the value.

also `bun run notify:test`, which fires officer/core:test at a registered
device. the sidecar's port is ephemeral, so it now writes its address to
data/notify-server.url on boot and removes it on clean shutdown; the CLI
treats an unreachable address as "not running" rather than trusting the file,
because a crash leaves one behind.

the script says what actually happened, because the day-waster here is that a
send with nothing configured returns success and delivers nothing — it refuses
up front when channels is empty, and calls out sent:0/failed:0 as "no device
registered" rather than letting it read as success.

verified end to end with a p-256 key generated locally and then deleted from
the store: zero APNS vars in .env, sidecar reported channels: apns, and the
send reached the channel. store is back to jwt and offscale only.
This commit is contained in:
2026-08-19 19:57:51 +00:00
parent 2cefa3d378
commit 16369f3570
10 changed files with 288 additions and 27 deletions
+108
View File
@@ -0,0 +1,108 @@
#!/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
*
* 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.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../src/servers/data-path';
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)}`);
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),
});
const out = (await res.json()) as { ok?: boolean; error?: string; results?: Array<Record<string, unknown>> };
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`);
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bun
/**
* Put a secret the platform did not generate into the secret store.
*
* bun run secret:set apns-p8 < AuthKey_ABCD123456.p8
* pbpaste | bun run secret:set fcm-service-account
*
* ── Why stdin, and no value argument ──
*
* A secret passed as `argv` is in the shell history, in `ps` output for the life of the process, and in
* any audit log that records command lines. Stdin is none of those. There is deliberately no `--value`
* flag: offering one would mean the convenient path is the leaky one.
*
* Multi-line input is kept verbatim, which is what a PEM needs — no escaping, unlike `.env`.
*/
import { putSecret, getSecret, secretStorePath } from '../src/databases/officer_db/src/secret-store';
const purpose = process.argv[2];
if (!purpose || purpose.startsWith('-')) {
console.error('usage: bun run secret:set <purpose> < file');
console.error(' cat AuthKey_ABCD123456.p8 | bun run secret:set apns-p8');
process.exit(2);
}
const value = (await Bun.stdin.text()).trim();
if (!value) {
console.error(`[secret:set] nothing on stdin — refusing to store an empty secret for '${purpose}'`);
console.error(' (did you mean `bun run secret:set <purpose> < file`?)');
process.exit(1);
}
const previous = putSecret(purpose, value);
// The value itself is never printed, here or anywhere. Enough to confirm the right thing landed:
// the length and last four characters identify a paste without reproducing it.
const fingerprint = `${value.length} chars, ending …${value.slice(-4)}`;
if (previous === null) {
console.log(`[secret:set] '${purpose}' stored — ${fingerprint}`);
} else if (previous === value) {
console.log(`[secret:set] '${purpose}' unchanged — ${fingerprint}`);
} else {
console.log(`[secret:set] '${purpose}' REPLACED — ${fingerprint}`);
console.log(` the previous value is retired, not deleted (retiredKeys('${purpose}') has it)`);
}
console.log(`[secret:set] store: ${secretStorePath()}`);
console.log(`[secret:set] read back: ${getSecret(purpose) === value ? 'ok' : 'MISMATCH — do not restart anything'}`);