feat(runtime): add production control-plane foundation
This commit is contained in:
@@ -7,7 +7,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run --root ../.. apps/api/src",
|
"test": "vitest run --root ../.. apps/api/src",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@multi-simadmin/contracts": "workspace:*",
|
"@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 type { Writable } from 'node:stream';
|
||||||
import Fastify, {
|
import Fastify, {
|
||||||
type FastifyInstance,
|
type FastifyInstance,
|
||||||
@@ -12,6 +13,7 @@ export const API_DEFAULT_PORT = 8790 as const;
|
|||||||
const LEGACY_PORT = 8788;
|
const LEGACY_PORT = 8788;
|
||||||
|
|
||||||
export const REDACT_PATHS = Object.freeze([
|
export const REDACT_PATHS = Object.freeze([
|
||||||
|
`req.headers.${GATEWAY_AUTH_HEADER}`,
|
||||||
'req.headers.authorization',
|
'req.headers.authorization',
|
||||||
'req.headers.cookie',
|
'req.headers.cookie',
|
||||||
'req.headers.x-confirmation-token',
|
'req.headers.x-confirmation-token',
|
||||||
@@ -26,11 +28,24 @@ export interface ReadinessResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
|
readonly gatewayToken?: string;
|
||||||
readonly readiness?: () => ReadinessResult | Promise<ReadinessResult>;
|
readonly readiness?: () => ReadinessResult | Promise<ReadinessResult>;
|
||||||
readonly registerRoutes?: (app: FastifyInstance) => void | Promise<void>;
|
readonly registerRoutes?: (app: FastifyInstance) => void | Promise<void>;
|
||||||
readonly logger?: false | { readonly stream?: Writable };
|
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 {
|
export interface ListenEnvironment {
|
||||||
readonly API_HOST?: string;
|
readonly API_HOST?: string;
|
||||||
readonly API_PORT?: string;
|
readonly API_PORT?: string;
|
||||||
@@ -171,10 +186,32 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const app = Fastify(fastifyOptions);
|
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) => {
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
reply.header('X-Request-Id', request.id);
|
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;
|
const host = request.headers.host;
|
||||||
if (!host || !isLoopbackAuthority(host)) {
|
if (!host || !isLoopbackAuthority(host)) {
|
||||||
return reply
|
return reply
|
||||||
|
|||||||
@@ -1,24 +1,7 @@
|
|||||||
import { resolve } from 'node:path';
|
import { createCanaryGateway } from './canary-gateway.ts';
|
||||||
|
import { readCanaryRuntimeOptions } from './canary-runtime.ts';
|
||||||
|
|
||||||
import {
|
const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env));
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
async function stop(signal: string): Promise<void> {
|
async function stop(signal: string): Promise<void> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from 'node:http';
|
} from 'node:http';
|
||||||
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||||
import { pipeline } from 'node:stream/promises';
|
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_HOST = '127.0.0.1';
|
||||||
export const CANARY_DEFAULT_PORT = 8789;
|
export const CANARY_DEFAULT_PORT = 8789;
|
||||||
@@ -55,6 +56,7 @@ function isNumericLoopback(host: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CanaryGatewayOptions {
|
export interface CanaryGatewayOptions {
|
||||||
|
readonly gatewayToken?: string;
|
||||||
readonly distDir: string;
|
readonly distDir: string;
|
||||||
readonly host?: string;
|
readonly host?: string;
|
||||||
readonly port?: number;
|
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 {
|
function isProxyPath(pathname: string): boolean {
|
||||||
return pathname === '/healthz' || pathname === '/readyz' || pathname.startsWith('/api/v1/');
|
return pathname === '/healthz' || pathname === '/readyz' || pathname.startsWith('/api/v1/');
|
||||||
}
|
}
|
||||||
@@ -114,6 +126,7 @@ async function proxyRequest(
|
|||||||
response: ServerResponse,
|
response: ServerResponse,
|
||||||
upstreamHost: string,
|
upstreamHost: string,
|
||||||
upstreamPort: number,
|
upstreamPort: number,
|
||||||
|
gatewayToken: string | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await new Promise<void>((resolvePromise) => {
|
await new Promise<void>((resolvePromise) => {
|
||||||
const upstream = httpRequest(
|
const upstream = httpRequest(
|
||||||
@@ -122,7 +135,7 @@ async function proxyRequest(
|
|||||||
port: upstreamPort,
|
port: upstreamPort,
|
||||||
method: incoming.method,
|
method: incoming.method,
|
||||||
path: incoming.url,
|
path: incoming.url,
|
||||||
headers: withoutHopByHop(incoming.headers),
|
headers: trustedUpstreamHeaders(incoming.headers, gatewayToken),
|
||||||
},
|
},
|
||||||
(upstreamResponse) => {
|
(upstreamResponse) => {
|
||||||
response.writeHead(
|
response.writeHead(
|
||||||
@@ -235,7 +248,7 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isProxyPath(pathname)) {
|
if (isProxyPath(pathname)) {
|
||||||
await proxyRequest(request, response, upstreamHost, upstreamPort);
|
await proxyRequest(request, response, upstreamHost, upstreamPort, options.gatewayToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pathname.startsWith('/api')) return sendText(response, 404, 'Not Found');
|
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));
|
const distRoot = await realpath(resolve(options.distDir)).catch(() => resolve(options.distDir));
|
||||||
if (pathname !== '/' && (await serveFile(request, response, distRoot, pathname))) return;
|
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');
|
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 { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
|
||||||
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
||||||
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
|
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
|
||||||
|
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
|
||||||
|
|
||||||
export interface ProductionControlPlaneOptions {
|
export interface ProductionControlPlaneOptions {
|
||||||
readonly databasePath: string;
|
readonly databasePath: string;
|
||||||
readonly store?: SecretStore;
|
readonly store?: SecretStore;
|
||||||
readonly upstream?: SafeControlPlaneUpstream;
|
readonly upstream?: SafeControlPlaneUpstream;
|
||||||
|
readonly gatewayToken?: string;
|
||||||
|
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProductionControlPlane(
|
export function buildProductionControlPlane(
|
||||||
@@ -29,11 +32,20 @@ export function buildProductionControlPlane(
|
|||||||
const db = openDatabase(options.databasePath);
|
const db = openDatabase(options.databasePath);
|
||||||
try {
|
try {
|
||||||
migrateDatabase(db);
|
migrateDatabase(db);
|
||||||
|
const store = options.store ?? new MacOSKeychainSecretStore();
|
||||||
const app = buildControlPlaneApp({
|
const app = buildControlPlaneApp({
|
||||||
db,
|
db,
|
||||||
store: options.store ?? new MacOSKeychainSecretStore(),
|
store,
|
||||||
upstream: options.upstream ?? createProductionUpstream(),
|
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 () => {
|
app.addHook('onClose', async () => {
|
||||||
if (db.open) db.close();
|
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 { createListenOptions, type ListenEnvironment } from './app.js';
|
||||||
import { buildProductionControlPlane } from './production-control-plane.js';
|
import { buildProductionControlPlane } from './production-control-plane.js';
|
||||||
|
import { createRuntimeConfig, type RuntimeEnvironment } from './runtime-config.js';
|
||||||
|
|
||||||
export interface StartApiOptions {
|
export interface StartApiOptions {
|
||||||
readonly environment?: ListenEnvironment;
|
readonly environment?: ListenEnvironment;
|
||||||
readonly databasePath?: string;
|
readonly databasePath?: string;
|
||||||
|
readonly runtimeEnvironment?: RuntimeEnvironment;
|
||||||
readonly app?: {
|
readonly app?: {
|
||||||
listen(options: { readonly host: string; readonly port: number }): Promise<string>;
|
listen(options: { readonly host: string; readonly port: number }): Promise<string>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
@@ -12,9 +14,16 @@ export interface StartApiOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startApi(options: StartApiOptions = {}): Promise<void> {
|
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 =
|
const app =
|
||||||
options.app ?? buildProductionControlPlane({ databasePath: options.databasePath ?? '' });
|
options.app ??
|
||||||
|
buildProductionControlPlane({
|
||||||
|
databasePath: runtime?.databasePath ?? options.databasePath ?? '',
|
||||||
|
...(runtime ? { gatewayToken: runtime.gatewayToken } : {}),
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await app.listen(listenOptions);
|
await app.listen(listenOptions);
|
||||||
} catch (listenError) {
|
} catch (listenError) {
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["src/canary-cli.ts"]
|
"exclude": ["src/canary-cli.ts", "src/production-cli.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user