/** * A2 — P3 control-plane server entrypoint. Wires validated env → Postgres pool/stores → migrations → * ioredis revocation bus → real capability verifier → Fastify app, then binds the ADMIN provisioning * API to a LOOPBACK host by default (CP_BIND_HOST). This process owns no terminal/PTY state, so * PTY!=WS restart-safety (INV7) is unaffected: it only serves provisioning/pairing/revocation. * * Config is env-only (no hardcoded hosts/ports/secrets): control-plane secrets via `loadEnv` * (fail-fast, never echoes values — INV9); bind address via CP_BIND_HOST / CP_BIND_PORT. * accountId is only ever derived from the authenticated capability token inside buildControlPlane * (INV3) — this entrypoint never fabricates identity. */ import { loadEnv } from './env.js' import { createPgPool, createQuery } from './db/pool.js' import { createPgStores } from './store/pg.js' import { runMigrations } from './db/migrate.js' import { createRedisRevocationBus } from './routing/bus.js' import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js' import { createRedisClient, createRedisPublisher } from './boot/redis.js' import { buildControlPlane, type ControlPlaneOverrides } from './main.js' import { buildFileBackedNativeCas } from './boot/native-ca.js' /** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */ const DEFAULT_CP_BIND_HOST = '127.0.0.1' const DEFAULT_CP_BIND_PORT = 8080 const MAX_TCP_PORT = 65535 function resolveBindHost(source: NodeJS.ProcessEnv): string { const raw = source.CP_BIND_HOST?.trim() return raw === undefined || raw === '' ? DEFAULT_CP_BIND_HOST : raw } function resolveBindPort(source: NodeJS.ProcessEnv): number { const raw = source.CP_BIND_PORT?.trim() if (raw === undefined || raw === '') return DEFAULT_CP_BIND_PORT const port = Number(raw) if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) { throw new Error('Invalid CP_BIND_PORT: must be an integer 1-65535') } return port } async function main(): Promise { const env = loadEnv(process.env) // Postgres: parameterized-only query wrapper + PG-backed repository ports, migrations applied // idempotently at boot. const pool = createPgPool(env.pgUrl) const query = createQuery(pool) const stores = createPgStores(query) await runMigrations(query) // Redis: single client drives the revocation-bus publish side (relay:revocations). const redis = createRedisClient(env.redisUrl) const bus = createRedisRevocationBus(createRedisPublisher(redis)) // Capability verifier: load the §4.3 verifying key into relay-auth's registry BEFORE serving, // then delegate to relay-auth's async verifyCapabilityToken (INV3). await configureCapabilityVerifyKey(env.capabilitySignPubkey) const verifier = createCapabilityVerifier() // A2-prep: when NATIVE_* on-disk CA material is configured, load the two REAL P-256 CAs (frp-client // + device) from disk and thread them into the app so /enroll + /device/enroll issue leaves from the // production CAs. `buildFileBackedNativeCas` fails fast (INV9) on unreadable/non-P-256 material. When // unset, `buildControlPlane` keeps its existing behaviour (dev self-signed CAs, or fail-closed if // NODE_ENV=production) — the dev/test injection path is untouched. const nativeOverrides: Pick = env.nativeCa !== undefined ? { production: true, nativeCas: await buildFileBackedNativeCas(env, env.nativeCa), nativeDnsZone: env.nativeCa.dnsZone, } : {} const { app } = await buildControlPlane(env, { stores, bus, verifier, ...nativeOverrides }) const host = resolveBindHost(process.env) const port = resolveBindPort(process.env) let shuttingDown = false const shutdown = async (signal: string): Promise => { if (shuttingDown) return shuttingDown = true process.stdout.write(`control-plane received ${signal}, shutting down\n`) try { await app.close() } finally { // Best-effort Redis close; force-disconnect if a graceful QUIT cannot complete. await redis.quit().catch(() => redis.disconnect()) await pool.end() } } for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.once(signal, () => { void shutdown(signal).then( () => process.exit(0), (err: unknown) => { process.stderr.write(`shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`) process.exit(1) }, ) }) } await app.listen({ host, port }) // Startup breadcrumb — bind address only, never secrets (INV9). process.stdout.write(`control-plane listening on ${host}:${port}\n`) } main().catch((err: unknown) => { process.stderr.write(`control-plane failed to start: ${err instanceof Error ? err.message : String(err)}\n`) process.exit(1) })