From 293c015f89368f973462154cc68b5e4b9f2cc864 Mon Sep 17 00:00:00 2001 From: chick Date: Sat, 18 Jul 2026 12:43:46 +0800 Subject: [PATCH] feat(runtime): add production control-plane foundation --- apps/api/package.json | 3 +- apps/api/src/app.ts | 41 ++++- apps/api/src/canary-cli.ts | 23 +-- apps/api/src/canary-gateway.ts | 23 ++- apps/api/src/canary-runtime.ts | 43 +++++ apps/api/src/production-cli.ts | 19 +++ apps/api/src/production-control-plane.ts | 16 +- apps/api/src/production-readiness.ts | 62 ++++++++ apps/api/src/runtime-config.ts | 71 +++++++++ apps/api/src/runtime-foundation.test.ts | 191 +++++++++++++++++++++++ apps/api/src/shutdown.ts | 37 +++++ apps/api/src/start.ts | 13 +- apps/api/tsconfig.json | 2 +- 13 files changed, 513 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/canary-runtime.ts create mode 100644 apps/api/src/production-cli.ts create mode 100644 apps/api/src/production-readiness.ts create mode 100644 apps/api/src/runtime-config.ts create mode 100644 apps/api/src/runtime-foundation.test.ts create mode 100644 apps/api/src/shutdown.ts diff --git a/apps/api/package.json b/apps/api/package.json index 3ff295d..a67f177 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,8 @@ "scripts": { "test": "vitest run --root ../.. apps/api/src", "typecheck": "tsc -p tsconfig.json", - "canary": "node --experimental-strip-types src/canary-cli.ts" + "canary": "node --experimental-strip-types src/canary-cli.ts", + "start:production": "node --experimental-strip-types src/production-cli.ts" }, "dependencies": { "@multi-simadmin/contracts": "workspace:*", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 5e889fd..ab5b714 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,4 +1,5 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; +import { GATEWAY_AUTH_HEADER } from './runtime-config.js'; import type { Writable } from 'node:stream'; import Fastify, { type FastifyInstance, @@ -12,6 +13,7 @@ export const API_DEFAULT_PORT = 8790 as const; const LEGACY_PORT = 8788; export const REDACT_PATHS = Object.freeze([ + `req.headers.${GATEWAY_AUTH_HEADER}`, 'req.headers.authorization', 'req.headers.cookie', 'req.headers.x-confirmation-token', @@ -26,11 +28,24 @@ export interface ReadinessResult { } export interface BuildAppOptions { + readonly gatewayToken?: string; readonly readiness?: () => ReadinessResult | Promise; readonly registerRoutes?: (app: FastifyInstance) => void | Promise; readonly logger?: false | { readonly stream?: Writable }; } +export function secureTokenEqual( + supplied: string, + expected: string, + compare: ( + actual: NodeJS.ArrayBufferView, + expected: NodeJS.ArrayBufferView, + ) => boolean = timingSafeEqual, +): boolean { + const digest = (value: string): Buffer => createHash('sha256').update(value, 'utf8').digest(); + return compare(digest(supplied), digest(expected)); +} + export interface ListenEnvironment { readonly API_HOST?: string; readonly API_PORT?: string; @@ -171,10 +186,32 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance { }, }; const app = Fastify(fastifyOptions); - const readiness = options.readiness ?? (() => ({ ready: true, checks: { bootstrap: 'ready' } })); + const readiness = + options.readiness ?? (() => ({ ready: false, checks: { bootstrap: 'unconfigured' } })); app.addHook('onRequest', async (request, reply) => { reply.header('X-Request-Id', request.id); + if (options.gatewayToken !== undefined) { + const supplied = request.headers[GATEWAY_AUTH_HEADER]; + const authenticated = secureTokenEqual( + typeof supplied === 'string' ? supplied : '', + options.gatewayToken, + ); + if (!authenticated) { + return reply + .code(401) + .type('application/problem+json') + .send( + sendProblem( + request, + 401, + 'Unauthorized', + 'GATEWAY_AUTH_REQUIRED', + 'Trusted gateway authentication is required.', + ), + ); + } + } const host = request.headers.host; if (!host || !isLoopbackAuthority(host)) { return reply diff --git a/apps/api/src/canary-cli.ts b/apps/api/src/canary-cli.ts index 8d0f6db..da14d9d 100644 --- a/apps/api/src/canary-cli.ts +++ b/apps/api/src/canary-cli.ts @@ -1,24 +1,7 @@ -import { resolve } from 'node:path'; +import { createCanaryGateway } from './canary-gateway.ts'; +import { readCanaryRuntimeOptions } from './canary-runtime.ts'; -import { - CANARY_DEFAULT_PORT, - CANARY_DEFAULT_UPSTREAM_PORT, - createCanaryGateway, -} from './canary-gateway.ts'; - -function environmentPort(name: string, fallback: number): number { - const value = process.env[name]; - if (value === undefined || value === '') return fallback; - const port = Number(value); - if (!Number.isInteger(port)) throw new Error(`${name} must be an integer port`); - return port; -} - -const gateway = createCanaryGateway({ - distDir: resolve(process.env.CANARY_DIST_DIR ?? 'apps/web/dist'), - port: environmentPort('CANARY_PORT', CANARY_DEFAULT_PORT), - upstreamPort: environmentPort('CANARY_UPSTREAM_PORT', CANARY_DEFAULT_UPSTREAM_PORT), -}); +const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env)); let stopping = false; async function stop(signal: string): Promise { diff --git a/apps/api/src/canary-gateway.ts b/apps/api/src/canary-gateway.ts index 6cf59f7..834e319 100644 --- a/apps/api/src/canary-gateway.ts +++ b/apps/api/src/canary-gateway.ts @@ -10,6 +10,7 @@ import { } from 'node:http'; import { extname, isAbsolute, relative, resolve, sep } from 'node:path'; import { pipeline } from 'node:stream/promises'; +import { GATEWAY_AUTH_HEADER } from './runtime-config.js'; export const CANARY_DEFAULT_HOST = '127.0.0.1'; export const CANARY_DEFAULT_PORT = 8789; @@ -55,6 +56,7 @@ function isNumericLoopback(host: string): boolean { } export interface CanaryGatewayOptions { + readonly gatewayToken?: string; readonly distDir: string; readonly host?: string; readonly port?: number; @@ -87,6 +89,16 @@ function withoutHopByHop(headers: IncomingHttpHeaders): IncomingHttpHeaders { ); } +function trustedUpstreamHeaders( + headers: IncomingHttpHeaders, + gatewayToken: string | undefined, +): IncomingHttpHeaders { + const sanitized = withoutHopByHop(headers); + delete sanitized[GATEWAY_AUTH_HEADER]; + if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken; + return sanitized; +} + function isProxyPath(pathname: string): boolean { return pathname === '/healthz' || pathname === '/readyz' || pathname.startsWith('/api/v1/'); } @@ -114,6 +126,7 @@ async function proxyRequest( response: ServerResponse, upstreamHost: string, upstreamPort: number, + gatewayToken: string | undefined, ): Promise { await new Promise((resolvePromise) => { const upstream = httpRequest( @@ -122,7 +135,7 @@ async function proxyRequest( port: upstreamPort, method: incoming.method, path: incoming.url, - headers: withoutHopByHop(incoming.headers), + headers: trustedUpstreamHeaders(incoming.headers, gatewayToken), }, (upstreamResponse) => { response.writeHead( @@ -235,7 +248,7 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa } if (isProxyPath(pathname)) { - await proxyRequest(request, response, upstreamHost, upstreamPort); + await proxyRequest(request, response, upstreamHost, upstreamPort, options.gatewayToken); return; } if (pathname.startsWith('/api')) return sendText(response, 404, 'Not Found'); @@ -245,7 +258,11 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa const distRoot = await realpath(resolve(options.distDir)).catch(() => resolve(options.distDir)); if (pathname !== '/' && (await serveFile(request, response, distRoot, pathname))) return; - if (!(await serveFile(request, response, distRoot, '/index.html'))) { + if ( + pathname.startsWith('/assets/') || + extname(pathname) !== '' || + !(await serveFile(request, response, distRoot, '/index.html')) + ) { sendText(response, 404, 'Not Found'); } } diff --git a/apps/api/src/canary-runtime.ts b/apps/api/src/canary-runtime.ts new file mode 100644 index 0000000..f040c7b --- /dev/null +++ b/apps/api/src/canary-runtime.ts @@ -0,0 +1,43 @@ +import { resolve } from 'node:path'; +import { + CANARY_DEFAULT_PORT, + CANARY_DEFAULT_UPSTREAM_PORT, + type CanaryGatewayOptions, +} from './canary-gateway.js'; + +export interface CanaryRuntimeEnvironment { + readonly CANARY_DIST_DIR?: string; + readonly CANARY_PORT?: string; + readonly CANARY_UPSTREAM_PORT?: string; + readonly MULTI_SIMADMIN_GATEWAY_TOKEN?: string; +} + +function environmentPort(value: string | undefined, name: string, fallback: number): number { + if (value === undefined || value === '') return fallback; + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65_535) + throw new Error(`${name} must be an integer port`); + return port; +} + +export function readCanaryRuntimeOptions( + environment: CanaryRuntimeEnvironment, +): CanaryGatewayOptions { + const gatewayToken = environment.MULTI_SIMADMIN_GATEWAY_TOKEN; + if ( + !gatewayToken || + Buffer.byteLength(gatewayToken) < 32 || + Buffer.byteLength(gatewayToken) > 512 + ) + throw new Error('Gateway token must contain between 32 and 512 bytes'); + return Object.freeze({ + distDir: resolve(environment.CANARY_DIST_DIR ?? 'apps/web/dist'), + port: environmentPort(environment.CANARY_PORT, 'CANARY_PORT', CANARY_DEFAULT_PORT), + upstreamPort: environmentPort( + environment.CANARY_UPSTREAM_PORT, + 'CANARY_UPSTREAM_PORT', + CANARY_DEFAULT_UPSTREAM_PORT, + ), + gatewayToken, + }); +} diff --git a/apps/api/src/production-cli.ts b/apps/api/src/production-cli.ts new file mode 100644 index 0000000..fdeaf51 --- /dev/null +++ b/apps/api/src/production-cli.ts @@ -0,0 +1,19 @@ +import { buildProductionControlPlane } from './production-control-plane.ts'; +import { createRuntimeConfig } from './runtime-config.ts'; +import { installBoundedShutdown } from './shutdown.ts'; + +try { + const config = createRuntimeConfig(process.env); + const app = buildProductionControlPlane({ + databasePath: config.databasePath, + gatewayToken: config.gatewayToken, + }); + await app.listen(config.api); + installBoundedShutdown(process, app, { + terminate: () => process.exit(1), + }); + process.stdout.write(`API listening at http://${config.api.host}:${config.api.port}\n`); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/apps/api/src/production-control-plane.ts b/apps/api/src/production-control-plane.ts index 441ea23..6007361 100644 --- a/apps/api/src/production-control-plane.ts +++ b/apps/api/src/production-control-plane.ts @@ -9,11 +9,14 @@ import { migrateDatabase } from './infrastructure/database/migrations.js'; import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js'; import type { SecretStore } from './infrastructure/secrets/secret-store.js'; import { createProductionUpstream } from './infrastructure/transport/production-upstream.js'; +import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js'; export interface ProductionControlPlaneOptions { readonly databasePath: string; readonly store?: SecretStore; readonly upstream?: SafeControlPlaneUpstream; + readonly gatewayToken?: string; + readonly keychainMetadataCheck?: () => boolean | Promise; } export function buildProductionControlPlane( @@ -29,11 +32,20 @@ export function buildProductionControlPlane( const db = openDatabase(options.databasePath); try { migrateDatabase(db); + const store = options.store ?? new MacOSKeychainSecretStore(); const app = buildControlPlaneApp({ db, - store: options.store ?? new MacOSKeychainSecretStore(), + store, upstream: options.upstream ?? createProductionUpstream(), - app: { logger: {} }, + app: { + logger: {}, + ...(options.gatewayToken ? { gatewayToken: options.gatewayToken } : {}), + readiness: createProductionReadiness({ + db, + gatewayToken: options.gatewayToken ?? '', + keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck, + }), + }, }); app.addHook('onClose', async () => { if (db.open) db.close(); diff --git a/apps/api/src/production-readiness.ts b/apps/api/src/production-readiness.ts new file mode 100644 index 0000000..24bf2d7 --- /dev/null +++ b/apps/api/src/production-readiness.ts @@ -0,0 +1,62 @@ +import type Database from 'better-sqlite3'; +import { stat as fsStat } from 'node:fs/promises'; +import { MIGRATIONS } from './infrastructure/database/migrations.js'; +import type { ReadinessResult } from './app.js'; + +export interface ProductionReadinessOptions { + readonly db: Database.Database; + readonly gatewayToken: string; + /** Metadata/availability probe only. This callback must never retrieve a secret. */ + readonly keychainMetadataCheck: () => boolean | Promise; +} +interface ExecutableMetadata { + isFile(): boolean; + readonly mode: number; +} +export async function defaultKeychainMetadataCheck( + platform: NodeJS.Platform = process.platform, + stat: (path: string) => Promise = fsStat, +): Promise { + if (platform !== 'darwin') return false; + try { + const metadata = await stat('/usr/bin/security'); + return metadata.isFile() && (metadata.mode & 0o111) !== 0; + } catch { + return false; + } +} +export function createProductionReadiness( + options: ProductionReadinessOptions, +): () => Promise { + return async () => { + const checks: Record = {}; + let ready = true; + try { + options.db.prepare('SELECT 1').get(); + checks.database = 'ready'; + } catch { + checks.database = 'unavailable'; + ready = false; + } + try { + const expected = MIGRATIONS.at(-1)?.id ?? 0; + const row = options.db.prepare('SELECT MAX(id) AS id FROM schema_migrations').get() as { + id: number | null; + }; + checks.migrations = row.id === expected ? 'ready' : 'pending'; + if (row.id !== expected) ready = false; + } catch { + checks.migrations = 'unavailable'; + ready = false; + } + checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured'; + if (checks.gatewayAuth !== 'ready') ready = false; + try { + checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable'; + } catch { + checks.keychain = 'unavailable'; + } + if (checks.keychain !== 'ready') ready = false; + return { ready, checks }; + }; +} diff --git a/apps/api/src/runtime-config.ts b/apps/api/src/runtime-config.ts new file mode 100644 index 0000000..e2407a4 --- /dev/null +++ b/apps/api/src/runtime-config.ts @@ -0,0 +1,71 @@ +import { realpathSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; + +export const GATEWAY_AUTH_HEADER = 'x-multi-simadmin-gateway-token' as const; +export interface RuntimeEnvironment { + readonly MULTI_SIMADMIN_DATA_ROOT?: string; + readonly MULTI_SIMADMIN_DATABASE_PATH?: string; + readonly MULTI_SIMADMIN_GATEWAY_TOKEN?: string; + readonly API_HOST?: string; + readonly API_PORT?: string; +} +export interface RuntimeConfig { + readonly api: { readonly host: '127.0.0.1'; readonly port: 8790 }; + readonly dataRoot: string; + readonly databasePath: string; + readonly gatewayToken: string; +} +function within(root: string, candidate: string): boolean { + const child = relative(root, candidate); + return child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child); +} +function canonicalExistingAncestor(path: string): string { + let existing = path; + const suffix: string[] = []; + for (;;) { + try { + return resolve(realpathSync(existing), ...suffix); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const parent = dirname(existing); + if (parent === existing) throw error; + suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1))); + existing = parent; + } + } +} +export function createRuntimeConfig(environment: RuntimeEnvironment): RuntimeConfig { + if (environment.API_HOST !== undefined && environment.API_HOST !== '127.0.0.1') + throw new Error('API host is fixed at 127.0.0.1'); + if (environment.API_PORT !== undefined && environment.API_PORT !== '8790') + throw new Error('API port is fixed at 8790'); + const rootInput = environment.MULTI_SIMADMIN_DATA_ROOT; + const databaseInput = environment.MULTI_SIMADMIN_DATABASE_PATH; + if (!rootInput || !isAbsolute(rootInput)) + throw new Error('An explicit absolute local app data root is required'); + if (!databaseInput || !isAbsolute(databaseInput)) + throw new Error('An explicit absolute SQLite database path is required'); + const dataRoot = resolve(rootInput); + const databasePath = resolve(databaseInput); + if (!within(dataRoot, databasePath) || databasePath === dataRoot) + throw new Error('Database path must be within the local app data root'); + const canonicalRoot = canonicalExistingAncestor(dataRoot); + const canonicalDatabase = canonicalExistingAncestor(databasePath); + if (!within(canonicalRoot, canonicalDatabase) || canonicalDatabase === canonicalRoot) + throw new Error( + 'Database path must remain within the local app data root after resolving symlinks', + ); + const gatewayToken = environment.MULTI_SIMADMIN_GATEWAY_TOKEN; + if ( + !gatewayToken || + Buffer.byteLength(gatewayToken) < 32 || + Buffer.byteLength(gatewayToken) > 512 + ) + throw new Error('Gateway token must contain at least 32 bytes'); + return Object.freeze({ + api: Object.freeze({ host: '127.0.0.1' as const, port: 8790 as const }), + dataRoot, + databasePath, + gatewayToken, + }); +} diff --git a/apps/api/src/runtime-foundation.test.ts b/apps/api/src/runtime-foundation.test.ts new file mode 100644 index 0000000..9f3e326 --- /dev/null +++ b/apps/api/src/runtime-foundation.test.ts @@ -0,0 +1,191 @@ +import { EventEmitter } from 'node:events'; +import { lstat, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRuntimeConfig, GATEWAY_AUTH_HEADER } from './runtime-config.js'; +import { buildApp } from './app.js'; +import { createCanaryGateway, type CanaryGateway } from './canary-gateway.js'; +import { createProductionReadiness } from './production-readiness.js'; +import { defaultKeychainMetadataCheck } from './production-readiness.js'; +import { readCanaryRuntimeOptions } from './canary-runtime.js'; +import { installBoundedShutdown } from './shutdown.js'; +import { secureTokenEqual } from './app.js'; +import { openDatabase } from './infrastructure/database/database.js'; +import { migrateDatabase } from './infrastructure/database/migrations.js'; + +const cleanup: Array<() => Promise> = []; +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((fn) => fn())); +}); +const token = 'runtime-gateway-token-with-at-least-32-bytes'; + +describe('production runtime foundation', () => { + it('creates one deeply immutable fixed-listener config and validates before side effects', () => { + const root = resolve('/tmp/multi-simadmin-data'); + const config = createRuntimeConfig({ + MULTI_SIMADMIN_DATA_ROOT: root, + MULTI_SIMADMIN_DATABASE_PATH: join(root, 'db.sqlite'), + MULTI_SIMADMIN_GATEWAY_TOKEN: token, + API_HOST: '127.0.0.1', + API_PORT: '8790', + }); + expect(config).toEqual({ + api: { host: '127.0.0.1', port: 8790 }, + dataRoot: root, + databasePath: join(root, 'db.sqlite'), + gatewayToken: token, + }); + expect(Object.isFrozen(config)).toBe(true); + expect(Object.isFrozen(config.api)).toBe(true); + expect(() => + createRuntimeConfig({ + MULTI_SIMADMIN_DATA_ROOT: root, + MULTI_SIMADMIN_DATABASE_PATH: '/tmp/escape.sqlite', + MULTI_SIMADMIN_GATEWAY_TOKEN: token, + }), + ).toThrow(/data root/i); + expect(() => + createRuntimeConfig({ + MULTI_SIMADMIN_DATA_ROOT: root, + MULTI_SIMADMIN_DATABASE_PATH: join(root, 'db.sqlite'), + MULTI_SIMADMIN_GATEWAY_TOKEN: 'weak', + }), + ).toThrow(/token/i); + }); + + it('rejects a database path whose existing symlink ancestor escapes before creating a DB', async () => { + const directory = await mkdtemp(join(tmpdir(), 'runtime-containment-')); + cleanup.push(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'data'); + const outside = join(directory, 'outside'); + await mkdir(root); + await mkdir(outside); + await symlink(outside, join(root, 'escape')); + const databasePath = join(root, 'escape', 'db.sqlite'); + + expect(() => + createRuntimeConfig({ + MULTI_SIMADMIN_DATA_ROOT: root, + MULTI_SIMADMIN_DATABASE_PATH: databasePath, + MULTI_SIMADMIN_GATEWAY_TOKEN: token, + }), + ).toThrow(/data root|symlink/i); + await expect(lstat(join(outside, 'db.sqlite'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reads and validates the canary gateway token from its environment', () => { + expect( + readCanaryRuntimeOptions({ + MULTI_SIMADMIN_GATEWAY_TOKEN: token, + CANARY_DIST_DIR: '/tmp/dist', + CANARY_PORT: '8789', + CANARY_UPSTREAM_PORT: '8790', + }), + ).toMatchObject({ gatewayToken: token, port: 8789, upstreamPort: 8790 }); + expect(() => readCanaryRuntimeOptions({ CANARY_DIST_DIR: '/tmp/dist' })).toThrow(/token/i); + expect(() => + readCanaryRuntimeOptions({ + CANARY_DIST_DIR: '/tmp/dist', + MULTI_SIMADMIN_GATEWAY_TOKEN: 'weak', + }), + ).toThrow(/token/i); + }); + + it('always compares fixed-size token digests with timingSafeEqual', () => { + const compare = vi.fn(() => false); + expect(secureTokenEqual('x', token, compare)).toBe(false); + expect(compare).toHaveBeenCalledOnce(); + const [actual, expected] = compare.mock.calls[0] as unknown as [Buffer, Buffer]; + expect(actual).toHaveLength(32); + expect(expected).toHaveLength(32); + }); + + it('forces pluggable termination when graceful shutdown exceeds its deadline', async () => { + vi.useFakeTimers(); + const target = new EventEmitter(); + const terminate = vi.fn(); + const stop = installBoundedShutdown( + target, + { close: () => new Promise(() => undefined) }, + { timeoutMs: 25, terminate }, + ); + const stopping = stop(); + await vi.advanceTimersByTimeAsync(25); + await stopping; + expect(terminate).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }); + + it('default Keychain readiness checks only Darwin security executable metadata', async () => { + const stat = vi.fn(async () => ({ isFile: () => true, mode: 0o100755 })); + await expect(defaultKeychainMetadataCheck('darwin', stat)).resolves.toBe(true); + expect(stat).toHaveBeenCalledExactlyOnceWith('/usr/bin/security'); + stat.mockClear(); + await expect(defaultKeychainMetadataCheck('linux', stat)).resolves.toBe(false); + expect(stat).not.toHaveBeenCalled(); + }); + + it('authenticates internal requests without reflecting credentials', async () => { + const app = buildApp({ gatewayToken: token }); + cleanup.push(() => app.close()); + expect((await app.inject({ url: '/healthz' })).statusCode).toBe(401); + expect( + ( + await app.inject({ + url: '/healthz', + headers: { [GATEWAY_AUTH_HEADER]: 'wrong-but-same-length-xxxxxxxxxxxxxxxxx' }, + }) + ).statusCode, + ).toBe(401); + const ok = await app.inject({ url: '/healthz', headers: { [GATEWAY_AUTH_HEADER]: token } }); + expect(ok.statusCode).toBe(200); + expect(ok.payload).not.toContain(token); + }); + + it('readiness checks a live migrated DB and auth configuration without reading secrets', async () => { + const directory = await mkdtemp(join(tmpdir(), 'runtime-ready-')); + cleanup.push(() => rm(directory, { recursive: true, force: true })); + const db = openDatabase(join(directory, 'db.sqlite')); + migrateDatabase(db); + const keychainMetadataCheck = vi.fn(async () => true); + const readiness = createProductionReadiness({ db, gatewayToken: token, keychainMetadataCheck }); + expect(await readiness()).toEqual({ + ready: true, + checks: { database: 'ready', migrations: 'ready', gatewayAuth: 'ready', keychain: 'ready' }, + }); + expect(keychainMetadataCheck).toHaveBeenCalledOnce(); + db.close(); + expect((await readiness()).ready).toBe(false); + }); + + it('gateway replaces forged auth and only falls back for extensionless routes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'runtime-gateway-')); + await mkdir(join(directory, 'assets')); + await writeFile(join(directory, 'index.html'), '
SPA
'); + cleanup.push(() => rm(directory, { recursive: true, force: true })); + let seen: string | string[] | undefined; + const upstream = createServer((request, response) => { + seen = request.headers[GATEWAY_AUTH_HEADER]; + response.end('ok'); + }); + await new Promise((done) => upstream.listen(0, '127.0.0.1', done)); + cleanup.push(() => new Promise((done) => upstream.close(() => done()))); + const address = upstream.address(); + if (!address || typeof address === 'string') throw new Error('address'); + const gateway: CanaryGateway = createCanaryGateway({ + distDir: directory, + port: 0, + upstreamPort: address.port, + gatewayToken: token, + }); + await gateway.start(); + cleanup.push(() => gateway.stop()); + await fetch(`${gateway.origin}/healthz`, { headers: { [GATEWAY_AUTH_HEADER]: 'forged' } }); + expect(seen).toBe(token); + expect((await fetch(`${gateway.origin}/assets/missing.js`)).status).toBe(404); + expect((await fetch(`${gateway.origin}/missing.css`)).status).toBe(404); + expect((await fetch(`${gateway.origin}/instances/one`)).status).toBe(200); + }); +}); diff --git a/apps/api/src/shutdown.ts b/apps/api/src/shutdown.ts new file mode 100644 index 0000000..fbc764e --- /dev/null +++ b/apps/api/src/shutdown.ts @@ -0,0 +1,37 @@ +export interface ClosableRuntime { + close(): Promise; +} +export interface SignalTarget { + once(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown; + removeListener(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown; +} +export function installBoundedShutdown( + target: SignalTarget, + runtime: ClosableRuntime, + options: { readonly timeoutMs?: number; readonly terminate?: () => void } = {}, +): () => Promise { + let shutdown: Promise | undefined; + const timeoutMs = options.timeoutMs ?? 5_000; + const stop = (): Promise => { + if (shutdown) return shutdown; + let timeout: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => { + options.terminate?.(); + resolve(); + }, timeoutMs); + }); + shutdown = Promise.race([runtime.close(), deadline]).finally(() => { + if (timeout !== undefined) clearTimeout(timeout); + target.removeListener('SIGINT', listener); + target.removeListener('SIGTERM', listener); + }); + return shutdown; + }; + const listener = (): void => { + void stop(); + }; + target.once('SIGINT', listener); + target.once('SIGTERM', listener); + return stop; +} diff --git a/apps/api/src/start.ts b/apps/api/src/start.ts index a12e7fd..17afe3c 100644 --- a/apps/api/src/start.ts +++ b/apps/api/src/start.ts @@ -1,9 +1,11 @@ import { createListenOptions, type ListenEnvironment } from './app.js'; import { buildProductionControlPlane } from './production-control-plane.js'; +import { createRuntimeConfig, type RuntimeEnvironment } from './runtime-config.js'; export interface StartApiOptions { readonly environment?: ListenEnvironment; readonly databasePath?: string; + readonly runtimeEnvironment?: RuntimeEnvironment; readonly app?: { listen(options: { readonly host: string; readonly port: number }): Promise; close(): Promise; @@ -12,9 +14,16 @@ export interface StartApiOptions { } export async function startApi(options: StartApiOptions = {}): Promise { - const listenOptions = createListenOptions(options.environment ?? {}); + const runtime = options.runtimeEnvironment + ? createRuntimeConfig(options.runtimeEnvironment) + : undefined; + const listenOptions = runtime?.api ?? createListenOptions(options.environment ?? {}); const app = - options.app ?? buildProductionControlPlane({ databasePath: options.databasePath ?? '' }); + options.app ?? + buildProductionControlPlane({ + databasePath: runtime?.databasePath ?? options.databasePath ?? '', + ...(runtime ? { gatewayToken: runtime.gatewayToken } : {}), + }); try { await app.listen(listenOptions); } catch (listenError) { diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index acedda5..dfcafb5 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -5,5 +5,5 @@ "types": ["node"] }, "include": ["src/**/*.ts"], - "exclude": ["src/canary-cli.ts"] + "exclude": ["src/canary-cli.ts", "src/production-cli.ts"] }