feat(runtime): add production control-plane foundation
This commit is contained in:
@@ -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:*",
|
||||
|
||||
+39
-2
@@ -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<ReadinessResult>;
|
||||
readonly registerRoutes?: (app: FastifyInstance) => void | Promise<void>;
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<void> {
|
||||
await new Promise<void>((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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
interface ExecutableMetadata {
|
||||
isFile(): boolean;
|
||||
readonly mode: number;
|
||||
}
|
||||
export async function defaultKeychainMetadataCheck(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
stat: (path: string) => Promise<ExecutableMetadata> = fsStat,
|
||||
): Promise<boolean> {
|
||||
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<ReadinessResult> {
|
||||
return async () => {
|
||||
const checks: Record<string, string> = {};
|
||||
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 };
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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<void>> = [];
|
||||
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'), '<main>SPA</main>');
|
||||
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<void>((done) => upstream.listen(0, '127.0.0.1', done));
|
||||
cleanup.push(() => new Promise<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface ClosableRuntime {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
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<void> {
|
||||
let shutdown: Promise<void> | undefined;
|
||||
const timeoutMs = options.timeoutMs ?? 5_000;
|
||||
const stop = (): Promise<void> => {
|
||||
if (shutdown) return shutdown;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<void>((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;
|
||||
}
|
||||
+11
-2
@@ -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<string>;
|
||||
close(): Promise<void>;
|
||||
@@ -12,9 +14,16 @@ export interface StartApiOptions {
|
||||
}
|
||||
|
||||
export async function startApi(options: StartApiOptions = {}): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/canary-cli.ts"]
|
||||
"exclude": ["src/canary-cli.ts", "src/production-cli.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user