/**
* `celilo subscribers serve --port
--secret ` — long-running
* HTTP receiver for build-bus webhooks.
*
* Verifies signed envelopes against the configured shared secret
* and emits a `build-bus.publish` event onto the local SQLite bus
* for every verified delivery. Phase 4's hook dispatcher (also
* started by this command when --dispatch is set) picks up those
* local events and runs module-side `on_upstream_publish` hooks.
*
* Designed to run under systemd / launchd; blocks indefinitely
* until killed.
*/
import { startHookDispatcher } from '../../services/build-bus/hook-dispatcher';
import { startReceiverServer } from '../../services/build-bus/receiver-server';
import type { CommandResult } from '../types';
/**
* Resolve the receiver's shared HMAC secret. The `--secret` flag wins;
* otherwise `CELILO_BUS_SECRET` from the environment — which is what
* the supervisor unit (celilo-build-bus-receiver.service / the launchd
* plist) sets, and what the doctor's build-bus-publishing remediation
* has always told operators to export. Returns null when neither has a
* usable value; the caller turns that into the flag-required error.
*/
export function resolveServeSecret(
flags: Record,
env: Record = process.env,
): string | null {
if (typeof flags.secret === 'string' && flags.secret) return flags.secret;
const fromEnv = env.CELILO_BUS_SECRET;
if (typeof fromEnv === 'string' && fromEnv) return fromEnv;
return null;
}
export async function handleSubscribersServe(
_args: string[],
flags: Record,
): Promise {
const port = typeof flags.port === 'string' ? Number.parseInt(flags.port, 10) : 8123;
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
return {
success: false,
error: `Invalid --port value: ${flags.port}. Must be 1–65535.`,
};
}
const secret = resolveServeSecret(flags);
if (!secret) {
return {
success: false,
error: '--secret is required (or export CELILO_BUS_SECRET).',
};
}
// When --dispatch is set (default), this process also runs the
// hook dispatcher on the same bus. Operators can pass --no-dispatch
// to run the receiver alone (e.g. when the dispatcher already runs
// in another process).
const wantsDispatch = flags['no-dispatch'] !== true;
const dispatcher = wantsDispatch ? await startHookDispatcher() : null;
const server = startReceiverServer({
port,
secret,
onEvent: dispatcher
? async (envelope) => {
// handleEvent returns UpstreamHookResult[]; the receiver's
// onEvent contract is void | Promise. Discard the
// array — the dispatcher logs its own outcomes.
await dispatcher.handleEvent(envelope.event);
}
: undefined,
});
console.log(`✓ build-bus receiver listening on ${server.url}`);
console.log(` dispatch: ${wantsDispatch ? 'enabled' : 'disabled'}`);
console.log(` events emit to local bus as "build-bus.publish"`);
console.log('Ctrl-C to stop.');
// Graceful shutdown.
const stop = async (signal: string) => {
console.log(`\nReceived ${signal}; shutting down…`);
await server.stop();
if (dispatcher) await dispatcher.stop();
process.exit(0);
};
process.on('SIGINT', () => void stop('SIGINT'));
process.on('SIGTERM', () => void stop('SIGTERM'));
// Block indefinitely. The signal handlers exit the process.
await new Promise(() => {});
// Unreachable, but TypeScript wants a return.
return { success: true, message: '' };
}