Compare commits

...
53 Commits
Author SHA1 Message Date
chick f7d69a0fa7 feat(web): prioritize SMS operations workspace 2026-07-19 10:30:52 +08:00
chick 0112e3320a feat(sms): add real instance message workflow 2026-07-19 02:12:57 +08:00
chick 38a26c2ce1 fix(transport): allow passwordless resource reads 2026-07-19 00:56:03 +08:00
chick 4e7bdea230 fix(resources): probe passwordless instances 2026-07-19 00:51:55 +08:00
chick 23f18963ef feat(fleet): show instance resource metrics 2026-07-19 00:22:23 +08:00
chick 2411801240 feat(web): make instance overview card-first 2026-07-18 23:23:41 +08:00
chick abfd07f8a3 feat(web): localize and redesign operations console 2026-07-18 22:22:41 +08:00
chick d575ab4d9d feat(gateway): allow secured LAN console access 2026-07-18 19:47:23 +08:00
chick e390069edb fix(cutover): use direct detached spawn 2026-07-18 18:36:26 +08:00
chick 0b775b29bc fix(cutover): terminate detached launcher parent 2026-07-18 18:32:42 +08:00
chick d323370a75 fix(cutover): close identity binding races 2026-07-18 18:13:53 +08:00
chick 63d167d720 fix(cutover): persist rollback completion state 2026-07-18 18:02:59 +08:00
chick bd8a3fed5b fix(cutover): detach listener file descriptors 2026-07-18 18:00:21 +08:00
chick 320e65587b fix(cutover): avoid detached child stdout inheritance 2026-07-18 17:58:45 +08:00
chick f326da705c fix(cutover): parse detached child pid safely 2026-07-18 17:56:03 +08:00
chick e4e2c32998 fix(cutover): track direct detached listener pid 2026-07-18 17:51:40 +08:00
chick 020879f7e6 fix(cutover): launch gateway without wrapper pid 2026-07-18 17:46:05 +08:00
chick f7d111588b fix(cutover): separate api health from gateway readiness 2026-07-18 17:40:08 +08:00
chick bad00b67e9 fix(cutover): support absolute legacy restart executable 2026-07-18 17:34:09 +08:00
chick 4a7e29cf06 feat(release): add reversible production cutover gates 2026-07-18 17:24:51 +08:00
chick 3718c91706 test(runtime): verify live same-origin canary 2026-07-18 15:45:57 +08:00
chick f89f393485 fix(runtime): make production commands executable 2026-07-18 15:12:48 +08:00
chick ae27b522ab feat(migration): add verified import and rollback workflow 2026-07-18 14:13:11 +08:00
chick 293c015f89 feat(runtime): add production control-plane foundation 2026-07-18 12:43:46 +08:00
chick 9451e1ee42 feat(runtime): add isolated same-origin canary gateway 2026-07-18 04:54:21 +08:00
chick cc8eb0f54b fix(web): contain jobs table on narrow screens 2026-07-18 03:53:25 +08:00
chick a884033352 test(quality): add concurrency and real browser gates 2026-07-18 03:20:42 +08:00
chick 88d41a7a18 feat(web): connect jobs and audit workspaces 2026-07-18 01:42:17 +08:00
chick 53d9229b45 feat(api): add redacted audit read endpoints 2026-07-18 00:44:23 +08:00
chick e45b011bb8 feat(api): add contract-safe jobs read endpoints 2026-07-18 00:23:45 +08:00
chick e46e81ea77 feat(web): add jobs audit and settings workspaces 2026-07-17 23:08:03 +08:00
chick 5eb8680393 feat(web): complete capability-driven instance modules 2026-07-17 21:48:20 +08:00
chick 0c84e8c88e feat(web): add messages and calls module slices 2026-07-17 20:27:53 +08:00
chick d0fd943082 feat(web): add cellular and network module slices 2026-07-17 20:15:04 +08:00
chick 4a595540e9 feat(web): add overview system module foundation 2026-07-17 19:40:13 +08:00
chick be1741c2f8 feat(web): add durable control-plane event updates 2026-07-17 18:31:07 +08:00
chick 6e817f380a feat(web): complete fleet and instance management slices 2026-07-17 17:12:35 +08:00
chick 4093634e13 feat(web): add runnable fleet console foundation 2026-07-17 16:02:15 +08:00
chick b4ae28c8f0 feat(api): add fleet snapshots and durable event stream 2026-07-17 14:16:46 +08:00
chick 1cc4a995ee feat(api): advance capability and secure operations slices 2026-07-17 11:57:20 +08:00
chick 8018b36adf feat(operations): add audited runtime catalog 2026-07-17 09:07:50 +08:00
chick 243565a75e feat(registry): add fail-closed runtime lookup 2026-07-17 01:34:13 +08:00
chick 7b91dbbad1 feat(api): complete phase 2.4 control plane 2026-07-17 00:37:11 +08:00
chick c2702b5fc6 feat(secrets): add hardened macOS Keychain store 2026-07-16 14:56:10 +08:00
chick 8a307781c6 feat(migration): add safe legacy import preview 2026-07-16 13:51:11 +08:00
chick ef9a634a02 feat(storage): add resilient SQLite foundation 2026-07-16 13:20:59 +08:00
chick 26c1bcc6da feat(api): add secure Fastify skeleton 2026-07-16 11:36:24 +08:00
chick a86970d33b feat(api): define v1 control-plane contract 2026-07-16 10:58:05 +08:00
chick aa3c3f9803 build: establish strict TypeScript workspace 2026-07-16 09:34:50 +08:00
chick 43a69d8617 docs: close Phase 0 acceptance gate 2026-07-16 08:07:58 +08:00
chick c7da857aff test(fixtures): capture redacted SimAdmin responses 2026-07-16 06:35:28 +08:00
chick 9dffadbec2 docs(api): inventory all SimAdmin operations 2026-07-16 05:33:50 +08:00
chick c789ec62e1 docs: define API-first console charter 2026-07-16 00:38:05 +08:00
393 changed files with 88153 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
coverage
pnpm-lock.yaml
packages/**/test/fixtures
packages/test-fixtures/src/**/*.json
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@multi-simadmin/api",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"test": "vitest run --root ../.. apps/api/src",
"typecheck": "tsc -p tsconfig.json",
"canary": "tsx src/canary-cli.ts",
"gateway:production": "tsx src/production-gateway-cli.ts",
"start:production": "tsx src/production-cli.ts",
"migration": "tsx src/application/legacy-import/migration-command.ts"
},
"dependencies": {
"@multi-simadmin/contracts": "workspace:*",
"@multi-simadmin/operation-registry": "workspace:*",
"better-sqlite3": "12.11.1",
"drizzle-orm": "0.45.2",
"fastify": "5.10.0",
"tsx": "4.22.4"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/node": "24.13.3"
}
}
+231
View File
@@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { Writable } from 'node:stream';
import {
API_DEFAULT_HOST,
API_DEFAULT_PORT,
buildApp,
createListenOptions,
REDACT_PATHS,
} from './app.js';
const registerProbe = async (app: FastifyInstance): Promise<void> => {
app.post(
'/api/v1/test-only/schema-probe',
{
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['name'],
properties: { name: { type: 'string', minLength: 1 } },
},
},
},
async (request) => ({ name: (request.body as { name: string }).name }),
);
app.get('/api/v1/test-only/failure', async () => {
throw new Error('internal secret detail');
});
};
describe('Fastify API skeleton', () => {
it('is injectable without listening and exposes health/readiness with server request IDs', async () => {
const app = buildApp({ readiness: () => ({ ready: true, checks: { bootstrap: 'ready' } }) });
const health = await app.inject({
method: 'GET',
url: '/healthz',
headers: { 'x-request-id': 'attacker-controlled' },
});
const ready = await app.inject({ method: 'GET', url: '/readyz' });
expect(health.statusCode).toBe(200);
expect(health.json()).toEqual({ status: 'ok' });
expect(ready.statusCode).toBe(200);
expect(ready.json()).toEqual({ ready: true, checks: { bootstrap: 'ready' } });
expect(health.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/);
expect(health.headers['x-request-id']).not.toBe('attacker-controlled');
await app.close();
}, 15_000);
it('returns RFC Problem Details for schema failures and unexpected errors without leaking detail', async () => {
let logs = '';
const logStream = new Writable({
write(chunk, _encoding, callback) {
logs += String(chunk);
callback();
},
});
const app = buildApp({ logger: { stream: logStream }, registerRoutes: registerProbe });
const invalid = await app.inject({
method: 'POST',
url: '/api/v1/test-only/schema-probe',
headers: { 'content-type': 'application/json' },
payload: {},
});
expect(invalid.statusCode).toBe(400);
expect(invalid.headers['content-type']).toContain('application/problem+json');
expect(invalid.json()).toMatchObject({
type: 'about:blank',
title: 'Bad Request',
status: 400,
code: 'VALIDATION_FAILED',
requestId: invalid.headers['x-request-id'],
});
expect(invalid.json().validation).toEqual(
expect.arrayContaining([
expect.objectContaining({
field: expect.any(String),
code: expect.any(String),
message: expect.any(String),
}),
]),
);
const failure = await app.inject({ method: 'GET', url: '/api/v1/test-only/failure' });
expect(failure.statusCode).toBe(500);
expect(failure.headers['content-type']).toContain('application/problem+json');
expect(JSON.stringify(failure.json())).not.toContain('internal secret detail');
expect(logs).toContain('request failed');
expect(logs).not.toContain('internal secret detail');
await app.close();
});
it.each([
{
name: 'malformed JSON',
request: {
method: 'POST' as const,
url: '/api/v1/test-only/schema-probe',
headers: { 'content-type': 'application/json' },
payload: '{',
},
status: 400,
},
{
name: 'unsupported media type',
request: {
method: 'POST' as const,
url: '/api/v1/test-only/schema-probe',
headers: { 'content-type': 'application/x-unsupported' },
payload: 'x',
},
status: 415,
},
{
name: 'oversized body',
request: {
method: 'POST' as const,
url: '/api/v1/test-only/schema-probe',
headers: { 'content-type': 'application/json' },
payload: JSON.stringify({ name: 'x'.repeat(1_048_577) }),
},
status: 413,
},
])('preserves Fastify client status for $name', async ({ request, status }) => {
const app = buildApp({ registerRoutes: registerProbe });
const response = await app.inject(request);
expect(response.statusCode).toBe(status);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ status, requestId: response.headers['x-request-id'] });
await app.close();
});
it('rejects non-loopback Host and cross-origin browser requests before route execution', async () => {
const app = buildApp({ registerRoutes: registerProbe });
const foreignHost = await app.inject({
method: 'GET',
url: '/healthz',
headers: { host: 'example.com' },
});
expect(foreignHost.statusCode).toBe(403);
expect(foreignHost.headers['content-type']).toContain('application/problem+json');
const crossOrigin = await app.inject({
method: 'GET',
url: '/healthz',
headers: { host: '127.0.0.1:8790', origin: 'http://localhost:8790' },
});
expect(crossOrigin.statusCode).toBe(403);
const sameOrigin = await app.inject({
method: 'GET',
url: '/healthz',
headers: { host: '127.0.0.1:8790', origin: 'http://127.0.0.1:8790' },
});
expect(sameOrigin.statusCode).toBe(200);
for (const host of [
'127.0.0.1@example.com',
'127.0.0.1/path',
'[::ffff:127.0.0.1]:8790',
'2130706433',
'0177.0.0.1',
'0x7f.0.0.1',
'127.1',
'127.0.1',
'[0:0:0:0:0:0:0:1]',
]) {
const response = await app.inject({ method: 'GET', url: '/healthz', headers: { host } });
expect(response.statusCode, host).toBe(403);
}
for (const host of ['localhost:8790', '[::1]:8790']) {
const response = await app.inject({
method: 'GET',
url: '/healthz',
headers: { host, origin: `http://${host}` },
});
expect(response.statusCode, host).toBe(200);
}
for (const origin of [
'https://127.0.0.1:8790',
'http://evil@127.0.0.1:8790',
'http://127.0.0.1:8790/path',
'http://2130706433:8790',
]) {
const response = await app.inject({
method: 'GET',
url: '/healthz',
headers: { host: '127.0.0.1:8790', origin },
});
expect(response.statusCode, origin).toBe(403);
}
await app.close();
});
it('returns 503 with structured readiness checks when dependencies are not ready', async () => {
const app = buildApp({
readiness: () => ({ ready: false, checks: { database: 'unavailable' } }),
});
const response = await app.inject({ method: 'GET', url: '/readyz' });
expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({ ready: false, checks: { database: 'unavailable' } });
await app.close();
});
it('defaults to a separate loopback listener and rejects unsafe listen overrides', () => {
expect(API_DEFAULT_HOST).toBe('127.0.0.1');
expect(API_DEFAULT_PORT).not.toBe(8788);
expect(createListenOptions({})).toEqual({ host: API_DEFAULT_HOST, port: API_DEFAULT_PORT });
expect(createListenOptions({ API_HOST: '::1' })).toEqual({
host: '::1',
port: API_DEFAULT_PORT,
});
expect(() => createListenOptions({ API_HOST: '0.0.0.0' })).toThrow(/loopback/i);
expect(() => createListenOptions({ API_PORT: '8788' })).toThrow(/reserved|legacy/i);
expect(() => createListenOptions({ API_PORT: 'not-a-port' })).toThrow(/port/i);
});
it('defines immutable logger redaction for credentials, confirmation headers and cookies', () => {
expect(REDACT_PATHS).toEqual(
expect.arrayContaining([
'req.headers.authorization',
'req.headers.cookie',
'req.headers.x-confirmation-token',
'req.body.password',
'res.headers.set-cookie',
]),
);
expect(new Set(REDACT_PATHS).size).toBe(REDACT_PATHS.length);
});
});
+344
View File
@@ -0,0 +1,344 @@
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,
type FastifyServerOptions,
type FastifyRequest,
} from 'fastify';
import { UpstreamError } from './infrastructure/transport/upstream-error.js';
export const API_DEFAULT_HOST = '127.0.0.1' as const;
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',
'req.body.password',
'req.body.confirmationToken',
'res.headers.set-cookie',
]);
export interface ReadinessResult {
readonly ready: boolean;
readonly checks: Readonly<Record<string, string>>;
}
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;
}
export interface ListenOptions {
readonly host: string;
readonly port: number;
}
interface ValidationIssue {
readonly field: string;
readonly code: string;
readonly message: string;
}
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
const LOOPBACK_AUTHORITY = /^(?:127\.0\.0\.1|localhost|\[::1\])(?::([1-9]\d{0,4}))?$/i;
const authority = (raw: string): { host: string; authority: string } | undefined => {
const match = LOOPBACK_AUTHORITY.exec(raw);
if (!match) return undefined;
const port = match[1] === undefined ? undefined : Number(match[1]);
if (port !== undefined && port > 65_535) return undefined;
const separator = raw.lastIndexOf(':');
const hasPort = match[1] !== undefined;
const host = hasPort ? raw.slice(0, separator) : raw;
return { host: host.replace(/^\[|\]$/g, '').toLowerCase(), authority: raw.toLowerCase() };
};
const isLoopbackAuthority = (raw: string): boolean => {
const parsed = authority(raw);
return parsed !== undefined && LOOPBACK_HOSTS.has(parsed.host.toLowerCase());
};
const isSameOrigin = (origin: string, host: string): boolean => {
const parsedHost = authority(host);
return parsedHost !== undefined && origin.toLowerCase() === `http://${parsedHost.authority}`;
};
const validationIssues = (error: unknown): readonly ValidationIssue[] => {
if (!error || typeof error !== 'object' || !('validation' in error)) return [];
const validation = (error as { validation?: unknown }).validation;
if (!Array.isArray(validation)) return [];
return validation.map((entry) => {
const issue = entry as {
keyword?: unknown;
instancePath?: unknown;
message?: unknown;
params?: { missingProperty?: unknown };
};
const instancePath = typeof issue.instancePath === 'string' ? issue.instancePath : '';
const missingProperty = issue.params?.missingProperty;
return {
field:
instancePath || (typeof missingProperty === 'string' ? `/${missingProperty}` : '/request'),
code: typeof issue.keyword === 'string' ? issue.keyword : 'validation',
message: typeof issue.message === 'string' ? issue.message : 'is invalid',
};
});
};
const CLIENT_ERROR_TITLES: Readonly<Record<number, string>> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
409: 'Conflict',
412: 'Precondition Failed',
413: 'Content Too Large',
415: 'Unsupported Media Type',
422: 'Unprocessable Content',
429: 'Too Many Requests',
};
const clientStatus = (error: unknown): number | undefined => {
if (!error || typeof error !== 'object' || !('statusCode' in error)) return undefined;
const status = (error as { statusCode?: unknown }).statusCode;
return typeof status === 'number' && status >= 400 && status < 500 ? status : undefined;
};
const sendProblem = (
request: FastifyRequest,
status: number,
title: string,
code: string,
detail: string,
validation?: readonly ValidationIssue[],
) => {
const body: Record<string, unknown> = {
type: 'about:blank',
title,
status,
code,
detail,
requestId: request.id,
};
if (validation && validation.length > 0) body.validation = validation;
return body;
};
export function createListenOptions(environment: ListenEnvironment): ListenOptions {
const host = environment.API_HOST ?? API_DEFAULT_HOST;
if (!LOOPBACK_HOSTS.has(host.toLowerCase())) {
throw new Error('API host must be an explicit loopback address');
}
const rawPort = environment.API_PORT;
const port = rawPort === undefined ? API_DEFAULT_PORT : Number(rawPort);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error('API port must be an integer between 1 and 65535');
}
if (port === LEGACY_PORT) throw new Error('API port 8788 is reserved for the legacy service');
if (port !== API_DEFAULT_PORT) throw new Error('API port is fixed at 8790');
return { host, port };
}
export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
const logger =
options.logger === false || options.logger === undefined
? false
: {
...(options.logger.stream ? { stream: options.logger.stream } : {}),
redact: {
paths: [...REDACT_PATHS],
censor: '[REDACTED]',
},
};
const fastifyOptions: FastifyServerOptions = {
genReqId: () => randomUUID(),
logger,
ajv: {
customOptions: {
removeAdditional: false,
coerceTypes: false,
},
},
};
const app = Fastify(fastifyOptions);
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
.code(403)
.type('application/problem+json')
.send(
sendProblem(
request,
403,
'Forbidden',
'UNTRUSTED_HOST',
'The Host header is not allowed.',
),
);
}
const origin = request.headers.origin;
if (origin && !isSameOrigin(origin, host)) {
return reply
.code(403)
.type('application/problem+json')
.send(
sendProblem(
request,
403,
'Forbidden',
'CROSS_ORIGIN_REQUEST',
'The Origin header is not allowed.',
),
);
}
});
app.setErrorHandler((error, request, reply) => {
const issues = validationIssues(error);
if (issues.length > 0) {
return reply
.code(400)
.type('application/problem+json')
.send(
sendProblem(
request,
400,
'Bad Request',
'VALIDATION_FAILED',
'The request did not satisfy the endpoint schema.',
issues,
),
);
}
if (error instanceof UpstreamError && error.code !== 'UPSTREAM_REQUEST_INVALID') {
const status =
error.code === 'UPSTREAM_INSECURE_AUTH'
? 400
: error.code === 'UPSTREAM_TIMEOUT'
? 504
: 502;
const title =
status === 400 ? 'Bad Request' : status === 504 ? 'Gateway Timeout' : 'Bad Gateway';
const detail =
status === 400
? 'Authentication requires an HTTPS instance origin.'
: status === 504
? 'The upstream instance did not respond in time.'
: 'The upstream instance could not complete the request.';
return reply
.code(status)
.type('application/problem+json')
.send(sendProblem(request, status, title, error.code, detail));
}
const status = clientStatus(error);
if (status !== undefined) {
const title = CLIENT_ERROR_TITLES[status] ?? 'Bad Request';
return reply
.code(status)
.type('application/problem+json')
.send(
sendProblem(
request,
status,
title,
`HTTP_${status}`,
'The request could not be accepted.',
),
);
}
request.log.error(
{ errorType: error instanceof Error ? error.name : typeof error },
'request failed',
);
return reply
.code(500)
.type('application/problem+json')
.send(
sendProblem(
request,
500,
'Internal Server Error',
'INTERNAL_ERROR',
'The request could not be completed.',
),
);
});
app.setNotFoundHandler((request, reply) =>
reply
.code(404)
.type('application/problem+json')
.send(
sendProblem(
request,
404,
'Not Found',
'NOT_FOUND',
'The requested resource does not exist.',
),
),
);
app.get('/healthz', async () => ({ status: 'ok' }));
app.get('/readyz', async (_request, reply) => {
const result = await readiness();
if (!result.ready) reply.code(503);
return result;
});
if (options.registerRoutes) {
app.register(async (scope) => options.registerRoutes?.(scope));
}
return app;
}
@@ -0,0 +1,159 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { AuditQueryError, AuditQueryService } from './audit-query-service.js';
const t1 = '2026-07-17T10:00:00.000Z';
const t2 = '2026-07-17T11:00:00.000Z';
function setup() {
const db = new Database(':memory:');
migrateDatabase(db);
return { db, subject: new AuditQueryService(db) };
}
function insert(db: Database.Database, values: Record<string, unknown> = {}): void {
const row = {
id: 'audit-1',
actor: 'actor-1',
operation: 'network.register-auto',
request: 'request-1',
summary: JSON.stringify([{ fieldId: 'password', displayValue: 'secret', redacted: true }]),
outcome: 'succeeded',
created: t1,
...values,
};
db.prepare(
`INSERT INTO audit_events
(id, instance_id, job_id, actor, operation_id, risk_level, request_id,
parameters_summary_json, result_code, duration_ms, created_at)
VALUES (?, NULL, NULL, ?, ?, 'R1', ?, ?, ?, 1, ?)`,
).run(row.id, row.actor, row.operation, row.request, row.summary, row.outcome, row.created);
}
function expectCode(action: () => unknown, code: string): void {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(AuditQueryError);
expect((error as AuditQueryError).code).toBe(code);
return;
}
throw new Error('expected AuditQueryError');
}
describe('AuditQueryService', () => {
it('projects the exact frozen contract and replaces persisted display values', () => {
const { db, subject } = setup();
insert(db);
const event = subject.get('audit-1');
expect(event).toEqual({
id: 'audit-1',
occurredAt: t1,
actorId: 'actor-1',
action: 'network.register-auto',
outcome: 'succeeded',
requestId: 'request-1',
parameterSummary: [{ fieldId: 'password', displayValue: '[REDACTED]', redacted: true }],
});
expect(Object.isFrozen(event)).toBe(true);
expect(Object.isFrozen(event.parameterSummary)).toBe(true);
expect(Object.isFrozen(event.parameterSummary![0])).toBe(true);
});
it.each(['succeeded', 'failed', 'partially-succeeded', 'denied'])(
'accepts only the frozen outcome %s without mapping',
(outcome) => {
const { db, subject } = setup();
insert(db, { outcome });
expect(subject.get('audit-1').outcome).toBe(outcome);
},
);
it('filters all fields, applies inclusive date bounds, and uses ascending id as tie-break', () => {
const { db, subject } = setup();
insert(db, { id: 'z', created: t1 });
insert(db, { id: 'a', created: t1 });
insert(db, { id: 'other', actor: 'other', created: t2 });
expect(
subject
.list({
actorId: 'actor-1',
operationId: 'network.register-auto',
requestId: 'request-1',
outcome: 'succeeded',
occurredFrom: t1,
occurredTo: t1,
sort: 'occurredAt',
direction: 'desc',
})
.items.map(({ id }) => id),
).toEqual(['a', 'z']);
});
it('paginates in SQL with safe offset bounds', () => {
const { db, subject } = setup();
insert(db, { id: 'a' });
insert(db, { id: 'b', created: t2 });
expect(subject.list({ page: 2, pageSize: 1 })).toMatchObject({
items: [{ id: 'b' }],
page: { page: 2, pageSize: 1, total: 2 },
});
expect(
subject.list({ page: Math.floor(Number.MAX_SAFE_INTEGER / 100) + 1, pageSize: 100 }).items,
).toEqual([]);
expectCode(
() => subject.list({ page: Math.floor(Number.MAX_SAFE_INTEGER / 100) + 2, pageSize: 100 }),
'VALIDATION_FAILED',
);
});
it.each([
['unknown outcome', { outcome: 'ok' }],
['noncanonical timestamp', { created: '2026-07-17T10:00:00Z' }],
['malformed summary', { summary: '{bad' }],
['non-array summary', { summary: '{}' }],
['false redaction', { summary: '[{"fieldId":"x","displayValue":"x","redacted":false}]' }],
['missing key', { summary: '[{"fieldId":"x","redacted":true}]' }],
['extra key', { summary: '[{"fieldId":"x","displayValue":"x","redacted":true,"x":1}]' }],
])('fails the whole result as UNREPRESENTABLE for %s', (_name, values) => {
const { db, subject } = setup();
insert(db, values);
expectCode(() => subject.list(), 'UNREPRESENTABLE');
});
it('bounds summary collections at 100 and persisted strings at 200 characters', () => {
const atBound = setup();
insert(atBound.db, {
actor: 'a'.repeat(200),
summary: JSON.stringify(
Array.from({ length: 100 }, (_, index) => ({
fieldId: String(index),
displayValue: 'hidden',
redacted: true,
})),
),
});
expect(atBound.subject.get('audit-1').parameterSummary).toHaveLength(100);
const over = setup();
insert(over.db, {
summary: JSON.stringify(
Array.from({ length: 101 }, (_, index) => ({
fieldId: String(index),
displayValue: 'hidden',
redacted: true,
})),
),
});
expectCode(() => over.subject.get('audit-1'), 'UNREPRESENTABLE');
const long = setup();
insert(long.db, { actor: 'a'.repeat(201) });
expectCode(() => long.subject.get('audit-1'), 'UNREPRESENTABLE');
});
it('returns NOT_FOUND and rejects invalid service input', () => {
const { subject } = setup();
expectCode(() => subject.get('missing'), 'NOT_FOUND');
expectCode(() => subject.get(''), 'VALIDATION_FAILED');
expectCode(() => subject.list({ page: 0 }), 'VALIDATION_FAILED');
expectCode(() => subject.list({ occurredFrom: t2, occurredTo: t1 }), 'VALIDATION_FAILED');
});
});
@@ -0,0 +1,223 @@
import type Database from 'better-sqlite3';
import {
AUDIT_OUTCOMES,
type AuditEvent,
type AuditPage,
type AuditPageQuery,
type RedactedParameterSummaryItem,
} from '@multi-simadmin/contracts';
export type AuditQueryErrorCode = 'VALIDATION_FAILED' | 'NOT_FOUND' | 'UNREPRESENTABLE';
export class AuditQueryError extends Error {
constructor(
readonly code: AuditQueryErrorCode,
message: string,
) {
super(message);
this.name = 'AuditQueryError';
}
}
interface AuditRow {
id: unknown;
instance_id: unknown;
job_id: unknown;
actor: unknown;
operation_id: unknown;
request_id: unknown;
parameters_summary_json: unknown;
result_code: unknown;
created_at: unknown;
}
const COLUMNS =
'id, instance_id, job_id, actor, operation_id, request_id, parameters_summary_json, result_code, created_at';
const CANONICAL_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
const MAX_STRING_LENGTH = 200;
const MAX_PARAMETER_ITEMS = 100;
function validation(message: string): never {
throw new AuditQueryError('VALIDATION_FAILED', message);
}
function fail(message: string): never {
throw new AuditQueryError('UNREPRESENTABLE', message);
}
function freeze<T extends object>(value: T): Readonly<T> {
return Object.freeze(value);
}
function boundedString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.length < 1 || value.length > MAX_STRING_LENGTH)
fail(`Persisted audit ${field} is invalid`);
return value;
}
function optionalString(value: unknown, field: string): string | undefined {
return value === null ? undefined : boundedString(value, field);
}
function canonicalTimestamp(value: unknown, field: string): string {
const result = boundedString(value, field);
if (!CANONICAL_TIMESTAMP.test(result)) fail(`Persisted audit ${field} is not canonical`);
const parsed = new Date(result);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== result)
fail(`Persisted audit ${field} is invalid`);
return result;
}
function inputString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.length < 1 || value.length > MAX_STRING_LENGTH)
validation(`${field} is invalid`);
return value;
}
function inputTimestamp(value: unknown, field: string): string {
const result = inputString(value, field);
if (!CANONICAL_TIMESTAMP.test(result)) validation(`${field} must be a canonical timestamp`);
const parsed = new Date(result);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== result)
validation(`${field} must be a canonical timestamp`);
return result;
}
function positiveInteger(value: unknown, fallback: number, maximum: number, field: string): number {
const result = value ?? fallback;
if (!Number.isSafeInteger(result) || (result as number) < 1 || (result as number) > maximum)
validation(`${field} is invalid`);
return result as number;
}
function parameterSummary(value: unknown): readonly RedactedParameterSummaryItem[] {
if (typeof value !== 'string') fail('Persisted audit parameter summary is invalid');
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
fail('Persisted audit parameter summary is invalid');
}
if (!Array.isArray(parsed) || parsed.length > MAX_PARAMETER_ITEMS)
fail('Persisted audit parameter summary is invalid');
return freeze(
parsed.map((item): RedactedParameterSummaryItem => {
if (typeof item !== 'object' || item === null || Array.isArray(item))
fail('Persisted audit parameter summary item is invalid');
const row = item as Record<string, unknown>;
const keys = Object.keys(row).sort();
if (
keys.length !== 3 ||
keys[0] !== 'displayValue' ||
keys[1] !== 'fieldId' ||
keys[2] !== 'redacted' ||
row.redacted !== true
)
fail('Persisted audit parameter summary item is invalid');
boundedString(row.displayValue, 'parameter displayValue');
return freeze({
fieldId: boundedString(row.fieldId, 'parameter fieldId'),
displayValue: '[REDACTED]',
redacted: true,
});
}),
);
}
export class AuditQueryService {
constructor(private readonly db: Database.Database) {}
get(id: string): AuditEvent {
inputString(id, 'eventId');
const row = this.db.prepare(`SELECT ${COLUMNS} FROM audit_events WHERE id = ?`).get(id) as
| AuditRow
| undefined;
if (!row) throw new AuditQueryError('NOT_FOUND', 'Audit event was not found');
return this.project(row);
}
list(query: AuditPageQuery = {}): AuditPage {
const pageSize = positiveInteger(query.pageSize, 25, 100, 'pageSize');
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
const page = positiveInteger(query.page, 1, maximumPage, 'page');
const sort = query.sort ?? 'occurredAt';
if (sort !== 'occurredAt' && sort !== 'action' && sort !== 'outcome')
validation('sort is invalid');
const direction = query.direction ?? 'asc';
if (direction !== 'asc' && direction !== 'desc') validation('direction is invalid');
const clauses: string[] = [];
const parameters: unknown[] = [];
const equal = (column: string, value: unknown, field: string): void => {
if (value === undefined) return;
clauses.push(`${column} = ?`);
parameters.push(inputString(value, field));
};
equal('a.actor', query.actorId, 'actorId');
equal('a.instance_id', query.instanceId, 'instanceId');
equal('a.job_id', query.jobId, 'jobId');
equal('a.operation_id', query.operationId, 'operationId');
equal('a.request_id', query.requestId, 'requestId');
if (query.outcome !== undefined) {
if (!(AUDIT_OUTCOMES as readonly string[]).includes(query.outcome))
validation('outcome is invalid');
equal('a.result_code', query.outcome, 'outcome');
}
// Both date bounds are deliberately inclusive.
if (query.occurredFrom !== undefined) {
clauses.push('a.created_at >= ?');
parameters.push(inputTimestamp(query.occurredFrom, 'occurredFrom'));
}
if (query.occurredTo !== undefined) {
clauses.push('a.created_at <= ?');
parameters.push(inputTimestamp(query.occurredTo, 'occurredTo'));
}
if (
query.occurredFrom !== undefined &&
query.occurredTo !== undefined &&
query.occurredFrom > query.occurredTo
)
validation('occurredFrom must not be after occurredTo');
const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
const count = this.db
.prepare(`SELECT COUNT(*) AS total FROM audit_events a${where}`)
.get(...parameters) as { total: unknown } | undefined;
if (
!count ||
typeof count.total !== 'number' ||
!Number.isSafeInteger(count.total) ||
count.total < 0
)
fail('Persisted audit count is invalid');
const sortColumn = {
occurredAt: 'a.created_at',
action: 'a.operation_id',
outcome: 'a.result_code',
}[sort];
const offset = (page - 1) * pageSize;
const rows = this.db
.prepare(
`SELECT ${COLUMNS.split(', ')
.map((column) => `a.${column}`)
.join(', ')}
FROM audit_events a${where}
ORDER BY ${sortColumn} ${direction.toUpperCase()}, a.id ASC LIMIT ? OFFSET ?`,
)
.all(...parameters, pageSize, offset) as AuditRow[];
return freeze({
items: freeze(rows.map((row) => this.project(row))),
page: freeze({ page, pageSize, total: count.total }),
});
}
private project(row: AuditRow): AuditEvent {
const outcome = boundedString(row.result_code, 'result_code');
if (!(AUDIT_OUTCOMES as readonly string[]).includes(outcome))
fail('Persisted audit result_code is unrepresentable');
const instanceId = optionalString(row.instance_id, 'instance_id');
const jobId = optionalString(row.job_id, 'job_id');
return freeze({
id: boundedString(row.id, 'id'),
occurredAt: canonicalTimestamp(row.created_at, 'created_at'),
actorId: boundedString(row.actor, 'actor'),
action: boundedString(row.operation_id, 'operation_id'),
outcome: outcome as AuditEvent['outcome'],
requestId: boundedString(row.request_id, 'request_id'),
...(instanceId === undefined ? {} : { instanceId }),
...(jobId === undefined ? {} : { jobId }),
parameterSummary: parameterSummary(row.parameters_summary_json),
});
}
}
@@ -0,0 +1,258 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
import {
CapabilityObservationError,
CapabilityObservationService,
parseHealthVersion,
type CapabilityOperationDescriptor,
} from './capability-observation-service.js';
const databases: Database.Database[] = [];
afterEach(() => {
for (const database of databases.splice(0)) database.close();
});
const operation = (
overrides: Partial<CapabilityOperationDescriptor> = {},
): CapabilityOperationDescriptor => ({
operationId: 'getHealth',
method: 'GET',
pathTemplate: '/api/health',
riskLevel: 'R0',
...overrides,
});
function fixture(
options: {
descriptor?: CapabilityOperationDescriptor;
response?: {
status: number;
headers: Readonly<Record<string, string | undefined>>;
body: string;
};
failure?: unknown;
now?: string;
} = {},
) {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
databases.push(db);
const created = '2026-07-17T10:00:00.000Z';
db.prepare(
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
).run('instance-1', 'LAN', 'http://192.168.1.20:3000', 'none', 1, 1, created, created);
const events: string[] = [];
const instances = {
get: vi.fn(async (id: string) => {
events.push('instance');
return id === 'instance-1' ? { id, origin: 'http://192.168.1.20:3000' } : undefined;
}),
};
const registry = {
requireOperation: vi.fn((id: string) => {
events.push('operation');
if (id !== 'getHealth') throw new Error('unknown operation');
return options.descriptor ?? operation();
}),
};
const transport = {
get: vi.fn(async (url: string) => {
events.push('network');
expect(url).toBe('http://192.168.1.20:3000/api/health');
if (options.failure) throw options.failure;
return options.response ?? { status: 200, headers: {}, body: '{"version":"1.2.3"}' };
}),
};
const service = new CapabilityObservationService({
db,
instances,
registry,
transport,
now: () => new Date(options.now ?? '2026-07-17T12:00:00.000Z'),
});
return { db, events, instances, registry, transport, service };
}
const row = (db: Database.Database) =>
db
.prepare(
'SELECT instance_id,operation_id,state,upstream_version,detail_code,observed_at,created_at,updated_at FROM capabilities',
)
.get();
describe('parseHealthVersion', () => {
it.each([
['{"version":"1.2.3"}', '1.2.3'],
['{"data":{"version":"v2026.07.17-beta+5"}}', 'v2026.07.17-beta+5'],
['{"version":" 2.0.0 "}', '2.0.0'],
])('extracts a bounded version from supported health envelopes', (body, expected) => {
expect(parseHealthVersion(body)).toBe(expected);
});
it.each([
['not-json'],
['null'],
['{"version":7}'],
['{"version":""}'],
[`{"version":"${'a'.repeat(129)}"}`],
['{"version":"1.2.3\\nsecret"}'],
['{"data":{"version":{"raw":"secret"}}}'],
])('rejects malformed, unbounded, or unsafe values without leaking them', (body) => {
expect(parseHealthVersion(body)).toBeUndefined();
});
});
describe('CapabilityObservationService', () => {
it('resolves instance then operation before probing an R0 GET and persists one redacted supported row', async () => {
const { db, events, service } = fixture();
await expect(service.observe('instance-1', 'getHealth')).resolves.toEqual({
instanceId: 'instance-1',
operationId: 'getHealth',
state: 'supported',
upstreamVersion: '1.2.3',
detailCode: 'ACTIVE_PROBE_SUPPORTED',
observedAt: '2026-07-17T12:00:00.000Z',
});
expect(events).toEqual(['instance', 'operation', 'network']);
expect(row(db)).toEqual({
instance_id: 'instance-1',
operation_id: 'getHealth',
state: 'supported',
upstream_version: '1.2.3',
detail_code: 'ACTIVE_PROBE_SUPPORTED',
observed_at: '2026-07-17T12:00:00.000Z',
created_at: '2026-07-17T12:00:00.000Z',
updated_at: '2026-07-17T12:00:00.000Z',
});
});
it.each([
[401, 'auth-required', 'ACTIVE_PROBE_AUTH_REQUIRED'],
[403, 'auth-required', 'ACTIVE_PROBE_AUTH_REQUIRED'],
[404, 'unsupported', 'ACTIVE_PROBE_UNSUPPORTED'],
[500, 'degraded', 'ACTIVE_PROBE_UPSTREAM_DEGRADED'],
[503, 'degraded', 'ACTIVE_PROBE_UPSTREAM_DEGRADED'],
] as const)(
'classifies HTTP %i without persisting response content',
async (status, state, detailCode) => {
const body = '{"password":"do-not-store","version":"9.9.9"}';
const { db, service } = fixture({
response: { status, headers: { 'set-cookie': 'secret=1' }, body },
});
await expect(service.observe('instance-1', 'getHealth')).resolves.toMatchObject({
state,
detailCode,
upstreamVersion: undefined,
});
expect(JSON.stringify(row(db))).not.toContain('do-not-store');
expect(JSON.stringify(row(db))).not.toContain('secret=1');
},
);
it.each([
[new UpstreamError('UPSTREAM_TIMEOUT'), 'ACTIVE_PROBE_TIMEOUT'],
[new UpstreamError('UPSTREAM_UNAVAILABLE'), 'ACTIVE_PROBE_NETWORK_ERROR'],
[new Error('password=never-persist'), 'ACTIVE_PROBE_NETWORK_ERROR'],
])('maps transport failures to unknown stable codes only', async (failure, detailCode) => {
const { db, service } = fixture({ failure });
await expect(service.observe('instance-1', 'getHealth')).resolves.toMatchObject({
state: 'unknown',
detailCode,
});
expect(JSON.stringify(row(db))).not.toContain(failure.message);
});
it.each([
[operation({ operationId: 'getHealth', method: 'POST', riskLevel: 'R1' })],
[operation({ operationId: 'getHealth', method: 'DELETE', riskLevel: 'R3' })],
[operation({ operationId: 'getHealth', method: 'GET', riskLevel: 'R1' })],
])('never actively probes non-R0-GET operations and records unknown', async (descriptor) => {
const { db, service, transport } = fixture({ descriptor });
await expect(service.observe('instance-1', 'getHealth')).resolves.toMatchObject({
state: 'unknown',
detailCode: 'ACTIVE_PROBE_NOT_SAFE',
upstreamVersion: undefined,
});
expect(transport.get).not.toHaveBeenCalled();
expect(row(db)).toBeTruthy();
});
it('does not let an older in-flight observation overwrite a newer completed observation', async () => {
const { db, service, transport } = fixture();
let resolveOlder!: (response: {
status: number;
headers: Record<string, string>;
body: string;
}) => void;
const olderResponse = new Promise<{
status: number;
headers: Record<string, string>;
body: string;
}>((resolve) => {
resolveOlder = resolve;
});
transport.get
.mockImplementationOnce(async () => olderResponse)
.mockResolvedValueOnce({ status: 404, headers: {}, body: 'newer-sensitive-body' });
const older = service.observe('instance-1', 'getHealth');
await vi.waitFor(() => expect(transport.get).toHaveBeenCalledTimes(1));
const newer = service.observe('instance-1', 'getHealth');
await expect(newer).resolves.toMatchObject({
state: 'unsupported',
detailCode: 'ACTIVE_PROBE_UNSUPPORTED',
});
resolveOlder({ status: 200, headers: {}, body: '{"version":"older-secret-version"}' });
await expect(older).resolves.toMatchObject({ state: 'supported' });
expect(row(db)).toMatchObject({
state: 'unsupported',
upstream_version: null,
detail_code: 'ACTIVE_PROBE_UNSUPPORTED',
});
expect(JSON.stringify(row(db))).not.toContain('older-secret-version');
expect(JSON.stringify(row(db))).not.toContain('newer-sensitive-body');
});
it('upserts the single instance-operation row while preserving created_at', async () => {
const first = fixture();
await first.service.observe('instance-1', 'getHealth');
const second = new CapabilityObservationService({
db: first.db,
instances: first.instances,
registry: first.registry,
transport: { get: async () => ({ status: 404, headers: {}, body: 'sensitive' }) },
now: () => new Date('2026-07-17T13:00:00.000Z'),
});
await second.observe('instance-1', 'getHealth');
expect(first.db.prepare('SELECT COUNT(*) count FROM capabilities').get()).toEqual({ count: 1 });
expect(row(first.db)).toMatchObject({
state: 'unsupported',
upstream_version: null,
created_at: '2026-07-17T12:00:00.000Z',
updated_at: '2026-07-17T13:00:00.000Z',
});
});
it('does no operation resolution, write, or network for an unknown instance', async () => {
const { db, service, registry, transport } = fixture();
await expect(service.observe('missing', 'getHealth')).rejects.toEqual(
expect.objectContaining<Partial<CapabilityObservationError>>({ code: 'INSTANCE_NOT_FOUND' }),
);
expect(registry.requireOperation).not.toHaveBeenCalled();
expect(transport.get).not.toHaveBeenCalled();
expect(row(db)).toBeUndefined();
});
it('does no write or network for an unknown operation', async () => {
const { db, service, transport } = fixture();
await expect(service.observe('instance-1', 'missing')).rejects.toEqual(
expect.objectContaining<Partial<CapabilityObservationError>>({ code: 'OPERATION_NOT_FOUND' }),
);
expect(transport.get).not.toHaveBeenCalled();
expect(row(db)).toBeUndefined();
});
});
@@ -0,0 +1,216 @@
import type Database from 'better-sqlite3';
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
import type { TransportResponse } from '../../infrastructure/transport/safe-instance-transport.js';
export type CapabilityState =
| 'supported'
| 'unsupported'
| 'auth-required'
| 'degraded'
| 'unknown';
export type CapabilityDetailCode =
| 'ACTIVE_PROBE_SUPPORTED'
| 'ACTIVE_PROBE_AUTH_REQUIRED'
| 'ACTIVE_PROBE_UNSUPPORTED'
| 'ACTIVE_PROBE_UPSTREAM_DEGRADED'
| 'ACTIVE_PROBE_TIMEOUT'
| 'ACTIVE_PROBE_NETWORK_ERROR'
| 'ACTIVE_PROBE_UNEXPECTED_STATUS'
| 'ACTIVE_PROBE_NOT_SAFE';
export interface CapabilityOperationDescriptor {
readonly operationId: string;
readonly method: string;
readonly pathTemplate: string;
readonly riskLevel: 'R0' | 'R1' | 'R2' | 'R3';
}
export interface CapabilityInstance {
readonly id: string;
readonly origin: string;
}
export interface CapabilityObservation {
readonly instanceId: string;
readonly operationId: string;
readonly state: CapabilityState;
readonly upstreamVersion: string | undefined;
readonly detailCode: CapabilityDetailCode;
readonly observedAt: string;
}
export interface CapabilityObservationOptions {
readonly db: Database.Database;
readonly instances: { get(instanceId: string): Promise<CapabilityInstance | undefined> };
readonly registry: { requireOperation(operationId: string): CapabilityOperationDescriptor };
readonly transport: { get(url: string): Promise<TransportResponse> };
readonly now?: () => Date;
}
export type CapabilityObservationErrorCode = 'INSTANCE_NOT_FOUND' | 'OPERATION_NOT_FOUND';
export class CapabilityObservationError extends Error {
constructor(readonly code: CapabilityObservationErrorCode) {
super(code);
this.name = 'CapabilityObservationError';
}
}
const MAX_VERSION_LENGTH = 128;
const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
/** Maps only documented health envelope locations into bounded, inert version metadata. */
export function parseHealthVersion(body: string): string | undefined {
let value: unknown;
try {
value = JSON.parse(body);
} catch {
return undefined;
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
const root = value as Record<string, unknown>;
const data = root.data;
const candidate =
root.version ??
(data !== null && typeof data === 'object' && !Array.isArray(data)
? (data as Record<string, unknown>).version
: undefined);
if (typeof candidate !== 'string') return undefined;
const normalized = candidate.trim();
return normalized.length > 0 &&
normalized.length <= MAX_VERSION_LENGTH &&
VERSION_PATTERN.test(normalized)
? normalized
: undefined;
}
const joinUrl = (origin: string, path: string): string =>
`${origin.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
function classifyStatus(status: number): {
state: CapabilityState;
detailCode: CapabilityDetailCode;
} {
if (status >= 200 && status < 300)
return { state: 'supported', detailCode: 'ACTIVE_PROBE_SUPPORTED' };
if (status === 401 || status === 403)
return { state: 'auth-required', detailCode: 'ACTIVE_PROBE_AUTH_REQUIRED' };
if (status === 404) return { state: 'unsupported', detailCode: 'ACTIVE_PROBE_UNSUPPORTED' };
if (status >= 500 && status < 600)
return { state: 'degraded', detailCode: 'ACTIVE_PROBE_UPSTREAM_DEGRADED' };
return { state: 'unknown', detailCode: 'ACTIVE_PROBE_UNEXPECTED_STATUS' };
}
export class CapabilityObservationService {
readonly #db: Database.Database;
readonly #instances: CapabilityObservationOptions['instances'];
readonly #registry: CapabilityObservationOptions['registry'];
readonly #transport: CapabilityObservationOptions['transport'];
readonly #now: () => Date;
readonly #latestGeneration = new Map<string, number>();
#nextGeneration = 0;
constructor(options: CapabilityObservationOptions) {
this.#db = options.db;
this.#instances = options.instances;
this.#registry = options.registry;
this.#transport = options.transport;
this.#now = options.now ?? (() => new Date());
}
async observe(instanceId: string, operationId: string): Promise<CapabilityObservation> {
// Resolution order is intentional: neither unknown identity crosses the network or persistence boundary.
const instance = await this.#instances.get(instanceId);
if (!instance) throw new CapabilityObservationError('INSTANCE_NOT_FOUND');
let operation: CapabilityOperationDescriptor;
try {
operation = this.#registry.requireOperation(operationId);
} catch {
throw new CapabilityObservationError('OPERATION_NOT_FOUND');
}
const observationKey = JSON.stringify([instanceId, operation.operationId]);
const generation = ++this.#nextGeneration;
this.#latestGeneration.set(observationKey, generation);
const observedAt = this.#now().toISOString();
if (operation.method !== 'GET' || operation.riskLevel !== 'R0') {
return this.#persist(
{
instanceId,
operationId: operation.operationId,
state: 'unknown',
upstreamVersion: undefined,
detailCode: 'ACTIVE_PROBE_NOT_SAFE',
observedAt,
},
observationKey,
generation,
);
}
let state: CapabilityState;
let detailCode: CapabilityDetailCode;
let upstreamVersion: string | undefined;
try {
const response = await this.#transport.get(joinUrl(instance.origin, operation.pathTemplate));
({ state, detailCode } = classifyStatus(response.status));
if (state === 'supported' && operation.operationId === 'getHealth') {
upstreamVersion = parseHealthVersion(response.body);
}
} catch (error) {
state = 'unknown';
detailCode =
error instanceof UpstreamError && error.code === 'UPSTREAM_TIMEOUT'
? 'ACTIVE_PROBE_TIMEOUT'
: 'ACTIVE_PROBE_NETWORK_ERROR';
}
return this.#persist(
{
instanceId,
operationId: operation.operationId,
state,
upstreamVersion,
detailCode,
observedAt,
},
observationKey,
generation,
);
}
#persist(
observation: CapabilityObservation,
observationKey: string,
generation: number,
): CapabilityObservation {
if (this.#latestGeneration.get(observationKey) !== generation) {
return Object.freeze({ ...observation });
}
this.#db
.prepare(
`INSERT INTO capabilities
(instance_id,operation_id,state,upstream_version,detail_code,observed_at,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(instance_id,operation_id) DO UPDATE SET
state=excluded.state,
upstream_version=excluded.upstream_version,
detail_code=excluded.detail_code,
observed_at=excluded.observed_at,
updated_at=excluded.updated_at`,
)
.run(
observation.instanceId,
observation.operationId,
observation.state,
observation.upstreamVersion ?? null,
observation.detailCode,
observation.observedAt,
observation.observedAt,
observation.observedAt,
);
this.#latestGeneration.delete(observationKey);
return Object.freeze({ ...observation });
}
}
@@ -0,0 +1,95 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { InstanceService } from '../instances/instance-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import type { TransportResponse } from '../../infrastructure/transport/safe-instance-transport.js';
import { ConnectionProbe } from './connection-probe.js';
class Store implements SecretStore {
async set(key: { instanceId: string; purpose: string; slot?: string }) {
return `keychain://multi-simadmin/${Buffer.from(JSON.stringify([key.instanceId, key.purpose, key.slot])).toString('base64url')}`;
}
async get() {
return undefined;
}
async delete() {
return false;
}
}
const dbs: Database.Database[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
const fixture = (response: TransportResponse) => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const instances = new InstanceService({
db,
store: new Store(),
idFactory: () => 'instance-1',
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const transport = {
get: async (url: string) => {
expect(url).toBe('http://192.168.1.20:3000/api/health');
return response;
},
};
return {
db,
instances,
probe: new ConnectionProbe({
db,
instances,
transport,
now: () => new Date('2026-07-16T12:00:00.000Z'),
}),
};
};
describe('ConnectionProbe', () => {
it('uses the unauthenticated health endpoint and records a reachable unauthenticated instance', async () => {
const { instances, probe } = fixture({ status: 200, headers: {}, body: '{}' });
await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' });
await expect(probe.test('instance-1')).resolves.toEqual({
instanceId: 'instance-1',
authenticated: true,
checkedAt: '2026-07-16T12:00:00.000Z',
});
});
it('treats 401 as reachable but not authenticated rather than offline', async () => {
const { instances, probe } = fixture({ status: 401, headers: {}, body: '{}' });
await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' });
await expect(probe.test('instance-1')).resolves.toMatchObject({ authenticated: false });
});
it('persists only a redacted connection freshness snapshot, not a blanket capability claim', async () => {
const { db, instances, probe } = fixture({ status: 401, headers: {}, body: '{}' });
await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' });
await probe.test('instance-1');
await probe.test('instance-1');
const snapshots = db
.prepare('SELECT category, state, payload_json, expires_at FROM status_snapshots')
.all() as {
category: string;
state: string;
payload_json: string;
expires_at: string;
}[];
expect(snapshots).toHaveLength(1);
expect(snapshots[0]).toMatchObject({ category: 'connection', state: 'fresh' });
expect(JSON.parse(snapshots[0]!.payload_json)).toEqual({
reachable: true,
authenticated: false,
});
expect(snapshots[0]!.expires_at).toBe('2026-07-16T12:00:30.000Z');
expect(db.prepare('SELECT * FROM capabilities').all()).toEqual([]);
});
it('does not invoke transport for an unknown instance', async () => {
const { probe } = fixture({ status: 200, headers: {}, body: '{}' });
await expect(probe.test('missing')).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
});
@@ -0,0 +1,53 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import type { SessionMetadata } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from '../instances/instance-service.js';
import type { TransportResponse } from '../../infrastructure/transport/safe-instance-transport.js';
export interface ConnectionTransport {
readonly get: (url: string) => Promise<TransportResponse>;
}
export interface ConnectionProbeOptions {
readonly db: Database.Database;
readonly instances: InstanceService;
readonly transport: ConnectionTransport;
readonly now?: () => Date;
}
const CONNECTION_SNAPSHOT_TTL_MS = 30_000;
export class ConnectionProbe {
private readonly now: () => Date;
constructor(private readonly options: ConnectionProbeOptions) {
this.now = options.now ?? (() => new Date());
}
async test(instanceId: string): Promise<SessionMetadata> {
const instance = await this.options.instances.get(instanceId);
if (!instance) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
const response = await this.options.transport.get(`${instance.origin}/api/health`);
const observed = this.now();
const observedAt = observed.toISOString();
const expiresAt = new Date(observed.getTime() + CONNECTION_SNAPSHOT_TTL_MS).toISOString();
const authenticated = response.status >= 200 && response.status < 300;
this.options.db
.prepare(
`INSERT INTO status_snapshots (id, instance_id, category, state, payload_json, observed_at, expires_at, created_at)
VALUES (?, ?, 'connection', 'fresh', ?, ?, ?, ?)
ON CONFLICT(instance_id, category) DO UPDATE SET
id=excluded.id,
state=excluded.state,
payload_json=excluded.payload_json,
observed_at=excluded.observed_at,
expires_at=excluded.expires_at,
created_at=excluded.created_at`,
)
.run(
randomUUID(),
instanceId,
JSON.stringify({ reachable: true, authenticated }),
observedAt,
expiresAt,
observedAt,
);
return { instanceId, authenticated, checkedAt: observedAt };
}
}
@@ -0,0 +1,73 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { InstanceCredentialResolver } from './instance-credential-resolver.js';
const refs = (instanceId: string) =>
`keychain://multi-simadmin/${Buffer.from(
JSON.stringify([instanceId, 'instance-password', 'slot-1']),
).toString('base64url')}`;
const dbs: Database.Database[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
const fixture = (store: SecretStore) => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
db.prepare(
"INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES ('i1','N','http://192.168.1.20:3000','password',1,1,'t','t')",
).run();
return { db, resolver: new InstanceCredentialResolver({ db, store }) };
};
describe('InstanceCredentialResolver', () => {
it('resolves only the canonical password reference for the requested existing instance', async () => {
let asked: string | undefined;
const { db, resolver } = fixture({
set: async () => '',
get: async (reference) => {
asked = reference;
return '[REDACTED]';
},
delete: async () => false,
});
const reference = refs('i1');
db.prepare(
"INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES ('s1','i1','instance-password','macos-keychain',?,'t','t')",
).run(reference);
await expect(resolver.resolve('i1')).resolves.toBe('[REDACTED]');
expect(asked).toBe(reference);
});
it('does not call SecretStore when no saved credential exists', async () => {
let calls = 0;
const { resolver } = fixture({
set: async () => '',
get: async () => {
calls += 1;
return '[REDACTED]';
},
delete: async () => false,
});
await expect(resolver.resolve('i1')).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' });
expect(calls).toBe(0);
});
it('rejects a reference bound to another instance before SecretStore access', async () => {
let calls = 0;
const { db, resolver } = fixture({
set: async () => '',
get: async () => {
calls += 1;
return '[REDACTED]';
},
delete: async () => false,
});
db.prepare(
"INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES ('s1','i1','instance-password','macos-keychain',?,'t','t')",
).run(refs('other'));
await expect(resolver.resolve('i1')).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' });
expect(calls).toBe(0);
});
});
@@ -0,0 +1,40 @@
import type Database from 'better-sqlite3';
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
export class CredentialResolverError extends Error {
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
super(code);
}
}
export interface InstanceCredentialResolverOptions {
readonly db: Database.Database;
readonly store: SecretStore;
}
export class InstanceCredentialResolver {
constructor(private readonly options: InstanceCredentialResolverOptions) {}
async resolve(instanceId: string): Promise<string> {
const instance = this.options.db
.prepare('SELECT id FROM instances WHERE id=?')
.get(instanceId) as { id: string } | undefined;
if (!instance) throw new CredentialResolverError('NOT_FOUND');
const row = this.options.db
.prepare(
'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?',
)
.get(instanceId, PURPOSE, PROVIDER) as { external_reference: string } | undefined;
if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
try {
const parsed = parseKeychainReference(row.external_reference);
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot)
throw new Error('invalid binding');
const secret = await this.options.store.get(row.external_reference);
if (!secret) throw new Error('missing secret');
return secret;
} catch {
throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
}
}
}
@@ -0,0 +1,82 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { InstanceSessionStore, UpstreamSessionClient } from './upstream-session-client.js';
import { InstanceLoginService } from './instance-login-service.js';
const dbs: Database.Database[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
const fixture = () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
db.prepare(
"INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES ('i1','N','http://192.168.1.20:3000','password',1,1,'t','t')",
).run();
const calls: unknown[] = [];
const sessions = new InstanceSessionStore();
const client = new UpstreamSessionClient({
sessions,
request: async (request) => {
calls.push(request);
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=opaque; HttpOnly' },
body: '',
};
},
});
let resolverCalls = 0;
const resolver = {
resolve: async () => {
resolverCalls += 1;
return '[REDACTED]';
},
};
return {
calls,
sessions,
resolverCalls: () => resolverCalls,
service: new InstanceLoginService({
db,
client,
resolver,
now: () => new Date('2026-07-16T12:00:00.000Z'),
}),
};
};
describe('InstanceLoginService', () => {
it('uses a supplied one-shot password without invoking saved-credential resolution', async () => {
const { service, resolverCalls } = fixture();
await expect(service.login('i1', '[REDACTED]')).resolves.toEqual({
instanceId: 'i1',
authenticated: true,
checkedAt: '2026-07-16T12:00:00.000Z',
});
expect(resolverCalls()).toBe(0);
});
it('uses resolver only when the password input is omitted', async () => {
const { service } = fixture();
await expect(service.login('i1')).resolves.toMatchObject({ authenticated: true });
});
it('returns unauthenticated metadata after logout and clears the local session', async () => {
const { service, sessions } = fixture();
await service.login('i1', '[REDACTED]');
await expect(service.logout('i1')).resolves.toEqual({
instanceId: 'i1',
authenticated: false,
checkedAt: '2026-07-16T12:00:00.000Z',
});
expect(sessions.cookieFor('i1')).toBeUndefined();
});
it('does not contact upstream for an unknown instance', async () => {
const { service, calls } = fixture();
await expect(service.login('missing', '[REDACTED]')).rejects.toMatchObject({
code: 'NOT_FOUND',
});
expect(calls).toHaveLength(0);
});
});
@@ -0,0 +1,48 @@
import type Database from 'better-sqlite3';
import type { SessionMetadata } from '@multi-simadmin/contracts';
import { CredentialResolverError } from './instance-credential-resolver.js';
import { UpstreamSessionClient } from './upstream-session-client.js';
export interface CredentialResolver {
resolve(instanceId: string): Promise<string>;
}
export class InstanceLoginServiceError extends Error {
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
super(code);
}
}
export interface InstanceLoginServiceOptions {
readonly db: Database.Database;
readonly client: UpstreamSessionClient;
readonly resolver: CredentialResolver;
readonly now?: () => Date;
}
export class InstanceLoginService {
private readonly now: () => Date;
constructor(private readonly options: InstanceLoginServiceOptions) {
this.now = options.now ?? (() => new Date());
}
async login(instanceId: string, password?: string): Promise<SessionMetadata> {
const origin = this.origin(instanceId);
let resolved: string;
try {
resolved = password ?? (await this.options.resolver.resolve(instanceId));
} catch (error) {
if (error instanceof CredentialResolverError) throw new InstanceLoginServiceError(error.code);
throw error;
}
const result = await this.options.client.login(instanceId, origin, resolved);
return { instanceId, authenticated: result.authenticated, checkedAt: this.now().toISOString() };
}
async logout(instanceId: string): Promise<SessionMetadata> {
await this.options.client.logout(instanceId, this.origin(instanceId));
return { instanceId, authenticated: false, checkedAt: this.now().toISOString() };
}
private origin(instanceId: string): string {
const row = this.options.db
.prepare('SELECT base_url FROM instances WHERE id=?')
.get(instanceId) as { base_url: string } | undefined;
if (!row) throw new InstanceLoginServiceError('NOT_FOUND');
return row.base_url;
}
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { InstanceSessionStore, UpstreamSessionClient } from './upstream-session-client.js';
describe('UpstreamSessionClient', () => {
it('keeps only the simadmin_session cookie in memory after successful login', async () => {
const sessions = new InstanceSessionStore();
const client = new UpstreamSessionClient({
sessions,
request: async (request) => {
expect(request.url).toBe('http://192.168.1.20:3000/api/auth/login');
expect(request.method).toBe('POST');
expect(request.headers['content-type']).toBe('application/json');
expect(request.body).toBe('[REDACTED]');
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=opaque; HttpOnly; Path=/, another=value' },
body: '{}',
};
},
});
await expect(
client.login('instance-1', 'http://192.168.1.20:3000', '[REDACTED]'),
).resolves.toEqual({ authenticated: true });
expect(sessions.cookieFor('instance-1')).toBe('simadmin_session=opaque');
});
it('does not create a session when login is rejected', async () => {
const sessions = new InstanceSessionStore();
const client = new UpstreamSessionClient({
sessions,
request: async () => ({ status: 401, headers: {}, body: '' }),
});
await expect(
client.login('instance-1', 'http://192.168.1.20:3000', '[REDACTED]'),
).resolves.toEqual({ authenticated: false });
expect(sessions.cookieFor('instance-1')).toBeUndefined();
});
it('does not authenticate a successful response that omits simadmin_session', async () => {
const sessions = new InstanceSessionStore();
const client = new UpstreamSessionClient({
sessions,
request: async () => ({
status: 200,
headers: { 'set-cookie': 'another=value; HttpOnly' },
body: '',
}),
});
await expect(
client.login('instance-1', 'http://192.168.1.20:3000', '[REDACTED]'),
).resolves.toEqual({ authenticated: false });
expect(sessions.cookieFor('instance-1')).toBeUndefined();
});
it('does not send a cookie after the instance origin changes', async () => {
const sessions = new InstanceSessionStore();
const requests: unknown[] = [];
const client = new UpstreamSessionClient({
sessions,
request: async (request) => {
requests.push(request);
return { status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '' };
},
});
await client.login('one', 'http://192.168.1.10:8080', '[REDACTED]');
await client.logout('one', 'http://192.168.1.11:8080');
expect(requests).toHaveLength(1);
expect(sessions.cookieFor('one')).toBeUndefined();
});
it('sends the stored cookie only to the matching instance logout endpoint, then clears it', async () => {
const sessions = new InstanceSessionStore();
sessions.set('instance-1', 'http://192.168.1.20:3000', 'simadmin_session=opaque');
const client = new UpstreamSessionClient({
sessions,
request: async (request) => {
expect(request.url).toBe('http://192.168.1.20:3000/api/auth/logout');
expect(request.headers.cookie).toBe('simadmin_session=opaque');
return { status: 200, headers: {}, body: '' };
},
});
await client.logout('instance-1', 'http://192.168.1.20:3000');
expect(sessions.cookieFor('instance-1')).toBeUndefined();
});
});
@@ -0,0 +1,83 @@
export interface UpstreamRequest {
readonly url: string;
readonly method: 'GET' | 'POST';
readonly headers: Readonly<Record<string, string>>;
/** One-shot secret. Request implementations must not log or persist this field. */
readonly secret?: string;
/** Redacted representation suitable for observability, never the password. */
readonly body?: '[REDACTED]';
/** Explicit one-shot SMS action payload. Implementations must not log or persist it. */
readonly sms?: { readonly phoneNumber: string; readonly content: string };
}
export interface UpstreamResponse {
readonly status: number;
readonly headers: Readonly<Record<string, string | undefined>>;
readonly body: string;
}
export interface UpstreamSessionClientOptions {
readonly sessions: InstanceSessionStore;
readonly request: (request: UpstreamRequest) => Promise<UpstreamResponse>;
}
export interface InstanceSession {
readonly origin: string;
readonly cookie: string;
}
export class InstanceSessionStore {
private readonly sessions = new Map<string, InstanceSession>();
set(instanceId: string, origin: string, cookie: string): void {
this.sessions.set(instanceId, { origin, cookie });
}
sessionFor(instanceId: string): InstanceSession | undefined {
return this.sessions.get(instanceId);
}
cookieFor(instanceId: string): string | undefined {
return this.sessions.get(instanceId)?.cookie;
}
clear(instanceId: string): void {
this.sessions.delete(instanceId);
}
}
const sessionCookie = (header: string | undefined): string | undefined => {
if (!header) return undefined;
const match = /(?:^|,\s*)simadmin_session=([^;\s,]+)/.exec(header);
return match ? `simadmin_session=${match[1]}` : undefined;
};
export class UpstreamSessionClient {
constructor(private readonly options: UpstreamSessionClientOptions) {}
async login(
instanceId: string,
origin: string,
password: string,
): Promise<{ authenticated: boolean }> {
const response = await this.options.request({
url: `${origin}/api/auth/login`,
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: password,
body: '[REDACTED]',
});
const cookie =
response.status >= 200 && response.status < 300
? sessionCookie(response.headers['set-cookie'])
: undefined;
if (!cookie) {
this.options.sessions.clear(instanceId);
return { authenticated: false };
}
this.options.sessions.set(instanceId, origin, cookie);
return { authenticated: true };
}
async logout(instanceId: string, origin: string): Promise<void> {
const session = this.options.sessions.sessionFor(instanceId);
try {
if (session?.origin === origin)
await this.options.request({
url: `${origin}/api/auth/logout`,
method: 'POST',
headers: { cookie: session.cookie },
});
} finally {
this.options.sessions.clear(instanceId);
}
}
}
@@ -0,0 +1,164 @@
import Database from 'better-sqlite3';
import { describe, expect, it, vi } from 'vitest';
import { EventJournal, formatServerSentEvent, type EventEnvelope } from './event-journal.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
const at = '2026-07-17T12:34:56.789Z';
function event(kind: EventEnvelope['kind'], id = `${kind}-1`): EventEnvelope {
const common = {
kind,
id,
occurredAt: at,
requestId: 'request-1',
};
switch (kind) {
case 'instance':
return { ...common, kind, instanceId: 'instance-1' };
case 'job':
return { ...common, kind, jobId: 'job-1' };
case 'item':
return { ...common, kind, jobId: 'job-1', itemId: 'item-1' };
case 'attempt':
return { ...common, kind, jobId: 'job-1', attemptId: 'attempt-1' };
case 'audit':
return { ...common, kind, auditEventId: 'audit-event-1' };
}
}
function journal(database = new Database(':memory:')): EventJournal {
migrateDatabase(database);
return new EventJournal(database);
}
describe('EventJournal', () => {
it('requires its forward-migrated schema instead of creating it implicitly', () => {
const database = new Database(':memory:');
expect(() => new EventJournal(database)).toThrow(
/event_journal.*schema|schema.*event_journal/i,
);
expect(
database
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='event_journal'")
.get(),
).toBeUndefined();
});
it('durably appends the exact five envelope variants with stable integer sequence', () => {
const database = new Database(':memory:');
migrateDatabase(database);
const first = new EventJournal(database);
const envelopes = (['instance', 'job', 'item', 'attempt', 'audit'] as const).map((kind) =>
event(kind),
);
expect(envelopes.map((envelope) => first.append(envelope).sequence)).toEqual([1, 2, 3, 4, 5]);
const reopened = new EventJournal(database);
expect(reopened.readAfter()).toEqual({
status: 'current',
events: [],
latestEventId: 'audit-1',
});
expect(reopened.readAfter('instance-1')).toEqual({
status: 'found',
events: envelopes.slice(1).map((envelope, index) => ({ sequence: index + 2, envelope })),
latestEventId: 'audit-1',
});
});
it('enforces unique public IDs without consuming a sequence on failure', () => {
const subject = journal();
subject.append(event('job', 'same'));
expect(() => subject.append(event('audit', 'same'))).toThrow(/unique|already exists/i);
expect(subject.append(event('audit', 'next')).sequence).toBe(2);
});
it('reports current, found (strictly after), and unavailable cursors', () => {
const subject = journal();
subject.append(event('instance', 'one'));
subject.append(event('job', 'two'));
expect(subject.readAfter()).toEqual({ status: 'current', events: [], latestEventId: 'two' });
expect(subject.readAfter('two')).toEqual({ status: 'found', events: [], latestEventId: 'two' });
expect(subject.readAfter('one')).toEqual({
status: 'found',
events: [{ sequence: 2, envelope: event('job', 'two') }],
latestEventId: 'two',
});
expect(subject.readAfter('missing')).toEqual({
status: 'unavailable',
events: [],
latestEventId: 'two',
});
});
it.each([
[{ ...event('job'), kind: 'other' }, /kind/i],
[{ ...event('job'), type: 'job' }, /additional|field/i],
[{ ...event('job'), extra: true }, /additional|field/i],
[{ ...event('job'), occurredAt: 'Friday' }, /occurredAt|date-time/i],
[{ ...event('job'), id: 'bad\nvalue' }, /id|control/i],
[{ ...event('job'), requestId: 'bad\rvalue' }, /requestId|control/i],
[{ ...event('job'), jobId: 'bad\0value' }, /jobId|control/i],
[{ kind: 'job', id: 'job-1', occurredAt: at, requestId: 'request-1' }, /jobId|missing/i],
[{ ...event('item'), attemptId: 'attempt-1' }, /additional|field/i],
[{ ...event('job'), id: '' }, /id/i],
])('rejects invalid envelopes before persistence: %j', (invalid, message) => {
const subject = journal();
expect(() => subject.append(invalid as EventEnvelope)).toThrow(message);
expect(subject.readAfter()).toEqual({
status: 'current',
events: [],
latestEventId: undefined,
});
});
it('validates persisted envelope_json rather than trusting database contents', () => {
const database = new Database(':memory:');
migrateDatabase(database);
database
.prepare('INSERT INTO event_journal (public_id, envelope_json) VALUES (?, ?)')
.run('corrupt', '{"kind":"job"}');
expect(() => new EventJournal(database).readAfter('corrupt')).toThrow(
/persisted.*envelope|invalid/i,
);
});
it('notifies subscribers only after durable append and supports idempotent unsubscribe', () => {
const subject = journal();
const listener = vi.fn();
const unsubscribe = subject.subscribe(listener);
const appended = subject.append(event('attempt'));
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(appended);
unsubscribe();
unsubscribe();
subject.append(event('audit'));
expect(listener).toHaveBeenCalledOnce();
});
});
describe('formatServerSentEvent', () => {
it('formats exactly id, compact JSON data, and a blank line without an event field', () => {
const envelope = event('item', 'public-7');
expect(formatServerSentEvent(envelope)).toBe(
`id: public-7\ndata: ${JSON.stringify(envelope)}\n\n`,
);
expect(formatServerSentEvent(envelope)).not.toContain('\nevent:');
});
it('applies the same strict envelope validation at the wire boundary', () => {
expect(() =>
formatServerSentEvent({ ...event('audit'), requestId: 'inject\ndata: x' }),
).toThrow(/requestId|control/i);
expect(() =>
formatServerSentEvent({ ...event('audit'), unexpected: 1 } as unknown as EventEnvelope),
).toThrow(/additional|field/i);
});
});
@@ -0,0 +1,264 @@
import type Database from 'better-sqlite3';
const EVENT_KINDS = ['instance', 'job', 'item', 'attempt', 'audit'] as const;
const COMMON_EVENT_FIELDS = ['kind', 'id', 'occurredAt', 'requestId'] as const;
const CORRELATION_FIELDS = ['instanceId', 'jobId', 'itemId', 'attemptId', 'auditEventId'] as const;
const FORBIDDEN_ID_CHARACTERS = /[\r\n\0]/u;
const DATE_TIME =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/u;
export type EventKind = (typeof EVENT_KINDS)[number];
interface EventEnvelopeFields {
readonly id: string;
readonly occurredAt: string;
readonly requestId: string;
}
export type EventEnvelope =
| (EventEnvelopeFields & { readonly kind: 'instance'; readonly instanceId: string })
| (EventEnvelopeFields & { readonly kind: 'job'; readonly jobId: string })
| (EventEnvelopeFields & {
readonly kind: 'item';
readonly jobId: string;
readonly itemId: string;
})
| (EventEnvelopeFields & {
readonly kind: 'attempt';
readonly jobId: string;
readonly attemptId: string;
})
| (EventEnvelopeFields & { readonly kind: 'audit'; readonly auditEventId: string });
export interface JournalEvent {
readonly sequence: number;
readonly envelope: EventEnvelope;
}
export type EventJournalRead =
| {
readonly status: 'current';
readonly events: readonly [];
readonly latestEventId: string | undefined;
}
| {
readonly status: 'found';
readonly events: readonly JournalEvent[];
readonly latestEventId: string | undefined;
}
| {
readonly status: 'unavailable';
readonly events: readonly [];
readonly latestEventId: string | undefined;
};
export type EventJournalSubscriber = (event: JournalEvent) => void;
interface JournalRow {
sequence: number;
public_id: string;
envelope_json: string;
}
function validateIdentifier(value: unknown, field: string): asserts value is string {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError(`${field} must be a non-empty string`);
}
if (FORBIDDEN_ID_CHARACTERS.test(value)) {
throw new TypeError(`${field} contains a forbidden CR, LF, or NUL control character`);
}
}
function isValidDateTime(value: unknown): value is string {
if (typeof value !== 'string') return false;
const match = DATE_TIME.exec(value);
if (!match) return false;
const zone = match[8]!;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const second = Number(match[6]);
if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return false;
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
if (day < 1 || day > daysInMonth) return false;
if (zone !== 'Z') {
const zoneHour = Number(zone.slice(1, 3));
const zoneMinute = Number(zone.slice(4, 6));
if (zoneHour > 23 || zoneMinute > 59) return false;
}
return Number.isFinite(Date.parse(value));
}
export function validateEventEnvelope(value: unknown): asserts value is EventEnvelope {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('event envelope must be an object');
}
const record = value as Record<string, unknown>;
if (!(EVENT_KINDS as readonly unknown[]).includes(record.kind)) {
throw new TypeError('event envelope kind must be instance, job, item, attempt, or audit');
}
const variantFields: Record<EventKind, readonly string[]> = {
instance: ['instanceId'],
job: ['jobId'],
item: ['jobId', 'itemId'],
attempt: ['jobId', 'attemptId'],
audit: ['auditEventId'],
};
const expected: readonly string[] = [
...COMMON_EVENT_FIELDS,
...variantFields[record.kind as EventKind],
];
const keys = Object.keys(record);
const additional = keys.filter((key) => !expected.includes(key));
const missing = expected.filter((key) => !Object.prototype.hasOwnProperty.call(record, key));
if (additional.length > 0) {
throw new TypeError(`event envelope has additional field: ${additional.join(', ')}`);
}
if (missing.length > 0) {
throw new TypeError(`event envelope is missing field: ${missing.join(', ')}`);
}
validateIdentifier(record.id, 'id');
validateIdentifier(record.requestId, 'requestId');
for (const field of CORRELATION_FIELDS) {
if (Object.prototype.hasOwnProperty.call(record, field))
validateIdentifier(record[field], field);
}
if (!isValidDateTime(record.occurredAt)) {
throw new TypeError('occurredAt must be a valid RFC 3339 date-time');
}
}
export function formatServerSentEvent(envelope: EventEnvelope): string {
validateEventEnvelope(envelope);
return `id: ${envelope.id}\ndata: ${JSON.stringify(envelope)}\n\n`;
}
export interface EventJournalOptions {
readonly retentionMs?: number;
readonly maxReplayEvents?: number;
}
export class EventJournal {
readonly #database: Database.Database;
readonly #subscribers = new Set<EventJournalSubscriber>();
readonly #retentionMs: number;
readonly #maxReplayEvents: number;
constructor(database: Database.Database, options: EventJournalOptions = {}) {
if (
options.retentionMs !== undefined &&
(!Number.isSafeInteger(options.retentionMs) || options.retentionMs <= 0)
) {
throw new RangeError('retentionMs must be a positive safe integer');
}
if (
options.maxReplayEvents !== undefined &&
(!Number.isSafeInteger(options.maxReplayEvents) || options.maxReplayEvents <= 0)
) {
throw new RangeError('maxReplayEvents must be a positive safe integer');
}
this.#database = database;
this.#retentionMs = options.retentionMs ?? 86_400_000;
this.#maxReplayEvents = options.maxReplayEvents ?? 1_000;
const columns = database.pragma('table_info(event_journal)') as Array<{ name: string }>;
const names = new Set(columns.map(({ name }) => name));
if (
columns.length === 0 ||
!names.has('sequence') ||
!names.has('public_id') ||
!names.has('envelope_json')
) {
throw new Error(
'event_journal schema is missing or incompatible; apply its forward migration',
);
}
}
append(envelope: EventEnvelope): JournalEvent {
validateEventEnvelope(envelope);
const serialized = JSON.stringify(envelope);
const result = this.#database
.prepare('INSERT INTO event_journal (public_id, envelope_json) VALUES (?, ?)')
.run(envelope.id, serialized);
const sequence = Number(result.lastInsertRowid);
if (!Number.isSafeInteger(sequence))
throw new Error('event journal sequence is not a safe integer');
this.#prune();
const appended = { sequence, envelope };
for (const subscriber of [...this.#subscribers]) subscriber(appended);
return appended;
}
readAfter(lastEventId?: string): EventJournalRead {
const latest = this.#database
.prepare('SELECT public_id FROM event_journal ORDER BY sequence DESC LIMIT 1')
.get() as { public_id: string } | undefined;
const latestEventId = latest?.public_id;
if (lastEventId === undefined) return { status: 'current', events: [], latestEventId };
validateIdentifier(lastEventId, 'lastEventId');
const cursor = this.#database
.prepare('SELECT sequence, public_id, envelope_json FROM event_journal WHERE public_id = ?')
.get(lastEventId) as JournalRow | undefined;
if (!cursor) return { status: 'unavailable', events: [], latestEventId };
this.#decodeRow(cursor);
const oldest = this.#database
.prepare('SELECT sequence FROM event_journal ORDER BY sequence ASC LIMIT 1')
.get() as { sequence: number } | undefined;
if (oldest && cursor.sequence < oldest.sequence) {
return { status: 'unavailable', events: [], latestEventId };
}
const rows = this.#database
.prepare(
'SELECT sequence, public_id, envelope_json FROM event_journal WHERE sequence > ? ORDER BY sequence ASC LIMIT ?',
)
.all(cursor.sequence, this.#maxReplayEvents + 1) as JournalRow[];
if (rows.length > this.#maxReplayEvents) {
return { status: 'unavailable', events: [], latestEventId };
}
return {
status: 'found',
events: rows.map((row) => this.#decodeRow(row)),
latestEventId,
};
}
subscribe(subscriber: EventJournalSubscriber): () => void {
this.#subscribers.add(subscriber);
let subscribed = true;
return () => {
if (!subscribed) return;
subscribed = false;
this.#subscribers.delete(subscriber);
};
}
#prune(): void {
const cutoff = new Date(Date.now() - this.#retentionMs).toISOString();
this.#database
.prepare("DELETE FROM event_journal WHERE json_extract(envelope_json, '$.occurredAt') < ?")
.run(cutoff);
}
#decodeRow(row: JournalRow): JournalEvent {
let envelope: unknown;
try {
envelope = JSON.parse(row.envelope_json);
validateEventEnvelope(envelope);
if (envelope.id !== row.public_id)
throw new TypeError('public ID does not match envelope ID');
} catch (error) {
const detail = error instanceof Error ? `: ${error.message}` : '';
throw new Error(`invalid persisted event envelope at sequence ${row.sequence}${detail}`, {
cause: error,
});
}
if (!Number.isSafeInteger(row.sequence) || row.sequence < 1) {
throw new Error(`invalid persisted event journal sequence: ${row.sequence}`);
}
return { sequence: row.sequence, envelope };
}
}
@@ -0,0 +1,593 @@
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { openDatabase } from '../../infrastructure/database/database.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import type { Instance } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from './instance-service.js';
class MemorySecrets implements SecretStore {
readonly values = new Map<string, string>();
readonly deletes: string[] = [];
readonly sets: Array<{
key: { instanceId: string; purpose: string; slot?: string };
value: string;
}> = [];
onDelete?: (reference: string) => void;
overrideReference?: string;
failSet = false;
failDelete = false;
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
if (this.failSet) throw new Error(`keychain leaked ${value}`);
const account = Buffer.from(
JSON.stringify([key.instanceId, key.purpose, key.slot]),
'utf8',
).toString('base64url');
const ref = this.overrideReference ?? `keychain://multi-simadmin/${account}`;
this.sets.push({ key, value });
this.values.set(ref, value);
return ref;
}
async get(reference: string) {
return this.values.get(reference);
}
async delete(reference: string) {
this.deletes.push(reference);
this.onDelete?.(reference);
if (this.failDelete) throw new Error(`cannot delete ${this.values.get(reference)}`);
return this.values.delete(reference);
}
}
const dbs: Database.Database[] = [];
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
for (const directory of temporaryDirectories.splice(0))
rmSync(directory, { recursive: true, force: true });
});
function fixture(ids = ['id-1', 'slot-1', 'ref-1', 'id-2', 'slot-2', 'ref-2']) {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const store = new MemorySecrets();
let index = 0;
const service = new InstanceService({
db,
store,
idFactory: () => ids[index++]!,
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
return { db, store, service };
}
function crossConnectionFixture() {
const directory = mkdtempSync(join(tmpdir(), 'instance-cas-'));
temporaryDirectories.push(directory);
const path = join(directory, 'state.sqlite');
const firstDb = openDatabase(path);
migrateDatabase(firstDb);
const secondDb = openDatabase(path);
dbs.push(firstDb, secondDb);
const store = new MemorySecrets();
const options = { store, now: () => new Date('2026-07-16T12:00:00.000Z') };
const firstIds = ['id-1', 'first-slot', 'first-reference'];
const secondIds = ['second-slot', 'second-reference'];
const first = new InstanceService({
...options,
db: firstDb,
idFactory: () => firstIds.shift()!,
});
const second = new InstanceService({
...options,
db: secondDb,
idFactory: () => secondIds.shift()!,
});
return { firstDb, secondDb, store, first, second };
}
const basic = { name: ' Alpha ', origin: 'http://192.168.1.10:8080/', tags: [' z ', 'a', 'a'] };
const code = async (promise: Promise<unknown>) => {
try {
await promise;
} catch (e) {
return (e as InstanceServiceError).code;
}
throw new Error('expected rejection');
};
describe('InstanceService', () => {
it('starts RED until the repository module exists', () =>
expect(InstanceService).toBeTypeOf('function'));
it('creates and gets normalized instances without reading or persisting a secret', async () => {
const { db, store, service } = fixture();
const created = await service.create(basic);
expect(created).toEqual({
id: 'id-1',
name: 'Alpha',
origin: 'http://192.168.1.10:8080',
tags: ['a', 'z'],
revision: 1,
capabilityStatus: 'unknown',
freshness: 'unknown',
credentialConfigured: false,
});
expect(await service.get('id-1')).toEqual(created);
expect(store.sets).toHaveLength(0);
expect(
JSON.stringify(
db.prepare('select * from instances join instance_tags on id=instance_id').all(),
),
).not.toContain('password');
});
it('stores a password under a unique slot and SQLite contains only its opaque reference', async () => {
const { db, store, service } = fixture();
const secret = 'super-secret-2.4A';
const result = await service.create({
...basic,
password: { action: 'set', password: secret },
});
expect(result.credentialConfigured).toBe(true);
expect(store.sets[0]).toMatchObject({
key: { instanceId: 'id-1', purpose: 'instance-password', slot: 'slot-1' },
value: secret,
});
const allText = JSON.stringify(
db
.prepare('select id,instance_id,purpose,provider,external_reference from secret_references')
.all(),
);
expect(allText).toContain('macos-keychain');
expect(allText).not.toContain(secret);
expect(JSON.stringify(result)).not.toContain(secret);
expect(store.get).toBeTypeOf('function');
});
it('rejects a secret-store reference not bound to the requested instance and slot', async () => {
const { db, store, service } = fixture();
store.overrideReference = `keychain://multi-simadmin/${Buffer.from(
JSON.stringify(['other-instance', 'instance-password', 'other-slot']),
'utf8',
).toString('base64url')}`;
expect(
await code(service.create({ ...basic, password: { action: 'set', password: 'secret' } })),
).toBe('SECRET_STORE_FAILED');
expect(db.prepare('SELECT * FROM secret_references').all()).toEqual([]);
expect(store.deletes).toEqual([store.overrideReference]);
expect(store.values.size).toBe(0);
});
it('fails closed when an invalid returned secret reference cannot be removed', async () => {
const { db, store, service } = fixture();
store.overrideReference = `keychain://multi-simadmin/${Buffer.from(
JSON.stringify(['other-instance', 'instance-password', 'other-slot']),
'utf8',
).toString('base64url')}`;
store.failDelete = true;
expect(
await code(service.create({ ...basic, password: { action: 'set', password: 'secret' } })),
).toBe('COMPENSATION_PERSISTENCE_FAILED');
expect(db.prepare('SELECT * FROM secret_references').all()).toEqual([]);
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toEqual([]);
});
it('rejects duplicate normalized origins and duplicate generated ids with stable errors', async () => {
const x = fixture(['same', 'same', 'same']);
await x.service.create(basic);
expect(await code(x.service.create({ ...basic, name: 'B' }))).toBe('DUPLICATE_ORIGIN');
expect(await code(x.service.create({ ...basic, origin: 'http://10.1.2.3' }))).toBe(
'DUPLICATE_ID',
);
});
it('filters, sorts with id tie-breaks, and paginates aggregate output', async () => {
const { db, service } = fixture(['b', 'a']);
await service.create({ name: 'Same', origin: 'http://10.0.0.2', tags: ['blue'] });
await service.create({
name: 'Same',
origin: 'http://10.0.0.3',
tags: ['red'],
password: { action: 'preserve' },
});
db.prepare('insert into capabilities values (?,?,?,?,?,?,?,?)').run(
'b',
'x',
'supported',
null,
null,
'2026-07-16T11:00:00Z',
'2026-07-16T11:00:00Z',
'2026-07-16T11:00:00Z',
);
db.prepare('insert into capabilities values (?,?,?,?,?,?,?,?)').run(
'b',
'y',
'degraded',
null,
null,
'2026-07-16T11:00:00Z',
'2026-07-16T11:00:00Z',
'2026-07-16T11:00:00Z',
);
db.prepare('insert into status_snapshots values (?,?,?,?,?,?,?,?)').run(
's',
'b',
'health',
'fresh',
'{}',
'2026-07-16T11:00:00Z',
'2026-07-16T13:00:00Z',
'2026-07-16T11:00:00Z',
);
expect(
(
await service.list({
search: 'b',
tag: 'blue',
capabilityStatus: 'degraded',
freshness: 'fresh',
})
).items.map((x: Instance) => x.id),
).toEqual(['b']);
expect((await service.list({ sort: 'name', page: 1, pageSize: 1 })).items[0]?.id).toBe('a');
expect((await service.list({ sort: 'name', page: 2, pageSize: 1 })).page).toEqual({
page: 2,
pageSize: 1,
total: 2,
});
});
it('updates with revision CAS, normalized fields, and one revision increment', async () => {
const { service } = fixture();
await service.create(basic);
expect(await code(service.update('id-1', 9, { name: 'No' }))).toBe('REVISION_CONFLICT');
const result = await service.update('id-1', 1, { name: ' Beta ', tags: ['x'] });
expect(result).toMatchObject({
name: 'Beta',
tags: ['x'],
revision: 2,
credentialConfigured: false,
});
});
it('rejects stale update and delete CAS across managed connections', async () => {
const { firstDb, first, second } = crossConnectionFixture();
await first.create(basic);
await second.update('id-1', 1, { name: 'Committed elsewhere' });
await expect(first.update('id-1', 1, { name: 'Stale update' })).rejects.toMatchObject({
code: 'REVISION_CONFLICT',
});
await expect(first.delete('id-1', 1)).rejects.toMatchObject({ code: 'REVISION_CONFLICT' });
expect(
firstDb.prepare('SELECT name,config_revision FROM instances WHERE id=?').get('id-1'),
).toEqual({
name: 'Committed elsewhere',
config_revision: 2,
});
});
it('preserves, replaces and clears credentials without exposing them', async () => {
const { db, store, service } = fixture();
await service.create({ ...basic, password: { action: 'set', password: 'first-secret' } });
await service.update('id-1', 1, { password: { action: 'preserve' } });
expect(store.sets).toHaveLength(1);
const replaced = await service.update('id-1', 2, {
password: { action: 'set', password: 'second-secret' },
});
expect(replaced.credentialConfigured).toBe(true);
expect(store.deletes).toEqual([
expect.stringMatching(/^keychain:\/\/multi-simadmin\/[A-Za-z0-9_-]+$/),
]);
const cleared = await service.update('id-1', 3, { password: { action: 'clear' } });
expect(cleared.credentialConfigured).toBe(false);
expect(db.prepare('select * from secret_references').all()).toHaveLength(0);
});
it('compensates a newly stored secret when the DB transaction fails', async () => {
const { db, store, service } = fixture();
await service.create(basic);
const occupiedReference = `keychain://multi-simadmin/${Buffer.from(
JSON.stringify(['id-1', 'instance-password', 'slot-1']),
'utf8',
).toString('base64url')}`;
db.prepare('insert into secret_references values (?,?,?,?,?,?,?)').run(
'occupied',
null,
'other',
'macos-keychain',
occupiedReference,
'now',
'now',
);
const error = await code(
service.update('id-1', 1, { password: { action: 'set', password: 'super-secret' } }),
);
expect(error).toBe('DATABASE_FAILED');
expect(store.values.size).toBe(0);
});
it('surfaces an explicit fail-closed error when compensation cleanup cannot be persisted', async () => {
const { db, store, service } = fixture();
await service.create(basic);
const occupiedReference = `keychain://multi-simadmin/${Buffer.from(
JSON.stringify(['id-1', 'instance-password', 'slot-1']),
'utf8',
).toString('base64url')}`;
db.prepare('insert into secret_references values (?,?,?,?,?,?,?)').run(
'occupied',
null,
'other',
'macos-keychain',
occupiedReference,
'now',
'now',
);
db.exec(
`CREATE TRIGGER fail_cleanup_outbox BEFORE INSERT ON secret_cleanup_tasks BEGIN SELECT RAISE(ABORT, 'outbox unavailable'); END`,
);
store.failDelete = true;
expect(
await code(
service.update('id-1', 1, { password: { action: 'set', password: 'super-secret' } }),
),
).toBe('COMPENSATION_PERSISTENCE_FAILED');
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toEqual([]);
});
it('deletes with CAS, cascades dependent state, then cleans the secret', async () => {
const { db, store, service } = fixture();
await service.create({ ...basic, password: { action: 'set', password: 'gone' } });
db.prepare('insert into instance_tags values (?,?,?) on conflict do nothing').run(
'id-1',
'extra',
'now',
);
expect(await code(service.delete('id-1', 2))).toBe('REVISION_CONFLICT');
await service.delete('id-1', 1);
expect(db.prepare('select * from instances').all()).toHaveLength(0);
expect(db.prepare('select * from secret_references').all()).toHaveLength(0);
expect(store.values.size).toBe(0);
});
it.each([
'http://localhost',
'http://127.0.0.1',
'http://169.254.169.254',
'http://0.0.0.0',
'http://224.0.0.1',
'https://8.8.8.8',
'ftp://10.0.0.1',
'http://u:p@10.0.0.1',
'http://10.0.0.1/x?y=1',
'http://10.0.0.1/#x',
])('rejects unsafe origin %s', async (origin) => {
const { service } = fixture();
expect(await code(service.create({ name: 'x', origin }))).toBe('VALIDATION_FAILED');
});
it('validates bounded names/tags/password and accepts private IPv4 plus IPv6 ULA', async () => {
const { service } = fixture(['one', 'two']);
await service.create({ name: 'x', origin: 'https://172.16.0.1', tags: [' ok '] });
await service.create({ name: 'y', origin: 'http://[fd00::1]:80' });
for (const input of [
{ name: ' ', origin: 'http://10.0.0.1' },
{ name: 'x', origin: 'http://10.0.0.2', tags: [' '] },
{ name: 'x', origin: 'http://10.0.0.3', password: { action: 'set', password: '' } as const },
])
expect(await code(service.create(input))).toBe('VALIDATION_FAILED');
});
it('returns redacted stable errors for secret-store failures and keeps committed cleanup durable', async () => {
const { db, store, service } = fixture();
store.failSet = true;
const setErr = await service
.create({ ...basic, password: { action: 'set', password: 'never-print-me' } })
.then(
() => {
throw new Error('expected set rejection');
},
(e) => e as InstanceServiceError,
);
expect(setErr.code).toBe('SECRET_STORE_FAILED');
expect(JSON.stringify(setErr)).not.toContain('never-print-me');
store.failSet = false;
const created = await service.create({
...basic,
origin: 'http://10.0.0.2',
password: { action: 'set', password: 'cleanup-secret' },
});
store.failDelete = true;
await expect(service.delete(created.id, 1)).resolves.toBeUndefined();
expect(await service.get(created.id)).toBeUndefined();
const pending = db
.prepare('SELECT reference,instance_id,purpose,provider FROM secret_cleanup_tasks')
.all();
expect(pending).toEqual([
expect.objectContaining({
instance_id: created.id,
purpose: 'instance-password',
provider: 'macos-keychain',
reference: expect.stringContaining('keychain://multi-simadmin/'),
}),
]);
store.failDelete = false;
expect(await service.retryPendingSecretCleanup()).toEqual({
attempted: 1,
cleaned: 1,
remaining: 0,
});
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toEqual([]);
});
it('persists cleanup work for replacement and clear until explicit retry succeeds', async () => {
const { db, store, service } = fixture();
await service.create({ ...basic, password: { action: 'set', password: 'first-secret' } });
store.failDelete = true;
const replacement = await service.update('id-1', 1, {
password: { action: 'set', password: 'second-secret' },
});
expect(replacement).toMatchObject({ revision: 2, credentialConfigured: true });
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toHaveLength(1);
store.failDelete = false;
expect(await service.retryPendingSecretCleanup()).toMatchObject({ remaining: 0 });
const updated = await service.get('id-1');
store.failDelete = true;
const cleared = await service.update('id-1', updated!.revision, {
password: { action: 'clear' },
});
expect(cleared).toMatchObject({ credentialConfigured: false });
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toHaveLength(1);
});
it('rejects forged cleanup references and treats already absent credentials as cleaned', async () => {
const { db, store, service } = fixture();
const now = '2026-07-16T12:00:00.000Z';
const encoded = (parts: string[]) =>
`keychain://multi-simadmin/${Buffer.from(JSON.stringify(parts), 'utf8').toString('base64url')}`;
const forged = encoded(['other-instance', 'instance-password', 'slot']);
db.prepare(
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
).run(forged, 'id-1', 'instance-password', 'macos-keychain', now, now);
expect(await service.retryPendingSecretCleanup()).toEqual({
attempted: 1,
cleaned: 0,
remaining: 1,
});
expect(store.deletes).toEqual([]);
db.prepare('DELETE FROM secret_cleanup_tasks').run();
const absent = encoded(['id-1', 'instance-password', 'slot']);
db.prepare(
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
).run(absent, 'id-1', 'instance-password', 'macos-keychain', now, now);
expect(await service.retryPendingSecretCleanup()).toEqual({
attempted: 1,
cleaned: 1,
remaining: 0,
});
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toEqual([]);
});
it('isolates malformed cleanup rows while processing valid work', async () => {
const { db, store, service } = fixture();
const now = '2026-07-16T12:00:00.000Z';
const valid = `keychain://multi-simadmin/${Buffer.from(
JSON.stringify(['id-1', 'instance-password', 'slot']),
'utf8',
).toString('base64url')}`;
for (const [reference, instanceId] of [
['not-a-reference', 'id-1'],
[valid, 'id-1'],
])
db.prepare(
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
).run(reference, instanceId, 'instance-password', 'macos-keychain', now, now);
expect(await service.retryPendingSecretCleanup()).toEqual({
attempted: 2,
cleaned: 1,
remaining: 1,
});
expect(store.deletes).toEqual([valid]);
});
it('never deletes a cleanup reference that is active again', async () => {
const { db, store, service } = fixture();
await service.create({ ...basic, password: { action: 'set', password: 'active' } });
const active = db.prepare('SELECT external_reference FROM secret_references').get() as {
external_reference: string;
};
const now = '2026-07-16T12:00:00.000Z';
db.prepare(
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
).run(active.external_reference, 'id-1', 'instance-password', 'macos-keychain', now, now);
expect(await service.retryPendingSecretCleanup()).toEqual({
attempted: 1,
cleaned: 0,
remaining: 1,
});
expect(store.deletes).toEqual([]);
expect(store.values.has(active.external_reference)).toBe(true);
});
it('does not ACK a requeued cleanup task with an obsolete generation', async () => {
const { db, store, service } = fixture();
await service.create({ ...basic, password: { action: 'set', password: 'first' } });
store.failDelete = true;
await service.update('id-1', 1, { password: { action: 'set', password: 'second' } });
store.failDelete = false;
const reference = db.prepare('SELECT reference FROM secret_cleanup_tasks').get() as {
reference: string;
};
store.onDelete = () => {
db.prepare('UPDATE secret_cleanup_tasks SET generation=generation+1 WHERE reference=?').run(
reference.reference,
);
delete store.onDelete;
};
const result = await service.retryPendingSecretCleanup();
expect(result).toMatchObject({ attempted: 1, cleaned: 0, remaining: 1 });
expect(db.prepare('SELECT generation FROM secret_cleanup_tasks').get()).toEqual({
generation: 2,
});
});
it('aggregates freshness across categories by the worst observed state', async () => {
const { db, service } = fixture();
const created = await service.create(basic);
const now = '2026-07-16T12:00:00.000Z';
db.prepare(
'INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?)',
).run('snapshot-fresh', created.id, 'cellular', 'fresh', '{}', now, null, now);
db.prepare(
'INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?)',
).run('snapshot-stale', created.id, 'network', 'stale', '{}', now, null, now);
expect((await service.get(created.id))?.freshness).toBe('stale');
});
it('rejects control separators in tags rather than corrupting tag round-trips', async () => {
const { service } = fixture();
expect(
await code(service.create({ ...basic, tags: ['safe', `bad${String.fromCharCode(31)}tag`] })),
).toBe('VALIDATION_FAILED');
});
it('orders names and tags by stable code points instead of host locale rules', async () => {
const { service } = fixture(['z-id', 'slot-1', 'ref-1', 'a-id', 'slot-2', 'ref-2']);
await service.create({ name: 'a', origin: 'http://10.0.0.1', tags: ['a', 'Z'] });
await service.create({ name: 'Z', origin: 'http://10.0.0.2', tags: [] });
expect((await service.get('z-id'))?.tags).toEqual(['Z', 'a']);
expect((await service.list({ sort: 'name' })).items.map(({ id }) => id)).toEqual([
'slot-1',
'z-id',
]);
});
it('rejects page and revision values that would overflow subsequent calculations', async () => {
const { service } = fixture();
await service.create(basic);
expect(await code(service.list({ page: Number.MAX_SAFE_INTEGER, pageSize: 100 }))).toBe(
'VALIDATION_FAILED',
);
expect(await code(service.update('id-1', Number.MAX_SAFE_INTEGER, { name: 'x' }))).toBe(
'VALIDATION_FAILED',
);
});
it('reports a stable history conflict when deleting an instance referenced by a job item', async () => {
const { db, service } = fixture();
const created = await service.create(basic);
const now = '2026-07-16T12:00:00.000Z';
db.prepare(
'INSERT INTO jobs (id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)',
).run('job-1', 'system.health', 'R0', 'completed', 'test', 'request-1', 'digest', now, now);
db.prepare(
'INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run('job-item-1', 'job-1', created.id, 1, 'completed', now, now);
expect(await code(service.delete(created.id, 1))).toBe('HAS_JOB_HISTORY');
expect(await service.get(created.id)).toBeDefined();
});
});
@@ -0,0 +1,602 @@
import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import type {
CapabilityStatus,
Instance,
InstanceInput,
InstancePage,
InstancePageQuery,
InstancePatch,
SnapshotFreshness,
} from '@multi-simadmin/contracts';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
const MAX_TAGS = 50;
const MAX_TAG_LENGTH = 100;
export type InstanceServiceErrorCode =
| 'VALIDATION_FAILED'
| 'NOT_FOUND'
| 'REVISION_CONFLICT'
| 'HAS_JOB_HISTORY'
| 'DUPLICATE_ID'
| 'DUPLICATE_ORIGIN'
| 'SECRET_STORE_FAILED'
| 'DATABASE_FAILED'
| 'SECRET_CLEANUP_FAILED'
| 'COMPENSATION_FAILED'
| 'COMPENSATION_PERSISTENCE_FAILED';
export class InstanceServiceError extends Error {
constructor(
readonly code: InstanceServiceErrorCode,
message: string,
) {
super(message);
this.name = 'InstanceServiceError';
}
}
export interface InstanceServiceOptions {
readonly db: Database.Database;
readonly store: SecretStore;
readonly idFactory?: () => string;
readonly now?: () => Date;
}
interface InstanceRow {
id: string;
name: string;
base_url: string;
config_revision: number;
updated_at: string;
}
interface SecretRow {
id: string;
external_reference: string;
}
interface CleanupEntry {
readonly instanceId: string;
readonly reference: string;
readonly purpose: string;
readonly provider: string;
readonly generation: number;
readonly queuedAt: string;
}
interface AggregateRow extends InstanceRow {
tags: string | null;
credential: number;
}
function validation(message: string): never {
throw new InstanceServiceError('VALIDATION_FAILED', message);
}
function normalizeName(value: unknown): string {
if (typeof value !== 'string') return validation('Name must be a string');
const result = value.trim();
if (!result || result.length > 200) return validation('Name must contain 1 to 200 characters');
return result;
}
function normalizeTags(value: readonly string[] | undefined): string[] {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > MAX_TAGS) return validation('Tags are invalid');
const tags = value.map((tag) => {
if (typeof tag !== 'string') return validation('Tag must be a string');
const normalized = tag.trim();
if (!normalized || normalized.length > MAX_TAG_LENGTH || /[\x00-\x1F\x7F]/.test(normalized))
return validation('Tag is invalid');
return normalized;
});
const unique = [...new Set(tags)].sort(codePointCompare);
if (unique.length > MAX_TAGS) return validation('Too many tags');
return unique;
}
function privateIpv4(host: string): boolean {
const parts = host.split('.');
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false;
const octets = parts.map(Number);
if (octets.some((part) => part > 255)) return false;
const [a, b] = octets as [number, number, number, number];
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
function privateIpv6(host: string): boolean {
const normalized = host.toLowerCase().replace(/^\[|\]$/g, '');
return /^(fc|fd)[0-9a-f]{2}:/.test(normalized);
}
function normalizeOrigin(value: unknown): string {
if (typeof value !== 'string') return validation('Origin must be a URL');
let url: URL;
try {
url = new URL(value);
} catch {
return validation('Origin must be a valid URL');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:')
return validation('Origin must use HTTP or HTTPS');
if (url.username || url.password || url.search || url.hash)
return validation('Origin must not contain credentials, query, or fragment');
if (url.pathname !== '/' && url.pathname !== '')
return validation('Origin must not contain a path');
if (!privateIpv4(url.hostname) && !privateIpv6(url.hostname))
return validation('Origin must target a private LAN address');
return url.origin;
}
function validatePassword(value: InstanceInput['password'] | InstancePatch['password']): void {
if (value === undefined || value.action === 'preserve' || value.action === 'clear') return;
if (value.action !== 'set' || typeof value.password !== 'string' || value.password.length === 0)
validation('Password update is invalid');
}
const capabilityRank: Record<CapabilityStatus, number> = {
degraded: 0,
'auth-required': 1,
unsupported: 2,
supported: 3,
unknown: 4,
};
const freshnessRank: Record<SnapshotFreshness, number> = {
expired: 0,
stale: 1,
fresh: 2,
unknown: 3,
};
export class InstanceService {
private readonly db: Database.Database;
private readonly store: SecretStore;
private readonly id: () => string;
private readonly clock: () => Date;
constructor(options: InstanceServiceOptions) {
this.db = options.db;
this.store = options.store;
this.id = options.idFactory ?? randomUUID;
this.clock = options.now ?? (() => new Date());
}
async create(input: InstanceInput): Promise<Instance> {
const name = normalizeName(input.name);
const origin = normalizeOrigin(input.origin);
const tags = normalizeTags(input.tags);
validatePassword(input.password);
const instanceId = this.id();
let newSecret: { id: string; external: string } | undefined;
if (input.password?.action === 'set')
newSecret = await this.storeSecret(instanceId, input.password.password);
const now = this.clock().toISOString();
try {
this.db.transaction(() => {
this.db
.prepare(
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?, ?,1,1,?,?)',
)
.run(instanceId, name, origin, newSecret ? 'password' : 'none', now, now);
this.replaceTags(instanceId, tags, now);
if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now);
})();
} catch (error) {
if (newSecret) await this.compensate(instanceId, newSecret.external);
if (isUnique(error, 'base_url'))
throw new InstanceServiceError('DUPLICATE_ORIGIN', 'Instance origin already exists');
if (isUnique(error, 'instances.id'))
throw new InstanceServiceError('DUPLICATE_ID', 'Instance id already exists');
throw new InstanceServiceError('DATABASE_FAILED', 'Could not create instance');
}
return (await this.get(instanceId))!;
}
async get(instanceId: string): Promise<Instance | undefined> {
const rows = this.loadRows('WHERE i.id = ?', [instanceId]);
return rows[0] ? this.toInstance(rows[0]) : undefined;
}
async list(query: InstancePageQuery = {}): Promise<InstancePage> {
const pageSize = integerInRange(query.pageSize ?? 25, 1, 100, 'pageSize');
const page = integerInRange(
query.page ?? 1,
1,
Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1,
'page',
);
if (query.direction !== undefined && query.direction !== 'asc' && query.direction !== 'desc')
validation('Direction is invalid');
const validSort = ['name', 'status', 'freshness', 'updatedAt'];
if (query.sort !== undefined && !validSort.includes(query.sort)) validation('Sort is invalid');
let values = this.loadRows('', []).map((row) => ({
value: this.toInstance(row),
updatedAt: row.updated_at,
}));
const search = query.search?.trim().toLowerCase();
if (search)
values = values.filter(
({ value }) =>
value.name.toLowerCase().includes(search) || value.id.toLowerCase().includes(search),
);
if (query.tag !== undefined)
values = values.filter(({ value }) => value.tags.includes(query.tag!.trim()));
if (query.credentialConfigured !== undefined)
values = values.filter(
({ value }) => value.credentialConfigured === query.credentialConfigured,
);
if (query.capabilityStatus !== undefined)
values = values.filter(({ value }) => value.capabilityStatus === query.capabilityStatus);
if (query.freshness !== undefined)
values = values.filter(({ value }) => value.freshness === query.freshness);
const direction = query.direction === 'desc' ? -1 : 1;
const sort = query.sort ?? 'name';
values.sort((left, right) => {
let result: number;
if (sort === 'status')
result =
capabilityRank[left.value.capabilityStatus]! -
capabilityRank[right.value.capabilityStatus]!;
else if (sort === 'freshness')
result = freshnessRank[left.value.freshness]! - freshnessRank[right.value.freshness]!;
else if (sort === 'updatedAt') result = codePointCompare(left.updatedAt, right.updatedAt);
else result = codePointCompare(left.value.name, right.value.name);
return result === 0 ? codePointCompare(left.value.id, right.value.id) : result * direction;
});
const total = values.length;
const start = (page - 1) * pageSize;
return {
items: values.slice(start, start + pageSize).map(({ value }) => value),
page: { page, pageSize, total },
};
}
async update(instanceId: string, revision: number, patch: InstancePatch): Promise<Instance> {
integerInRange(revision, 1, Number.MAX_SAFE_INTEGER - 1, 'revision');
validatePassword(patch.password);
const current = this.row(instanceId);
if (!current) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
if (current.config_revision !== revision)
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
const name = patch.name === undefined ? current.name : normalizeName(patch.name);
const origin = patch.origin === undefined ? current.base_url : normalizeOrigin(patch.origin);
const tags = patch.tags === undefined ? undefined : normalizeTags(patch.tags);
let committedOldSecret: SecretRow | undefined;
let newSecret: { id: string; external: string } | undefined;
if (patch.password?.action === 'set')
newSecret = await this.storeSecret(instanceId, patch.password.password);
const now = this.clock().toISOString();
try {
this.db.transaction(() => {
const transactionCurrent = this.row(instanceId);
if (!transactionCurrent)
throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
if (transactionCurrent.config_revision !== revision)
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
const transactionOldSecret = this.secret(instanceId);
const changed = this.db
.prepare(
'UPDATE instances SET name=?,base_url=?,auth_mode=?,config_revision=config_revision+1,updated_at=? WHERE id=? AND config_revision=?',
)
.run(
name,
origin,
newSecret || (transactionOldSecret && patch.password?.action !== 'clear')
? 'password'
: 'none',
now,
instanceId,
revision,
);
if (changed.changes !== 1)
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
if (tags) this.replaceTags(instanceId, tags, now);
if (patch.password?.action === 'clear' || newSecret)
this.db
.prepare('DELETE FROM secret_references WHERE instance_id=? AND purpose=?')
.run(instanceId, PURPOSE);
if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now);
if (transactionOldSecret && (patch.password?.action === 'clear' || newSecret)) {
this.queueCleanup(instanceId, transactionOldSecret.external_reference, now);
committedOldSecret = transactionOldSecret;
}
})();
} catch (error) {
if (newSecret) await this.compensate(instanceId, newSecret.external);
if (error instanceof InstanceServiceError) throw error;
if (isUnique(error, 'base_url'))
throw new InstanceServiceError('DUPLICATE_ORIGIN', 'Instance origin already exists');
throw new InstanceServiceError('DATABASE_FAILED', 'Could not update instance');
}
if (committedOldSecret)
await this.tryCleanup(instanceId, committedOldSecret.external_reference);
return (await this.get(instanceId))!;
}
async delete(
instanceId: string,
revision: number,
options?: { allowJobHistory?: boolean },
): Promise<void> {
integerInRange(revision, 1, Number.MAX_SAFE_INTEGER - 1, 'revision');
const allowJobHistory = options?.allowJobHistory === true;
let committedOldSecret: SecretRow | undefined;
try {
this.db.transaction(() => {
const transactionCurrent = this.row(instanceId);
if (!transactionCurrent)
throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
if (transactionCurrent.config_revision !== revision)
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
if (
!allowJobHistory &&
this.db.prepare('SELECT 1 FROM job_items WHERE instance_id=? LIMIT 1').get(instanceId)
)
throw new InstanceServiceError('HAS_JOB_HISTORY', 'Instance has immutable job history');
const transactionOldSecret = this.secret(instanceId);
if (transactionOldSecret) {
this.queueCleanup(
instanceId,
transactionOldSecret.external_reference,
this.clock().toISOString(),
);
committedOldSecret = transactionOldSecret;
}
const deleted = this.db
.prepare('DELETE FROM instances WHERE id=? AND config_revision=?')
.run(instanceId, revision);
if (deleted.changes !== 1)
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
})();
} catch (error) {
if (error instanceof InstanceServiceError) throw error;
if (
!allowJobHistory &&
this.db.prepare('SELECT 1 FROM job_items WHERE instance_id=? LIMIT 1').get(instanceId)
)
throw new InstanceServiceError('HAS_JOB_HISTORY', 'Instance has immutable job history');
throw new InstanceServiceError('DATABASE_FAILED', 'Could not delete instance');
}
if (committedOldSecret)
await this.tryCleanup(instanceId, committedOldSecret.external_reference);
}
async retryPendingSecretCleanup(): Promise<{
attempted: number;
cleaned: number;
remaining: number;
}> {
const entries = this.cleanupEntries();
let cleaned = 0;
for (const entry of entries) {
try {
this.validateCleanupEntry(entry);
if (this.isActiveReference(entry.reference)) continue;
await this.store.delete(entry.reference);
if (this.removeCleanup(entry.reference, entry.generation)) cleaned += 1;
} catch {
// Retain the opaque reference for a later explicit retry.
}
}
return { attempted: entries.length, cleaned, remaining: this.cleanupEntries().length };
}
private row(id: string): InstanceRow | undefined {
return this.db
.prepare('SELECT id,name,base_url,config_revision,updated_at FROM instances WHERE id=?')
.get(id) as InstanceRow | undefined;
}
private secret(id: string): SecretRow | undefined {
return this.db
.prepare(
'SELECT id,external_reference FROM secret_references WHERE instance_id=? AND purpose=?',
)
.get(id, PURPOSE) as SecretRow | undefined;
}
private replaceTags(id: string, tags: readonly string[], now: string): void {
this.db.prepare('DELETE FROM instance_tags WHERE instance_id=?').run(id);
const insert = this.db.prepare(
'INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)',
);
for (const tag of tags) insert.run(id, tag, now);
}
private insertReference(id: string, instanceId: string, external: string, now: string): void {
this.db
.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
)
.run(id, instanceId, PURPOSE, PROVIDER, external, now, now);
}
private async storeSecret(
instanceId: string,
password: string,
): Promise<{ id: string; external: string }> {
const slot = this.id();
let external: string;
try {
external = await this.store.set({ instanceId, purpose: PURPOSE, slot }, password);
} catch {
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
}
try {
const parsed = parseKeychainReference(external);
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
throw new Error('secret store returned a reference with an invalid binding');
} catch {
try {
await this.store.delete(external);
} catch {
throw new InstanceServiceError(
'COMPENSATION_PERSISTENCE_FAILED',
'Credential binding was invalid and cleanup could not be confirmed',
);
}
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
}
return { id: this.id(), external };
}
private async compensate(instanceId: string, reference: string): Promise<void> {
try {
this.validateCleanupReference(instanceId, reference);
await this.store.delete(reference);
} catch {
try {
this.queueCleanup(instanceId, reference, this.clock().toISOString());
} catch {
throw new InstanceServiceError(
'COMPENSATION_PERSISTENCE_FAILED',
'Database operation failed and credential cleanup could not be persisted',
);
}
throw new InstanceServiceError(
'COMPENSATION_FAILED',
'Database operation failed and new credential cleanup failed',
);
}
}
private async tryCleanup(instanceId: string, reference: string): Promise<void> {
const generation = this.cleanupGeneration(reference);
try {
this.validateCleanupReference(instanceId, reference);
await this.store.delete(reference);
if (generation !== undefined) this.removeCleanup(reference, generation);
} catch {
// The durable outbox is authoritative after the business transaction commits.
}
}
private cleanupEntries(): CleanupEntry[] {
return this.db
.prepare(
'SELECT reference,instance_id,purpose,provider,generation,queued_at FROM secret_cleanup_tasks ORDER BY queued_at ASC,reference ASC',
)
.all()
.map((row) => {
const task = row as {
reference: string;
instance_id: string;
purpose: string;
provider: string;
generation: number;
queued_at: string;
};
return {
reference: task.reference,
instanceId: task.instance_id,
purpose: task.purpose,
provider: task.provider,
generation: task.generation,
queuedAt: task.queued_at,
};
});
}
private queueCleanup(instanceId: string, reference: string, now: string): void {
this.validateCleanupReference(instanceId, reference);
const queued = this.db
.prepare(
`INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at) VALUES (?,?,?,?,1,?,?)
ON CONFLICT(reference) DO UPDATE SET
generation=secret_cleanup_tasks.generation+1,
updated_at=excluded.updated_at
WHERE secret_cleanup_tasks.instance_id=excluded.instance_id
AND secret_cleanup_tasks.purpose=excluded.purpose
AND secret_cleanup_tasks.provider=excluded.provider`,
)
.run(reference, instanceId, PURPOSE, PROVIDER, now, now);
if (queued.changes !== 1)
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
}
private cleanupGeneration(reference: string): number | undefined {
const row = this.db
.prepare('SELECT generation FROM secret_cleanup_tasks WHERE reference=?')
.get(reference) as { generation: number } | undefined;
return row?.generation;
}
private removeCleanup(reference: string, generation: number): boolean {
return (
this.db
.prepare('DELETE FROM secret_cleanup_tasks WHERE reference=? AND generation=?')
.run(reference, generation).changes === 1
);
}
private isActiveReference(reference: string): boolean {
return Boolean(
this.db
.prepare('SELECT 1 FROM secret_references WHERE external_reference=? LIMIT 1')
.get(reference),
);
}
private validateCleanupEntry(entry: CleanupEntry): void {
if (entry.purpose !== PURPOSE || entry.provider !== PROVIDER)
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
this.validateCleanupReference(entry.instanceId, entry.reference);
}
private validateCleanupReference(instanceId: string, reference: string): void {
try {
const parsed = parseKeychainReference(reference);
if (
parsed.instanceId !== instanceId ||
parsed.purpose !== PURPOSE ||
parsed.slot === undefined
)
throw new Error('mismatch');
} catch {
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
}
}
private loadRows(where: string, parameters: unknown[]): AggregateRow[] {
return this.db
.prepare(
`SELECT i.id,i.name,i.base_url,i.config_revision,i.updated_at,
group_concat(t.tag, char(31)) tags,
CASE WHEN EXISTS(SELECT 1 FROM secret_references r WHERE r.instance_id=i.id AND r.purpose=?) THEN 1 ELSE 0 END credential
FROM instances i LEFT JOIN instance_tags t ON t.instance_id=i.id ${where} GROUP BY i.id`,
)
.all(PURPOSE, ...parameters) as AggregateRow[];
}
private toInstance(row: AggregateRow): Instance {
const capabilityRows = this.db
.prepare('SELECT state FROM capabilities WHERE instance_id=?')
.all(row.id) as { state: CapabilityStatus }[];
const capabilityStatus =
capabilityRows.length === 0
? 'unknown'
: capabilityRows
.map((x) => x.state)
.sort((a, b) => capabilityRank[a]! - capabilityRank[b]!)[0]!;
const snapshots = this.db
.prepare('SELECT state,expires_at FROM status_snapshots WHERE instance_id=?')
.all(row.id) as { state: SnapshotFreshness; expires_at: string | null }[];
const freshness: SnapshotFreshness =
snapshots.length === 0
? 'unknown'
: snapshots
.map((snapshot) =>
snapshot.expires_at && snapshot.expires_at <= this.clock().toISOString()
? 'expired'
: snapshot.state,
)
.sort((left, right) => freshnessRank[left]! - freshnessRank[right]!)[0]!;
return {
id: row.id,
name: row.name,
origin: row.base_url,
tags: row.tags ? row.tags.split(String.fromCharCode(31)).sort(codePointCompare) : [],
revision: row.config_revision,
capabilityStatus,
freshness,
credentialConfigured: row.credential === 1,
};
}
}
function integerInRange(value: number, minimum: number, maximum: number, name: string): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
validation(`${name} is invalid`);
return value;
}
function codePointCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function isUnique(error: unknown, fragment: string): boolean {
return (
error instanceof Error &&
error.message.includes('UNIQUE constraint failed') &&
error.message.includes(fragment)
);
}
@@ -0,0 +1,296 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { JobQueryError, JobQueryService } from './job-query-service.js';
const t1 = '2026-07-17T10:00:00.000Z';
const t2 = '2026-07-17T11:00:00.000Z';
function setup(): { db: Database.Database; subject: JobQueryService } {
const db = new Database(':memory:');
migrateDatabase(db);
return { db, subject: new JobQueryService(db) };
}
function insertJob(
db: Database.Database,
values: Partial<Record<'id' | 'root' | 'operation' | 'status' | 'created', unknown>> = {},
): void {
const id = values.id ?? 'job-1';
const root = Object.prototype.hasOwnProperty.call(values, 'root') ? values.root : id;
db.prepare(
`INSERT INTO jobs
(id, root_job_id, operation_id, risk_level, status, requested_by, request_id,
parameters_digest, created_at, updated_at)
VALUES (?, ?, ?, 'R1', ?, 'actor', 'request-1', 'digest', ?, ?)`,
).run(
id,
root,
values.operation ?? 'op.one',
values.status ?? 'succeeded',
values.created ?? t1,
values.created ?? t1,
);
}
function insertItem(db: Database.Database, overrides: Record<string, unknown> = {}): void {
const row = {
id: 'item-1',
job: 'job-1',
instance: 'instance-1',
status: 'succeeded',
source: null,
error: null,
created: t1,
...overrides,
};
db.prepare(
`INSERT INTO job_items
(id, job_id, instance_id, attempt_number, status, error_json, source_job_item_id,
created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)`,
).run(row.id, row.job, row.instance, row.status, row.error, row.source, row.created, row.created);
}
function insertAttempt(db: Database.Database, overrides: Record<string, unknown> = {}): void {
const row = {
id: 'attempt-1',
job: 'job-1',
status: 'succeeded',
started: t1,
finished: t2,
created: t1,
...overrides,
};
db.prepare(
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(row.id, row.job, row.status, row.started, row.finished, row.created);
}
function expectCode(action: () => unknown, code: string): void {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(JobQueryError);
expect((error as JobQueryError).code).toBe(code);
return;
}
throw new Error('expected JobQueryError');
}
describe('JobQueryService', () => {
it.each(['queued', 'running', 'cancelling'])(
'projects only terminal items while a %s job is active and retains its running attempt',
(status) => {
const { db, subject } = setup();
insertJob(db, { status });
insertItem(db, { id: 'terminal', instance: 'done', status: 'succeeded' });
insertItem(db, { id: 'internal', instance: 'pending', status: 'running' });
insertAttempt(db, { status: 'running', finished: null });
expect(subject.get('job-1')).toMatchObject({
status,
items: [{ id: 'terminal', targetId: 'done', state: 'succeeded' }],
attempts: [{ id: 'attempt-1', state: 'running', startedAt: t1 }],
});
},
);
it('returns the exact frozen projection with deterministic child ordering', () => {
const { db, subject } = setup();
insertJob(db);
insertItem(db, { id: 'item-z', instance: 'target-z' });
insertItem(db, { id: 'item-a', instance: 'target-a', source: 'item-z' });
insertAttempt(db, { id: 'attempt-z', started: t1 });
insertAttempt(db, { id: 'attempt-a', started: t1, status: 'running', finished: null });
expect(subject.get('job-1')).toEqual({
id: 'job-1',
operationId: 'op.one',
status: 'succeeded',
rootJobId: 'job-1',
items: [
{ id: 'item-a', targetId: 'target-a', state: 'succeeded', sourceJobItemId: 'item-z' },
{ id: 'item-z', targetId: 'target-z', state: 'succeeded' },
],
attempts: [
{ id: 'attempt-a', state: 'running', startedAt: t1 },
{ id: 'attempt-z', state: 'succeeded', startedAt: t1, finishedAt: t2 },
],
createdAt: t1,
});
});
it('paginates in SQL with filters and an ascending id tie-break independent of direction', () => {
const { db, subject } = setup();
insertJob(db, { id: 'z', operation: 'same', created: t2 });
insertJob(db, { id: 'a', operation: 'same', created: t2 });
insertJob(db, { id: 'other', operation: 'other', created: t1 });
insertItem(db, { id: 'z-item', job: 'z', instance: 'wanted' });
insertItem(db, { id: 'a-item', job: 'a', instance: 'wanted' });
expect(
subject.list({
page: 1,
pageSize: 1,
sort: 'createdAt',
direction: 'desc',
operationId: 'same',
status: 'succeeded',
rootJobId: 'z',
instanceId: 'wanted',
}),
).toEqual({
items: [expect.objectContaining({ id: 'z' })],
page: { page: 1, pageSize: 1, total: 1 },
});
expect(
subject.list({ sort: 'createdAt', direction: 'desc' }).items.map(({ id }) => id),
).toEqual(['a', 'z', 'other']);
});
it('reports not found without exposing database details', () => {
const { subject } = setup();
expect(() => subject.get('missing')).toThrow('Job was not found');
expectCode(() => subject.get('missing'), 'NOT_FOUND');
});
it.each([
[{ page: 0 }, 'page'],
[{ page: 1.5 }, 'page'],
[{ pageSize: 0 }, 'pageSize'],
[{ pageSize: 101 }, 'pageSize'],
[{ sort: 'id' }, 'sort'],
[{ direction: 'sideways' }, 'direction'],
])('rejects invalid query input: %j', (query, field) => {
const { subject } = setup();
expect(() => subject.list(query as never)).toThrow(new RegExp(field, 'i'));
expectCode(() => subject.list(query as never), 'VALIDATION_FAILED');
});
it.each([
[
'job status',
() => {
const x = setup();
insertJob(x.db, { status: 'invented' });
return x;
},
],
[
'missing root',
() => {
const x = setup();
insertJob(x.db, { root: null });
return x;
},
],
[
'running item',
() => {
const x = setup();
insertJob(x.db);
insertItem(x.db, { status: 'running' });
return x;
},
],
[
'attempt state',
() => {
const x = setup();
insertJob(x.db);
x.db.pragma('ignore_check_constraints = ON');
insertAttempt(x.db, { status: 'queued' });
return x;
},
],
[
'timestamp',
() => {
const x = setup();
insertJob(x.db, { created: '2026-07-17T10:00:00Z' });
return x;
},
],
])('fails the whole selection as UNREPRESENTABLE for invalid persisted %s', (_name, make) => {
const { subject } = make();
expectCode(() => subject.list(), 'UNREPRESENTABLE');
});
it.each([
[
'items',
(db: Database.Database, count: number) => {
const insert = db.prepare(
`INSERT INTO job_items
(id, job_id, instance_id, attempt_number, status, created_at, updated_at)
VALUES (?, 'job-1', 'instance-1', ?, 'succeeded', ?, ?)`,
);
db.transaction(() => {
for (let index = 1; index <= count; index += 1)
insert.run(`item-${index}`, index, t1, t1);
})();
},
],
[
'attempts',
(db: Database.Database, count: number) => {
const insert = db.prepare(
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
VALUES (?, 'job-1', 'succeeded', ?, ?, ?)`,
);
db.transaction(() => {
for (let index = 1; index <= count; index += 1)
insert.run(`attempt-${index}`, t1, t2, t1);
})();
},
],
])('bounds hydrated child %s at 1000 rows per selected job', (_name, populate) => {
const atBound = setup();
insertJob(atBound.db);
populate(atBound.db, 1000);
expect(atBound.subject.get('job-1')).toBeDefined();
const overBound = setup();
insertJob(overBound.db);
populate(overBound.db, 1001);
expectCode(() => overBound.subject.get('job-1'), 'UNREPRESENTABLE');
});
it('emits only a closed redacted item error and omits malformed optional errors', () => {
const { db, subject } = setup();
insertJob(db);
insertItem(db, {
id: 'good',
instance: 'instance-good',
error: JSON.stringify({
type: 'https://evil/secret',
title: 'database password leaked',
status: 502,
detail: 'password=secret',
code: 'UPSTREAM_FAILED',
requestId: 'req-safe',
extra: 'secret',
}),
});
insertItem(db, { id: 'bad', instance: 'instance-bad', error: '{bad json' });
expect(subject.get('job-1').items).toEqual([
{ id: 'bad', targetId: 'instance-bad', state: 'succeeded' },
{
id: 'good',
targetId: 'instance-good',
state: 'succeeded',
error: {
type: 'about:blank',
title: 'Job item failed',
status: 502,
detail: 'The job item did not complete successfully.',
code: 'UPSTREAM_FAILED',
requestId: 'req-safe',
},
},
]);
});
});
@@ -0,0 +1,279 @@
import type Database from 'better-sqlite3';
import {
ATTEMPT_STATUSES,
JOB_ITEM_TERMINAL_STATES,
JOB_STATUSES,
type Attempt,
type Job,
type JobItem,
type JobPage,
type JobPageQuery,
type ProblemDetails,
} from '@multi-simadmin/contracts';
export type JobQueryErrorCode = 'VALIDATION_FAILED' | 'NOT_FOUND' | 'UNREPRESENTABLE';
export class JobQueryError extends Error {
constructor(
readonly code: JobQueryErrorCode,
message: string,
) {
super(message);
this.name = 'JobQueryError';
}
}
interface JobRow {
id: unknown;
operation_id: unknown;
status: unknown;
retry_of_job_id: unknown;
root_job_id: unknown;
created_at: unknown;
}
interface ItemRow {
id: unknown;
job_id: unknown;
instance_id: unknown;
status: unknown;
source_job_item_id: unknown;
error_json: unknown;
}
interface AttemptRow {
id: unknown;
job_id: unknown;
status: unknown;
started_at: unknown;
finished_at: unknown;
}
const JOB_COLUMNS = 'id, operation_id, status, retry_of_job_id, root_job_id, created_at';
const CANONICAL_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
const SAFE_ERROR_CODES = /^[A-Z][A-Z0-9_]{0,63}$/u;
const SAFE_REQUEST_IDS = /^[^\x00-\x1f\x7f]{1,200}$/u;
const MAX_CHILD_ROWS_PER_JOB = 1000;
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']);
function fail(message: string): never {
throw new JobQueryError('UNREPRESENTABLE', message);
}
function validation(message: string): never {
throw new JobQueryError('VALIDATION_FAILED', message);
}
function requiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.length === 0) fail(`Persisted job ${field} is invalid`);
return value;
}
function optionalString(value: unknown, field: string): string | undefined {
if (value === null) return undefined;
return requiredString(value, field);
}
function timestamp(value: unknown, field: string): string {
const result = requiredString(value, field);
if (!CANONICAL_TIMESTAMP.test(result)) fail(`Persisted job ${field} is not canonical`);
const parsed = new Date(result);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== result)
fail(`Persisted job ${field} is invalid`);
return result;
}
function positiveInteger(value: unknown, fallback: number, maximum: number, field: string): number {
const result = value ?? fallback;
if (!Number.isSafeInteger(result) || (result as number) < 1 || (result as number) > maximum)
validation(`${field} must be an integer from 1 to ${maximum}`);
return result as number;
}
function freeze<T extends object>(value: T): Readonly<T> {
return Object.freeze(value);
}
function safeError(serialized: unknown): ProblemDetails | undefined {
if (serialized === null) return undefined;
if (typeof serialized !== 'string') return undefined;
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
return undefined;
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const row = value as Record<string, unknown>;
if (
typeof row.status !== 'number' ||
!Number.isInteger(row.status) ||
row.status < 400 ||
row.status > 599 ||
typeof row.code !== 'string' ||
!SAFE_ERROR_CODES.test(row.code) ||
typeof row.requestId !== 'string' ||
!SAFE_REQUEST_IDS.test(row.requestId)
)
return undefined;
return freeze({
type: 'about:blank',
title: 'Job item failed',
status: row.status,
detail: 'The job item did not complete successfully.',
code: row.code,
requestId: row.requestId,
});
}
export class JobQueryService {
constructor(private readonly db: Database.Database) {}
get(id: string): Job {
if (typeof id !== 'string' || id.length === 0) validation('id must be a non-empty string');
const row = this.db.prepare(`SELECT ${JOB_COLUMNS} FROM jobs WHERE id = ?`).get(id) as
| JobRow
| undefined;
if (!row) throw new JobQueryError('NOT_FOUND', 'Job was not found');
return this.project([row])[0]!;
}
list(query: JobPageQuery = {}): JobPage {
const pageSize = positiveInteger(query.pageSize, 25, 100, 'pageSize');
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
const page = positiveInteger(query.page, 1, maximumPage, 'page');
const sort = query.sort ?? 'createdAt';
if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId')
validation('sort is invalid');
const direction = query.direction ?? 'asc';
if (direction !== 'asc' && direction !== 'desc') validation('direction is invalid');
const clauses: string[] = [];
const parameters: unknown[] = [];
const filter = (column: string, value: unknown): void => {
if (value === undefined) return;
if (typeof value !== 'string' || value.length === 0)
validation(`${column} filter is invalid`);
clauses.push(`${column} = ?`);
parameters.push(value);
};
filter('j.status', query.status);
filter('j.operation_id', query.operationId);
filter('j.root_job_id', query.rootJobId);
if (query.instanceId !== undefined) {
if (typeof query.instanceId !== 'string' || query.instanceId.length === 0)
validation('instanceId filter is invalid');
clauses.push(
'EXISTS (SELECT 1 FROM job_items ji WHERE ji.job_id = j.id AND ji.instance_id = ?)',
);
parameters.push(query.instanceId);
}
const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
const totalRow = this.db
.prepare(`SELECT COUNT(*) AS total FROM jobs j${where}`)
.get(...parameters) as { total: unknown } | undefined;
if (!totalRow || typeof totalRow.total !== 'number' || !Number.isSafeInteger(totalRow.total))
fail('Persisted job count is invalid');
const sortColumn = {
createdAt: 'j.created_at',
status: 'j.status',
operationId: 'j.operation_id',
}[sort];
const offset = (page - 1) * pageSize;
const rows = this.db
.prepare(
`SELECT ${JOB_COLUMNS.split(', ')
.map((column) => `j.${column}`)
.join(', ')} FROM jobs j${where}
ORDER BY ${sortColumn} ${direction.toUpperCase()}, j.id ASC LIMIT ? OFFSET ?`,
)
.all(...parameters, pageSize, offset) as JobRow[];
return freeze({
items: freeze(this.project(rows)),
page: freeze({ page, pageSize, total: totalRow.total }),
});
}
private project(rows: JobRow[]): Job[] {
if (rows.length === 0) return [];
const statuses = new Map<string, Job['status']>();
const ids = rows.map((row) => {
const id = requiredString(row.id, 'id');
const status = requiredString(row.status, 'status');
if (!(JOB_STATUSES as readonly string[]).includes(status))
fail('Persisted job status is invalid');
statuses.set(id, status as Job['status']);
return id;
});
const placeholders = ids.map(() => '?').join(', ');
for (const table of ['job_items', 'job_attempts'] as const) {
const counts = this.db
.prepare(
`SELECT job_id, COUNT(*) AS count FROM ${table}
WHERE job_id IN (${placeholders}) GROUP BY job_id HAVING COUNT(*) > ?`,
)
.all(...ids, MAX_CHILD_ROWS_PER_JOB) as { job_id: unknown; count: unknown }[];
if (counts.length > 0) fail('Persisted job child collection exceeds the resource bound');
}
const items = this.db
.prepare(
`SELECT id, job_id, instance_id, status, source_job_item_id, error_json
FROM job_items WHERE job_id IN (${placeholders}) ORDER BY job_id ASC, id ASC`,
)
.all(...ids) as ItemRow[];
const attempts = this.db
.prepare(
`SELECT id, job_id, status, started_at, finished_at
FROM job_attempts WHERE job_id IN (${placeholders}) ORDER BY job_id ASC, started_at ASC, id ASC`,
)
.all(...ids) as AttemptRow[];
const itemMap = new Map<string, JobItem[]>();
const attemptMap = new Map<string, Attempt[]>();
for (const row of items) {
const jobId = requiredString(row.job_id, 'item job_id');
const state = requiredString(row.status, 'item status');
if (!(JOB_ITEM_TERMINAL_STATES as readonly string[]).includes(state)) {
if (ACTIVE_JOB_STATUSES.has(statuses.get(jobId) ?? '')) continue;
fail('Persisted job item is not terminal');
}
const sourceJobItemId = optionalString(row.source_job_item_id, 'item source_job_item_id');
const error = safeError(row.error_json);
const item: JobItem = freeze({
id: requiredString(row.id, 'item id'),
targetId: requiredString(row.instance_id, 'item instance_id'),
state: state as JobItem['state'],
...(sourceJobItemId === undefined ? {} : { sourceJobItemId }),
...(error === undefined ? {} : { error }),
});
const values = itemMap.get(jobId) ?? [];
values.push(item);
itemMap.set(jobId, values);
}
for (const row of attempts) {
const jobId = requiredString(row.job_id, 'attempt job_id');
const state = requiredString(row.status, 'attempt status');
if (!(ATTEMPT_STATUSES as readonly string[]).includes(state))
fail('Persisted attempt status is invalid');
const finishedAt =
row.finished_at === null ? undefined : timestamp(row.finished_at, 'attempt finished_at');
const attempt: Attempt = freeze({
id: requiredString(row.id, 'attempt id'),
state: state as Attempt['state'],
startedAt: timestamp(row.started_at, 'attempt started_at'),
...(finishedAt === undefined ? {} : { finishedAt }),
});
const values = attemptMap.get(jobId) ?? [];
values.push(attempt);
attemptMap.set(jobId, values);
}
return rows.map((row) => {
const id = requiredString(row.id, 'id');
const status = statuses.get(id)!;
const retryOfJobId = optionalString(row.retry_of_job_id, 'retry_of_job_id');
return freeze({
id,
operationId: requiredString(row.operation_id, 'operation_id'),
status: status as Job['status'],
...(retryOfJobId === undefined ? {} : { retryOfJobId }),
rootJobId: requiredString(row.root_job_id, 'root_job_id'),
items: freeze(itemMap.get(id) ?? []),
attempts: freeze(attemptMap.get(id) ?? []),
createdAt: timestamp(row.created_at, 'created_at'),
});
});
}
}
@@ -0,0 +1,277 @@
import Database from 'better-sqlite3';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { openDatabase } from '../../infrastructure/database/database.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { ActivationError, activatePendingSecret } from './activate-pending-secret.js';
const secret = 'activation-secret-never-in-sqlite';
const databases: Database.Database[] = [];
afterEach(() => {
for (const db of databases.splice(0)) db.close();
});
function pendingDatabase(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const now = '2026-07-16T00:00:00.000Z';
db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run(
'alpha',
'Alpha',
'https://203.0.113.8',
'none',
1,
3,
now,
now,
);
db.prepare('INSERT INTO app_settings VALUES (?,?,?,?)').run(
'legacy-import.instance.alpha',
JSON.stringify({
source: 'legacy-config',
secretPending: true,
requestedAuthMode: 'password',
secretPurpose: 'instance-password',
}),
now,
now,
);
databases.push(db);
return db;
}
class FakeStore implements SecretStore {
readonly sets: { key: SecretKey; value: string }[] = [];
readonly deletes: string[] = [];
setError?: Error;
deleteError?: Error;
reference = 'keychain://multi-simadmin/b3BhcXVl';
async set(key: SecretKey, value: string): Promise<string> {
this.sets.push({ key, value });
if (this.setError) throw this.setError;
return key.slot ? `${this.reference}-${key.slot}` : this.reference;
}
async get(): Promise<string | undefined> {
return undefined;
}
async delete(reference: string): Promise<boolean> {
this.deletes.push(reference);
if (this.deleteError) throw this.deleteError;
return true;
}
}
function allText(db: Database.Database): string {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
const rows: unknown[] = [];
for (const { name } of tables) {
const columns = (
db.prepare(`PRAGMA table_info("${name}")`).all() as { name: string; type: string }[]
)
.filter(({ type }) => type.toUpperCase().includes('TEXT'))
.map(({ name: column }) => `"${column}"`);
if (columns.length)
rows.push(...db.prepare(`SELECT ${columns.join(',')} FROM "${name}"`).all());
}
return JSON.stringify(rows);
}
function state(db: Database.Database): Uint8Array {
return db.serialize();
}
describe('activatePendingSecret', () => {
it('writes keychain first then atomically activates password auth and clears pending metadata', async () => {
const db = pendingDatabase();
const store = new FakeStore();
const result = await activatePendingSecret({ db, store, instanceId: 'alpha', secret });
expect(store.sets).toHaveLength(1);
expect(store.sets[0]).toEqual({
key: {
instanceId: 'alpha',
purpose: 'instance-password',
slot: expect.stringMatching(/^[A-Za-z0-9_-]+$/),
},
value: secret,
});
const storedReference = `${store.reference}-${store.sets[0]?.key.slot}`;
expect(result).toEqual({
instanceId: 'alpha',
referenceId: 'legacy-secret:alpha:instance-password',
externalReference: storedReference,
configRevision: 4,
});
expect(
db.prepare('SELECT auth_mode,config_revision FROM instances WHERE id=?').get('alpha'),
).toEqual({
auth_mode: 'password',
config_revision: 4,
});
expect(
db
.prepare('SELECT id,instance_id,purpose,provider,external_reference FROM secret_references')
.get(),
).toEqual({
id: 'legacy-secret:alpha:instance-password',
instance_id: 'alpha',
purpose: 'instance-password',
provider: 'macos-keychain',
external_reference: storedReference,
});
const metadata = JSON.parse(
(
db
.prepare('SELECT value_json FROM app_settings WHERE key=?')
.get('legacy-import.instance.alpha') as { value_json: string }
).value_json,
);
expect(metadata).toMatchObject({ secretPending: false, requestedAuthMode: 'password' });
expect(JSON.stringify(metadata)).not.toContain(secret);
expect(allText(db)).not.toContain(secret);
});
it('rolls back every DB write and compensates keychain when the transaction fails', async () => {
const db = pendingDatabase();
const store = new FakeStore();
const before = state(db);
db.exec(
"CREATE TRIGGER fail_activation BEFORE INSERT ON secret_references BEGIN SELECT RAISE(ABORT, 'forced'); END",
);
const withTrigger = state(db);
await expect(
activatePendingSecret({ db, store, instanceId: 'alpha', secret }),
).rejects.toMatchObject({
code: 'DATABASE_FAILED',
});
expect(store.deletes).toHaveLength(1);
expect(store.deletes[0]).toBe(`${store.reference}-${store.sets[0]?.key.slot}`);
expect(state(db)).toEqual(withTrigger);
expect(withTrigger).not.toEqual(before);
});
it('returns a stable compensation error without leaking secret when cleanup fails', async () => {
const db = pendingDatabase();
db.exec(
"CREATE TRIGGER fail_activation BEFORE INSERT ON secret_references BEGIN SELECT RAISE(ABORT, 'forced'); END",
);
const store = new FakeStore();
store.deleteError = new Error(`malicious ${secret}`);
const error = await activatePendingSecret({ db, store, instanceId: 'alpha', secret }).catch(
(reason: unknown) => reason,
);
expect(error).toBeInstanceOf(ActivationError);
expect(error).toMatchObject({ code: 'COMPENSATION_FAILED' });
expect(
JSON.stringify({ name: (error as Error).name, message: (error as Error).message }),
).not.toContain(secret);
});
it('does not touch DB when store set fails', async () => {
const db = pendingDatabase();
const before = state(db);
const store = new FakeStore();
store.setError = new Error(`malicious ${secret}`);
const error = await activatePendingSecret({ db, store, instanceId: 'alpha', secret }).catch(
(reason: unknown) => reason,
);
expect(error).toMatchObject({ code: 'SECRET_STORE_FAILED' });
expect(state(db)).toEqual(before);
expect(
JSON.stringify({ name: (error as Error).name, message: (error as Error).message }),
).not.toContain(secret);
});
it('rejects duplicate activation before store access and leaves DB unchanged', async () => {
const db = pendingDatabase();
const firstStore = new FakeStore();
await activatePendingSecret({ db, store: firstStore, instanceId: 'alpha', secret });
const before = state(db);
const secondStore = new FakeStore();
await expect(
activatePendingSecret({ db, store: secondStore, instanceId: 'alpha', secret }),
).rejects.toMatchObject({ code: 'ALREADY_ACTIVE' });
expect(secondStore.sets).toHaveLength(0);
expect(state(db)).toEqual(before);
});
it('serializes concurrent activation so only one unique keychain item is created', async () => {
const db = pendingDatabase();
const store = new FakeStore();
const results = await Promise.allSettled([
activatePendingSecret({ db, store, instanceId: 'alpha', secret: `${secret}-one` }),
activatePendingSecret({ db, store, instanceId: 'alpha', secret: `${secret}-two` }),
]);
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(store.sets).toHaveLength(1);
expect(store.sets[0]?.key.slot).toMatch(/^[A-Za-z0-9_-]+$/);
expect(store.deletes).toHaveLength(0);
const saved = db.prepare('SELECT external_reference FROM secret_references').get() as {
external_reference: string;
};
expect(saved.external_reference).toContain(store.sets[0]?.key.slot);
});
it('serializes activation across two managed connections to the same physical database', async () => {
const directory = await mkdtemp(join(tmpdir(), 'secret-activation-'));
const path = join(directory, 'state.sqlite');
const first = openDatabase(path);
const second = openDatabase(path);
migrateDatabase(first);
const now = '2026-07-16T00:00:00.000Z';
first
.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)')
.run('alpha', 'Alpha', 'https://203.0.113.8', 'none', 1, 3, now, now);
first
.prepare('INSERT INTO app_settings VALUES (?,?,?,?)')
.run(
'legacy-import.instance.alpha',
JSON.stringify({ secretPending: true, requestedAuthMode: 'password' }),
now,
now,
);
const store = new FakeStore();
try {
const results = await Promise.allSettled([
activatePendingSecret({ db: first, store, instanceId: 'alpha', secret: `${secret}-one` }),
activatePendingSecret({ db: second, store, instanceId: 'alpha', secret: `${secret}-two` }),
]);
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(store.sets).toHaveLength(1);
expect(store.deletes).toHaveLength(0);
} finally {
second.close();
first.close();
await rm(directory, { recursive: true, force: true });
}
});
it('rejects empty secrets and invalid pending metadata before store access', async () => {
const db = pendingDatabase();
const store = new FakeStore();
await expect(
activatePendingSecret({ db, store, instanceId: 'alpha', secret: '' }),
).rejects.toMatchObject({ code: 'EMPTY_SECRET' });
db.prepare('UPDATE app_settings SET value_json=?').run(
JSON.stringify({ secretPending: false, requestedAuthMode: 'password' }),
);
await expect(
activatePendingSecret({ db, store, instanceId: 'alpha', secret }),
).rejects.toMatchObject({ code: 'NOT_PENDING' });
expect(store.sets).toHaveLength(0);
});
});
@@ -0,0 +1,205 @@
import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { getManagedDatabaseIdentity } from '../../infrastructure/database/database.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`;
const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`;
export type ActivationErrorCode =
| 'EMPTY_SECRET'
| 'INSTANCE_NOT_FOUND'
| 'ALREADY_ACTIVE'
| 'NOT_PENDING'
| 'SECRET_STORE_FAILED'
| 'DATABASE_FAILED'
| 'COMPENSATION_FAILED';
export class ActivationError extends Error {
constructor(
readonly code: ActivationErrorCode,
message: string,
) {
super(message);
this.name = 'ActivationError';
}
}
export interface ActivatePendingSecretInput {
readonly db: Database.Database;
readonly store: SecretStore;
readonly instanceId: string;
readonly secret: string;
}
export interface ActivatePendingSecretResult {
readonly instanceId: string;
readonly referenceId: string;
readonly externalReference: string;
readonly configRevision: number;
}
interface InstanceRow {
readonly auth_mode: string;
readonly config_revision: number;
}
interface MetadataRow {
readonly value_json: string;
}
const connectionActivationTails = new WeakMap<Database.Database, Map<string, Promise<void>>>();
const physicalActivationTails = new Map<string, Map<string, Promise<void>>>();
function activationTailMap(db: Database.Database): Map<string, Promise<void>> {
try {
const identity = getManagedDatabaseIdentity(db);
let tails = physicalActivationTails.get(identity.deviceInode);
if (!tails) {
tails = new Map();
physicalActivationTails.set(identity.deviceInode, tails);
}
return tails;
} catch {
let tails = connectionActivationTails.get(db);
if (!tails) {
tails = new Map();
connectionActivationTails.set(db, tails);
}
return tails;
}
}
async function withActivationLock<T>(
db: Database.Database,
instanceId: string,
action: () => Promise<T>,
): Promise<T> {
const instanceTails = activationTailMap(db);
const previous = instanceTails.get(instanceId) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const tail = previous.then(() => gate);
instanceTails.set(instanceId, tail);
await previous;
try {
return await action();
} finally {
release();
if (instanceTails.get(instanceId) === tail) {
instanceTails.delete(instanceId);
try {
const identity = getManagedDatabaseIdentity(db);
if (instanceTails.size === 0) physicalActivationTails.delete(identity.deviceInode);
} catch {
// WeakMap-backed unmanaged connection state is reclaimed with the connection.
}
}
}
}
function inspectPending(
db: Database.Database,
instanceId: string,
): { metadata: Record<string, unknown>; revision: number } {
const instance = db
.prepare('SELECT auth_mode,config_revision FROM instances WHERE id=?')
.get(instanceId) as InstanceRow | undefined;
if (!instance) throw new ActivationError('INSTANCE_NOT_FOUND', 'Instance was not found');
if (instance.auth_mode !== 'none')
throw new ActivationError('ALREADY_ACTIVE', 'Instance authentication is already active');
const row = db
.prepare('SELECT value_json FROM app_settings WHERE key=?')
.get(metadataKey(instanceId)) as MetadataRow | undefined;
let metadata: Record<string, unknown>;
try {
metadata = JSON.parse(row?.value_json ?? '') as Record<string, unknown>;
} catch {
throw new ActivationError('NOT_PENDING', 'Instance has no valid pending password secret');
}
if (metadata.secretPending !== true || metadata.requestedAuthMode !== 'password')
throw new ActivationError('NOT_PENDING', 'Instance has no pending password secret');
return { metadata, revision: instance.config_revision };
}
export async function activatePendingSecret(
input: ActivatePendingSecretInput,
): Promise<ActivatePendingSecretResult> {
return withActivationLock(input.db, input.instanceId, () =>
activatePendingSecretExclusive(input),
);
}
async function activatePendingSecretExclusive({
db,
store,
instanceId,
secret,
}: ActivatePendingSecretInput): Promise<ActivatePendingSecretResult> {
if (typeof secret !== 'string' || secret.length === 0)
throw new ActivationError('EMPTY_SECRET', 'Secret must not be empty');
// Validate state before touching Keychain. It is checked again inside the transaction for race safety.
const pending = inspectPending(db, instanceId);
let externalReference: string;
try {
externalReference = await store.set(
{ instanceId, purpose: PURPOSE, slot: randomUUID() },
secret,
);
} catch {
throw new ActivationError('SECRET_STORE_FAILED', 'Could not store pending secret');
}
const id = referenceId(instanceId);
const now = new Date().toISOString();
try {
db.transaction(() => {
const current = inspectPending(db, instanceId);
if (current.revision !== pending.revision)
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
db.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run(id, instanceId, PURPOSE, PROVIDER, externalReference, now, now);
const updated = db
.prepare(
"UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?",
)
.run(now, instanceId, current.revision);
if (updated.changes !== 1)
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
const sanitizedMetadata: Record<string, unknown> = {
...current.metadata,
secretPending: false,
};
delete sanitizedMetadata.secret;
delete sanitizedMetadata.password;
const metadataUpdated = db
.prepare('UPDATE app_settings SET value_json=?,updated_at=? WHERE key=?')
.run(JSON.stringify(sanitizedMetadata), now, metadataKey(instanceId));
if (metadataUpdated.changes !== 1)
throw new ActivationError('DATABASE_FAILED', 'Pending secret metadata changed');
})();
} catch {
try {
const removed = await store.delete(externalReference);
if (!removed) throw new Error('cleanup did not remove stored secret');
} catch {
throw new ActivationError(
'COMPENSATION_FAILED',
'Database activation failed and stored-secret cleanup failed',
);
}
throw new ActivationError('DATABASE_FAILED', 'Database secret activation failed');
}
return {
instanceId,
referenceId: id,
externalReference,
configRevision: pending.revision + 1,
};
}
@@ -0,0 +1,315 @@
import Database from 'better-sqlite3';
import { lstat, mkdtemp, readFile, rename, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
confirmLegacyImport,
previewLegacyImport,
type LegacyImportPreview,
} from './legacy-import.js';
const databases: Database.Database[] = [];
afterEach(() => {
for (const database of databases.splice(0)) database.close();
});
function database(): Database.Database {
const result = new Database(':memory:');
result.pragma('foreign_keys = ON');
migrateDatabase(result);
databases.push(result);
return result;
}
async function legacyFile(value: unknown): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'legacy-import-'));
const path = join(directory, 'legacy.json');
await writeFile(path, JSON.stringify(value), { mode: 0o600 });
return path;
}
const password = 'phase-two-super-secret-value';
const valid = {
server: { host: '0.0.0.0', port: 8788 },
instances: [
{
id: 'alpha',
name: 'Alpha',
url: 'https://203.0.113.8/path?discard=yes#fragment',
description: 'primary',
tags: ['prod', 'blue'],
auth: { mode: 'password', password },
capabilities: ['status.read', 'sim.restart'],
},
{ id: 'beta', url: 'http://[2001:4860:4860::8888]:8080', auth: { mode: 'none' } },
],
};
function scan(value: unknown): string {
if (value instanceof Error) return JSON.stringify({ name: value.name, message: value.message });
return JSON.stringify(value);
}
function scanDatabaseText(database: Database.Database): string {
const tables = database
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
const values: unknown[] = [];
for (const { name } of tables) {
const quotedTable = `"${name.replaceAll('"', '""')}"`;
const columns = database.prepare(`PRAGMA table_info(${quotedTable})`).all() as {
name: string;
type: string;
}[];
const textColumns = columns.filter(({ type }) => type.toUpperCase().includes('TEXT'));
if (textColumns.length === 0) continue;
const selection = textColumns
.map(({ name: column }) => `"${column.replaceAll('"', '""')}"`)
.join(',');
values.push(...database.prepare(`SELECT ${selection} FROM ${quotedTable}`).all());
}
return JSON.stringify(values);
}
async function rejection(action: () => Promise<unknown>): Promise<Error> {
try {
await action();
} catch (error) {
return error as Error;
}
throw new Error('expected rejection');
}
describe('legacy import', () => {
it('previews a deeply frozen, redacted, digest-bound plan without writing the database', async () => {
const path = await legacyFile(valid);
const db = database();
const before = db.serialize();
const preview = await previewLegacyImport(path, db);
expect(preview.counts).toEqual({
total: 2,
planned: 2,
conflict: 0,
unchanged: 0,
secretPending: 1,
});
expect(preview.instances[0]).toEqual({
id: 'alpha',
name: 'Alpha',
baseUrl: 'https://203.0.113.8/path',
description: 'primary',
tags: ['prod', 'blue'],
capabilities: ['status.read', 'sim.restart'],
authMode: 'password',
credentialPresent: true,
status: 'planned',
});
expect(preview.source.digest).toMatch(/^[a-f0-9]{64}$/);
expect(preview.planDigest).toMatch(/^[a-f0-9]{64}$/);
expect(preview.source.identity).toMatchObject({
device: expect.any(String),
inode: expect.any(String),
});
expect(Object.isFrozen(preview)).toBe(true);
expect(Object.isFrozen(preview.instances[0]?.tags)).toBe(true);
expect(scan(preview)).not.toContain(password);
expect(db.serialize()).toEqual(before);
});
it.each([
['bad JSON', '{'],
['array root', '[]'],
['missing instances', '{}'],
['instances object', '{"instances":{}}'],
[
'dangerous key',
'{"instances":[{"id":"x","url":"https://203.0.113.1","__proto__":{"polluted":true}}]}',
],
[
'duplicate id',
'{"instances":[{"id":"x","url":"https://203.0.113.1"},{"id":"x","url":"https://203.0.113.2"}]}',
],
['credentials in URL', '{"instances":[{"id":"x","url":"https://u:p@203.0.113.1"}]}'],
['hostname', '{"instances":[{"id":"x","url":"https://example.com"}]}'],
['loopback', '{"instances":[{"id":"x","url":"http://127.0.0.1"}]}'],
['malformed member', '{"instances":[{"id":"ok","url":"https://203.0.113.1"},null]}'],
])('rejects %s as an all-or-nothing malformed preview', async (_name, contents) => {
const path = await legacyFile({});
await writeFile(path, contents);
const error = await rejection(() => previewLegacyImport(path));
expect(scan(error)).not.toContain(password);
});
it('never includes a malformed source excerpt in an error', async () => {
const path = await legacyFile({});
const marker = 'malformed-secret-never-echo';
await writeFile(path, `{"instances":[],"password":"${marker}" trailing`);
const error = await rejection(() => previewLegacyImport(path));
expect(error.message).toBe('Legacy config is not valid JSON');
expect(scan(error)).not.toContain(marker);
});
it('rejects symlinks and non-regular files', async () => {
const path = await legacyFile(valid);
const link = `${path}.link`;
await symlink(path, link);
await expect(previewLegacyImport(link)).rejects.toThrow(/regular|symlink/i);
const details = await lstat(link);
expect(details.isSymbolicLink()).toBe(true);
await expect(previewLegacyImport(join(path, 'child'))).rejects.toThrow();
});
it('rejects oversized source files before parsing without echoing their content', async () => {
const path = await legacyFile({});
const marker = 'oversized-secret-marker';
await writeFile(path, JSON.stringify({ instances: [], padding: marker.repeat(100_000) }));
const error = await rejection(() => previewLegacyImport(path));
expect(error.message).toMatch(/large|size|limit/i);
expect(scan(error)).not.toContain(marker);
});
it('imports atomically, ignores legacy server, records pending secrets without storing credentials, and is idempotent', async () => {
const path = await legacyFile(valid);
const db = database();
const preview = await previewLegacyImport(path, db);
const result = await confirmLegacyImport(path, preview, db);
expect(result).toMatchObject({ imported: 2, unchanged: 0, secretPending: ['alpha'] });
expect(scan(result)).not.toContain(password);
expect(
db
.prepare(
'SELECT id,name,base_url,auth_mode,enabled,config_revision FROM instances ORDER BY id',
)
.all(),
).toEqual([
{
id: 'alpha',
name: 'Alpha',
base_url: 'https://203.0.113.8/path',
auth_mode: 'none',
enabled: 1,
config_revision: 1,
},
{
id: 'beta',
name: 'beta',
base_url: 'http://[2001:4860:4860::8888]:8080',
auth_mode: 'none',
enabled: 1,
config_revision: 1,
},
]);
expect(
db.prepare('SELECT instance_id,tag FROM instance_tags ORDER BY instance_id,tag').all(),
).toEqual([
{ instance_id: 'alpha', tag: 'blue' },
{ instance_id: 'alpha', tag: 'prod' },
]);
expect(db.prepare('SELECT COUNT(*) AS count FROM secret_references').get()).toEqual({
count: 0,
});
expect(
db
.prepare("SELECT COUNT(*) AS count FROM app_settings WHERE key LIKE 'legacy-import.%'")
.get(),
).toEqual({ count: 2 });
expect(scanDatabaseText(db)).not.toContain(password);
const secondPreview = await previewLegacyImport(path, db);
expect(secondPreview.counts).toMatchObject({ planned: 0, unchanged: 2, conflict: 0 });
const second = await confirmLegacyImport(path, secondPreview, db);
expect(second).toMatchObject({ imported: 0, unchanged: 2, secretPending: ['alpha'] });
expect(db.prepare('SELECT id,config_revision FROM instances ORDER BY id').all()).toEqual([
{ id: 'alpha', config_revision: 1 },
{ id: 'beta', config_revision: 1 },
]);
});
it('binds confirmation to source identity, source digest, and plan digest', async () => {
const path = await legacyFile(valid);
const db = database();
const preview = await previewLegacyImport(path, db);
await writeFile(path, JSON.stringify({ instances: [] }));
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(
/changed|digest|identity/i,
);
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 });
const fresh = await legacyFile(valid);
const good = await previewLegacyImport(fresh, db);
const replacement = `${fresh}.new`;
await writeFile(replacement, await readFile(fresh));
await rename(replacement, fresh);
const replacementPreview = await previewLegacyImport(fresh, db);
await expect(confirmLegacyImport(fresh, good, db)).rejects.toThrow(/changed|identity/i);
const tampered = { ...replacementPreview, planDigest: '0'.repeat(64) } as LegacyImportPreview;
await expect(confirmLegacyImport(fresh, tampered, db)).rejects.toThrow(/digest/i);
});
it('binds status and auth mode into the execution plan digest', async () => {
const path = await legacyFile(valid);
const db = database();
const preview = await previewLegacyImport(path, db);
const instance = preview.instances[0]!;
const statusTampered = {
...preview,
instances: [{ ...instance, status: 'unchanged' as const }, ...preview.instances.slice(1)],
} as LegacyImportPreview;
await expect(confirmLegacyImport(path, statusTampered, db)).rejects.toThrow(/digest/i);
const authTampered = {
...preview,
instances: [{ ...instance, authMode: 'none' as const }, ...preview.instances.slice(1)],
} as LegacyImportPreview;
await expect(confirmLegacyImport(path, authTampered, db)).rejects.toThrow(/digest/i);
});
it('reports conflicts and confirmation rejects the entire transaction', async () => {
const path = await legacyFile(valid);
const db = database();
const now = new Date().toISOString();
db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run(
'alpha',
'Different',
'https://203.0.113.99',
'none',
1,
7,
now,
now,
);
db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run(
'owner',
'Owner',
'http://[2001:4860:4860::8888]:8080',
'none',
1,
1,
now,
now,
);
const preview = await previewLegacyImport(path, db);
expect(preview.instances.map((item) => item.status)).toEqual(['conflict', 'conflict']);
expect(preview.counts.conflict).toBe(2);
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/conflict/i);
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 2 });
});
it('rolls back all writes on any database failure', async () => {
const path = await legacyFile(valid);
const db = database();
db.exec(
"CREATE TRIGGER fail_beta BEFORE INSERT ON instances WHEN NEW.id = 'beta' BEGIN SELECT RAISE(ABORT, 'forced'); END",
);
const preview = await previewLegacyImport(path, db);
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/forced/);
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 });
expect(db.prepare('SELECT COUNT(*) AS count FROM app_settings').get()).toEqual({ count: 0 });
});
});
@@ -0,0 +1,406 @@
import type Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import { lstat, open } from 'node:fs/promises';
import { isIP } from 'node:net';
export type LegacyImportStatus = 'planned' | 'conflict' | 'unchanged';
export interface LegacyImportInstancePreview {
readonly id: string;
readonly name: string;
readonly baseUrl: string;
readonly description: string;
readonly tags: readonly string[];
readonly capabilities: readonly string[];
readonly credentialPresent: boolean;
readonly authMode: 'none' | 'password';
readonly status: LegacyImportStatus;
}
export interface LegacyImportPreview {
readonly source: {
readonly digest: string;
readonly identity: { readonly device: string; readonly inode: string };
};
readonly planDigest: string;
readonly counts: {
readonly total: number;
readonly planned: number;
readonly conflict: number;
readonly unchanged: number;
readonly secretPending: number;
};
readonly instances: readonly LegacyImportInstancePreview[];
}
export interface LegacyImportResult {
readonly imported: number;
readonly unchanged: number;
readonly secretPending: readonly string[];
readonly sourceDigest: string;
readonly planDigest: string;
}
export type LegacyImportTransactionHook = (result: LegacyImportResult) => void;
type NormalizedInstance = Omit<LegacyImportInstancePreview, 'status'>;
interface SourceRead {
readonly bytes: Buffer;
readonly digest: string;
readonly identity: { readonly device: string; readonly inode: string };
}
interface ExistingInstance {
id: string;
name: string;
base_url: string;
auth_mode: string;
}
const dangerousKeys = new Set(['__proto__', 'prototype', 'constructor']);
const MAX_LEGACY_CONFIG_BYTES = 1024 * 1024;
const metadataKey = (id: string) => `legacy-import.instance.${id}`;
const sha256 = (value: Buffer | string): string => createHash('sha256').update(value).digest('hex');
export class LegacyImportError extends Error {
constructor(message: string) {
super(message);
this.name = 'LegacyImportError';
}
}
function deepFreeze<T>(value: T): T {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
}
return value;
}
function sameIdentity(
left: { dev: number | bigint; ino: number | bigint },
right: { dev: number | bigint; ino: number | bigint },
): boolean {
return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
}
async function securelyRead(path: string): Promise<SourceRead> {
let beforePath;
try {
beforePath = await lstat(path, { bigint: true });
} catch (error) {
throw new LegacyImportError(`Cannot inspect legacy config: ${(error as Error).message}`);
}
if (!beforePath.isFile() || beforePath.isSymbolicLink())
throw new LegacyImportError('Legacy config must be a regular non-symlink file');
if (beforePath.size > BigInt(MAX_LEGACY_CONFIG_BYTES))
throw new LegacyImportError('Legacy config exceeds the size limit');
let handle;
try {
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
const before = await handle.stat({ bigint: true });
if (!before.isFile() || !sameIdentity(beforePath, before))
throw new LegacyImportError('Legacy config identity changed before read');
if (before.size > BigInt(MAX_LEGACY_CONFIG_BYTES))
throw new LegacyImportError('Legacy config exceeds the size limit');
const bytes = await handle.readFile();
const after = await handle.stat({ bigint: true });
const afterPath = await lstat(path, { bigint: true });
if (
!after.isFile() ||
!afterPath.isFile() ||
afterPath.isSymbolicLink() ||
!sameIdentity(before, after) ||
!sameIdentity(after, afterPath) ||
before.size !== after.size ||
before.mtimeNs !== after.mtimeNs ||
before.ctimeNs !== after.ctimeNs
) {
throw new LegacyImportError('Legacy config changed while it was being read');
}
return {
bytes,
digest: sha256(bytes),
identity: { device: String(after.dev), inode: String(after.ino) },
};
} catch (error) {
if (error instanceof LegacyImportError) throw error;
throw new LegacyImportError(`Cannot safely read legacy config: ${(error as Error).message}`);
} finally {
await handle?.close();
}
}
function parseJson(bytes: Buffer): unknown {
try {
return JSON.parse(bytes.toString('utf8'), (key, value: unknown) => {
if (dangerousKeys.has(key))
throw new LegacyImportError(`Dangerous JSON key is prohibited: ${key}`);
return value;
});
} catch (error) {
if (error instanceof LegacyImportError) throw error;
throw new LegacyImportError('Legacy config is not valid JSON');
}
}
function normalizeBaseUrl(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new LegacyImportError('Instance URL is invalid');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:')
throw new LegacyImportError('Instance URL must use http or https');
if (url.username || url.password)
throw new LegacyImportError('Instance URL must not contain credentials');
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const ipv4 = hostname.split('.').map(Number);
const isIpv4 =
ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255);
const prohibitedIpv4 =
isIpv4 &&
(ipv4[0] === 127 ||
ipv4[0] === 0 ||
(ipv4[0] === 169 && ipv4[1] === 254) ||
(ipv4[0] !== undefined && ipv4[0] >= 224));
const prohibitedIpv6 =
hostname === '::' ||
hostname === '::1' ||
/^fe[89ab][0-9a-f]:/.test(hostname) ||
hostname.startsWith('ff') ||
hostname.startsWith('::ffff:') ||
hostname.startsWith('64:ff9b:');
const prohibitedSpecial =
hostname === '100.100.100.200' || hostname === '168.63.129.16' || hostname.startsWith('2002:');
if (isIP(hostname) === 0 || prohibitedIpv4 || prohibitedIpv6 || prohibitedSpecial)
throw new LegacyImportError(
'Instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited',
);
url.hash = '';
url.search = '';
return url.toString().replace(/\/$/, '');
}
function uniqueStrings(value: unknown): readonly string[] {
return Array.isArray(value) ? [...new Set(value.map(String))] : [];
}
function normalize(raw: unknown): readonly NormalizedInstance[] {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
throw new LegacyImportError('Legacy config root must be an object');
if (
!Object.hasOwn(raw, 'instances') ||
!Array.isArray((raw as { instances?: unknown }).instances)
)
throw new LegacyImportError('Legacy config instances must be an array');
const ids = new Set<string>();
return (raw as { instances: unknown[] }).instances.map((candidate, index) => {
if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate))
throw new LegacyImportError(`instances[${index}] must be an object`);
const item = candidate as Record<string, unknown>;
const id = String(item.id || '').trim();
if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id))
throw new LegacyImportError(
`instances[${index}].id is required and may only contain letters, numbers, _, -, .`,
);
if (ids.has(id)) throw new LegacyImportError(`Duplicate instance id: ${id}`);
ids.add(id);
const rawUrl = String(item.url || '').trim();
if (!rawUrl) throw new LegacyImportError(`instances[${index}].url is required`);
const auth =
item.auth !== null && typeof item.auth === 'object'
? (item.auth as Record<string, unknown>)
: undefined;
const credential = auth?.password ?? item.password ?? '';
const authMode = credential ? 'password' : String(auth?.mode || 'none');
if (authMode !== 'none' && authMode !== 'password')
throw new LegacyImportError(`instances[${index}].auth.mode must be none or password`);
return {
id,
name: String(item.name || id || `SimAdmin ${index + 1}`).trim(),
baseUrl: normalizeBaseUrl(rawUrl),
description: String(item.description || '').trim(),
tags: uniqueStrings(item.tags),
capabilities: uniqueStrings(item.capabilities),
credentialPresent: Boolean(credential),
authMode,
};
});
}
function metadata(instance: NormalizedInstance): string {
return JSON.stringify({
source: 'legacy-config',
description: instance.description,
capabilities: instance.capabilities,
secretPending: instance.credentialPresent,
requestedAuthMode: instance.authMode,
secretPurpose: instance.credentialPresent ? 'instance-password' : null,
});
}
function statusFor(
database: Database.Database | undefined,
item: NormalizedInstance,
): LegacyImportStatus {
if (!database) return 'planned';
const byId = database
.prepare('SELECT id,name,base_url,auth_mode FROM instances WHERE id = ?')
.get(item.id) as ExistingInstance | undefined;
const byUrl = database
.prepare('SELECT id FROM instances WHERE base_url = ?')
.get(item.baseUrl) as { id: string } | undefined;
if (!byId) return byUrl ? 'conflict' : 'planned';
if (byUrl && byUrl.id !== item.id) return 'conflict';
const tags = (
database
.prepare('SELECT tag FROM instance_tags WHERE instance_id = ? ORDER BY tag')
.all(item.id) as {
tag: string;
}[]
).map(({ tag }) => tag);
const expectedTags = [...item.tags].sort();
const savedMetadata = database
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
.get(metadataKey(item.id)) as { value_json: string } | undefined;
return byId.name === item.name &&
byId.base_url === item.baseUrl &&
byId.auth_mode === (item.credentialPresent ? 'none' : item.authMode) &&
JSON.stringify(tags) === JSON.stringify(expectedTags) &&
savedMetadata?.value_json === metadata(item)
? 'unchanged'
: 'conflict';
}
function digestPlan(
source: Pick<SourceRead, 'digest' | 'identity'>,
instances: readonly LegacyImportInstancePreview[],
): string {
const normalizedPlan = instances.map((instance) => ({
id: instance.id,
name: instance.name,
baseUrl: instance.baseUrl,
description: instance.description,
tags: instance.tags,
capabilities: instance.capabilities,
credentialPresent: instance.credentialPresent,
authMode: instance.authMode,
status: instance.status,
}));
return sha256(
JSON.stringify({
sourceDigest: source.digest,
sourceIdentity: source.identity,
instances: normalizedPlan,
}),
);
}
export async function previewLegacyImport(
path: string,
database?: Database.Database,
): Promise<LegacyImportPreview> {
const source = await securelyRead(path);
const normalized = normalize(parseJson(source.bytes));
const instances = normalized.map((item) => ({
...item,
status: statusFor(database, item),
}));
const counts = {
total: instances.length,
planned: instances.filter(({ status }) => status === 'planned').length,
conflict: instances.filter(({ status }) => status === 'conflict').length,
unchanged: instances.filter(({ status }) => status === 'unchanged').length,
secretPending: instances.filter(({ credentialPresent }) => credentialPresent).length,
};
return deepFreeze({
source: { digest: source.digest, identity: source.identity },
planDigest: digestPlan(source, instances),
counts,
instances,
});
}
function validateConfirmation(preview: LegacyImportPreview, current: LegacyImportPreview): void {
if (
!preview ||
preview.source?.digest !== current.source.digest ||
preview.source?.identity?.device !== current.source.identity.device ||
preview.source?.identity?.inode !== current.source.identity.inode
)
throw new LegacyImportError('Legacy config source changed since preview');
if (
preview.planDigest !== digestPlan(preview.source, preview.instances) ||
preview.planDigest !== current.planDigest
)
throw new LegacyImportError('Legacy import plan digest mismatch');
if (current.counts.conflict > 0)
throw new LegacyImportError('Legacy import plan contains database conflicts');
}
export async function confirmLegacyImport(
path: string,
preview: LegacyImportPreview,
database: Database.Database,
transactionHook?: LegacyImportTransactionHook,
): Promise<LegacyImportResult> {
const current = await previewLegacyImport(path, database);
validateConfirmation(preview, current);
// Re-read independently inside confirmation: data used for writes is never taken from the caller preview.
const source = await securelyRead(path);
if (
source.digest !== current.source.digest ||
source.identity.device !== current.source.identity.device ||
source.identity.inode !== current.source.identity.inode
)
throw new LegacyImportError('Legacy config changed during confirmation');
const normalized = normalize(parseJson(source.bytes));
const now = new Date().toISOString();
const plannedIds = new Set(
current.instances.filter(({ status }) => status === 'planned').map(({ id }) => id),
);
const result = deepFreeze({
imported: current.counts.planned,
unchanged: current.counts.unchanged,
secretPending: normalized
.filter(({ credentialPresent }) => credentialPresent)
.map(({ id }) => id),
sourceDigest: current.source.digest,
planDigest: current.planDigest,
});
database.transaction(() => {
const insertInstance = database.prepare(
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,1,1,?,?)',
);
const insertTag = database.prepare(
'INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)',
);
const insertMetadata = database.prepare(
'INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)',
);
for (const item of normalized) {
if (!plannedIds.has(item.id)) continue;
insertInstance.run(
item.id,
item.name,
item.baseUrl,
item.credentialPresent ? 'none' : item.authMode,
now,
now,
);
for (const tag of item.tags) insertTag.run(item.id, tag, now);
insertMetadata.run(metadataKey(item.id), metadata(item), now, now);
}
transactionHook?.(result);
})();
return result;
}
@@ -0,0 +1,144 @@
import Database from 'better-sqlite3';
import { access, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { runMigrationCli } from './migration-cli.js';
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'phase9-cli-'));
const source = join(root, 'legacy.json');
const database = join(root, 'app.sqlite');
await writeFile(
source,
JSON.stringify({
instances: [
{
id: 'alpha',
url: 'https://203.0.113.8',
auth: { mode: 'password', password: 'never-print-this-secret' },
},
],
}),
{ mode: 0o600 },
);
return { root, source, database };
}
describe('operator migration CLI', () => {
it('does not create or migrate a missing target during preview', async () => {
const paths = await fixture();
await runMigrationCli(['preview', '--source', paths.source, '--database', paths.database], {
stdout: vi.fn(),
});
await expect(access(paths.database)).rejects.toThrow();
});
it('previews redacted JSON and requires digest-bound explicit confirmation', async () => {
const paths = await fixture();
const output: string[] = [];
expect(
await runMigrationCli(['preview', '--source', paths.source, '--database', paths.database], {
stdout: (line) => output.push(line),
}),
).toBe(0);
const preview = JSON.parse(output.join('')) as { planDigest: string };
expect(output.join('')).not.toContain('never-print-this-secret');
await expect(
runMigrationCli(
[
'confirm',
'--source',
paths.source,
'--database',
paths.database,
'--plan-digest',
'0'.repeat(64),
],
{ stdout: vi.fn() },
),
).rejects.toThrow(/digest/i);
output.length = 0;
expect(
await runMigrationCli(
[
'confirm',
'--source',
paths.source,
'--database',
paths.database,
'--plan-digest',
preview.planDigest,
],
{ stdout: (line) => output.push(line) },
),
).toBe(0);
expect(output.join('')).not.toContain('never-print-this-secret');
const db = new Database(paths.database);
expect(db.prepare('SELECT id FROM instances').all()).toEqual([{ id: 'alpha' }]);
const release = db
.prepare("SELECT value_json FROM app_settings WHERE key LIKE 'release.legacy-import.%'")
.get() as { value_json: string };
expect(JSON.parse(release.value_json)).toMatchObject({
kind: 'legacy-import',
planDigest: preview.planDigest,
imported: 1,
reconciliation: { status: 'pending-secret-activation', pendingSecretCount: 1 },
});
db.close();
});
it('is idempotent and records a reconciled second release', async () => {
const paths = await fixture();
const collect = async (args: string[]) => {
let text = '';
await runMigrationCli(args, { stdout: (line) => (text += line) });
return JSON.parse(text) as { planDigest: string };
};
const preview = await collect([
'preview',
'--source',
paths.source,
'--database',
paths.database,
]);
await collect([
'confirm',
'--source',
paths.source,
'--database',
paths.database,
'--plan-digest',
preview.planDigest,
]);
const second = await collect([
'preview',
'--source',
paths.source,
'--database',
paths.database,
]);
const result = await collect([
'confirm',
'--source',
paths.source,
'--database',
paths.database,
'--plan-digest',
second.planDigest,
]);
expect(result).toMatchObject({ imported: 0, unchanged: 1 });
const db = new Database(paths.database, { readonly: true });
const releases = db
.prepare(
"SELECT value_json FROM app_settings WHERE key LIKE 'release.legacy-import.%' ORDER BY key",
)
.all() as { value_json: string }[];
expect(JSON.parse(releases.at(-1)!.value_json)).toMatchObject({
reconciliation: { status: 'pending-secret-activation', pendingSecretCount: 1 },
});
db.close();
});
});
@@ -0,0 +1,82 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { access } from 'node:fs/promises';
import { resolve } from 'node:path';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { openDatabase } from '../../infrastructure/database/database.js';
import { confirmLegacyImport, previewLegacyImport } from './legacy-import.js';
export interface MigrationCliIo {
readonly stdout: (text: string) => void;
}
function option(args: readonly string[], name: string): string {
const index = args.indexOf(name);
const value = index < 0 ? undefined : args[index + 1];
if (!value || value.startsWith('--')) throw new Error(`Required option missing: ${name}`);
return value;
}
function print(io: MigrationCliIo, value: unknown): void {
io.stdout(`${JSON.stringify(value, null, 2)}\n`);
}
export async function runMigrationCli(
args: readonly string[],
io: MigrationCliIo = { stdout: (text) => process.stdout.write(text) },
): Promise<number> {
const command = args[0];
if (command !== 'preview' && command !== 'confirm')
throw new Error(
'Usage: migration-cli preview|confirm --source PATH --database PATH [--plan-digest SHA256]',
);
const sourcePath = resolve(option(args, '--source'));
const databasePath = resolve(option(args, '--database'));
if (command === 'preview') {
let database: Database.Database | undefined;
try {
await access(databasePath);
database = new Database(databasePath, { readonly: true, fileMustExist: true });
print(io, await previewLegacyImport(sourcePath, database));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
print(io, await previewLegacyImport(sourcePath));
} finally {
database?.close();
}
return 0;
}
const database = openDatabase(databasePath);
try {
migrateDatabase(database);
const preview = await previewLegacyImport(sourcePath, database);
const confirmedDigest = option(args, '--plan-digest');
if (!/^[a-f0-9]{64}$/.test(confirmedDigest) || confirmedDigest !== preview.planDigest)
throw new Error('Legacy import plan digest mismatch; run preview again');
const result = await confirmLegacyImport(sourcePath, preview, database, (confirmed) => {
const now = new Date().toISOString();
const release = {
kind: 'legacy-import',
sourceDigest: confirmed.sourceDigest,
planDigest: confirmed.planDigest,
imported: confirmed.imported,
unchanged: confirmed.unchanged,
reconciliation: {
status: confirmed.secretPending.length > 0 ? 'pending-secret-activation' : 'reconciled',
pendingSecretCount: confirmed.secretPending.length,
},
recordedAt: now,
};
database
.prepare('INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)')
.run(`release.legacy-import.${now}.${randomUUID()}`, JSON.stringify(release), now, now);
});
print(io, result);
return 0;
} finally {
database.close();
}
}
@@ -0,0 +1,10 @@
#!/usr/bin/env node
import { runMigrationCli } from './migration-cli.js';
try {
process.exitCode = await runMigrationCli(process.argv.slice(2));
} catch (error) {
// Import errors are deliberately content-free; never print source JSON or credentials.
process.stderr.write(`${error instanceof Error ? error.message : 'Migration failed'}\n`);
process.exitCode = 1;
}
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest';
import { InstanceSessionStore } from '../connections/upstream-session-client.js';
import {
InstanceMessageService,
MessageServiceError,
parseMessageList,
} from './instance-message-service.js';
const instance = { id: 'alpha', origin: 'http://192.168.1.10:8080' };
const instances = { get: vi.fn(async (id: string) => (id === 'alpha' ? instance : undefined)) };
describe('InstanceMessageService', () => {
it('parses a bounded explicit message allowlist and never exposes pdu or excess fields', () => {
const messages = parseMessageList(
{
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 'm1',
direction: 'received',
phone_number: '+15550199',
content: 'hello',
timestamp: '2026-07-19T12:00:00Z',
status: 'delivered',
pdu: 'SECRET-PDU',
transport: 'modem',
password: 'secret',
},
],
},
}),
},
10,
);
expect(messages).toEqual([
{
id: 'm1',
direction: 'received',
phoneNumber: '+15550199',
content: 'hello',
timestamp: '2026-07-19T12:00:00Z',
status: 'delivered',
transport: 'modem',
},
]);
expect(JSON.stringify(messages)).not.toContain('PDU');
expect(JSON.stringify(messages)).not.toContain('secret');
});
it('uses exact owner, bounded query and matching optional session cookie', async () => {
const sessions = new InstanceSessionStore();
sessions.set('alpha', instance.origin, 'simadmin_session=opaque');
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"success","data":{"messages":[]}}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions,
request,
});
await expect(
service.list('alpha', { limit: 20, offset: 2, direction: 'outgoing' }),
).resolves.toEqual({ messages: [] });
expect(request).toHaveBeenCalledWith({
url: 'http://192.168.1.10:8080/api/sms/list?limit=20&offset=2&direction=outgoing',
method: 'GET',
headers: { accept: 'application/json', cookie: 'simadmin_session=opaque' },
});
});
it('sends once with an explicit payload and returns only safe success', async () => {
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"success","message":"queued","data":{"token":"secret"}}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions: new InstanceSessionStore(),
request,
});
await expect(
service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }),
).resolves.toEqual({ sent: true });
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith({
url: 'http://192.168.1.10:8080/api/sms/send',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
sms: { phoneNumber: '+15550199', content: 'hello' },
});
});
it('fails closed for missing owners, stale sessions and upstream status:error', async () => {
const sessions = new InstanceSessionStore();
sessions.set('alpha', 'http://192.168.1.99', 'simadmin_session=opaque');
const request = vi.fn(async () => ({
status: 200,
headers: {},
body: '{"status":"error","message":"recipient secret"}',
}));
const service = new InstanceMessageService({
instances: instances as never,
sessions,
request,
});
await expect(service.list('alpha', { limit: 10, offset: 0 })).rejects.toBeInstanceOf(
MessageServiceError,
);
await expect(service.list('missing', { limit: 10, offset: 0 })).rejects.toMatchObject({
code: 'NOT_FOUND',
});
sessions.clear('alpha');
await expect(
service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }),
).rejects.toMatchObject({ code: 'UPSTREAM_FAILED' });
});
});
@@ -0,0 +1,191 @@
import type { InstanceService } from '../instances/instance-service.js';
import type {
InstanceSessionStore,
UpstreamResponse,
UpstreamSessionClientOptions,
} from '../connections/upstream-session-client.js';
export type MessageDirection = 'received' | 'sent' | 'incoming' | 'outgoing' | 'unknown';
export interface InstanceMessage {
readonly id: string;
readonly direction: MessageDirection;
readonly phoneNumber: string;
readonly content: string;
readonly timestamp: string;
readonly status: string;
readonly transport: string;
}
export interface MessageListQuery {
readonly limit: number;
readonly offset: number;
readonly direction?: Exclude<MessageDirection, 'unknown'>;
}
export interface SendMessageInput {
readonly phoneNumber: string;
readonly content: string;
}
export type MessageServiceErrorCode =
| 'NOT_FOUND'
| 'VALIDATION_FAILED'
| 'SESSION_INVALID'
| 'UPSTREAM_FAILED';
export class MessageServiceError extends Error {
constructor(readonly code: MessageServiceErrorCode) {
super(code);
this.name = 'MessageServiceError';
}
}
const MAX_RESPONSE_BYTES = 262_144;
const DIRECTIONS = new Set<MessageDirection>(['received', 'sent', 'incoming', 'outgoing']);
const record = (value: unknown): Record<string, unknown> | undefined =>
value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const bounded = (value: unknown, maximum: number): string | undefined =>
(typeof value === 'string' || typeof value === 'number') &&
String(value).length <= maximum &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(String(value))
? String(value)
: undefined;
export function validPhoneNumber(value: string): boolean {
return value.length >= 3 && value.length <= 32 && /^\+?[0-9][0-9 ()-]*$/u.test(value);
}
export function validMessageContent(value: string): boolean {
return (
value.length >= 1 &&
value.length <= 1600 &&
Buffer.byteLength(value, 'utf8') <= 6400 &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
);
}
export function parseMessageList(
response: UpstreamResponse,
limit: number,
): readonly InstanceMessage[] {
if (
response.status < 200 ||
response.status >= 300 ||
Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES
)
throw new MessageServiceError('UPSTREAM_FAILED');
let root: Record<string, unknown> | undefined;
try {
root = record(JSON.parse(response.body));
} catch {
throw new MessageServiceError('UPSTREAM_FAILED');
}
if (root?.status !== 'success' && root?.status !== 'ok')
throw new MessageServiceError('UPSTREAM_FAILED');
const source = record(root.data)?.messages;
if (!Array.isArray(source) || source.length > limit)
throw new MessageServiceError('UPSTREAM_FAILED');
const output: InstanceMessage[] = [];
for (const item of source) {
const value = record(item);
const id = bounded(value?.id, 128);
const phoneNumber = bounded(value?.phone_number, 32);
const content = bounded(value?.content, 1600);
const timestamp = bounded(value?.timestamp, 64);
const status = bounded(value?.status, 32);
const transport = bounded(value?.transport, 32);
const rawDirection = bounded(value?.direction, 16);
if (
!id ||
!phoneNumber ||
!validPhoneNumber(phoneNumber) ||
content === undefined ||
timestamp === undefined ||
status === undefined ||
transport === undefined
)
continue;
const direction: MessageDirection =
rawDirection && DIRECTIONS.has(rawDirection as MessageDirection)
? (rawDirection as MessageDirection)
: 'unknown';
output.push({ id, direction, phoneNumber, content, timestamp, status, transport });
}
return output;
}
function parseSendSuccess(response: UpstreamResponse): void {
if (
response.status < 200 ||
response.status >= 300 ||
Buffer.byteLength(response.body, 'utf8') > 32_768
)
throw new MessageServiceError('UPSTREAM_FAILED');
try {
if (record(JSON.parse(response.body))?.status !== 'success')
throw new MessageServiceError('UPSTREAM_FAILED');
} catch (error) {
if (error instanceof MessageServiceError) throw error;
throw new MessageServiceError('UPSTREAM_FAILED');
}
}
export class InstanceMessageService {
constructor(
private readonly options: {
readonly instances: InstanceService;
readonly sessions: InstanceSessionStore;
readonly request: UpstreamSessionClientOptions['request'];
},
) {}
private async owner(instanceId: string) {
const instance = await this.options.instances.get(instanceId);
if (!instance) throw new MessageServiceError('NOT_FOUND');
const session = this.options.sessions.sessionFor(instanceId);
if (session && session.origin !== instance.origin)
throw new MessageServiceError('SESSION_INVALID');
return { instance, session };
}
async list(
instanceId: string,
query: MessageListQuery,
): Promise<{ readonly messages: readonly InstanceMessage[] }> {
if (
!Number.isSafeInteger(query.limit) ||
query.limit < 1 ||
query.limit > 100 ||
!Number.isSafeInteger(query.offset) ||
query.offset < 0 ||
query.offset > 10_000 ||
(query.direction !== undefined &&
query.direction !== 'incoming' &&
query.direction !== 'outgoing')
)
throw new MessageServiceError('VALIDATION_FAILED');
const { instance, session } = await this.owner(instanceId);
const direction = query.direction ? `&direction=${query.direction}` : '';
const response = await this.options.request({
url: `${instance.origin}/api/sms/list?limit=${query.limit}&offset=${query.offset}${direction}`,
method: 'GET',
headers: { accept: 'application/json', ...(session ? { cookie: session.cookie } : {}) },
});
return { messages: parseMessageList(response, query.limit) };
}
async send(instanceId: string, input: SendMessageInput): Promise<{ readonly sent: true }> {
if (!validPhoneNumber(input.phoneNumber) || !validMessageContent(input.content))
throw new MessageServiceError('VALIDATION_FAILED');
const { instance, session } = await this.owner(instanceId);
const response = await this.options.request({
url: `${instance.origin}/api/sms/send`,
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
...(session ? { cookie: session.cookie } : {}),
},
sms: { phoneNumber: input.phoneNumber, content: input.content },
});
parseSendSuccess(response);
return { sent: true };
}
}
@@ -0,0 +1,399 @@
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { afterEach, describe, expect, it } from 'vitest';
import { InstanceService } from '../instances/instance-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import {
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
DeleteInstanceOperation,
DeleteInstanceOperationError,
} from './delete-instance-operation.js';
class Store implements SecretStore {
async set() {
return '';
}
async get() {
return undefined;
}
async delete() {
return false;
}
}
const databases: Database.Database[] = [];
afterEach(() => {
for (const database of databases.splice(0)) database.close();
});
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
databases.push(db);
let sequence = 0;
const now = () => new Date('2026-07-16T12:00:00.000Z');
const instances = new InstanceService({
db,
store: new Store(),
idFactory: () => `instance-${++sequence}`,
now,
});
const operation = new DeleteInstanceOperation({
db,
instances,
now,
idFactory: () => `operation-${++sequence}`,
tokenFactory: () => Buffer.alloc(32, 7).toString('base64url'),
});
return { db, instances, operation };
}
describe('DeleteInstanceOperation', () => {
it('prepares only the fixed delete shape and persists only a token digest with TTL', async () => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const prepared = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
expect(prepared).toMatchObject({
status: 'prepared',
operationId: 'deleteInstance',
risk: 'R3',
targetCount: 1,
});
expect(Buffer.from(prepared.confirmationToken, 'base64url')).toHaveLength(32);
expect(prepared.expiresAt).toBe('2026-07-16T12:05:00.000Z');
const row = db
.prepare('SELECT token_digest, expires_at FROM operation_preparations WHERE id=?')
.get(prepared.id) as { token_digest: string; expires_at: string };
expect(row.token_digest).toBe(
createHash('sha256').update(prepared.confirmationToken).digest('hex'),
);
expect(JSON.stringify(db.prepare('SELECT * FROM operation_preparations').all())).not.toContain(
prepared.confirmationToken,
);
for (const input of [
{
operationId: 'other',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
{
operationId: 'deleteInstance',
targets: [],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
{
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 0 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
{
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: 'wrong', fields: [] },
},
{
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: {
parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
fields: [{ fieldId: 'x', kind: 'string' as const, value: 'x' }],
},
},
{
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
extra: true,
},
null,
])
await expect(operation.prepare(input as never)).rejects.toBeInstanceOf(
DeleteInstanceOperationError,
);
});
it('consumes once, creates immutable history, deletes safely, and returns a frozen Job shape', async () => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const prepared = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
const job = await operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-1',
});
expect(job).toEqual({
id: expect.any(String),
operationId: 'deleteInstance',
status: 'succeeded',
rootJobId: expect.any(String),
items: [{ id: expect.any(String), targetId: instance.id, state: 'succeeded' }],
attempts: [
{
id: expect.any(String),
state: 'succeeded',
startedAt: expect.any(String),
finishedAt: expect.any(String),
},
],
createdAt: expect.any(String),
});
expect(job.rootJobId).toBe(job.id);
expect(await instances.get(instance.id)).toBeUndefined();
expect(db.prepare('SELECT instance_id FROM job_items').get()).toEqual({
instance_id: instance.id,
});
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-2',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 1 });
});
it('does not consume a preparation when the token is wrong', async () => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const prepared = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: 'x'.repeat(20),
actor: 'loopback-control-plane',
requestId: 'request-1',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'prepared' });
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
});
it.each([
['risk_level', 'R2'],
['target_origin', 'http://192.168.1.99'],
['method', 'POST'],
['path', '/api/v1/instances/someone-else'],
['canonical_query', 'force=true'],
['body_digest', 'a'.repeat(64)],
['content_type', 'application/json'],
['nonce', 'tampered-nonce'],
])(
'fails closed and invalidates a confirmation with tampered durable %s binding',
async (column, value) => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const prepared = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
db.prepare(`UPDATE operation_preparations SET ${column}=? WHERE id=?`).run(
value,
prepared.id,
);
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'tampered-binding',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect(await instances.get(instance.id)).toBeDefined();
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'invalidated' });
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
},
);
it('fails closed when the current target origin no longer matches the durable binding', async () => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const prepared = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
db.prepare('UPDATE instances SET base_url=? WHERE id=?').run(
'http://192.168.1.11',
instance.id,
);
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'changed-origin',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect(await instances.get(instance.id)).toBeDefined();
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'invalidated' });
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
});
it('returns typed target errors after consuming a correctly bound confirmation', async () => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const prepared = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
await instances.update(instance.id, 1, { name: 'B' });
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'stale',
}),
).rejects.toMatchObject({ code: 'REVISION_CONFLICT' });
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'consumed' });
const current = await instances.get(instance.id);
const missing = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: current!.revision }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
await instances.delete(instance.id, current!.revision);
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: current!.revision,
preparationId: missing.id,
confirmationToken: missing.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'missing',
}),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('reconciles interrupted running delete jobs to unknown-result', async () => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const now = '2026-07-16T12:00:00.000Z';
db.prepare(
"INSERT INTO jobs (id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at) VALUES ('j','j','deleteInstance','R3','running','a','r','d',?,?,?)",
).run(now, now, now);
db.prepare(
"INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES ('a','j','running',?,?)",
).run(now, now);
db.prepare(
"INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at) VALUES ('i','j',?,1,'running',?,?,?)",
).run(instance.id, now, now, now);
expect(operation.reconcileInterruptedJobs()).toBe(1);
expect(db.prepare("SELECT status FROM jobs WHERE id='j'").get()).toEqual({
status: 'unknown-result',
});
expect(db.prepare("SELECT status FROM job_items WHERE id='i'").get()).toEqual({
status: 'unknown-result',
});
expect(db.prepare("SELECT status FROM job_attempts WHERE id='a'").get()).toEqual({
status: 'unknown-result',
});
});
it('does not delete when durable Job history cannot be created at execution entry', async () => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const prepared = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
db.exec(
"CREATE TRIGGER reject_delete_job_item BEFORE INSERT ON job_items BEGIN SELECT RAISE(ABORT, 'blocked'); END",
);
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-1',
}),
).rejects.toThrow();
expect(await instances.get(instance.id)).toBeDefined();
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
});
it('rejects an expired preparation once and never creates a Job', async () => {
const { db, instances } = fixture();
let current = new Date('2026-07-16T12:00:00.000Z');
let sequence = 100;
const operation = new DeleteInstanceOperation({
db,
instances,
now: () => current,
idFactory: () => `expiry-${++sequence}`,
tokenFactory: () => Buffer.alloc(32, 9).toString('base64url'),
});
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const prepared = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
current = new Date('2026-07-16T12:05:00.001Z');
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-expired',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-replay',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
});
});
@@ -0,0 +1,414 @@
import type Database from 'better-sqlite3';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { Job, Preparation, PrepareOperationRequest } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from '../instances/instance-service.js';
export const DELETE_INSTANCE_PARAMETER_SCHEMA_ID = 'deleteInstance.parameters.v1' as const;
const OPERATION_ID = 'deleteInstance';
const ACTOR = 'loopback-control-plane';
const TTL_MS = 5 * 60 * 1000;
const PARAMETERS_DIGEST = createHash('sha256')
.update(JSON.stringify({ parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] }))
.digest('hex');
export type DeleteInstanceOperationErrorCode =
| 'VALIDATION_FAILED'
| 'NOT_FOUND'
| 'REVISION_CONFLICT'
| 'CONFIRMATION_INVALID';
export class DeleteInstanceOperationError extends Error {
constructor(
readonly code: DeleteInstanceOperationErrorCode,
message: string,
) {
super(message);
this.name = 'DeleteInstanceOperationError';
}
}
interface PreparationRow {
operation_id: string;
risk_level: string;
status: string;
target_instance_id: string;
target_revision: number;
target_origin: string;
method: string;
path: string;
canonical_query: string;
body_digest: string;
content_type: string;
parameter_schema_id: string;
parameters_digest: string;
nonce: string;
token_digest: string;
expires_at: string;
}
interface ExecuteInput {
readonly instanceId: string;
readonly revision: number;
readonly preparationId: string;
readonly confirmationToken: string;
readonly actor: string;
readonly requestId: string;
}
interface Options {
readonly db: Database.Database;
readonly instances: InstanceService;
readonly now?: () => Date;
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
}
const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex');
const equalDigest = (left: string, right: string): boolean => {
const a = Buffer.from(left, 'hex');
const b = Buffer.from(right, 'hex');
return a.length === 32 && b.length === 32 && timingSafeEqual(a, b);
};
const validRevision = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
export class DeleteInstanceOperation {
private readonly db: Database.Database;
private readonly instances: InstanceService;
private readonly clock: () => Date;
private readonly id: () => string;
private readonly token: () => string;
constructor(options: Options) {
this.db = options.db;
this.instances = options.instances;
this.clock = options.now ?? (() => new Date());
this.id = options.idFactory ?? randomUUID;
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
}
async prepare(input: PrepareOperationRequest, requestId = this.id()): Promise<Preparation> {
if (
!input ||
typeof input !== 'object' ||
Array.isArray(input) ||
Object.keys(input).some((key) => !['operationId', 'targets', 'parameters'].includes(key)) ||
input.operationId !== OPERATION_ID ||
!Array.isArray(input.targets) ||
input.targets.length !== 1 ||
!input.targets[0] ||
typeof input.targets[0] !== 'object' ||
Array.isArray(input.targets[0]) ||
Object.keys(input.targets[0]).some((key) => !['instanceId', 'revision'].includes(key)) ||
typeof input.targets[0].instanceId !== 'string' ||
input.targets[0].instanceId.length === 0 ||
!validRevision(input.targets[0].revision) ||
!input.parameters ||
typeof input.parameters !== 'object' ||
Array.isArray(input.parameters) ||
Object.keys(input.parameters).some((key) => !['parameterSchemaId', 'fields'].includes(key)) ||
input.parameters.parameterSchemaId !== DELETE_INSTANCE_PARAMETER_SCHEMA_ID ||
!Array.isArray(input.parameters.fields) ||
input.parameters.fields.length !== 0
) {
throw new DeleteInstanceOperationError('VALIDATION_FAILED', 'Invalid delete preparation');
}
const target = input.targets[0]!;
const current = await this.instances.get(target.instanceId);
if (!current) throw new DeleteInstanceOperationError('NOT_FOUND', 'Instance was not found');
if (current.revision !== target.revision)
throw new DeleteInstanceOperationError(
'REVISION_CONFLICT',
'Instance revision does not match',
);
const token = this.token();
let tokenBytes: Buffer;
try {
tokenBytes = Buffer.from(token, 'base64url');
} catch {
tokenBytes = Buffer.alloc(0);
}
if (tokenBytes.length < 32 || token.length < 20 || !/^[A-Za-z0-9_-]+$/.test(token))
throw new Error('Confirmation token factory returned an unsafe token');
const id = this.id();
const created = this.clock();
const now = created.toISOString();
const expiresAt = new Date(created.getTime() + TTL_MS).toISOString();
this.db
.prepare(
`INSERT INTO operation_preparations
(id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path,
canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest,
requested_by,request_id,expires_at,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
.run(
id,
OPERATION_ID,
'R3',
'prepared',
target.instanceId,
target.revision,
current.origin,
'DELETE',
`/api/v1/instances/${target.instanceId}`,
'',
digest(''),
'',
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
PARAMETERS_DIGEST,
`delete-${id}`,
digest(token),
ACTOR,
requestId,
expiresAt,
now,
now,
);
return {
id,
status: 'prepared',
operationId: OPERATION_ID,
risk: 'R3',
expiresAt,
confirmationToken: token,
confirmationPrompt: `Delete instance ${target.instanceId}? This action cannot be undone.`,
targetCount: 1,
};
}
async executeDelete(input: ExecuteInput): Promise<Job> {
if (!validRevision(input.revision))
throw new DeleteInstanceOperationError('VALIDATION_FAILED', 'Invalid revision');
const now = this.clock().toISOString();
const ids = { job: this.id(), item: this.id(), attempt: this.id() };
let accepted = false;
let targetError: 'NOT_FOUND' | 'REVISION_CONFLICT' | undefined;
this.db.transaction(() => {
const row = this.db
.prepare(
`SELECT operation_id,risk_level,status,target_instance_id,target_revision,target_origin,
method,path,canonical_query,body_digest,content_type,parameter_schema_id,
parameters_digest,nonce,token_digest,expires_at
FROM operation_preparations WHERE id=?`,
)
.get(input.preparationId) as PreparationRow | undefined;
if (!row || row.status !== 'prepared')
throw new DeleteInstanceOperationError(
'CONFIRMATION_INVALID',
'Confirmation could not be accepted',
);
const confirmationValid =
row.expires_at > now && equalDigest(row.token_digest, digest(input.confirmationToken));
if (!confirmationValid) return;
const expectedPath = `/api/v1/instances/${input.instanceId}`;
const bindingValid =
row.operation_id === OPERATION_ID &&
row.risk_level === 'R3' &&
row.target_instance_id === input.instanceId &&
row.target_revision === input.revision &&
row.method === 'DELETE' &&
row.path === expectedPath &&
row.canonical_query === '' &&
row.body_digest === digest('') &&
row.content_type === '' &&
row.parameter_schema_id === DELETE_INSTANCE_PARAMETER_SCHEMA_ID &&
row.parameters_digest === PARAMETERS_DIGEST &&
row.nonce === `delete-${input.preparationId}`;
if (!bindingValid) {
this.db
.prepare(
"UPDATE operation_preparations SET status='invalidated',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
)
.run(now, now, input.preparationId);
return;
}
const current = this.db
.prepare('SELECT base_url,config_revision FROM instances WHERE id=?')
.get(input.instanceId) as { base_url: string; config_revision: number } | undefined;
if (current && current.base_url !== row.target_origin) {
this.db
.prepare(
"UPDATE operation_preparations SET status='invalidated',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
)
.run(now, now, input.preparationId);
return;
}
// A correctly authenticated confirmation is one-shot, including stale/missing target results.
this.db
.prepare(
"UPDATE operation_preparations SET status='consumed',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
)
.run(now, now, input.preparationId);
if (!current) {
targetError = 'NOT_FOUND';
return;
}
if (current.config_revision !== input.revision) {
targetError = 'REVISION_CONFLICT';
return;
}
this.db
.prepare(
`INSERT INTO jobs
(id,parent_job_id,root_job_id,retry_of_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at)
VALUES (?,NULL,?,NULL,?,'R3','running',?,?,?,?,?,?)`,
)
.run(
ids.job,
ids.job,
OPERATION_ID,
input.actor,
input.requestId,
PARAMETERS_DIGEST,
now,
now,
now,
);
this.db
.prepare(
`INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES (?,?,'running',?,?)`,
)
.run(ids.attempt, ids.job, now, now);
this.db
.prepare(
`INSERT INTO job_items
(id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at)
VALUES (?,?,?,1,'running',?,?,?)`,
)
.run(ids.item, ids.job, input.instanceId, now, now, now);
accepted = true;
})();
if (targetError)
throw new DeleteInstanceOperationError(
targetError,
targetError === 'NOT_FOUND' ? 'Instance was not found' : 'Instance revision does not match',
);
if (!accepted)
throw new DeleteInstanceOperationError(
'CONFIRMATION_INVALID',
'Confirmation could not be accepted',
);
try {
await this.instances.delete(input.instanceId, input.revision, { allowJobHistory: true });
const finished = this.clock().toISOString();
this.db.transaction(() => {
this.db
.prepare(
`UPDATE job_items
SET status='succeeded',result_code='INSTANCE_DELETED',finished_at=?,updated_at=?
WHERE id=? AND status='running'`,
)
.run(finished, finished, ids.item);
this.db
.prepare(
"UPDATE job_attempts SET status='succeeded',finished_at=? WHERE id=? AND status='running'",
)
.run(finished, ids.attempt);
this.db
.prepare(
"UPDATE jobs SET status='succeeded',finished_at=?,updated_at=? WHERE id=? AND status='running'",
)
.run(finished, finished, ids.job);
})();
} catch (error) {
const finished = this.clock().toISOString();
const state = error instanceof InstanceServiceError ? 'failed' : 'unknown-result';
const code = error instanceof InstanceServiceError ? error.code : 'DELETE_RESULT_UNKNOWN';
this.db.transaction(() => {
this.db
.prepare(
`UPDATE job_items
SET status=?,result_code=?,finished_at=?,updated_at=?
WHERE id=? AND status='running'`,
)
.run(state, code, finished, finished, ids.item);
this.db
.prepare('UPDATE job_attempts SET status=?,finished_at=? WHERE id=?')
.run(state, finished, ids.attempt);
this.db
.prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?')
.run(state, finished, finished, ids.job);
})();
}
return this.job(ids.job);
}
reconcileInterruptedJobs(): number {
const finished = this.clock().toISOString();
return this.db.transaction(() => {
const jobs = this.db
.prepare("SELECT id FROM jobs WHERE operation_id=? AND status='running'")
.all(OPERATION_ID) as Array<{ id: string }>;
for (const { id } of jobs) {
this.db
.prepare(
"UPDATE job_items SET status='unknown-result',result_code='INTERRUPTED',finished_at=?,updated_at=? WHERE job_id=? AND status='running'",
)
.run(finished, finished, id);
this.db
.prepare(
"UPDATE job_attempts SET status='unknown-result',finished_at=? WHERE job_id=? AND status='running'",
)
.run(finished, id);
this.db
.prepare(
"UPDATE jobs SET status='unknown-result',finished_at=?,updated_at=? WHERE id=? AND status='running'",
)
.run(finished, finished, id);
}
return jobs.length;
})();
}
private job(id: string): Job {
const row = this.db
.prepare(
'SELECT operation_id,status,root_job_id,retry_of_job_id,created_at FROM jobs WHERE id=?',
)
.get(id) as {
operation_id: string;
status: Job['status'];
root_job_id: string;
retry_of_job_id: string | null;
created_at: string;
};
const items = this.db
.prepare('SELECT id,instance_id,status FROM job_items WHERE job_id=? ORDER BY created_at,id')
.all(id) as Array<{
id: string;
instance_id: string;
status: 'succeeded' | 'failed' | 'unknown-result';
}>;
const attempts = this.db
.prepare(
'SELECT id,status,started_at,finished_at FROM job_attempts WHERE job_id=? ORDER BY started_at,id',
)
.all(id) as Array<{
id: string;
status: 'succeeded' | 'failed' | 'unknown-result';
started_at: string;
finished_at: string | null;
}>;
return {
id,
operationId: row.operation_id,
status: row.status,
...(row.retry_of_job_id ? { retryOfJobId: row.retry_of_job_id } : {}),
rootJobId: row.root_job_id,
items: items.map((item) => ({ id: item.id, targetId: item.instance_id, state: item.status })),
attempts: attempts.map((attempt) => ({
id: attempt.id,
state: attempt.status,
startedAt: attempt.started_at,
...(attempt.finished_at ? { finishedAt: attempt.finished_at } : {}),
})),
createdAt: row.created_at,
};
}
}
@@ -0,0 +1,938 @@
[
{
"operationId": "deleteCallHistoryId",
"title": "Delete Call History ID",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.deleteCallHistoryId.parameters.v1"
},
{
"operationId": "deleteEsimProfilesIccid",
"title": "Delete eSIM Profile ICCID",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.deleteEsimProfilesIccid.parameters.v1"
},
{
"operationId": "deleteNotificationsQueueId",
"title": "Delete Notifications Queue ID",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.deleteNotificationsQueueId.parameters.v1"
},
{
"operationId": "deleteSmsConversationPhoneNumber",
"title": "Delete SMS Conversation Phone Number",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.deleteSmsConversationPhoneNumber.parameters.v1"
},
{
"operationId": "deleteSmsMessageId",
"title": "Delete SMS Message ID",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.deleteSmsMessageId.parameters.v1"
},
{
"operationId": "getAirplaneMode",
"title": "Get Airplane Mode",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getAirplaneMode.parameters.v1"
},
{
"operationId": "getApn",
"title": "Get APN",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getApn.parameters.v1"
},
{
"operationId": "getAuthSettings",
"title": "Get Auth Settings",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getAuthSettings.parameters.v1"
},
{
"operationId": "getAuthStatus",
"title": "Get Auth Status",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getAuthStatus.parameters.v1"
},
{
"operationId": "getAutomationConfig",
"title": "Get Automation Config",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getAutomationConfig.parameters.v1"
},
{
"operationId": "getAutomationLogs",
"title": "Get Automation Logs",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getAutomationLogs.parameters.v1"
},
{
"operationId": "getBandLock",
"title": "Get Band Lock",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getBandLock.parameters.v1"
},
{
"operationId": "getBasebandRestartStatus",
"title": "Get Baseband Restart Status",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getBasebandRestartStatus.parameters.v1"
},
{
"operationId": "getCallForwarding",
"title": "Get Call Forwarding",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getCallForwarding.parameters.v1"
},
{
"operationId": "getCallHistory",
"title": "Get Call History",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getCallHistory.parameters.v1"
},
{
"operationId": "getCallSettings",
"title": "Get Call Settings",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getCallSettings.parameters.v1"
},
{
"operationId": "getCallVolume",
"title": "Get Call Volume",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getCallVolume.parameters.v1"
},
{
"operationId": "getCalls",
"title": "Get Calls",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getCalls.parameters.v1"
},
{
"operationId": "getCellLock",
"title": "Get Cell Lock",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getCellLock.parameters.v1"
},
{
"operationId": "getCells",
"title": "Get Cells",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getCells.parameters.v1"
},
{
"operationId": "getConnectivity",
"title": "Get Connectivity",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getConnectivity.parameters.v1"
},
{
"operationId": "getData",
"title": "Get Data",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getData.parameters.v1"
},
{
"operationId": "getDevice",
"title": "Get Device",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getDevice.parameters.v1"
},
{
"operationId": "getDeviceNetworkDdnsConfig",
"title": "Get Device Network DDNS Config",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getDeviceNetworkDdnsConfig.parameters.v1"
},
{
"operationId": "getDeviceNetworkDdnsLogs",
"title": "Get Device Network DDNS Logs",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getDeviceNetworkDdnsLogs.parameters.v1"
},
{
"operationId": "getDeviceNetworkDdnsStatus",
"title": "Get Device Network DDNS Status",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getDeviceNetworkDdnsStatus.parameters.v1"
},
{
"operationId": "getDeviceNetworkWlanProfiles",
"title": "Get Device Network WLAN Profiles",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getDeviceNetworkWlanProfiles.parameters.v1"
},
{
"operationId": "getDeviceNetworkWlanStatus",
"title": "Get Device Network WLAN Status",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getDeviceNetworkWlanStatus.parameters.v1"
},
{
"operationId": "getEsimConfig",
"title": "Get eSIM Config",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getEsimConfig.parameters.v1"
},
{
"operationId": "getEsimEuicc",
"title": "Get eSIM eUICC",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getEsimEuicc.parameters.v1"
},
{
"operationId": "getEsimLpacStatus",
"title": "Get eSIM LPAC Status",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getEsimLpacStatus.parameters.v1"
},
{
"operationId": "getEsimProfiles",
"title": "Get eSIM Profiles",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getEsimProfiles.parameters.v1"
},
{
"operationId": "getHealth",
"title": "Get Health",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getHealth.parameters.v1"
},
{
"operationId": "getImsStatus",
"title": "Get IMS Status",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getImsStatus.parameters.v1"
},
{
"operationId": "getLocationCellInfo",
"title": "Get Location Cell Info",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getLocationCellInfo.parameters.v1"
},
{
"operationId": "getNetwork",
"title": "Get Network",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNetwork.parameters.v1"
},
{
"operationId": "getNetworkConnectionAddresses",
"title": "Get Network Connection Addresses",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNetworkConnectionAddresses.parameters.v1"
},
{
"operationId": "getNetworkInterfaces",
"title": "Get Network Interfaces",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNetworkInterfaces.parameters.v1"
},
{
"operationId": "getNetworkOperators",
"title": "Get Network Operators",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNetworkOperators.parameters.v1"
},
{
"operationId": "getNetworkOperatorsScan",
"title": "Scan Network Operators",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNetworkOperatorsScan.parameters.v1"
},
{
"operationId": "getNetworkSignalStrength",
"title": "Get Network Signal Strength",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNetworkSignalStrength.parameters.v1"
},
{
"operationId": "getNotificationsConfig",
"title": "Get Notifications Config",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNotificationsConfig.parameters.v1"
},
{
"operationId": "getNotificationsLogs",
"title": "Get Notifications Logs",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNotificationsLogs.parameters.v1"
},
{
"operationId": "getNotificationsQueue",
"title": "Get Notifications Queue",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getNotificationsQueue.parameters.v1"
},
{
"operationId": "getOtaStatus",
"title": "Get OTA Status",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getOtaStatus.parameters.v1"
},
{
"operationId": "getRadioMode",
"title": "Get Radio Mode",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getRadioMode.parameters.v1"
},
{
"operationId": "getRoaming",
"title": "Get Roaming",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getRoaming.parameters.v1"
},
{
"operationId": "getSim",
"title": "Get SIM",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getSim.parameters.v1"
},
{
"operationId": "getSmsConversation",
"title": "Get SMS Conversation",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getSmsConversation.parameters.v1"
},
{
"operationId": "getSmsList",
"title": "Get SMS List",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getSmsList.parameters.v1"
},
{
"operationId": "getSmsStats",
"title": "Get SMS Stats",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getSmsStats.parameters.v1"
},
{
"operationId": "getStats",
"title": "Get Stats",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getStats.parameters.v1"
},
{
"operationId": "getStatsCpu",
"title": "Get Stats CPU",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getStatsCpu.parameters.v1"
},
{
"operationId": "getVoicemailStatus",
"title": "Get Voicemail Status",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getVoicemailStatus.parameters.v1"
},
{
"operationId": "getWorkMode",
"title": "Get Work Mode",
"riskLevel": "R0",
"capability": "query",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.getWorkMode.parameters.v1"
},
{
"operationId": "postAirplaneMode",
"title": "Set Airplane Mode",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAirplaneMode.parameters.v1"
},
{
"operationId": "postApn",
"title": "Set APN",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postApn.parameters.v1"
},
{
"operationId": "postAuthLogin",
"title": "Log In",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAuthLogin.parameters.v1"
},
{
"operationId": "postAuthLogout",
"title": "Log Out",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAuthLogout.parameters.v1"
},
{
"operationId": "postAuthPassword",
"title": "Change Auth Password",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAuthPassword.parameters.v1"
},
{
"operationId": "postAuthSettings",
"title": "Set Auth Settings",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAuthSettings.parameters.v1"
},
{
"operationId": "postAuthSetup",
"title": "Set Up Auth",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAuthSetup.parameters.v1"
},
{
"operationId": "postAutomationConfig",
"title": "Set Automation Config",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAutomationConfig.parameters.v1"
},
{
"operationId": "postAutomationLogsClear",
"title": "Clear Automation Logs",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAutomationLogsClear.parameters.v1"
},
{
"operationId": "postAutomationTestTaskId",
"title": "Test Automation Task ID",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postAutomationTestTaskId.parameters.v1"
},
{
"operationId": "postBandLock",
"title": "Set Band Lock",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postBandLock.parameters.v1"
},
{
"operationId": "postBasebandRestart",
"title": "Restart Baseband",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postBasebandRestart.parameters.v1"
},
{
"operationId": "postCallAnswer",
"title": "Answer Call",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCallAnswer.parameters.v1"
},
{
"operationId": "postCallDial",
"title": "Dial Call",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCallDial.parameters.v1"
},
{
"operationId": "postCallForwarding",
"title": "Set Call Forwarding",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCallForwarding.parameters.v1"
},
{
"operationId": "postCallHangup",
"title": "Hang Up Call",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCallHangup.parameters.v1"
},
{
"operationId": "postCallHangupAll",
"title": "Hang Up All Calls",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCallHangupAll.parameters.v1"
},
{
"operationId": "postCallHistoryClear",
"title": "Clear Call History",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCallHistoryClear.parameters.v1"
},
{
"operationId": "postCallSettings",
"title": "Set Call Settings",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCallSettings.parameters.v1"
},
{
"operationId": "postCallVolume",
"title": "Set Call Volume",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCallVolume.parameters.v1"
},
{
"operationId": "postCellLock",
"title": "Set Cell Lock",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCellLock.parameters.v1"
},
{
"operationId": "postCellLockUnlockAll",
"title": "Unlock All Cell Locks",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCellLockUnlockAll.parameters.v1"
},
{
"operationId": "postCellMonitorStart",
"title": "Start Cell Monitor",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCellMonitorStart.parameters.v1"
},
{
"operationId": "postCellMonitorStop",
"title": "Stop Cell Monitor",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postCellMonitorStop.parameters.v1"
},
{
"operationId": "postData",
"title": "Set Data",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postData.parameters.v1"
},
{
"operationId": "postDeviceNetworkDdnsConfig",
"title": "Set Device Network DDNS Config",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkDdnsConfig.parameters.v1"
},
{
"operationId": "postDeviceNetworkDdnsLogsClear",
"title": "Clear Device Network DDNS Logs",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkDdnsLogsClear.parameters.v1"
},
{
"operationId": "postDeviceNetworkDdnsSync",
"title": "Sync Device Network DDNS",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkDdnsSync.parameters.v1"
},
{
"operationId": "postDeviceNetworkWlanConnect",
"title": "Connect Device Network WLAN",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanConnect.parameters.v1"
},
{
"operationId": "postDeviceNetworkWlanDisconnect",
"title": "Disconnect Device Network WLAN",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanDisconnect.parameters.v1"
},
{
"operationId": "postDeviceNetworkWlanEnabled",
"title": "Set Device Network WLAN Enabled",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanEnabled.parameters.v1"
},
{
"operationId": "postDeviceNetworkWlanForget",
"title": "Forget Device Network WLAN",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanForget.parameters.v1"
},
{
"operationId": "postDeviceNetworkWlanProfile",
"title": "Set Device Network WLAN Profile",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanProfile.parameters.v1"
},
{
"operationId": "postDeviceNetworkWlanScan",
"title": "Scan Device Network WLAN",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postDeviceNetworkWlanScan.parameters.v1"
},
{
"operationId": "postEsimConfig",
"title": "Set eSIM Config",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postEsimConfig.parameters.v1"
},
{
"operationId": "postEsimLpacRepair",
"title": "Repair eSIM LPAC",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postEsimLpacRepair.parameters.v1"
},
{
"operationId": "postEsimProfiles",
"title": "Add eSIM Profile",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postEsimProfiles.parameters.v1"
},
{
"operationId": "postEsimProfilesIccidEnable",
"title": "Enable eSIM Profile ICCID",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postEsimProfilesIccidEnable.parameters.v1"
},
{
"operationId": "postEsimProfilesIccidRename",
"title": "Rename eSIM Profile ICCID",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postEsimProfilesIccidRename.parameters.v1"
},
{
"operationId": "postNetworkRegisterAuto",
"title": "Register Network Automatically",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postNetworkRegisterAuto.parameters.v1"
},
{
"operationId": "postNetworkRegisterManual",
"title": "Register Network Manually",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postNetworkRegisterManual.parameters.v1"
},
{
"operationId": "postNotificationsConfig",
"title": "Set Notifications Config",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postNotificationsConfig.parameters.v1"
},
{
"operationId": "postNotificationsLogsClear",
"title": "Clear Notifications Logs",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postNotificationsLogsClear.parameters.v1"
},
{
"operationId": "postNotificationsQueueClear",
"title": "Clear Notifications Queue",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postNotificationsQueueClear.parameters.v1"
},
{
"operationId": "postNotificationsQueueIdRetry",
"title": "Retry Notifications Queue ID",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postNotificationsQueueIdRetry.parameters.v1"
},
{
"operationId": "postNotificationsQueueRetryAll",
"title": "Retry All Notifications Queue Items",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postNotificationsQueueRetryAll.parameters.v1"
},
{
"operationId": "postNotificationsTestChannel",
"title": "Test Notifications Channel",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postNotificationsTestChannel.parameters.v1"
},
{
"operationId": "postOtaApply",
"title": "Apply OTA",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postOtaApply.parameters.v1"
},
{
"operationId": "postOtaCancel",
"title": "Cancel OTA",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postOtaCancel.parameters.v1"
},
{
"operationId": "postOtaLatestRelease",
"title": "Get OTA Latest Release",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postOtaLatestRelease.parameters.v1"
},
{
"operationId": "postOtaOnlinePrepare",
"title": "Prepare OTA Online",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postOtaOnlinePrepare.parameters.v1"
},
{
"operationId": "postOtaUpload",
"title": "Upload OTA",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postOtaUpload.parameters.v1"
},
{
"operationId": "postRadioMode",
"title": "Set Radio Mode",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postRadioMode.parameters.v1"
},
{
"operationId": "postRoaming",
"title": "Set Roaming",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postRoaming.parameters.v1"
},
{
"operationId": "postServiceRestart",
"title": "Restart Service",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postServiceRestart.parameters.v1"
},
{
"operationId": "postSimCache",
"title": "Run SIM Cache",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postSimCache.parameters.v1"
},
{
"operationId": "postSimDetailsRefresh",
"title": "Run SIM Details Refresh",
"riskLevel": "R1",
"capability": "command",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postSimDetailsRefresh.parameters.v1"
},
{
"operationId": "postSmsBatchDelete",
"title": "Delete SMS Batch",
"riskLevel": "R3",
"capability": "job",
"batchable": true,
"parameterSchemaId": "simadmin.58e2204.postSmsBatchDelete.parameters.v1"
},
{
"operationId": "postSmsClear",
"title": "Clear SMS",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postSmsClear.parameters.v1"
},
{
"operationId": "postSmsSend",
"title": "Send SMS",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postSmsSend.parameters.v1"
},
{
"operationId": "postSystemReboot",
"title": "Reboot System",
"riskLevel": "R3",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postSystemReboot.parameters.v1"
},
{
"operationId": "postWorkMode",
"title": "Set Work Mode",
"riskLevel": "R2",
"capability": "job",
"batchable": false,
"parameterSchemaId": "simadmin.58e2204.postWorkMode.parameters.v1"
}
]
@@ -0,0 +1,7 @@
import catalog from './operation-catalog-58e2204.json' with { type: 'json' };
import type { OperationCatalogRegistry, OperationCatalogRow } from './operation-catalog-service.js';
/** Generated safe six-field projection of the audited 58e2204 registry. */
export const operationCatalogRegistry: OperationCatalogRegistry = Object.freeze({
listOperations: (): readonly OperationCatalogRow[] => catalog as readonly OperationCatalogRow[],
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { operationCatalogRegistry } from './operation-catalog-data.js';
import { OperationCatalogService } from './operation-catalog-service.js';
const service = new OperationCatalogService(operationCatalogRegistry);
describe('OperationCatalogService', () => {
it('returns a frozen contract-safe default page with six fields only', () => {
const page = service.list({});
expect(page.page).toEqual({ page: 1, pageSize: 25, total: 117 });
expect(page.items).toHaveLength(25);
expect(Object.keys(page.items[0]!)).toEqual([
'operationId',
'title',
'risk',
'capability',
'batchable',
'parameterSchemaId',
]);
expect(page.items.map(({ operationId }) => operationId)).toEqual(
[...page.items.map(({ operationId }) => operationId)].sort(),
);
expect(Object.isFrozen(page)).toBe(true);
expect(Object.isFrozen(page.items)).toBe(true);
expect(page.items.every(Object.isFrozen)).toBe(true);
});
it('filters all supported fields and searches case-insensitively', () => {
const filtered = service.list({
risk: 'R0',
capability: 'query',
batchable: false,
search: 'sms',
});
expect(filtered.items.length).toBeGreaterThan(0);
expect(
filtered.items.every(
(item) =>
item.risk === 'R0' &&
item.capability === 'query' &&
!item.batchable &&
/sms/i.test(`${item.operationId} ${item.title}`),
),
).toBe(true);
});
it('keeps operationId ascending as the final tie breaker when descending', () => {
for (const sort of ['operationId', 'risk', 'capability'] as const) {
const page = service.list({ pageSize: 100, sort, direction: 'desc' });
for (let index = 1; index < page.items.length; index += 1) {
const previous = page.items[index - 1]!;
const current = page.items[index]!;
if (previous[sort] === current[sort])
expect(previous.operationId < current.operationId).toBe(true);
}
}
});
it('returns an empty beyond-last page without changing total', () => {
const page = service.list({ page: 999 });
expect(page.items).toEqual([]);
expect(page.page.total).toBe(117);
});
});
@@ -0,0 +1,73 @@
import type {
OperationCatalogEntry,
OperationPage,
OperationPageQuery,
} from '@multi-simadmin/contracts';
export interface OperationCatalogRow {
readonly operationId: string;
readonly title: string;
readonly riskLevel: OperationCatalogEntry['risk'];
readonly capability: string;
readonly batchable: boolean;
readonly parameterSchemaId: string;
}
export interface OperationCatalogRegistry {
listOperations(): readonly OperationCatalogRow[];
}
const freeze = <T>(value: T): T => Object.freeze(value);
const compare = (left: string, right: string): number => (left < right ? -1 : left > right ? 1 : 0);
/** Pure safe projection over the audited runtime registry. */
export class OperationCatalogService {
constructor(private readonly registry: OperationCatalogRegistry) {}
list(query: OperationPageQuery): OperationPage {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const search = query.search?.toLowerCase();
const rows: OperationCatalogEntry[] = [];
for (const row of this.registry.listOperations()) {
if (query.risk !== undefined && row.riskLevel !== query.risk) continue;
if (query.capability !== undefined && row.capability !== query.capability) continue;
if (query.batchable !== undefined && row.batchable !== query.batchable) continue;
if (
search !== undefined &&
!row.operationId.toLowerCase().includes(search) &&
!row.title.toLowerCase().includes(search)
) {
continue;
}
rows.push(
freeze({
operationId: row.operationId,
title: row.title,
risk: row.riskLevel,
capability: row.capability,
batchable: row.batchable,
parameterSchemaId: row.parameterSchemaId,
}),
);
}
const sort = query.sort ?? 'operationId';
const direction = query.direction === 'desc' ? -1 : 1;
const value = (row: OperationCatalogEntry): string => {
if (sort === 'risk') return row.risk;
if (sort === 'capability') return row.capability;
return row.operationId;
};
rows.sort((left, right) => {
const byField = compare(value(left), value(right));
return byField === 0 ? compare(left.operationId, right.operationId) : direction * byField;
});
const offset = (page - 1) * pageSize;
return freeze({
items: freeze(rows.slice(offset, offset + pageSize)),
page: freeze({ page, pageSize, total: rows.length }),
});
}
}
@@ -0,0 +1,215 @@
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
OperationNotDispatchedError,
SecureOperationExecution,
secureOperationRegistry,
type SafeOperationTransport,
} from './secure-operation-execution.js';
const SCHEMA = 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1';
const databases: Database.Database[] = [];
afterEach(() => databases.splice(0).forEach((db) => db.close()));
function fixture(transport?: SafeOperationTransport) {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
databases.push(db);
db.prepare(
`INSERT INTO instances
(id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at)
VALUES ('i-1','one','http://192.168.1.10','none',1,3,'2026-07-17T00:00:00.000Z','2026-07-17T00:00:00.000Z')`,
).run();
let sequence = 0;
const request = vi.fn(async () => ({ status: 204 }));
const execution = new SecureOperationExecution({
db,
registry: secureOperationRegistry,
transport: transport ?? { request },
now: () => new Date('2026-07-17T12:00:00.000Z'),
idFactory: () => `id-${++sequence}`,
tokenFactory: () => Buffer.alloc(32, 9).toString('base64url'),
nonceFactory: () => `nonce-${++sequence}`,
});
const input = {
operationId: 'postNetworkRegisterAuto',
targets: [{ instanceId: 'i-1', revision: 3 }],
parameters: { parameterSchemaId: SCHEMA, fields: [] },
} as const;
return { db, execution, input, request };
}
const expectCode = async (promise: Promise<unknown>, code: string) =>
expect(promise).rejects.toMatchObject({ code });
describe('SecureOperationExecution generic R2 slice', () => {
it('prepares the sole audited zero-parameter operation without upstream or a job and persists full binding plus digest only', async () => {
const { db, execution, input, request } = fixture();
const prepared = await execution.prepare(input, 'request-1');
expect(prepared).toMatchObject({
operationId: input.operationId,
risk: 'R2',
status: 'prepared',
targetCount: 1,
});
expect(prepared.expiresAt).toBe('2026-07-17T12:05:00.000Z');
expect(request).not.toHaveBeenCalled();
expect(db.prepare('SELECT count(*) count FROM jobs').get()).toEqual({ count: 0 });
const row = db
.prepare('SELECT * FROM operation_preparations WHERE id=?')
.get(prepared.id) as Record<string, unknown>;
expect(row).toMatchObject({
target_instance_id: 'i-1',
target_revision: 3,
target_origin: 'http://192.168.1.10',
operation_id: input.operationId,
risk_level: 'R2',
method: 'POST',
path: '/api/network/register-auto',
canonical_query: '',
body_digest: createHash('sha256').update('').digest('hex'),
content_type: '',
parameter_schema_id: SCHEMA,
status: 'prepared',
});
expect(row.token_digest).toBe(
createHash('sha256').update(prepared.confirmationToken).digest('hex'),
);
expect(JSON.stringify(row)).not.toContain(prepared.confirmationToken);
expect(typeof row.nonce).toBe('string');
});
it('strictly rejects unknown shapes, operations, parameters, targets, and stale revisions', async () => {
const { execution, input } = fixture();
await expectCode(execution.prepare({ ...input, extra: true } as never), 'VALIDATION_FAILED');
await expectCode(
execution.prepare({ ...input, operationId: 'getDevice' }),
'OPERATION_NOT_ALLOWED',
);
await expectCode(execution.prepare({ ...input, targets: [] }), 'VALIDATION_FAILED');
await expectCode(
execution.prepare({ ...input, targets: [{ instanceId: 'i-1', revision: 2 }] }),
'REVISION_CONFLICT',
);
await expectCode(
execution.prepare({
...input,
parameters: { ...input.parameters, fields: [{ fieldId: 'x', kind: 'string', value: 'x' }] },
} as never),
'VALIDATION_FAILED',
);
});
it('does not consume on wrong token, expires atomically, and consumes exactly once after re-resolving bindings', async () => {
const { db, execution, input, request } = fixture();
const prepared = await execution.prepare(input);
await expectCode(
execution.execute({ preparationId: prepared.id, confirmationToken: 'wrong' }, 'actor', 'r2'),
'CONFIRMATION_INVALID',
);
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'prepared' });
const job = await execution.execute(
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
'actor',
'r3',
);
expect(job).toMatchObject({
operationId: input.operationId,
status: 'succeeded',
items: [{ targetId: 'i-1', state: 'succeeded' }],
});
expect(request).toHaveBeenCalledWith({
origin: 'http://192.168.1.10',
method: 'POST',
path: '/api/network/register-auto',
query: '',
contentType: '',
body: undefined,
});
await expectCode(
execution.execute(
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
'actor',
'r4',
),
'CONFIRMATION_INVALID',
);
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'consumed' });
});
it('marks an expired preparation expired without transport', async () => {
const { db, execution, input, request } = fixture();
const prepared = await execution.prepare(input);
db.prepare(
"UPDATE operation_preparations SET expires_at='2020-01-01T00:00:00.000Z' WHERE id=?",
).run(prepared.id);
await expectCode(
execution.execute(
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
'a',
'r',
),
'CONFIRMATION_INVALID',
);
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'expired' });
expect(request).not.toHaveBeenCalled();
});
it('durably creates running lineage before transport and maps ambiguity/interruption to unknown-result', async () => {
let observed = false;
const { db, execution, input } = fixture({
request: async () => {
observed =
(
db.prepare("SELECT count(*) count FROM jobs WHERE status='running'").get() as {
count: number;
}
).count === 1;
throw new Error('socket reset');
},
});
const prepared = await execution.prepare(input);
const job = await execution.execute(
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
'a',
'r',
);
expect(observed).toBe(true);
expect(job.status).toBe('unknown-result');
expect(job.attempts[0]?.state).toBe('unknown-result');
expect(execution.reconcileInterruptedJobs()).toBe(0);
db.prepare("UPDATE jobs SET status='running',finished_at=NULL").run();
db.prepare("UPDATE job_items SET status='running',finished_at=NULL").run();
db.prepare("UPDATE job_attempts SET status='running',finished_at=NULL").run();
expect(execution.reconcileInterruptedJobs()).toBe(1);
expect(db.prepare('SELECT status FROM jobs').get()).toEqual({ status: 'unknown-result' });
});
it('maps a deterministic pre-dispatch rejection to failed rather than unknown-result', async () => {
const { execution, input, db } = fixture({
request: async () => {
throw new OperationNotDispatchedError('UPSTREAM_REQUEST_INVALID');
},
});
const prepared = await execution.prepare(input);
const job = await execution.execute(
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
'a',
'r',
);
expect(job.status).toBe('failed');
expect(job.items[0]?.state).toBe('failed');
expect(db.prepare('SELECT result_code FROM job_items').get()).toEqual({
result_code: 'UPSTREAM_NOT_DISPATCHED',
});
});
});
@@ -0,0 +1,466 @@
import type Database from 'better-sqlite3';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type {
ExecuteOperationRequest,
Job,
Preparation,
PrepareOperationRequest,
} from '@multi-simadmin/contracts';
export interface SecureOperationDescriptor {
readonly operationId: string;
readonly title: string;
readonly riskLevel: string;
readonly method: string;
readonly pathTemplate: string;
readonly requestContentType: string;
readonly parameterSchemaId: string;
}
export interface SecureOperationRegistry {
requireExecutableOperation(operationId: string): SecureOperationDescriptor;
}
export const secureOperationRegistry: SecureOperationRegistry = {
requireExecutableOperation(operationId) {
if (operationId !== 'postNetworkRegisterAuto') throw new Error('UNKNOWN_OPERATION');
return {
operationId: 'postNetworkRegisterAuto',
title: 'Register Network Automatically',
riskLevel: 'R2',
method: 'POST',
pathTemplate: '/api/network/register-auto',
requestContentType: 'none',
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
};
},
};
const ALLOWED_OPERATION = 'postNetworkRegisterAuto';
const ACTOR = 'loopback-control-plane';
const TTL_MS = 5 * 60 * 1000;
const EMPTY_DIGEST = createHash('sha256').update('').digest('hex');
export interface SafeOperationTransportRequest {
readonly origin: string;
readonly method: 'POST';
readonly path: string;
readonly query: string;
readonly contentType: string;
readonly body: undefined;
}
export interface SafeOperationTransport {
request(request: SafeOperationTransportRequest): Promise<{ readonly status: number }>;
}
/** The request was rejected before any bytes could have reached the upstream. */
export class OperationNotDispatchedError extends Error {
readonly dispatched = false;
constructor(readonly code: string) {
super(code);
this.name = 'OperationNotDispatchedError';
}
}
export type SecureOperationExecutionErrorCode =
| 'VALIDATION_FAILED'
| 'OPERATION_NOT_ALLOWED'
| 'NOT_FOUND'
| 'REVISION_CONFLICT'
| 'CONFIRMATION_INVALID';
export class SecureOperationExecutionError extends Error {
constructor(
readonly code: SecureOperationExecutionErrorCode,
message: string,
) {
super(message);
this.name = 'SecureOperationExecutionError';
}
}
interface Options {
readonly db: Database.Database;
readonly registry: SecureOperationRegistry;
readonly transport: SafeOperationTransport;
readonly now?: () => Date;
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
readonly nonceFactory?: () => string;
}
interface PreparationRow {
operation_id: string;
risk_level: string;
status: string;
target_instance_id: string;
target_revision: number;
target_origin: string;
method: string;
path: string;
canonical_query: string;
body_digest: string;
content_type: string;
parameter_schema_id: string;
parameters_digest: string;
nonce: string;
token_digest: string;
expires_at: string;
}
const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex');
const equalDigest = (left: string, right: string): boolean => {
const a = Buffer.from(left, 'hex');
const b = Buffer.from(right, 'hex');
return a.length === 32 && b.length === 32 && timingSafeEqual(a, b);
};
const validRevision = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
const parameterDigest = (schema: string): string =>
digest(JSON.stringify({ parameterSchemaId: schema, fields: [] }));
export class SecureOperationExecution {
private readonly clock: () => Date;
private readonly id: () => string;
private readonly token: () => string;
private readonly nonce: () => string;
constructor(private readonly options: Options) {
this.clock = options.now ?? (() => new Date());
this.id = options.idFactory ?? randomUUID;
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
this.nonce = options.nonceFactory ?? randomUUID;
}
async prepare(input: PrepareOperationRequest, requestId = this.id()): Promise<Preparation> {
if (
!input ||
typeof input !== 'object' ||
Array.isArray(input) ||
Object.keys(input).some((key) => !['operationId', 'targets', 'parameters'].includes(key)) ||
typeof input.operationId !== 'string'
)
this.validation();
if (input.operationId !== ALLOWED_OPERATION)
throw new SecureOperationExecutionError(
'OPERATION_NOT_ALLOWED',
'Operation is not enabled for generic execution',
);
let descriptor: SecureOperationDescriptor;
try {
descriptor = this.options.registry.requireExecutableOperation(input.operationId);
} catch {
throw new SecureOperationExecutionError(
'OPERATION_NOT_ALLOWED',
'Operation is not enabled for generic execution',
);
}
if (
descriptor.riskLevel !== 'R2' ||
descriptor.method !== 'POST' ||
descriptor.pathTemplate !== '/api/network/register-auto' ||
descriptor.requestContentType !== 'none' ||
!Array.isArray(input.targets) ||
input.targets.length !== 1
)
this.validation();
const target = input.targets[0];
if (
!target ||
typeof target !== 'object' ||
Array.isArray(target) ||
Object.keys(target).some((key) => !['instanceId', 'revision'].includes(key)) ||
typeof target.instanceId !== 'string' ||
!target.instanceId ||
!validRevision(target.revision)
)
this.validation();
const parameters = input.parameters;
if (
!parameters ||
typeof parameters !== 'object' ||
Array.isArray(parameters) ||
Object.keys(parameters).some((key) => !['parameterSchemaId', 'fields'].includes(key)) ||
parameters.parameterSchemaId !== descriptor.parameterSchemaId ||
!Array.isArray(parameters.fields) ||
parameters.fields.length !== 0
)
this.validation();
const instance = this.options.db
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
.get(target.instanceId) as { base_url: string; config_revision: number } | undefined;
if (!instance) throw new SecureOperationExecutionError('NOT_FOUND', 'Instance was not found');
if (instance.config_revision !== target.revision)
throw new SecureOperationExecutionError(
'REVISION_CONFLICT',
'Instance revision does not match',
);
const token = this.token();
let tokenBytes: Buffer;
try {
tokenBytes = Buffer.from(token, 'base64url');
} catch {
tokenBytes = Buffer.alloc(0);
}
if (tokenBytes.length < 32 || token.length < 20 || !/^[A-Za-z0-9_-]+$/.test(token))
throw new Error('Confirmation token factory returned an unsafe token');
const id = this.id();
const created = this.clock();
const now = created.toISOString();
const expiresAt = new Date(created.getTime() + TTL_MS).toISOString();
this.options.db
.prepare(
`INSERT INTO operation_preparations
(id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path,
canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest,
requested_by,request_id,expires_at,created_at,updated_at)
VALUES (?,?,?,'prepared',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
.run(
id,
descriptor.operationId,
descriptor.riskLevel,
target.instanceId,
target.revision,
instance.base_url,
descriptor.method,
descriptor.pathTemplate,
'',
EMPTY_DIGEST,
'',
descriptor.parameterSchemaId,
parameterDigest(descriptor.parameterSchemaId),
this.nonce(),
digest(token),
ACTOR,
requestId,
expiresAt,
now,
now,
);
return {
id,
status: 'prepared',
operationId: descriptor.operationId,
risk: 'R2',
expiresAt,
confirmationToken: token,
confirmationPrompt: `Execute ${descriptor.title} on ${target.instanceId}?`,
targetCount: 1,
};
}
async execute(input: ExecuteOperationRequest, actor: string, requestId: string): Promise<Job> {
if (
!input ||
typeof input !== 'object' ||
Array.isArray(input) ||
Object.keys(input).some((key) => !['preparationId', 'confirmationToken'].includes(key)) ||
typeof input.preparationId !== 'string' ||
!input.preparationId ||
typeof input.confirmationToken !== 'string' ||
!input.confirmationToken
)
this.validation();
const now = this.clock().toISOString();
const ids = { job: this.id(), item: this.id(), attempt: this.id() };
let bound: PreparationRow | undefined;
const outcome = this.options.db.transaction((): 'accepted' | 'invalid' => {
const row = this.options.db
.prepare('SELECT * FROM operation_preparations WHERE id=?')
.get(input.preparationId) as PreparationRow | undefined;
if (!row || row.status !== 'prepared') return 'invalid';
if (row.expires_at <= now) {
this.options.db
.prepare(
"UPDATE operation_preparations SET status='expired',updated_at=? WHERE id=? AND status='prepared'",
)
.run(now, input.preparationId);
return 'invalid';
}
if (!equalDigest(row.token_digest, digest(input.confirmationToken))) return 'invalid';
let descriptor: SecureOperationDescriptor;
try {
descriptor = this.options.registry.requireExecutableOperation(row.operation_id);
} catch {
return 'invalid';
}
const instance = this.options.db
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
.get(row.target_instance_id) as { base_url: string; config_revision: number } | undefined;
const bindingValid =
descriptor.operationId === ALLOWED_OPERATION &&
descriptor.riskLevel === 'R2' &&
descriptor.method === row.method &&
descriptor.pathTemplate === row.path &&
descriptor.parameterSchemaId === row.parameter_schema_id &&
row.canonical_query === '' &&
row.content_type === '' &&
row.body_digest === EMPTY_DIGEST &&
row.parameters_digest === parameterDigest(row.parameter_schema_id) &&
!!row.nonce &&
!!instance &&
instance.base_url === row.target_origin &&
instance.config_revision === row.target_revision;
if (!bindingValid) {
this.options.db
.prepare(
"UPDATE operation_preparations SET status='invalidated',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
)
.run(now, now, input.preparationId);
return 'invalid';
}
const consumed = this.options.db
.prepare(
"UPDATE operation_preparations SET status='consumed',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
)
.run(now, now, input.preparationId);
if (consumed.changes !== 1) return 'invalid';
this.options.db
.prepare(
`INSERT INTO jobs
(id,parent_job_id,root_job_id,retry_of_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at)
VALUES (?,NULL,?,NULL,?,'R2','running',?,?,?,?,?,?)`,
)
.run(
ids.job,
ids.job,
row.operation_id,
actor,
requestId,
row.parameters_digest,
now,
now,
now,
);
this.options.db
.prepare(
"INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES (?,?,'running',?,?)",
)
.run(ids.attempt, ids.job, now, now);
this.options.db
.prepare(
`INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at)
VALUES (?,?,?,1,'running',?,?,?)`,
)
.run(ids.item, ids.job, row.target_instance_id, now, now, now);
bound = row;
return 'accepted';
})();
if (outcome !== 'accepted' || !bound)
throw new SecureOperationExecutionError(
'CONFIRMATION_INVALID',
'Confirmation could not be accepted',
);
let state: 'succeeded' | 'failed' | 'unknown-result';
let code: string;
try {
const response = await this.options.transport.request({
origin: bound.target_origin,
method: 'POST',
path: bound.path,
query: '',
contentType: '',
body: undefined,
});
state = response.status >= 200 && response.status < 300 ? 'succeeded' : 'failed';
code = state === 'succeeded' ? 'UPSTREAM_SUCCEEDED' : 'UPSTREAM_REJECTED';
} catch (error) {
if (error instanceof OperationNotDispatchedError) {
state = 'failed';
code = 'UPSTREAM_NOT_DISPATCHED';
} else {
state = 'unknown-result';
code = 'UPSTREAM_RESULT_UNKNOWN';
}
}
this.finish(ids, state, code);
return this.job(ids.job);
}
reconcileInterruptedJobs(): number {
const now = this.clock().toISOString();
return this.options.db.transaction(() => {
const jobs = this.options.db
.prepare(
"SELECT id FROM jobs WHERE operation_id=? AND risk_level='R2' AND status='running'",
)
.all(ALLOWED_OPERATION) as Array<{ id: string }>;
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
return jobs.length;
})();
}
private finish(
ids: { job: string; item: string; attempt: string },
state: string,
code: string,
): void {
const now = this.clock().toISOString();
this.options.db.transaction(() => {
this.options.db
.prepare(
"UPDATE job_items SET status=?,result_code=?,finished_at=?,updated_at=? WHERE id=? AND status='running'",
)
.run(state, code, now, now, ids.item);
this.options.db
.prepare("UPDATE job_attempts SET status=?,finished_at=? WHERE id=? AND status='running'")
.run(state, now, ids.attempt);
this.options.db
.prepare(
"UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=? AND status='running'",
)
.run(state, now, now, ids.job);
})();
}
private finishByJob(jobId: string, now: string, state: string, code: string): void {
this.options.db
.prepare(
"UPDATE job_items SET status=?,result_code=?,finished_at=?,updated_at=? WHERE job_id=? AND status='running'",
)
.run(state, code, now, now, jobId);
this.options.db
.prepare("UPDATE job_attempts SET status=?,finished_at=? WHERE job_id=? AND status='running'")
.run(state, now, jobId);
this.options.db
.prepare(
"UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=? AND status='running'",
)
.run(state, now, now, jobId);
}
private job(id: string): Job {
const row = this.options.db
.prepare(
'SELECT operation_id,status,root_job_id,retry_of_job_id,created_at FROM jobs WHERE id=?',
)
.get(id) as {
operation_id: string;
status: Job['status'];
root_job_id: string;
retry_of_job_id: string | null;
created_at: string;
};
const items = this.options.db
.prepare('SELECT id,instance_id,status FROM job_items WHERE job_id=?')
.all(id) as Array<{
id: string;
instance_id: string;
status: 'succeeded' | 'failed' | 'unknown-result';
}>;
const attempts = this.options.db
.prepare('SELECT id,status,started_at,finished_at FROM job_attempts WHERE job_id=?')
.all(id) as Array<{
id: string;
status: 'succeeded' | 'failed' | 'unknown-result';
started_at: string;
finished_at: string | null;
}>;
return {
id,
operationId: row.operation_id,
status: row.status,
rootJobId: row.root_job_id,
...(row.retry_of_job_id ? { retryOfJobId: row.retry_of_job_id } : {}),
items: items.map((item) => ({ id: item.id, targetId: item.instance_id, state: item.status })),
attempts: attempts.map((attempt) => ({
id: attempt.id,
state: attempt.status,
startedAt: attempt.started_at,
...(attempt.finished_at ? { finishedAt: attempt.finished_at } : {}),
})),
createdAt: row.created_at,
};
}
private validation(): never {
throw new SecureOperationExecutionError('VALIDATION_FAILED', 'Invalid operation request');
}
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { InstanceResourceService, parseSim, parseStats } from './instance-resource-service.js';
const response = (body: unknown) => ({
status: 200,
headers: {},
body: JSON.stringify(body),
});
describe('instance resource allowlist parsing', () => {
it('extracts CPU, memory and only the highest valid temperature', () => {
expect(
parseStats(
response({
data: {
cpu_load: { load_percent: 23.4, secret: 'drop' },
memory: { used_percent: 67.8, total_bytes: 123 },
temperature: [
{ label: 'a', temperature: 41.2 },
{ label: 'b', temperature: 52.6 },
{ label: 'bad', temperature: 900 },
],
},
}),
),
).toEqual({ cpuPercent: 23.4, memoryPercent: 67.8, maxTemperatureCelsius: 52.6 });
});
it('extracts, validates, deduplicates and bounds phone numbers only', () => {
expect(
parseSim(
response({
data: {
phone_numbers: ['+86 138-0000-0000', '+86 138-0000-0000', 'bad<script>'],
imsi: 'drop',
iccid: 'drop',
},
}),
),
).toEqual({ phoneNumbers: ['+86 138-0000-0000'] });
});
it('probes passwordless instances without manufacturing a cookie', async () => {
const requests: Array<{ headers: Readonly<Record<string, string>> }> = [];
const service = new InstanceResourceService({
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
sessions: { sessionFor: () => undefined } as never,
request: async (request) => {
requests.push(request);
return response(
request.url.endsWith('/api/stats')
? { data: { cpu_load: { load_percent: 12 }, memory: { used_percent: 34 } } }
: { data: { phone_numbers: ['13800000000'] } },
);
},
});
await expect(service.get('device-1')).resolves.toEqual({
cpuPercent: 12,
memoryPercent: 34,
phoneNumbers: ['13800000000'],
});
expect(requests.map((request) => request.headers)).toEqual([
{ accept: 'application/json' },
{ accept: 'application/json' },
]);
});
it('fails closed for malformed and oversized payloads', () => {
expect(parseStats({ status: 200, headers: {}, body: '{' })).toEqual({});
expect(parseSim({ status: 200, headers: {}, body: 'x'.repeat(40_000) })).toEqual({});
});
});
@@ -0,0 +1,97 @@
import type { InstanceService } from '../instances/instance-service.js';
import type {
InstanceSessionStore,
UpstreamResponse,
UpstreamSessionClientOptions,
} from '../connections/upstream-session-client.js';
export interface InstanceResources {
readonly cpuPercent?: number;
readonly memoryPercent?: number;
readonly maxTemperatureCelsius?: number;
readonly phoneNumbers?: readonly string[];
}
const MAX_BODY_BYTES = 32_768;
const record = (value: unknown): Record<string, unknown> | undefined =>
value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const ranged = (value: unknown, minimum: number, maximum: number): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum
? value
: undefined;
function data(response: UpstreamResponse): Record<string, unknown> | undefined {
if (response.status < 200 || response.status >= 300) return undefined;
if (Buffer.byteLength(response.body, 'utf8') > MAX_BODY_BYTES) return undefined;
try {
return record(record(JSON.parse(response.body))?.data);
} catch {
return undefined;
}
}
export function parseStats(response: UpstreamResponse): InstanceResources {
const value = data(response);
if (!value) return {};
const cpuPercent = ranged(record(value.cpu_load)?.load_percent, 0, 100);
const memoryPercent = ranged(record(value.memory)?.used_percent, 0, 100);
const temperatures = Array.isArray(value.temperature)
? value.temperature
.map((item) => ranged(record(item)?.temperature, -100, 300))
.filter((item): item is number => item !== undefined)
: [];
const maxTemperatureCelsius = temperatures.length > 0 ? Math.max(...temperatures) : undefined;
return {
...(cpuPercent === undefined ? {} : { cpuPercent }),
...(memoryPercent === undefined ? {} : { memoryPercent }),
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
};
}
export function parseSim(response: UpstreamResponse): InstanceResources {
const value = data(response);
if (!value || !Array.isArray(value.phone_numbers)) return {};
const phoneNumbers = value.phone_numbers.filter(
(item): item is string =>
typeof item === 'string' &&
item.length > 0 &&
item.length <= 32 &&
/^[+0-9 ()-]+$/.test(item),
);
return phoneNumbers.length > 0 ? { phoneNumbers: [...new Set(phoneNumbers)].slice(0, 16) } : {};
}
export class InstanceResourceService {
constructor(
private readonly options: {
readonly instances: InstanceService;
readonly sessions: InstanceSessionStore;
readonly request: UpstreamSessionClientOptions['request'];
},
) {}
async get(instanceId: string): Promise<InstanceResources> {
const [instance, session] = await Promise.all([
this.options.instances.get(instanceId),
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
]);
if (!instance) return {};
if (session && session.origin !== instance.origin) return {};
const get = (path: '/api/stats' | '/api/sim') =>
this.options.request({
url: `${instance.origin}${path}`,
method: 'GET',
headers: {
accept: 'application/json',
...(session ? { cookie: session.cookie } : {}),
},
});
const [stats, sim] = await Promise.allSettled([get('/api/stats'), get('/api/sim')]);
return {
...(stats.status === 'fulfilled' ? parseStats(stats.value) : {}),
...(sim.status === 'fulfilled' ? parseSim(sim.value) : {}),
};
}
}
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetStatusCoordinator } from './fleet-status-coordinator.js';
import { StatusPollScheduler, type StatusPollSchedulerOptions } from './status-poll-scheduler.js';
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
afterEach(() => {
vi.useRealTimers();
});
describe('FleetStatusCoordinator', () => {
it('starts by syncing enabled IDs and delegates scheduled polls to refreshHealth', async () => {
const refreshHealth = vi.fn(async (instanceId: string) => {
void instanceId;
});
const coordinator = new FleetStatusCoordinator({
source: { listEnabled: vi.fn(async () => ['alpha', 'beta']) },
snapshots: { refreshHealth },
intervalMs: 100,
scheduler: (options) => new StatusPollScheduler({ ...options, concurrency: 1 }),
});
await coordinator.start();
await flush();
expect(refreshHealth.mock.calls.map(([id]) => id)).toEqual(['alpha', 'beta']);
await coordinator.stop();
});
it('reconciles additions and removals without re-registering unchanged IDs', async () => {
let enabled: readonly string[] = ['alpha', 'beta'];
let task!: StatusPollSchedulerOptions['task'];
const add = vi.fn();
const remove = vi.fn(() => true);
const start = vi.fn();
const stop = vi.fn(async () => undefined);
const scheduler = { add, remove, start, stop };
const refreshHealth = vi.fn(async (instanceId: string) => {
void instanceId;
});
const coordinator = new FleetStatusCoordinator({
source: { listEnabled: async () => enabled },
snapshots: { refreshHealth },
intervalMs: 250,
scheduler: (options) => {
task = options.task;
return scheduler;
},
});
await coordinator.start();
expect(add.mock.calls).toEqual([
['alpha', 250],
['beta', 250],
]);
expect(start).toHaveBeenCalledTimes(1);
enabled = ['beta', 'gamma'];
await coordinator.reconcile();
expect(remove).toHaveBeenCalledWith('alpha');
expect(add).toHaveBeenLastCalledWith('gamma', 250);
expect(add).toHaveBeenCalledTimes(3);
await task('gamma', new AbortController().signal);
expect(refreshHealth).toHaveBeenCalledWith('gamma');
await coordinator.stop();
});
it('makes concurrent start calls idempotent', async () => {
let release!: (ids: readonly string[]) => void;
const listEnabled = vi.fn(
() =>
new Promise<readonly string[]>((resolve) => {
release = resolve;
}),
);
const scheduler = {
add: vi.fn(),
remove: vi.fn(() => true),
start: vi.fn(),
stop: vi.fn(async () => undefined),
};
const coordinator = new FleetStatusCoordinator({
source: { listEnabled },
snapshots: { refreshHealth: vi.fn(async () => undefined) },
intervalMs: 50,
scheduler: () => scheduler,
});
const first = coordinator.start();
const second = coordinator.start();
await flush();
release(['alpha']);
await Promise.all([first, second]);
expect(listEnabled).toHaveBeenCalledTimes(1);
expect(scheduler.add).toHaveBeenCalledTimes(1);
expect(scheduler.start).toHaveBeenCalledTimes(1);
await coordinator.stop();
expect(scheduler.remove).toHaveBeenCalledWith('alpha');
});
it('stop removes every key and prevents future polling', async () => {
vi.useFakeTimers();
const refreshHealth = vi.fn(async () => undefined);
const coordinator = new FleetStatusCoordinator({
source: { listEnabled: async () => ['alpha', 'beta'] },
snapshots: { refreshHealth },
intervalMs: 10,
scheduler: (options) => new StatusPollScheduler({ ...options, concurrency: 2 }),
});
await coordinator.start();
await flush();
expect(refreshHealth).toHaveBeenCalledTimes(2);
await coordinator.stop();
await vi.advanceTimersByTimeAsync(100);
expect(refreshHealth).toHaveBeenCalledTimes(2);
await coordinator.reconcile();
expect(refreshHealth).toHaveBeenCalledTimes(2);
});
it('isolates one failed refresh so another fleet member is still polled', async () => {
const refreshHealth = vi.fn(async (id: string) => {
if (id === 'bad') throw new Error('unreachable');
});
const coordinator = new FleetStatusCoordinator({
source: { listEnabled: async () => ['bad', 'good'] },
snapshots: { refreshHealth },
intervalMs: 1_000,
scheduler: (options) => new StatusPollScheduler({ ...options, concurrency: 1 }),
});
await coordinator.start();
await flush();
expect(refreshHealth.mock.calls.map(([id]) => id)).toEqual(['bad', 'good']);
await coordinator.stop();
});
});
@@ -0,0 +1,110 @@
import { StatusPollScheduler, type StatusPollSchedulerOptions } from './status-poll-scheduler.js';
export interface FleetStatusSource {
listEnabled(): Promise<readonly string[]>;
}
export interface FleetStatusSnapshots {
refreshHealth(instanceId: string): Promise<unknown>;
}
export interface FleetStatusScheduler {
add(instanceId: string, intervalMs?: number): void;
remove(instanceId: string): boolean;
start(): void;
stop(): Promise<void>;
}
export type FleetStatusSchedulerFactory = (
options: Pick<StatusPollSchedulerOptions, 'task' | 'intervalMs'>,
) => FleetStatusScheduler;
export interface FleetStatusCoordinatorOptions {
readonly source: FleetStatusSource;
readonly snapshots: FleetStatusSnapshots;
readonly intervalMs: number;
/** Primarily an injection seam for testing; the default uses the fleet scheduler. */
readonly scheduler?: FleetStatusSchedulerFactory;
}
/** Keeps the polling scheduler's registrations aligned with the enabled fleet. */
export class FleetStatusCoordinator {
readonly #source: FleetStatusSource;
readonly #intervalMs: number;
readonly #scheduler: FleetStatusScheduler;
readonly #registered = new Set<string>();
#started = false;
#stopped = false;
#startPromise: Promise<void> | undefined;
#reconcileTail: Promise<void> = Promise.resolve();
#stopPromise?: Promise<void>;
constructor(options: FleetStatusCoordinatorOptions) {
if (!Number.isFinite(options.intervalMs) || options.intervalMs <= 0) {
throw new RangeError('intervalMs must be greater than zero');
}
this.#source = options.source;
this.#intervalMs = options.intervalMs;
const createScheduler =
options.scheduler ??
((schedulerOptions) => new StatusPollScheduler({ ...schedulerOptions, concurrency: 4 }));
this.#scheduler = createScheduler({
intervalMs: options.intervalMs,
task: async (instanceId) => {
await options.snapshots.refreshHealth(instanceId);
},
});
}
start(): Promise<void> {
if (this.#started || this.#stopped) return this.#startPromise ?? Promise.resolve();
if (this.#startPromise) return this.#startPromise;
this.#startPromise = (async () => {
await this.reconcile();
if (this.#stopped) return;
this.#scheduler.start();
this.#started = true;
})().catch((error: unknown) => {
this.#startPromise = undefined;
throw error;
});
return this.#startPromise;
}
reconcile(): Promise<void> {
if (this.#stopped) return Promise.resolve();
const reconciliation = this.#reconcileTail.then(async () => {
if (this.#stopped) return;
const enabled = new Set(await this.#source.listEnabled());
if (this.#stopped) return;
for (const instanceId of this.#registered) {
if (!enabled.has(instanceId)) {
this.#scheduler.remove(instanceId);
this.#registered.delete(instanceId);
}
}
for (const instanceId of enabled) {
if (!this.#registered.has(instanceId)) {
this.#scheduler.add(instanceId, this.#intervalMs);
this.#registered.add(instanceId);
}
}
});
this.#reconcileTail = reconciliation.catch(() => undefined);
return reconciliation;
}
stop(): Promise<void> {
if (this.#stopPromise) return this.#stopPromise;
this.#stopped = true;
this.#stopPromise = (async () => {
await this.#reconcileTail;
for (const instanceId of this.#registered) this.#scheduler.remove(instanceId);
this.#registered.clear();
await this.#scheduler.stop();
})();
return this.#stopPromise;
}
}
@@ -0,0 +1,249 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StatusPollScheduler } from './status-poll-scheduler.js';
const deferred = () => {
let resolve!: () => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
afterEach(() => {
vi.useRealTimers();
});
describe('StatusPollScheduler', () => {
it('starts registered keys in FIFO order while respecting the global concurrency bound', async () => {
const runs: Array<{ id: string; done: ReturnType<typeof deferred> }> = [];
const scheduler = new StatusPollScheduler({
intervalMs: 1_000,
concurrency: 2,
task: (id) => {
const done = deferred();
runs.push({ id, done });
return done.promise;
},
});
scheduler.add('a');
scheduler.add('b');
scheduler.add('c');
scheduler.start();
expect(runs.map(({ id }) => id)).toEqual(['a', 'b']);
runs[0]!.done.resolve();
await flush();
expect(runs.map(({ id }) => id)).toEqual(['a', 'b', 'c']);
runs[1]!.done.resolve();
runs[2]!.done.resolve();
await scheduler.stop();
});
it('maintains exactly one recurring timer chain when tasks resolve before the cadence', async () => {
vi.useFakeTimers();
const task = vi.fn(async () => undefined);
const scheduler = new StatusPollScheduler({ intervalMs: 10, concurrency: 1, task });
scheduler.add('a');
scheduler.start();
await flush();
expect(task).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(1);
for (let expectedCalls = 2; expectedCalls <= 5; expectedCalls++) {
await vi.advanceTimersByTimeAsync(10);
expect(task).toHaveBeenCalledTimes(expectedCalls);
expect(vi.getTimerCount()).toBe(1);
}
await scheduler.stop();
expect(vi.getTimerCount()).toBe(0);
});
it('is single-flight per key and coalesces arbitrarily many interval ticks into one rerun', async () => {
vi.useFakeTimers();
const runs: ReturnType<typeof deferred>[] = [];
const scheduler = new StatusPollScheduler({
intervalMs: 10,
concurrency: 3,
task: async () => {
const done = deferred();
runs.push(done);
await done.promise;
},
});
scheduler.add('a');
scheduler.start();
await vi.advanceTimersByTimeAsync(100);
expect(runs).toHaveLength(1);
runs[0]!.resolve();
await flush();
expect(runs).toHaveLength(2);
runs[1]!.resolve();
await scheduler.stop();
});
it('keeps equal-due recurring work FIFO instead of letting a busy key starve queued keys', async () => {
vi.useFakeTimers();
const runs: Array<{ id: string; done: ReturnType<typeof deferred> }> = [];
const scheduler = new StatusPollScheduler({
intervalMs: 10,
concurrency: 1,
task: async (id) => {
const done = deferred();
runs.push({ id, done });
await done.promise;
},
});
scheduler.add('a');
scheduler.add('b');
scheduler.start();
await vi.advanceTimersByTimeAsync(50);
runs[0]!.done.resolve();
await flush();
expect(runs.map(({ id }) => id)).toEqual(['a', 'b']);
runs[1]!.done.resolve();
await flush();
expect(runs.map(({ id }) => id)).toEqual(['a', 'b', 'a']);
runs[2]!.done.resolve();
await scheduler.stop();
});
it('supports per-key interval updates and fences the obsolete generation', async () => {
vi.useFakeTimers();
const calls: Array<{ signal: AbortSignal; done: ReturnType<typeof deferred> }> = [];
const scheduler = new StatusPollScheduler({
intervalMs: 100,
concurrency: 1,
task: async (_id, signal) => {
const done = deferred();
calls.push({ signal, done });
await done.promise;
},
});
scheduler.add('a');
scheduler.start();
scheduler.update('a', 10);
expect(calls[0]!.signal.aborted).toBe(true);
await vi.advanceTimersByTimeAsync(50);
expect(calls).toHaveLength(1);
calls[0]!.done.resolve();
await flush();
expect(calls).toHaveLength(2);
calls[1]!.done.resolve();
await flush();
await vi.advanceTimersByTimeAsync(9);
expect(calls).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
expect(calls).toHaveLength(3);
calls[2]!.done.resolve();
await scheduler.stop();
});
it('remove cancels queued work and aborts active work; re-add is a fresh fenced generation', async () => {
const calls: Array<{ id: string; signal: AbortSignal; done: ReturnType<typeof deferred> }> = [];
const scheduler = new StatusPollScheduler({
intervalMs: 100,
concurrency: 1,
task: async (id, signal) => {
const done = deferred();
calls.push({ id, signal, done });
await done.promise;
},
});
scheduler.add('a');
scheduler.add('b');
scheduler.start();
scheduler.remove('b');
scheduler.remove('a');
expect(calls[0]!.signal.aborted).toBe(true);
scheduler.add('a');
calls[0]!.done.resolve();
await flush();
expect(calls.map(({ id }) => id)).toEqual(['a', 'a']);
calls[1]!.done.resolve();
await scheduler.stop();
});
it('releases slots after rejection without an unhandled rejection', async () => {
const calls: string[] = [];
const unhandled = vi.fn();
process.on('unhandledRejection', unhandled);
const scheduler = new StatusPollScheduler({
intervalMs: 1_000,
concurrency: 1,
task: async (id) => {
calls.push(id);
if (id === 'a') throw new Error('probe failed');
},
});
scheduler.add('a');
scheduler.add('b');
scheduler.start();
await flush();
expect(calls).toEqual(['a', 'b']);
expect(unhandled).not.toHaveBeenCalled();
process.off('unhandledRejection', unhandled);
await scheduler.stop();
});
it('start is idempotent and add/update reject invalid registration values', async () => {
const task = vi.fn(async () => undefined);
const scheduler = new StatusPollScheduler({ intervalMs: 20, concurrency: 1, task });
expect(() => scheduler.add('')).toThrow();
expect(() => scheduler.add('a', 0)).toThrow();
expect(() => scheduler.update('missing', 10)).toThrow();
scheduler.add('a');
scheduler.start();
scheduler.start();
await flush();
expect(task).toHaveBeenCalledTimes(1);
await scheduler.stop();
});
it('stop is idempotent, clears timers, aborts active tasks, waits for settlement, and starts no queued work', async () => {
vi.useFakeTimers();
const active = deferred();
const calls: Array<{ id: string; signal: AbortSignal }> = [];
const scheduler = new StatusPollScheduler({
intervalMs: 10,
concurrency: 1,
task: async (id, signal) => {
calls.push({ id, signal });
await active.promise;
},
});
scheduler.add('a');
scheduler.add('b');
scheduler.start();
const stopped = scheduler.stop();
let settled = false;
void stopped.then(() => {
settled = true;
});
await flush();
expect(calls).toHaveLength(1);
expect(calls[0]!.signal.aborted).toBe(true);
expect(settled).toBe(false);
active.resolve();
await stopped;
await scheduler.stop();
await vi.advanceTimersByTimeAsync(100);
expect(calls).toHaveLength(1);
});
});
@@ -0,0 +1,220 @@
export type StatusPollTask = (instanceId: string, signal: AbortSignal) => Promise<void>;
export interface StatusPollSchedulerOptions {
task: StatusPollTask;
intervalMs: number;
concurrency: number;
}
interface Registration {
generation: number;
intervalMs: number;
timer: ReturnType<typeof setTimeout> | undefined;
}
interface QueueItem {
instanceId: string;
generation: number;
}
interface ActiveRun {
generation: number;
controller: AbortController;
promise: Promise<void>;
rerunGeneration?: number;
}
/**
* A bounded, keyed polling scheduler. Registrations are the queue: repeated
* ticks only set one rerun bit, so backlog cannot grow with time.
*/
export class StatusPollScheduler {
readonly #task: StatusPollTask;
readonly #defaultIntervalMs: number;
readonly #concurrency: number;
readonly #registrations = new Map<string, Registration>();
readonly #queue: QueueItem[] = [];
readonly #queuedGeneration = new Map<string, number>();
readonly #active = new Map<string, ActiveRun>();
#nextGeneration = 1;
#started = false;
#stopping = false;
#stopPromise?: Promise<void>;
constructor(options: StatusPollSchedulerOptions) {
this.#defaultIntervalMs = this.#validInterval(options.intervalMs);
if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
throw new RangeError('concurrency must be a positive integer');
}
this.#concurrency = options.concurrency;
this.#task = options.task;
}
add(instanceId: string, intervalMs = this.#defaultIntervalMs): void {
this.#validId(instanceId);
const interval = this.#validInterval(intervalMs);
if (this.#registrations.has(instanceId)) {
throw new Error(`instance is already registered: ${instanceId}`);
}
if (this.#stopping) throw new Error('scheduler is stopping');
const registration: Registration = {
generation: this.#nextGeneration++,
intervalMs: interval,
timer: undefined,
};
this.#registrations.set(instanceId, registration);
if (this.#started) this.#enqueue(instanceId, registration.generation);
}
update(instanceId: string, intervalMs: number): void {
const interval = this.#validInterval(intervalMs);
const previous = this.#registrations.get(instanceId);
if (!previous) throw new Error(`instance is not registered: ${instanceId}`);
if (this.#stopping) throw new Error('scheduler is stopping');
if (previous.timer !== undefined) clearTimeout(previous.timer);
this.#cancelQueued(instanceId);
const active = this.#active.get(instanceId);
active?.controller.abort();
const registration: Registration = {
generation: this.#nextGeneration++,
intervalMs: interval,
timer: undefined,
};
this.#registrations.set(instanceId, registration);
if (this.#started) this.#enqueue(instanceId, registration.generation);
}
remove(instanceId: string): boolean {
const registration = this.#registrations.get(instanceId);
if (!registration) return false;
this.#registrations.delete(instanceId);
if (registration.timer !== undefined) clearTimeout(registration.timer);
this.#cancelQueued(instanceId);
this.#active.get(instanceId)?.controller.abort();
return true;
}
start(): void {
if (this.#started || this.#stopping) return;
this.#started = true;
for (const [instanceId, registration] of this.#registrations) {
this.#enqueue(instanceId, registration.generation);
}
}
async stop(): Promise<void> {
if (this.#stopPromise) return this.#stopPromise;
this.#stopping = true;
this.#started = false;
for (const registration of this.#registrations.values()) {
if (registration.timer !== undefined) clearTimeout(registration.timer);
registration.timer = undefined;
}
this.#queue.length = 0;
this.#queuedGeneration.clear();
for (const run of this.#active.values()) run.controller.abort();
const settling = [...this.#active.values()].map(({ promise }) => promise);
this.#stopPromise = Promise.allSettled(settling).then(() => undefined);
return this.#stopPromise;
}
#enqueue(instanceId: string, generation: number): void {
if (!this.#started || this.#stopping) return;
const registration = this.#registrations.get(instanceId);
if (!registration || registration.generation !== generation) return;
const active = this.#active.get(instanceId);
if (active) {
active.rerunGeneration = generation;
return;
}
if (this.#queuedGeneration.get(instanceId) === generation) return;
this.#cancelQueued(instanceId);
this.#queue.push({ instanceId, generation });
this.#queuedGeneration.set(instanceId, generation);
this.#drain();
}
#drain(): void {
while (!this.#stopping && this.#active.size < this.#concurrency && this.#queue.length > 0) {
const item = this.#queue.shift()!;
if (this.#queuedGeneration.get(item.instanceId) === item.generation) {
this.#queuedGeneration.delete(item.instanceId);
}
const registration = this.#registrations.get(item.instanceId);
if (!registration || registration.generation !== item.generation) continue;
if (this.#active.has(item.instanceId)) {
this.#active.get(item.instanceId)!.rerunGeneration = item.generation;
continue;
}
this.#run(item, registration);
}
}
#run(item: QueueItem, registration: Registration): void {
const controller = new AbortController();
const active: ActiveRun = {
generation: item.generation,
controller,
promise: undefined as unknown as Promise<void>,
};
this.#active.set(item.instanceId, active);
this.#scheduleTick(item.instanceId, registration);
let result: Promise<void>;
try {
result = this.#task(item.instanceId, controller.signal);
} catch (error) {
result = Promise.reject(error);
}
const promise = Promise.resolve(result).then(
() => this.#finish(item.instanceId, active),
() => this.#finish(item.instanceId, active),
);
active.promise = promise;
}
#scheduleTick(instanceId: string, registration: Registration): void {
if (registration.timer !== undefined) return;
registration.timer = setTimeout(() => {
registration.timer = undefined;
if (this.#registrations.get(instanceId) !== registration || this.#stopping) return;
this.#scheduleTick(instanceId, registration);
this.#enqueue(instanceId, registration.generation);
}, registration.intervalMs);
}
#finish(instanceId: string, completed: ActiveRun): void {
if (this.#active.get(instanceId) !== completed) return;
this.#active.delete(instanceId);
if (!this.#stopping) {
const current = this.#registrations.get(instanceId);
const rerun = completed.rerunGeneration;
if (current && rerun === current.generation) this.#enqueue(instanceId, rerun);
this.#drain();
}
}
#cancelQueued(instanceId: string): void {
this.#queuedGeneration.delete(instanceId);
for (let index = this.#queue.length - 1; index >= 0; index--) {
if (this.#queue[index]!.instanceId === instanceId) this.#queue.splice(index, 1);
}
}
#validId(instanceId: string): void {
if (instanceId.length === 0) throw new TypeError('instanceId must not be empty');
}
#validInterval(intervalMs: number): number {
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
throw new RangeError('intervalMs must be greater than zero');
}
return intervalMs;
}
}
@@ -0,0 +1,411 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
import {
StatusSnapshotError,
StatusSnapshotService,
type HealthSnapshotInstance,
type HealthTransportResponse,
} from './status-snapshot-service.js';
const databases: Database.Database[] = [];
afterEach(() => {
for (const db of databases.splice(0)) db.close();
});
const instant = (value: string) => () => new Date(value);
function fixture(
options: {
response?: HealthTransportResponse;
failure?: unknown;
now?: () => Date;
idFactory?: () => string;
} = {},
) {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
databases.push(db);
const created = '2026-07-17T10:00:00.000Z';
db.prepare(
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
).run('instance-1', 'LAN', 'http://192.168.1.20:3000', 'none', 1, 1, created, created);
const owners = new Map<string, HealthSnapshotInstance>([
['instance-1', { id: 'instance-1', origin: 'http://192.168.1.20:3000', revision: 1 }],
]);
const instances = { get: vi.fn(async (id: string) => owners.get(id)) };
const transport = {
get: vi.fn(async (url: string) => {
expect(url).toBe('http://192.168.1.20:3000/api/health');
if (options.failure !== undefined) throw options.failure;
return (
options.response ?? {
status: 200,
body: '{"status":"ok","version":"1.2.3","platform":"darwin","secret":"no"}',
}
);
}),
};
const service = new StatusSnapshotService({
db,
instances,
transport,
ttlMs: 60_000,
maxStaleMs: 300_000,
now: options.now ?? instant('2026-07-17T12:00:00.000Z'),
idFactory: options.idFactory ?? (() => 'snapshot-id'),
});
return { db, instances, owners, transport, service };
}
const row = (db: Database.Database) =>
db
.prepare(
'SELECT id,instance_id,category,state,payload_json,observed_at,expires_at,created_at FROM status_snapshots',
)
.get() as Record<string, unknown> | undefined;
const payload = (db: Database.Database) =>
JSON.parse(String(row(db)?.payload_json)) as Record<string, unknown>;
const seed = (db: Database.Database, overrides: { observedAt?: string; payload?: string } = {}) => {
const observedAt = overrides.observedAt ?? '2026-07-17T11:58:00.000Z';
db.prepare(
'INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?)',
).run(
'original-id',
'instance-1',
'health',
'fresh',
overrides.payload ?? '{"status":"old","version":"1.0.0"}',
observedAt,
'2026-07-17T11:59:00.000Z',
'2026-07-17T11:58:00.000Z',
);
};
describe('StatusSnapshotService health snapshots', () => {
it('persists only a bounded allowlisted fresh envelope', async () => {
const { db, service } = fixture();
await expect(service.refreshHealth('instance-1')).resolves.toEqual({
instanceId: 'instance-1',
category: 'health',
state: 'fresh',
payload: { status: 'ok', version: '1.2.3', platform: 'darwin' },
observedAt: '2026-07-17T12:00:00.000Z',
expiresAt: '2026-07-17T12:01:00.000Z',
});
expect(payload(db)).toEqual({
schemaVersion: 1,
data: { status: 'ok', version: '1.2.3', platform: 'darwin' },
errorCode: null,
httpStatus: 200,
fetchedAt: '2026-07-17T12:00:00.000Z',
durationMs: 0,
freshness: 'fresh',
supported: true,
dataFetchedAt: '2026-07-17T12:00:00.000Z',
});
expect(JSON.stringify(row(db))).not.toContain('secret');
});
it.each([
[401, 'AUTH_REQUIRED'],
[403, 'AUTH_REQUIRED'],
[500, 'UPSTREAM_DEGRADED'],
[503, 'UPSTREAM_DEGRADED'],
] as const)('retains recent safe data as stale for HTTP %i', async (status, errorCode) => {
const { db, service } = fixture({ response: { status, body: 'password=never-store' } });
seed(db);
await expect(service.refreshHealth('instance-1')).resolves.toMatchObject({
state: 'stale',
payload: { status: 'old', version: '1.0.0' },
expiresAt: '2026-07-17T12:03:00.000Z',
});
expect(payload(db)).toEqual({
schemaVersion: 1,
data: { status: 'old', version: '1.0.0' },
errorCode,
httpStatus: status,
fetchedAt: '2026-07-17T12:00:00.000Z',
durationMs: 0,
freshness: 'stale',
supported: true,
dataFetchedAt: '2026-07-17T11:58:00.000Z',
});
expect(row(db)).toMatchObject({ id: 'original-id', created_at: '2026-07-17T11:58:00.000Z' });
expect(JSON.stringify(row(db))).not.toContain('password');
});
it('marks 404 unsupported without retaining upstream content', async () => {
const { db, service } = fixture({ response: { status: 404, body: '{"token":"never"}' } });
seed(db);
await expect(service.refreshHealth('instance-1')).resolves.toMatchObject({
state: 'unknown',
payload: {},
expiresAt: null,
});
expect(payload(db)).toEqual({
schemaVersion: 1,
data: {},
errorCode: 'UNSUPPORTED',
httpStatus: 404,
fetchedAt: '2026-07-17T12:00:00.000Z',
durationMs: 0,
freshness: 'unknown',
supported: false,
dataFetchedAt: null,
});
expect(JSON.stringify(row(db))).not.toContain('token');
});
it.each([
[new UpstreamError('UPSTREAM_TIMEOUT'), 'UPSTREAM_TIMEOUT'],
[new UpstreamError('UPSTREAM_RESPONSE_TOO_LARGE'), 'RESPONSE_TOO_LARGE'],
[new Error('private origin password'), 'UPSTREAM_UNAVAILABLE'],
] as const)('persists stable typed transport classification', async (failure, errorCode) => {
const { db, service } = fixture({ failure });
await service.refreshHealth('instance-1');
expect(payload(db)).toEqual({
schemaVersion: 1,
data: {},
errorCode,
httpStatus: null,
fetchedAt: '2026-07-17T12:00:00.000Z',
durationMs: 0,
freshness: 'unknown',
supported: null,
dataFetchedAt: null,
});
if (!(failure instanceof UpstreamError)) {
expect(JSON.stringify(row(db))).not.toContain(failure.message);
}
});
it.each([
{ status: 200, body: 'not-json' },
{ status: 200, body: '{"status":{"secret":"x"}}' },
{ status: 200, body: `{"version":"${'x'.repeat(129)}"}` },
{ status: 700, body: '{"status":"ok"}' },
])('treats invalid responses as unknown and never stores raw content', async (response) => {
const { db, service } = fixture({ response });
await expect(service.refreshHealth('instance-1')).resolves.toMatchObject({
state: 'unknown',
payload: {},
});
expect(payload(db)).toMatchObject({
data: {},
freshness: 'unknown',
errorCode: 'INVALID_RESPONSE',
});
expect(JSON.stringify(row(db))).not.toContain('secret');
});
it('does not retain data beyond max-stale', async () => {
const { db, service } = fixture({ response: { status: 503, body: 'private' } });
seed(db, { observedAt: '2026-07-17T11:54:59.999Z' });
await service.refreshHealth('instance-1');
expect(row(db)).toMatchObject({
id: 'original-id',
state: 'unknown',
expires_at: null,
created_at: '2026-07-17T11:58:00.000Z',
});
expect(payload(db)).toMatchObject({ data: {}, freshness: 'unknown' });
});
it('does not extend max-stale when repeated failures update observedAt', async () => {
const first = fixture({ response: { status: 503, body: 'private' } });
seed(first.db, {
payload: JSON.stringify({
schemaVersion: 1,
data: { status: 'old' },
errorCode: 'UPSTREAM_DEGRADED',
httpStatus: 503,
fetchedAt: '2026-07-17T11:59:30.000Z',
durationMs: 0,
freshness: 'stale',
supported: true,
dataFetchedAt: '2026-07-17T11:54:59.999Z',
}),
});
await first.service.refreshHealth('instance-1');
expect(payload(first.db)).toMatchObject({ data: {}, freshness: 'unknown' });
});
it('uses generation and observed-at fences for latest-wins', async () => {
const { db, service, transport } = fixture();
let resolveOld!: (value: HealthTransportResponse) => void;
const oldResponse = new Promise<HealthTransportResponse>((resolve) => {
resolveOld = resolve;
});
transport.get
.mockImplementationOnce(async () => oldResponse)
.mockResolvedValueOnce({ status: 200, body: '{"status":"new"}' });
const old = service.refreshHealth('instance-1');
await vi.waitFor(() => expect(transport.get).toHaveBeenCalledTimes(1));
await service.refreshHealth('instance-1');
resolveOld({ status: 200, body: '{"status":"old"}' });
await old;
expect(payload(db)).toMatchObject({ data: { status: 'new' } });
db.prepare(
"UPDATE status_snapshots SET observed_at=?,payload_json=? WHERE instance_id=? AND category='health'",
).run('2099-01-01T00:00:00.000Z', '{"status":"future"}', 'instance-1');
await service.refreshHealth('instance-1');
expect(row(db)).toMatchObject({
observed_at: '2099-01-01T00:00:00.000Z',
payload_json: '{"status":"future"}',
});
});
it('lets the latest overlapping probe replace an equal-time payload', async () => {
const first = fixture();
const secondTransport = { get: vi.fn<() => Promise<HealthTransportResponse>>() };
const second = new StatusSnapshotService({
db: first.db,
instances: first.instances,
transport: secondTransport,
ttlMs: 60_000,
maxStaleMs: 300_000,
now: instant('2026-07-17T12:00:00.000Z'),
idFactory: () => 'second-snapshot-id',
});
let resolveOlder!: (value: HealthTransportResponse) => void;
let resolveLatest!: (value: HealthTransportResponse) => void;
first.transport.get.mockImplementationOnce(
async () => new Promise((resolve) => (resolveOlder = resolve)),
);
secondTransport.get.mockImplementationOnce(
async () => new Promise((resolve) => (resolveLatest = resolve)),
);
const older = first.service.refreshHealth('instance-1');
await vi.waitFor(() => expect(first.transport.get).toHaveBeenCalledTimes(1));
const latest = second.refreshHealth('instance-1');
await vi.waitFor(() => expect(secondTransport.get).toHaveBeenCalledTimes(1));
resolveOlder({ status: 200, body: '{"status":"older"}' });
await older;
expect(payload(first.db)).toMatchObject({ data: { status: 'older' } });
resolveLatest({ status: 200, body: '{"status":"latest"}' });
await latest;
expect(row(first.db)).toMatchObject({ observed_at: '2026-07-17T12:00:00.000Z' });
expect(payload(first.db)).toMatchObject({ data: { status: 'latest' } });
});
it.each([
[{ id: 'instance-1', origin: 'http://192.168.1.20:3000', revision: 0 }],
[
{
id: 'instance-1',
origin: 'http://192.168.1.20:3000',
revision: Number.MAX_SAFE_INTEGER + 1,
},
],
[{ id: 'different-id', origin: 'http://192.168.1.20:3000', revision: 1 }],
[{ id: 'instance-1', origin: 'http://192.168.1.20:3000/', revision: 1 }],
] as const)('fails closed for an invalid owner token %#', async (invalidOwner) => {
const { db, owners, service, transport } = fixture();
owners.set('instance-1', invalidOwner);
await expect(service.refreshHealth('instance-1')).rejects.toEqual(
expect.objectContaining<Partial<StatusSnapshotError>>({ code: 'INSTANCE_NOT_FOUND' }),
);
expect(transport.get).not.toHaveBeenCalled();
expect(row(db)).toBeUndefined();
});
it('does not persist a probe after its owner origin and revision change', async () => {
const { db, instances, owners, service, transport } = fixture();
let resolve!: (value: HealthTransportResponse) => void;
transport.get.mockImplementationOnce(
async () =>
new Promise((done) => {
resolve = done;
}),
);
const running = service.refreshHealth('instance-1');
await vi.waitFor(() => expect(transport.get).toHaveBeenCalledTimes(1));
owners.set('instance-1', {
id: 'instance-1',
origin: 'http://192.168.1.21:3000',
revision: 2,
});
resolve({ status: 200, body: '{"status":"old-owner"}' });
await expect(running).resolves.toMatchObject({
state: 'fresh',
payload: { status: 'old-owner' },
});
expect(instances.get).toHaveBeenCalledTimes(2);
expect(row(db)).toBeUndefined();
});
it('does not persist a probe after its owner is deleted', async () => {
const { db, instances, owners, service, transport } = fixture();
let resolve!: (value: HealthTransportResponse) => void;
transport.get.mockImplementationOnce(
async () =>
new Promise((done) => {
resolve = done;
}),
);
const running = service.refreshHealth('instance-1');
await vi.waitFor(() => expect(transport.get).toHaveBeenCalledTimes(1));
owners.delete('instance-1');
resolve({ status: 200, body: '{"status":"deleted-owner"}' });
await expect(running).resolves.toMatchObject({ payload: { status: 'deleted-owner' } });
expect(instances.get).toHaveBeenCalledTimes(2);
expect(row(db)).toBeUndefined();
});
it('atomically fences persistence when the database owner changes after the reread', async () => {
const holder: { db?: Database.Database } = {};
const result = fixture({
idFactory: () => {
holder
.db!.prepare('UPDATE instances SET base_url=?,config_revision=? WHERE id=?')
.run('http://192.168.1.21:3000', 2, 'instance-1');
return 'snapshot-id';
},
});
holder.db = result.db;
await expect(result.service.refreshHealth('instance-1')).resolves.toMatchObject({
payload: { status: 'ok' },
});
expect(result.instances.get).toHaveBeenCalledTimes(2);
expect(row(result.db)).toBeUndefined();
});
it('does no network/write for missing instances and no write after in-flight deletion', async () => {
const first = fixture();
await expect(first.service.refreshHealth('missing')).rejects.toEqual(
expect.objectContaining<Partial<StatusSnapshotError>>({ code: 'INSTANCE_NOT_FOUND' }),
);
expect(first.transport.get).not.toHaveBeenCalled();
expect(row(first.db)).toBeUndefined();
let resolve!: (value: HealthTransportResponse) => void;
first.transport.get.mockImplementationOnce(
async () =>
new Promise((done) => {
resolve = done;
}),
);
const running = first.service.refreshHealth('instance-1');
await vi.waitFor(() => expect(first.transport.get).toHaveBeenCalledTimes(1));
first.owners.delete('instance-1');
first.db.prepare('DELETE FROM instances WHERE id=?').run('instance-1');
resolve({ status: 200, body: '{"status":"ok"}' });
await running;
expect(row(first.db)).toBeUndefined();
});
});
@@ -0,0 +1,425 @@
import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
export interface HealthTransportResponse {
readonly status: number;
readonly body: string;
}
export type HealthSnapshotErrorCode =
| 'AUTH_REQUIRED'
| 'UNSUPPORTED'
| 'UPSTREAM_DEGRADED'
| 'UPSTREAM_TIMEOUT'
| 'UPSTREAM_UNAVAILABLE'
| 'RESPONSE_TOO_LARGE'
| 'INVALID_RESPONSE';
export interface HealthSnapshotInstance {
readonly id: string;
readonly origin: string;
readonly revision: number;
}
export interface HealthEnvelope {
readonly status?: string;
readonly version?: string;
readonly platform?: string;
}
export type HealthSnapshotState = 'fresh' | 'stale' | 'unknown';
export interface HealthSnapshot {
readonly instanceId: string;
readonly category: 'health';
readonly state: HealthSnapshotState;
readonly payload: HealthEnvelope;
readonly observedAt: string;
readonly expiresAt: string | null;
}
export interface StatusSnapshotServiceOptions {
readonly db: Database.Database;
readonly instances: { get(instanceId: string): Promise<HealthSnapshotInstance | undefined> };
readonly transport: { get(url: string): Promise<HealthTransportResponse> };
readonly ttlMs: number;
readonly maxStaleMs: number;
readonly now?: () => Date;
readonly idFactory?: () => string;
}
export class StatusSnapshotError extends Error {
constructor(readonly code: 'INSTANCE_NOT_FOUND') {
super(code);
this.name = 'StatusSnapshotError';
}
}
interface SnapshotRow {
payload_json: string;
observed_at: string;
}
type SnapshotErrorCode = HealthSnapshotErrorCode;
interface PersistedHealthEnvelope {
readonly schemaVersion: 1;
readonly data: HealthEnvelope;
readonly errorCode: SnapshotErrorCode | null;
readonly httpStatus: number | null;
readonly fetchedAt: string;
readonly durationMs: number;
readonly freshness: HealthSnapshotState;
readonly supported: boolean | null;
readonly dataFetchedAt: string | null;
}
interface ClassifiedSnapshot {
readonly snapshot: HealthSnapshot;
readonly persisted: PersistedHealthEnvelope;
}
const MAX_FIELD_LENGTH = 128;
const SAFE_FIELD = /^[^\x00-\x1f\x7f]*$/;
function parseEnvelope(body: string): HealthEnvelope | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
return undefined;
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
const source = parsed as Record<string, unknown>;
const envelope: { status?: string; version?: string; platform?: string } = {};
for (const key of ['status', 'version', 'platform'] as const) {
const value = source[key];
if (value === undefined) continue;
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > MAX_FIELD_LENGTH ||
!SAFE_FIELD.test(value)
)
return undefined;
envelope[key] = value;
}
return Object.freeze(envelope);
}
function safePreviousPayload(payloadJson: string): HealthEnvelope | undefined {
let candidate = payloadJson;
try {
const parsed = JSON.parse(payloadJson) as { data?: unknown };
if (parsed && typeof parsed === 'object' && 'data' in parsed)
candidate = JSON.stringify(parsed.data);
} catch {
// parseEnvelope below handles malformed legacy payloads.
}
const envelope = parseEnvelope(candidate);
return envelope && Object.keys(envelope).length > 0 ? envelope : undefined;
}
function previousDataFetchedAt(previous: SnapshotRow): string {
try {
const parsed = JSON.parse(previous.payload_json) as { dataFetchedAt?: unknown };
if (
typeof parsed.dataFetchedAt === 'string' &&
Number.isFinite(Date.parse(parsed.dataFetchedAt))
) {
return parsed.dataFetchedAt;
}
} catch {
// Legacy rows use observed_at as their data fetch time.
}
return previous.observed_at;
}
function responseErrorCode(response: HealthTransportResponse | undefined): SnapshotErrorCode {
if (
!response ||
!Number.isSafeInteger(response.status) ||
response.status < 100 ||
response.status > 599
) {
return 'INVALID_RESPONSE';
}
if (response.status === 401 || response.status === 403) return 'AUTH_REQUIRED';
if (response.status === 404) return 'UNSUPPORTED';
if (response.status >= 500) return 'UPSTREAM_DEGRADED';
return 'INVALID_RESPONSE';
}
function transportErrorCode(error: unknown): SnapshotErrorCode {
if (!(error instanceof UpstreamError)) return 'UPSTREAM_UNAVAILABLE';
if (error.code === 'UPSTREAM_TIMEOUT') return 'UPSTREAM_TIMEOUT';
if (error.code === 'UPSTREAM_RESPONSE_TOO_LARGE') return 'RESPONSE_TOO_LARGE';
return 'UPSTREAM_UNAVAILABLE';
}
function isValidOwner(
instanceId: string,
owner: HealthSnapshotInstance | undefined,
): owner is HealthSnapshotInstance {
if (
!owner ||
owner.id !== instanceId ||
!Number.isSafeInteger(owner.revision) ||
owner.revision <= 0
) {
return false;
}
try {
const parsed = new URL(owner.origin);
return (
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
parsed.origin === owner.origin &&
parsed.username === '' &&
parsed.password === ''
);
} catch {
return false;
}
}
const healthUrl = (origin: string): string => `${origin}/api/health`;
export class StatusSnapshotService {
readonly #db: Database.Database;
readonly #instances: StatusSnapshotServiceOptions['instances'];
readonly #transport: StatusSnapshotServiceOptions['transport'];
readonly #ttlMs: number;
readonly #maxStaleMs: number;
readonly #now: () => Date;
readonly #id: () => string;
readonly #latestGeneration = new Map<string, number>();
#nextGeneration = 0;
constructor(options: StatusSnapshotServiceOptions) {
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
throw new RangeError('ttlMs must be greater than zero');
}
if (!Number.isFinite(options.maxStaleMs) || options.maxStaleMs < options.ttlMs) {
throw new RangeError('maxStaleMs must be at least ttlMs');
}
this.#db = options.db;
this.#instances = options.instances;
this.#transport = options.transport;
this.#ttlMs = options.ttlMs;
this.#maxStaleMs = options.maxStaleMs;
this.#now = options.now ?? (() => new Date());
this.#id = options.idFactory ?? randomUUID;
}
async refreshHealth(instanceId: string): Promise<HealthSnapshot> {
const instance = await this.#instances.get(instanceId);
if (!isValidOwner(instanceId, instance)) throw new StatusSnapshotError('INSTANCE_NOT_FOUND');
const generation = ++this.#nextGeneration;
this.#latestGeneration.set(instanceId, generation);
const started = this.#now();
let response: HealthTransportResponse | undefined;
let transportError: unknown;
try {
response = await this.#transport.get(healthUrl(instance.origin));
} catch (error) {
transportError = error;
}
let currentOwner: HealthSnapshotInstance | undefined;
try {
currentOwner = await this.#instances.get(instanceId);
} catch {
// Owner lookup failures fence persistence, but do not discard the computed probe result.
}
const completed = this.#now();
const observedAt = completed.toISOString();
const previous = this.#previous(instanceId);
const classified = this.#classify(
instanceId,
response,
transportError,
previous,
completed,
observedAt,
Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, completed.getTime() - started.getTime())),
);
const { snapshot } = classified;
if (this.#latestGeneration.get(instanceId) === generation) {
if (
isValidOwner(instanceId, currentOwner) &&
currentOwner.origin === instance.origin &&
currentOwner.revision === instance.revision
) {
this.#persist(instance, snapshot, classified.persisted);
}
this.#latestGeneration.delete(instanceId);
}
return Object.freeze({ ...snapshot, payload: Object.freeze({ ...snapshot.payload }) });
}
#previous(instanceId: string): SnapshotRow | undefined {
return this.#db
.prepare(
"SELECT payload_json,observed_at FROM status_snapshots WHERE instance_id=? AND category='health'",
)
.get(instanceId) as SnapshotRow | undefined;
}
#classify(
instanceId: string,
response: HealthTransportResponse | undefined,
transportError: unknown,
previous: SnapshotRow | undefined,
completed: Date,
observedAt: string,
durationMs: number,
): ClassifiedSnapshot {
if (
response &&
Number.isSafeInteger(response.status) &&
response.status >= 200 &&
response.status < 300 &&
typeof response.body === 'string'
) {
const payload = parseEnvelope(response.body);
if (payload) {
const snapshot: HealthSnapshot = {
instanceId,
category: 'health',
state: 'fresh',
payload,
observedAt,
expiresAt: new Date(completed.getTime() + this.#ttlMs).toISOString(),
};
return {
snapshot,
persisted: this.#envelope(snapshot, null, response.status, durationMs, true, observedAt),
};
}
}
if (response?.status === 404) {
const snapshot = this.#unknown(instanceId, observedAt);
return {
snapshot,
persisted: this.#envelope(snapshot, 'UNSUPPORTED', 404, durationMs, false, null),
};
}
const errorCode: SnapshotErrorCode = transportError
? transportErrorCode(transportError)
: responseErrorCode(response);
const httpStatus =
response &&
Number.isSafeInteger(response.status) &&
response.status >= 100 &&
response.status <= 599
? response.status
: null;
const previousPayload = previous && safePreviousPayload(previous.payload_json);
const previousTime = previous ? Date.parse(previousDataFetchedAt(previous)) : Number.NaN;
if (
previousPayload &&
Number.isFinite(previousTime) &&
completed.getTime() - previousTime <= this.#maxStaleMs
) {
const snapshot: HealthSnapshot = {
instanceId,
category: 'health',
state: 'stale',
payload: previousPayload,
observedAt,
expiresAt: new Date(previousTime + this.#maxStaleMs).toISOString(),
};
return {
snapshot,
persisted: this.#envelope(
snapshot,
errorCode,
httpStatus,
durationMs,
httpStatus === null ? null : true,
previousDataFetchedAt(previous),
),
};
}
const snapshot = this.#unknown(instanceId, observedAt);
return {
snapshot,
persisted: this.#envelope(
snapshot,
errorCode,
httpStatus,
durationMs,
httpStatus === null ? null : true,
null,
),
};
}
#unknown(instanceId: string, observedAt: string): HealthSnapshot {
return {
instanceId,
category: 'health',
state: 'unknown',
payload: {},
observedAt,
expiresAt: null,
};
}
#envelope(
snapshot: HealthSnapshot,
errorCode: SnapshotErrorCode | null,
httpStatus: number | null,
durationMs: number,
supported: boolean | null,
dataFetchedAt: string | null,
): PersistedHealthEnvelope {
return {
schemaVersion: 1,
data: snapshot.payload,
errorCode,
httpStatus,
fetchedAt: snapshot.observedAt,
durationMs,
freshness: snapshot.state,
supported,
dataFetchedAt,
};
}
#persist(
owner: HealthSnapshotInstance,
snapshot: HealthSnapshot,
persisted: PersistedHealthEnvelope,
): void {
this.#db
.prepare(
`INSERT INTO status_snapshots
(id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
SELECT ?,id,'health',?,?,?,?,? FROM instances
WHERE id=? AND base_url=? AND config_revision=?
ON CONFLICT(instance_id,category) DO UPDATE SET
state=excluded.state,
payload_json=excluded.payload_json,
observed_at=excluded.observed_at,
expires_at=excluded.expires_at
WHERE excluded.observed_at >= status_snapshots.observed_at`,
)
.run(
this.#id(),
snapshot.state,
JSON.stringify(persisted),
snapshot.observedAt,
snapshot.expiresAt,
snapshot.observedAt,
snapshot.instanceId,
owner.origin,
owner.revision,
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { createCanaryGateway } from './canary-gateway.js';
import { readCanaryRuntimeOptions } from './canary-runtime.js';
const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env));
let stopping = false;
async function stop(signal: string): Promise<void> {
if (stopping) return;
stopping = true;
process.stdout.write(`Stopping canary gateway (${signal})\n`);
await gateway.stop();
}
process.once('SIGINT', () => void stop('SIGINT'));
process.once('SIGTERM', () => void stop('SIGTERM'));
try {
await gateway.start();
process.stdout.write(`Canary gateway listening at ${gateway.origin}\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
+350
View File
@@ -0,0 +1,350 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { createServer, request as httpRequest, type Server } from 'node:http';
import { connect } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
CANARY_DEFAULT_HOST,
CANARY_DEFAULT_PORT,
createCanaryGateway,
type CanaryGateway,
} from './canary-gateway.js';
const gateways: CanaryGateway[] = [];
const servers: Server[] = [];
const directories: string[] = [];
async function listen(server: Server): Promise<number> {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected TCP server address');
return address.port;
}
async function fixtureDist(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'canary-gateway-'));
directories.push(directory);
await mkdir(join(directory, 'assets'));
await writeFile(join(directory, 'index.html'), '<!doctype html><main>canary</main>');
await writeFile(join(directory, 'assets', 'app.a1b2c3d4.js'), 'export const canary = true;');
await writeFile(join(directory, 'plain.css'), 'body { color: green; }');
return directory;
}
async function rawRequest(
origin: string,
path: string,
headers: Record<string, string> = {},
): Promise<{ status: number; headers: import('node:http').IncomingHttpHeaders; body: string }> {
const target = new URL(origin);
return await new Promise((resolve, reject) => {
const request = httpRequest(
{ host: target.hostname, port: target.port, path, headers },
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () =>
resolve({
status: response.statusCode ?? 0,
headers: response.headers,
body: Buffer.concat(chunks).toString(),
}),
);
},
);
request.once('error', reject);
request.end();
});
}
async function rawSocketRequest(origin: string, request: string): Promise<string> {
const target = new URL(origin);
return await new Promise((resolve, reject) => {
const socket = connect(Number(target.port), target.hostname);
const chunks: Buffer[] = [];
socket.once('connect', () => socket.write(request));
socket.on('data', (chunk: Buffer) => chunks.push(chunk));
socket.once('end', () => resolve(Buffer.concat(chunks).toString()));
socket.once('error', reject);
});
}
async function startGateway(distDir: string, upstreamPort: number): Promise<CanaryGateway> {
const gateway = createCanaryGateway({ distDir, port: 0, upstreamPort });
gateways.push(gateway);
await gateway.start();
return gateway;
}
afterEach(async () => {
await Promise.all(gateways.splice(0).map((gateway) => gateway.stop()));
await Promise.all(
servers
.splice(0)
.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))),
);
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
});
describe('same-origin canary gateway', () => {
it('uses the isolated loopback canary defaults and rejects reserved ports', () => {
expect(CANARY_DEFAULT_HOST).toBe('127.0.0.1');
expect(CANARY_DEFAULT_PORT).toBe(8789);
expect(() => createCanaryGateway({ distDir: '/tmp/dist', port: 8788 })).toThrow(/reserved/i);
expect(() => createCanaryGateway({ distDir: '/tmp/dist', port: 8790 })).toThrow(/reserved/i);
});
it('accepts only numeric loopback bind and upstream hosts', () => {
for (const host of ['127.0.0.1', '127.1.2.3', '::1']) {
expect(() => createCanaryGateway({ distDir: '/tmp/dist', host })).not.toThrow();
expect(() => createCanaryGateway({ distDir: '/tmp/dist', upstreamHost: host })).not.toThrow();
}
for (const host of [
'localhost',
'example.test',
'0.0.0.0',
'10.0.0.1',
'::',
'::ffff:127.0.0.1',
]) {
expect(() => createCanaryGateway({ distDir: '/tmp/dist', host })).toThrow(/loopback/i);
expect(() => createCanaryGateway({ distDir: '/tmp/dist', upstreamHost: host })).toThrow(
/loopback/i,
);
}
expect(() =>
createCanaryGateway({
distDir: '/tmp/dist',
host: '0.0.0.0',
port: 8788,
ownsProductionPort: true,
allowedHosts: ['0.0.0.0'],
}),
).toThrow(/invalid allowed gateway host/i);
expect(() =>
createCanaryGateway({
distDir: '/tmp/dist',
host: '0.0.0.0',
port: 8788,
ownsProductionPort: true,
allowedHosts: ['00.00.00.00'],
}),
).toThrow(/invalid allowed gateway host/i);
});
it('allows an explicit production LAN bind with an exact Host allowlist', async () => {
const upstream = createServer((_request, response) => response.end('upstream reached'));
servers.push(upstream);
const gateway = createCanaryGateway({
distDir: await fixtureDist(),
host: '0.0.0.0',
port: 0,
upstreamPort: await listen(upstream),
ownsProductionPort: true,
allowedHosts: ['192.168.2.69'],
});
gateways.push(gateway);
await gateway.start();
const allowed = await rawRequest(gateway.origin, '/', { host: '192.168.2.69:0' });
expect(allowed.status).toBe(200);
const denied = await rawRequest(gateway.origin, '/', { host: 'evil.test:0' });
expect(denied.status).toBe(400);
const wildcardHost = await rawRequest(gateway.origin, '/', { host: '0.0.0.0:0' });
expect(wildcardHost.status).toBe(400);
const wildcardOrigin = await rawRequest(gateway.origin, '/api/v1/instances', {
host: '192.168.2.69:0',
origin: 'http://0.0.0.0:0',
});
expect(wildcardOrigin.status).toBe(403);
const foreignOrigin = await rawRequest(gateway.origin, '/api/v1/instances', {
host: '192.168.2.69:0',
origin: 'https://evil.example',
});
expect(foreignOrigin.status).toBe(403);
const allowedOrigin = await rawRequest(gateway.origin, '/api/v1/instances', {
host: '192.168.2.69:0',
origin: 'http://192.168.2.69:0',
});
expect(allowedOrigin.status).toBe(200);
});
it('rejects missing, malformed, and foreign authority before serving or proxying', async () => {
const upstream = createServer((_request, response) => response.end('upstream reached'));
servers.push(upstream);
const gateway = await startGateway(await fixtureDist(), await listen(upstream));
const authority = new URL(gateway.origin).host;
const valid = await rawSocketRequest(
gateway.origin,
`GET / HTTP/1.1\r\nHost: ${authority}\r\nConnection: close\r\n\r\n`,
);
expect(valid).toMatch(/^HTTP\/1\.1 200 /);
for (const request of [
'GET / HTTP/1.0\r\nConnection: close\r\n\r\n',
'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n',
'GET /api/v1/instances HTTP/1.1\r\nHost: 127.0.0.1:1\r\nConnection: close\r\n\r\n',
`GET / HTTP/1.1\r\nHost: ${authority}\r\nHost: foreign.test\r\nConnection: close\r\n\r\n`,
]) {
const response = await rawSocketRequest(gateway.origin, request);
expect(response).toMatch(/^HTTP\/1\.1 400 /);
expect(response).not.toContain('canary');
expect(response).not.toContain('upstream reached');
}
});
it('rejects absolute-form and network-path request targets', async () => {
const gateway = await startGateway(await fixtureDist(), 1);
const authority = new URL(gateway.origin).host;
for (const target of [
`http://${authority}/`,
'//foreign.test/',
`http://${authority}/healthz`,
]) {
const response = await rawSocketRequest(
gateway.origin,
`GET ${target} HTTP/1.1\r\nHost: ${authority}\r\nConnection: close\r\n\r\n`,
);
expect(response).toMatch(/^HTTP\/1\.1 400 /);
expect(response).not.toContain('canary');
}
});
it('serves assets with MIME/cache policy and falls back only for browser routes', async () => {
const gateway = await startGateway(await fixtureDist(), 1);
const origin = gateway.origin;
const asset = await fetch(`${origin}/assets/app.a1b2c3d4.js`);
expect(asset.status).toBe(200);
expect(asset.headers.get('content-type')).toMatch(/^text\/javascript/);
expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
const plain = await fetch(`${origin}/plain.css`);
expect(plain.headers.get('content-type')).toMatch(/^text\/css/);
expect(plain.headers.get('cache-control')).toBe('no-cache');
const route = await fetch(`${origin}/instances/device-1`);
expect(route.status).toBe(200);
expect(route.headers.get('content-type')).toMatch(/^text\/html/);
expect(await route.text()).toContain('canary');
const favicon = await fetch(`${origin}/favicon.ico`);
expect(favicon.status).toBe(204);
expect(await favicon.text()).toBe('');
const unknownFile = await fetch(`${origin}/missing.ico`);
expect(unknownFile.status).toBe(404);
expect(await unknownFile.text()).not.toContain('canary');
const unknownApi = await fetch(`${origin}/api/not-real`);
expect(unknownApi.status).toBe(404);
expect(await unknownApi.text()).not.toContain('canary');
});
it('blocks encoded and plain path traversal rather than serving files outside dist', async () => {
const dist = await fixtureDist();
await writeFile(join(dist, '..', 'secret.txt'), 'secret');
const gateway = await startGateway(dist, 1);
for (const path of [
'/../secret.txt',
'/%2e%2e/secret.txt',
'/%2e%2e%2fsecret.txt',
'/assets/%2e%2e%2findex.html',
]) {
const response = await rawRequest(gateway.origin, path);
expect([400, 404]).toContain(response.status);
expect(response.body).not.toContain('secret');
}
});
it('proxies only approved API and health paths while preserving browser origin semantics', async () => {
const seen: Array<{
url?: string;
host?: string;
origin?: string;
connection?: string;
custom?: string;
}> = [];
const upstream = createServer((request, response) => {
const observation: (typeof seen)[number] = {};
if (request.url !== undefined) observation.url = request.url;
if (request.headers.host !== undefined) observation.host = request.headers.host;
if (request.headers.origin !== undefined) observation.origin = request.headers.origin;
if (request.headers.connection !== undefined)
observation.connection = request.headers.connection;
const custom = request.headers['x-remove-me'];
if (typeof custom === 'string') observation.custom = custom;
seen.push(observation);
response.setHeader('connection', 'x-upstream-hop');
response.setHeader('x-upstream-hop', 'removed');
response.setHeader('x-upstream', 'yes');
response.end(JSON.stringify({ path: request.url }));
});
servers.push(upstream);
const upstreamPort = await listen(upstream);
const gateway = await startGateway(await fixtureDist(), upstreamPort);
const browserOrigin = gateway.origin;
for (const path of ['/api/v1/instances?active=true', '/healthz', '/readyz']) {
const response = await rawRequest(browserOrigin, path, {
origin: browserOrigin,
connection: 'x-remove-me',
'x-remove-me': 'private',
});
expect(response.status).toBe(200);
expect(response.headers['x-upstream']).toBe('yes');
expect(response.headers['x-upstream-hop']).toBeUndefined();
}
expect(seen.map(({ url }) => url)).toEqual([
'/api/v1/instances?active=true',
'/healthz',
'/readyz',
]);
const upstreamOrigin = `http://127.0.0.1:${upstreamPort}`;
expect(
seen.every(
({ host, origin }) => host === `127.0.0.1:${upstreamPort}` && origin === upstreamOrigin,
),
).toBe(true);
expect(seen.every(({ custom }) => custom === undefined)).toBe(true);
});
it('streams SSE chunks without waiting for the upstream response to finish', async () => {
const upstream = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
response.write('data: first\n\n');
setTimeout(() => response.end('data: second\n\n'), 150);
});
servers.push(upstream);
const gateway = await startGateway(await fixtureDist(), await listen(upstream));
const response = await fetch(`${gateway.origin}/api/v1/events`);
const reader = response.body?.getReader();
const first = await reader?.read();
expect(new TextDecoder().decode(first?.value)).toContain('data: first');
expect(first?.done).toBe(false);
await reader?.cancel();
});
it('has reversible, idempotent start/stop lifecycle', async () => {
const gateway = createCanaryGateway({ distDir: await fixtureDist(), port: 0, upstreamPort: 1 });
gateways.push(gateway);
expect(gateway.running).toBe(false);
await gateway.start();
expect(gateway.running).toBe(true);
const origin = gateway.origin;
await gateway.stop();
await gateway.stop();
expect(gateway.running).toBe(false);
await gateway.start();
expect(gateway.origin).not.toBe(origin);
});
});
+373
View File
@@ -0,0 +1,373 @@
import { createReadStream } from 'node:fs';
import { realpath, stat } from 'node:fs/promises';
import {
createServer,
request as httpRequest,
type IncomingHttpHeaders,
type IncomingMessage,
type Server,
type ServerResponse,
} 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;
export const CANARY_DEFAULT_UPSTREAM_HOST = '127.0.0.1';
export const CANARY_DEFAULT_UPSTREAM_PORT = 8790;
const RESERVED_PORTS = new Set([8788, 8790]);
export const PRODUCTION_GATEWAY_PORT = 8788;
const BASE_HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const MIME_TYPES: Readonly<Record<string, string>> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
function isNumericLoopback(host: string): boolean {
if (host === '::1') return true;
const octets = host.split('.');
if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) return false;
const numbers = octets.map(Number);
return numbers[0] === 127 && numbers.every((octet) => octet >= 0 && octet <= 255);
}
export interface CanaryGatewayOptions {
readonly gatewayToken?: string;
readonly distDir: string;
readonly host?: string;
readonly allowedHosts?: readonly string[];
readonly port?: number;
readonly upstreamHost?: string;
readonly upstreamPort?: number;
/** Set only after the production runtime validates its explicit acknowledgement. */
readonly ownsProductionPort?: boolean;
}
export interface CanaryGateway {
readonly running: boolean;
readonly origin: string;
start(): Promise<void>;
stop(): Promise<void>;
}
function connectionTokens(headers: IncomingHttpHeaders): Set<string> {
const tokens = new Set(BASE_HOP_BY_HOP_HEADERS);
const connection = headers.connection;
if (connection) {
for (const token of connection.split(',')) tokens.add(token.trim().toLowerCase());
}
return tokens;
}
function withoutHopByHop(headers: IncomingHttpHeaders): IncomingHttpHeaders {
const blocked = connectionTokens(headers);
return Object.fromEntries(
Object.entries(headers).filter(
([name, value]) => value !== undefined && !blocked.has(name.toLowerCase()),
),
);
}
function trustedUpstreamHeaders(
headers: IncomingHttpHeaders,
gatewayToken: string | undefined,
upstreamHost: string,
upstreamPort: number,
): IncomingHttpHeaders {
const sanitized = withoutHopByHop(headers);
sanitized.host = `${upstreamHost}:${upstreamPort}`;
if (sanitized.origin !== undefined) sanitized.origin = `http://${upstreamHost}:${upstreamPort}`;
delete sanitized[GATEWAY_AUTH_HEADER];
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
return sanitized;
}
function isAllowedBrowserOrigin(origin: string, allowedAuthorities: ReadonlySet<string>): boolean {
try {
const parsed = new URL(origin);
return (
parsed.protocol === 'http:' &&
parsed.username === '' &&
parsed.password === '' &&
parsed.pathname === '/' &&
parsed.search === '' &&
parsed.hash === '' &&
allowedAuthorities.has(parsed.host.toLowerCase())
);
} catch {
return false;
}
}
function isProxyPath(pathname: string): boolean {
return pathname === '/healthz' || pathname === '/readyz' || pathname.startsWith('/api/v1/');
}
function isHashedAsset(pathname: string): boolean {
return /(?:^|[.-])[a-f0-9]{8,}(?:[.-]|$)/i.test(pathname);
}
function isWithin(root: string, candidate: string): boolean {
const child = relative(root, candidate);
return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
}
function sendText(response: ServerResponse, status: number, text: string): void {
response.writeHead(status, {
'content-type': 'text/plain; charset=utf-8',
'content-length': Buffer.byteLength(text),
'cache-control': 'no-store',
});
response.end(text);
}
async function proxyRequest(
incoming: IncomingMessage,
response: ServerResponse,
upstreamHost: string,
upstreamPort: number,
gatewayToken: string | undefined,
): Promise<void> {
await new Promise<void>((resolvePromise) => {
const upstream = httpRequest(
{
host: upstreamHost,
port: upstreamPort,
method: incoming.method,
path: incoming.url,
headers: trustedUpstreamHeaders(incoming.headers, gatewayToken, upstreamHost, upstreamPort),
},
(upstreamResponse) => {
response.writeHead(
upstreamResponse.statusCode ?? 502,
upstreamResponse.statusMessage,
withoutHopByHop(upstreamResponse.headers),
);
pipeline(upstreamResponse, response)
.then(resolvePromise)
.catch(() => {
response.destroy();
resolvePromise();
});
},
);
upstream.once('error', () => {
if (!response.headersSent) sendText(response, 502, 'Bad Gateway');
else response.destroy();
resolvePromise();
});
incoming.once('aborted', () => upstream.destroy());
incoming.pipe(upstream);
});
}
async function serveFile(
request: IncomingMessage,
response: ServerResponse,
distRoot: string,
pathname: string,
): Promise<boolean> {
const candidate = resolve(distRoot, `.${pathname}`);
if (!isWithin(distRoot, candidate)) return false;
let canonical: string;
try {
canonical = await realpath(candidate);
if (!isWithin(distRoot, canonical) || !(await stat(canonical)).isFile()) return false;
} catch {
return false;
}
const headers = {
'content-type': MIME_TYPES[extname(canonical).toLowerCase()] ?? 'application/octet-stream',
'cache-control': isHashedAsset(pathname) ? 'public, max-age=31536000, immutable' : 'no-cache',
'x-content-type-options': 'nosniff',
};
response.writeHead(200, headers);
if (request.method === 'HEAD') response.end();
else await pipeline(createReadStream(canonical), response);
return true;
}
export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGateway {
const host = options.host ?? CANARY_DEFAULT_HOST;
const port = options.port ?? CANARY_DEFAULT_PORT;
const upstreamHost = options.upstreamHost ?? CANARY_DEFAULT_UPSTREAM_HOST;
const upstreamPort = options.upstreamPort ?? CANARY_DEFAULT_UPSTREAM_PORT;
if (!isNumericLoopback(host) && !(options.ownsProductionPort && host === '0.0.0.0')) {
throw new Error('Canary gateway must bind to loopback; production may explicitly bind 0.0.0.0');
}
if (!isNumericLoopback(upstreamHost)) {
throw new Error('Canary upstream must use a loopback host');
}
if (!Number.isInteger(port) || port < 0 || port > 65_535) throw new Error('Invalid canary port');
if (RESERVED_PORTS.has(port) && !(port === PRODUCTION_GATEWAY_PORT && options.ownsProductionPort))
throw new Error(`Port ${port} is reserved and cannot run the canary`);
if (!options.distDir) throw new Error('distDir is required');
const allowedAuthorities = new Set(
options.allowedHosts?.map((allowedHost) => {
const ipv4Octets = /^\d+(?:\.\d+){3}$/u.test(allowedHost)
? allowedHost.split('.').map(Number)
: undefined;
if (
allowedHost === '' ||
allowedHost.includes(':') ||
(ipv4Octets !== undefined &&
(ipv4Octets.some((octet) => octet > 255) || ipv4Octets.every((octet) => octet === 0))) ||
!/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\d{1,3}(?:\.\d{1,3}){3})$/iu.test(allowedHost)
)
throw new Error('Invalid allowed gateway host');
return `${allowedHost.toLowerCase()}:${port}`;
}),
);
let server: Server | undefined;
let activeOrigin = '';
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (!request.url || !request.method) return sendText(response, 400, 'Bad Request');
if (!request.url.startsWith('/') || request.url.startsWith('//')) {
return sendText(response, 400, 'Bad Request');
}
const expectedAuthority = activeOrigin ? new URL(activeOrigin).host : '';
const acceptedAuthorities =
host === '0.0.0.0' ? allowedAuthorities : new Set([expectedAuthority, ...allowedAuthorities]);
const hostHeaders = request.headersDistinct.host;
const requestAuthority = hostHeaders?.[0]?.toLowerCase();
if (
hostHeaders?.length !== 1 ||
requestAuthority === undefined ||
!acceptedAuthorities.has(requestAuthority)
) {
return sendText(response, 400, 'Bad Request');
}
const incomingOrigin = request.headers.origin;
if (
incomingOrigin !== undefined &&
!isAllowedBrowserOrigin(incomingOrigin, acceptedAuthorities)
) {
return sendText(response, 403, 'Forbidden');
}
const rawPathname = request.url.split('?', 1)[0] ?? '';
let unnormalizedPathname: string;
try {
unnormalizedPathname = decodeURIComponent(rawPathname);
} catch {
return sendText(response, 400, 'Bad Request');
}
if (
unnormalizedPathname.includes('\0') ||
unnormalizedPathname.split('/').includes('..') ||
unnormalizedPathname.includes('\\')
) {
return sendText(response, 400, 'Bad Request');
}
let parsed: URL;
try {
parsed = new URL(request.url, 'http://canary.invalid');
} catch {
return sendText(response, 400, 'Bad Request');
}
let pathname: string;
try {
pathname = decodeURIComponent(parsed.pathname);
} catch {
return sendText(response, 400, 'Bad Request');
}
if (isProxyPath(pathname)) {
await proxyRequest(request, response, upstreamHost, upstreamPort, options.gatewayToken);
return;
}
if (pathname.startsWith('/api')) return sendText(response, 404, 'Not Found');
if (request.method !== 'GET' && request.method !== 'HEAD') {
return sendText(response, 405, 'Method Not Allowed');
}
// Browsers request this implicitly. Avoid a noisy console 404 without routing
// this file-like path through the SPA fallback.
if (pathname === '/favicon.ico') {
response.writeHead(204, { 'cache-control': 'no-cache' });
response.end();
return;
}
const distRoot = await realpath(resolve(options.distDir)).catch(() => resolve(options.distDir));
if (pathname !== '/' && (await serveFile(request, response, distRoot, pathname))) return;
if (
pathname.startsWith('/assets/') ||
extname(pathname) !== '' ||
!(await serveFile(request, response, distRoot, '/index.html'))
) {
sendText(response, 404, 'Not Found');
}
}
return {
get running() {
return server?.listening ?? false;
},
get origin() {
if (!activeOrigin) throw new Error('Canary gateway is not running');
return activeOrigin;
},
async start() {
if (server?.listening) return;
const nextServer = createServer((request, response) => {
void handle(request, response).catch(() => {
if (!response.headersSent) sendText(response, 500, 'Internal Server Error');
else response.destroy();
});
});
server = nextServer;
await new Promise<void>((resolvePromise, reject) => {
nextServer.once('error', reject);
nextServer.listen(port, host, resolvePromise);
}).catch((error: unknown) => {
server = undefined;
throw error;
});
const address = nextServer.address();
if (!address || typeof address === 'string') throw new Error('Expected TCP server address');
const originHost = address.family === 'IPv6' ? `[${address.address}]` : address.address;
activeOrigin = `http://${originHost}:${address.port}`;
},
async stop() {
const activeServer = server;
server = undefined;
activeOrigin = '';
if (!activeServer?.listening) return;
await new Promise<void>((resolvePromise, reject) => {
activeServer.close((error) => (error ? reject(error) : resolvePromise()));
activeServer.closeAllConnections();
});
},
};
}
+69
View File
@@ -0,0 +1,69 @@
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_HOST?: string;
readonly MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS?: string;
readonly MULTI_SIMADMIN_GATEWAY_TOKEN?: string;
readonly MULTI_SIMADMIN_CUTOVER_ACK?: string;
}
export const PRODUCTION_CUTOVER_ACK = 'I ACKNOWLEDGE MULTI-SIMADMIN OWNS PORT 8788';
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,
});
}
export function readProductionGatewayRuntimeOptions(
environment: CanaryRuntimeEnvironment,
): CanaryGatewayOptions {
if (environment.MULTI_SIMADMIN_CUTOVER_ACK !== PRODUCTION_CUTOVER_ACK) {
throw new Error(
`Production gateway requires MULTI_SIMADMIN_CUTOVER_ACK=${PRODUCTION_CUTOVER_ACK}`,
);
}
const options = readCanaryRuntimeOptions({ ...environment, CANARY_PORT: '8788' });
const host = environment.MULTI_SIMADMIN_GATEWAY_HOST ?? options.host;
const allowedHosts = environment.MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS?.split(',')
.map((value) => value.trim())
.filter(Boolean);
return Object.freeze({
...options,
...(host === undefined ? {} : { host }),
...(allowedHosts === undefined ? {} : { allowedHosts }),
ownsProductionPort: true,
});
}
+196
View File
@@ -0,0 +1,196 @@
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
const repositoryRoot = resolve(import.meta.dirname, '../../..');
const gatewayToken = 'synthetic-runtime-test-token-at-least-32-bytes';
const cleanup: Array<() => Promise<void>> = [];
const execFileAsync = promisify(execFile);
interface RunningCommand {
readonly child: ChildProcessWithoutNullStreams;
output(): string;
}
function startPackageCommand(
script: 'canary' | 'gateway:production' | 'start:production',
environment: NodeJS.ProcessEnv,
) {
const child = spawn('corepack', ['pnpm', '--filter', '@multi-simadmin/api', 'run', script], {
cwd: repositoryRoot,
env: { ...process.env, ...environment, FORCE_COLOR: '0' },
detached: true,
stdio: 'pipe',
});
let output = '';
child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
const command: RunningCommand = { child, output: () => output };
cleanup.push(() => stopCommand(command));
return command;
}
async function stopCommand(command: RunningCommand): Promise<void> {
if (command.child.exitCode !== null || command.child.signalCode !== null) return;
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) =>
command.child.once('exit', (code, signal) => resolveExit({ code, signal })),
);
if (command.child.pid === undefined) throw new Error('Runtime command has no pid');
process.kill(-command.child.pid, 'SIGTERM');
const result = await Promise.race([
exit,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Runtime did not stop after SIGTERM:\n${command.output()}`)),
5_000,
),
),
]);
// pnpm's wrapper reports 143 when its child handles the group SIGTERM cleanly.
expect(result.code === 0 || result.code === 143 || result.signal === 'SIGTERM').toBe(true);
}
async function waitForOutput(
command: RunningCommand,
pattern: string,
timeoutMs = 20_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!command.output().includes(pattern)) {
if (command.child.exitCode !== null || command.child.signalCode !== null) {
throw new Error(`Runtime exited before becoming ready:\n${command.output()}`);
}
if (Date.now() >= deadline)
throw new Error(`Runtime readiness timed out:\n${command.output()}`);
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
}
}
async function listenOn(port: number): Promise<ReturnType<typeof createServer>> {
const server = createServer();
await new Promise<void>((resolveListen, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolveListen);
});
return server;
}
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
await new Promise<void>((resolveClose, reject) =>
server.close((error) => (error ? reject(error) : resolveClose())),
);
}
async function expectPortFree(port: number): Promise<void> {
const server = await listenOn(port);
await closeServer(server);
}
async function listenerPids(port: number): Promise<readonly string[]> {
try {
const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
return stdout.trim().split(/\s+/u).filter(Boolean).sort();
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === 1 || code === '1') return [];
throw error;
}
}
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
describe.sequential('executable package runtimes', () => {
it('makes the exact production gateway executable attempt 8788 only with acknowledgement', async () => {
await expectPortFree(8788);
const fixture = await listenOn(8788);
cleanup.push(() => closeServer(fixture));
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-production-gateway-cli-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
await writeFile(join(directory, 'index.html'), '<main>production</main>');
const denied = startPackageCommand('gateway:production', {
CANARY_DIST_DIR: directory,
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
});
await waitForOutput(denied, 'Production gateway requires MULTI_SIMADMIN_CUTOVER_ACK=');
await new Promise<void>((resolveExit) => denied.child.once('exit', () => resolveExit()));
expect(denied.child.exitCode).not.toBe(0);
const acknowledged = startPackageCommand('gateway:production', {
CANARY_DIST_DIR: directory,
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
MULTI_SIMADMIN_CUTOVER_ACK: 'I ACKNOWLEDGE MULTI-SIMADMIN OWNS PORT 8788',
});
await waitForOutput(acknowledged, 'EADDRINUSE');
await new Promise<void>((resolveExit) => acknowledged.child.once('exit', () => resolveExit()));
expect(acknowledged.output()).toContain('127.0.0.1:8788');
expect(acknowledged.child.exitCode).not.toBe(0);
}, 20_000);
it('starts the exact production command on 8790 and shuts down on SIGTERM', async () => {
await expectPortFree(8790);
const legacyListeners = await listenerPids(8788);
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-production-cli-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
const dataRoot = join(directory, 'data');
await mkdir(dataRoot);
const command = startPackageCommand('start:production', {
MULTI_SIMADMIN_DATA_ROOT: dataRoot,
MULTI_SIMADMIN_DATABASE_PATH: join(dataRoot, 'synthetic.sqlite'),
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
MULTI_SIMADMIN_WEB_DIST: directory,
API_HOST: '127.0.0.1',
API_PORT: '8790',
});
await waitForOutput(command, 'API listening at http://127.0.0.1:8790');
const response = await fetch('http://127.0.0.1:8790/healthz', {
headers: { [GATEWAY_AUTH_HEADER]: gatewayToken },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: 'ok' });
expect(await listenerPids(8788)).toEqual(legacyListeners);
expect(command.output()).not.toContain(':8788');
expect(command.output()).not.toContain(gatewayToken);
await stopCommand(command);
await expectPortFree(8790);
expect(command.output()).not.toContain(gatewayToken);
}, 20_000);
it('starts the exact canary command on 8789 with synthetic dist and shuts down on SIGTERM', async () => {
const port = 8789;
await expectPortFree(port);
const legacyListeners = await listenerPids(8788);
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-canary-cli-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
await writeFile(join(directory, 'index.html'), '<main>synthetic canary</main>');
const command = startPackageCommand('canary', {
CANARY_DIST_DIR: directory,
CANARY_PORT: String(port),
CANARY_UPSTREAM_PORT: '8790',
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
});
await waitForOutput(command, `Canary gateway listening at http://127.0.0.1:${port}`);
const response = await fetch(`http://127.0.0.1:${port}/`);
expect(response.status).toBe(200);
expect(await response.text()).toBe('<main>synthetic canary</main>');
expect(await listenerPids(8788)).toEqual(legacyListeners);
expect(command.output()).not.toContain(':8788');
expect(command.output()).not.toContain(gatewayToken);
await stopCommand(command);
await expectPortFree(port);
expect(command.output()).not.toContain(gatewayToken);
}, 20_000);
});
@@ -0,0 +1,34 @@
import Database from 'better-sqlite3';
import { expect, it } from 'vitest';
import { buildControlPlaneApp } from './control-plane.js';
import { migrateDatabase } from './infrastructure/database/migrations.js';
it('registers the read-only operations catalog without touching upstream', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
let upstreamCalls = 0;
const app = buildControlPlaneApp({
db,
store: { set: async () => '', get: async () => undefined, delete: async () => false },
upstream: {
get: async () => {
upstreamCalls += 1;
throw new Error('unexpected');
},
request: async () => {
upstreamCalls += 1;
throw new Error('unexpected');
},
postNetworkRegisterAuto: async () => {
upstreamCalls += 1;
throw new Error('unexpected');
},
},
});
const response = await app.inject('/api/v1/operations?pageSize=1');
expect(response.statusCode).toBe(200);
expect(response.json().page.total).toBe(117);
expect(upstreamCalls).toBe(0);
await app.close();
db.close();
});
+176
View File
@@ -0,0 +1,176 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { buildControlPlaneApp } from './control-plane.js';
import { migrateDatabase } from './infrastructure/database/migrations.js';
import { UpstreamError } from './infrastructure/transport/upstream-error.js';
import { SafeInstanceTransport } from './infrastructure/transport/safe-instance-transport.js';
import { createSafeControlPlaneUpstream } from './infrastructure/transport/safe-control-plane-upstream.js';
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
const dbs: Database.Database[] = [];
afterEach(async () => {
for (const db of dbs.splice(0)) db.close();
});
class Store implements SecretStore {
async set() {
return '';
}
async get() {
return '[REDACTED]';
}
async delete() {
return false;
}
}
describe('buildControlPlaneApp', () => {
it('registers the durable event route fail-closed when no authentication dependency is supplied', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
},
});
const response = await app.inject({ method: 'GET', url: '/api/v1/events' });
expect(response.statusCode).toBe(401);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ status: 401, code: 'UNAUTHORIZED' });
const jobs = await app.inject({ method: 'GET', url: '/api/v1/jobs' });
expect(jobs.statusCode).toBe(200);
expect(jobs.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } });
const audit = await app.inject({ method: 'GET', url: '/api/v1/audit' });
expect(audit.statusCode).toBe(200);
expect(audit.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } });
await app.close();
});
it('passes an explicit event-stream authenticator to durable cursor replay', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
},
authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed',
});
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: {
authorization: 'Bearer allowed',
'last-event-id': 'unknown-cursor',
},
});
expect(response.statusCode).toBe(409);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({
status: 409,
code: 'EVENT_POSITION_UNAVAILABLE',
});
await app.close();
});
it('assembles instance CRUD, connection checks, login and logout without listening or reading external config', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: createSafeControlPlaneUpstream(
new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.10', family: 4 }],
request: async (request) => ({
status: request.url.endsWith('/api/health') ? 401 : 200,
headers: { 'set-cookie': 'simadmin_session=opaque' },
body: '',
}),
}),
),
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'A', origin: 'http://192.168.1.10:8080' },
});
expect(created.statusCode).toBe(201);
const tested = await app.inject({
method: 'POST',
url: '/api/v1/instances/' + created.json().id + '/test-connection',
});
expect(tested.json()).toMatchObject({ authenticated: false });
const insecureLogin = await app.inject({
method: 'POST',
url: '/api/v1/instances/' + created.json().id + '/login',
payload: { password: 'never-leak-this' },
});
expect(insecureLogin.statusCode).toBe(400);
expect(insecureLogin.json()).toMatchObject({
title: 'Bad Request',
status: 400,
code: 'UPSTREAM_INSECURE_AUTH',
});
expect(insecureLogin.body).not.toContain('never-leak-this');
expect(insecureLogin.body).not.toContain('192.168.1.10');
await app.close();
});
it('maps typed upstream failures to redacted gateway Problem Details', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => {
throw new UpstreamError('UPSTREAM_UNAVAILABLE', {
cause: new Error('ECONNREFUSED 192.168.1.99 cookie=simadmin_session=secret'),
});
},
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'B', origin: 'http://192.168.1.99' },
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances/' + created.json().id + '/test-connection',
});
expect(response.statusCode).toBe(502);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({
title: 'Bad Gateway',
status: 502,
code: 'UPSTREAM_UNAVAILABLE',
});
expect(response.body).not.toContain('ECONNREFUSED');
expect(response.body).not.toContain('192.168.1.99');
expect(response.body).not.toContain('simadmin_session');
await app.close();
});
});
+123
View File
@@ -0,0 +1,123 @@
import type Database from 'better-sqlite3';
import { EventJournal } from './application/events/event-journal.js';
import { registerOperationRoutes } from './interface/http/operation-routes.js';
import { operationCatalogRegistry } from './application/operations/operation-catalog-data.js';
import {
SecureOperationExecution,
secureOperationRegistry,
} from './application/operations/secure-operation-execution.js';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { buildApp, type BuildAppOptions } from './app.js';
import {
ConnectionProbe,
type ConnectionTransport,
} from './application/connections/connection-probe.js';
import { InstanceCredentialResolver } from './application/connections/instance-credential-resolver.js';
import { InstanceLoginService } from './application/connections/instance-login-service.js';
import {
InstanceSessionStore,
UpstreamSessionClient,
type UpstreamSessionClientOptions,
} from './application/connections/upstream-session-client.js';
import { InstanceService } from './application/instances/instance-service.js';
import { DeleteInstanceOperation } from './application/operations/delete-instance-operation.js';
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
import { registerInstanceRoutes } from './interface/http/instance-routes.js';
import { registerEventRoutes } from './interface/http/event-routes.js';
import { JobQueryService } from './application/jobs/job-query-service.js';
import { registerJobRoutes } from './interface/http/job-routes.js';
import { AuditQueryService } from './application/audit/audit-query-service.js';
import { registerAuditRoutes } from './interface/http/audit-routes.js';
import { InstanceResourceService } from './application/resources/instance-resource-service.js';
import { InstanceMessageService } from './application/messages/instance-message-service.js';
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
}
export interface ControlPlaneOptions {
readonly db: Database.Database;
readonly store: SecretStore;
readonly upstream: SafeControlPlaneUpstream;
readonly now?: () => Date;
readonly authenticateEventStream?: (request: FastifyRequest) => boolean;
readonly app?: Omit<BuildAppOptions, 'registerRoutes'>;
}
export interface ControlPlaneApp extends FastifyInstance {
retryPendingSecretCleanup(): Promise<unknown>;
}
export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp {
const eventJournal = new EventJournal(options.db);
const jobs = new JobQueryService(options.db);
const audit = new AuditQueryService(options.db);
const instances = options.now
? new InstanceService({ db: options.db, store: options.store, now: options.now })
: new InstanceService({ db: options.db, store: options.store });
const connections = options.now
? new ConnectionProbe({
db: options.db,
instances,
transport: options.upstream,
now: options.now,
})
: new ConnectionProbe({ db: options.db, instances, transport: options.upstream });
const sessions = new InstanceSessionStore();
const client = new UpstreamSessionClient({ sessions, request: options.upstream.request });
const resources = new InstanceResourceService({
instances,
sessions,
request: options.upstream.request,
});
const messages = new InstanceMessageService({
instances,
sessions,
request: options.upstream.request,
});
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
const login = options.now
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
: new InstanceLoginService({ db: options.db, client, resolver });
const deletion = options.now
? new DeleteInstanceOperation({ db: options.db, instances, now: options.now })
: new DeleteInstanceOperation({ db: options.db, instances });
deletion.reconcileInterruptedJobs();
const secureExecution = new SecureOperationExecution({
db: options.db,
registry: secureOperationRegistry,
transport: {
request: async ({ origin }) => {
const response = await options.upstream.postNetworkRegisterAuto(origin);
return { status: response.status };
},
},
...(options.now ? { now: options.now } : {}),
});
secureExecution.reconcileInterruptedJobs();
const app = buildApp({
...options.app,
registerRoutes: (app) => {
registerInstanceRoutes(app, {
instances,
connections,
login,
deletion,
resources,
messages,
registerDeletionPreparationRoute: false,
});
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
registerJobRoutes(app, { jobs });
registerAuditRoutes(app, { audit });
registerEventRoutes(app, {
journal: eventJournal,
...(options.authenticateEventStream
? { authenticate: options.authenticateEventStream }
: {}),
});
},
});
Object.assign(app, {
retryPendingSecretCleanup: () => instances.retryPendingSecretCleanup(),
});
return app as ControlPlaneApp;
}
+23
View File
@@ -0,0 +1,23 @@
import { readFile, stat } from 'node:fs/promises';
import {
cutover,
nodeCutoverSystem,
parseCutoverPlan,
preflight,
rollback,
} from './cutover-orchestrator.js';
const [action, planPath] = process.argv.slice(2);
if (!['preflight', 'cutover', 'rollback'].includes(action ?? '') || !planPath) {
throw new Error('Usage: pnpm cutover <preflight|cutover|rollback> <plan.json>');
}
const planInfo = await stat(planPath);
if (!planInfo.isFile() || (planInfo.mode & 0o077) !== 0) {
throw new Error('Cutover plan must be a regular file inaccessible to group and other users');
}
const plan = parseCutoverPlan(JSON.parse(await readFile(planPath, 'utf8')) as unknown);
const gatewayToken = process.env.MULTI_SIMADMIN_GATEWAY_TOKEN ?? '';
if (action === 'preflight') await preflight(plan, nodeCutoverSystem, gatewayToken);
else if (action === 'cutover') await cutover(plan, nodeCutoverSystem, gatewayToken);
else await rollback(plan, nodeCutoverSystem);
process.stdout.write(`${action} completed\n`);
+499
View File
@@ -0,0 +1,499 @@
import { execFile, spawn } from 'node:child_process';
import { constants } from 'node:fs';
import {
access,
chmod,
lstat,
mkdir,
open,
readdir,
readFile,
readlink,
realpath,
rename,
} from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { request } from 'node:http';
import { isAbsolute, join, resolve } from 'node:path';
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
import { PRODUCTION_CUTOVER_ACK } from './canary-runtime.js';
const STATE_VERSION = 1;
const STATE_FILE = 'cutover-state.json';
const GATEWAY_ARGV = [
process.execPath,
'--import',
resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'),
resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
] as const;
const DIGEST = /^[a-f0-9]{64}$/u;
export interface CutoverPlan {
readonly assetDir: string;
readonly backupEvidence: string;
readonly backupEvidenceSha256: string;
readonly drillEvidence: string;
readonly drillEvidenceSha256: string;
readonly legacyPid: number;
readonly legacyCommand: string;
readonly legacyIdentity: string;
readonly legacyStart: readonly [string, ...string[]];
readonly stateDir: string;
}
interface CutoverState {
readonly version: 1;
readonly phase: 'prepared' | 'active';
readonly legacyPid: number;
readonly legacyCommand: string;
readonly legacyIdentity: string;
readonly legacyStart: readonly [string, ...string[]];
readonly gatewayPid?: number;
readonly gatewayCommand?: string;
readonly gatewayIdentity?: string;
}
export interface CutoverSystem {
command(pid: number): Promise<string>;
identity(pid: number): Promise<string>;
ready(port: 8788 | 8790, gatewayToken: string): Promise<boolean>;
ownsProductionPort(pid: number): Promise<boolean>;
signal(pid: number, signal: NodeJS.Signals): void;
waitGone(pid: number): Promise<void>;
start(argv: readonly [string, ...string[]], environment?: NodeJS.ProcessEnv): number;
delay(milliseconds: number): Promise<void>;
}
function runFile(file: string, args: readonly string[]): Promise<string> {
return new Promise((resolvePromise, reject) =>
execFile(file, [...args], (error, stdout) =>
error ? reject(error) : resolvePromise(stdout.trim()),
),
);
}
function authenticatedReady(port: 8788 | 8790, gatewayToken: string): Promise<boolean> {
return new Promise((resolvePromise) => {
const path = port === 8790 ? '/healthz' : '/readyz';
const req = request(
{
host: '127.0.0.1',
port,
path,
timeout: 2_000,
headers: { [GATEWAY_AUTH_HEADER]: gatewayToken },
},
(response) => {
response.resume();
resolvePromise(response.statusCode === 200);
},
);
req.once('error', () => resolvePromise(false));
req.once('timeout', () => {
req.destroy();
resolvePromise(false);
});
req.end();
});
}
export const nodeCutoverSystem: CutoverSystem = {
async command(pid) {
const proc = await readFile(`/proc/${pid}/cmdline`).catch(() => undefined);
if (proc) return proc.toString().split('\0').filter(Boolean).join(' ');
return await runFile('ps', ['-p', String(pid), '-o', 'command=']);
},
async identity(pid) {
if (process.platform === 'linux') {
const value = await readFile(`/proc/${pid}/stat`, 'utf8');
const fields = value.slice(value.lastIndexOf(')') + 2).split(/\s+/u);
return fields[19] ?? '';
}
return await runFile('ps', ['-p', String(pid), '-o', 'lstart=']);
},
ready: authenticatedReady,
async ownsProductionPort(pid) {
if (process.platform === 'linux') {
const descriptorDirectory = `/proc/${pid}/fd`;
const descriptors = await readdir(descriptorDirectory).catch(() => []);
const listeners = await Promise.all(
descriptors.map((descriptor) =>
readlink(join(descriptorDirectory, descriptor)).catch(() => ''),
),
);
const socketInodes = new Set(
listeners
.map((line) => /^socket:\[(\d+)\]$/u.exec(line)?.[1])
.filter((inode): inode is string => inode !== undefined),
);
const tcp = await readFile('/proc/net/tcp', 'utf8').catch(() => '');
return tcp.split('\n').some((line) => {
const fields = line.trim().split(/\s+/u);
return (
fields[1]?.endsWith(':2254') && fields[3] === '0A' && socketInodes.has(fields[9] ?? '')
);
});
}
const output = await runFile('lsof', [
'-nP',
'-a',
'-p',
String(pid),
'-iTCP:8788',
'-sTCP:LISTEN',
'-t',
]).catch(() => '');
return output.split(/\s+/u).includes(String(pid));
},
signal: (pid, signal) => process.kill(pid, signal),
async waitGone(pid) {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
process.kill(pid, 0);
} catch {
return;
}
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
throw new Error(`PID ${pid} did not exit after SIGTERM`);
},
start(argv, environment) {
const child = spawn(argv[0], argv.slice(1), {
detached: true,
stdio: 'ignore',
env: environment ?? process.env,
});
child.unref();
if (!child.pid) throw new Error('Failed to start command');
return child.pid;
},
delay: (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)),
};
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error(`${label} must be an object`);
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const wanted = [...expected].sort();
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index]))
throw new Error(`${label} has an invalid schema`);
}
function commandMatchesArgv(command: string, commandArgv: readonly string[]): boolean {
return commandArgv.join(' ') === command;
}
function safeString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0 || value.includes('\0'))
throw new Error(`${label} must be a non-empty string`);
return value;
}
function argv(value: unknown, label: string): readonly [string, ...string[]] {
if (
!Array.isArray(value) ||
value.length === 0 ||
value.length > 64 ||
value.some((item) => typeof item !== 'string' || item.length === 0 || item.includes('\0'))
)
throw new Error(`${label} must be a non-empty string array`);
return value as [string, ...string[]];
}
export function parseCutoverPlan(value: unknown): CutoverPlan {
const input = record(value, 'Cutover plan');
exactKeys(
input,
[
'assetDir',
'backupEvidence',
'backupEvidenceSha256',
'drillEvidence',
'drillEvidenceSha256',
'legacyPid',
'legacyCommand',
'legacyIdentity',
'legacyStart',
'stateDir',
],
'Cutover plan',
);
const plan: CutoverPlan = {
assetDir: safeString(input.assetDir, 'assetDir'),
backupEvidence: safeString(input.backupEvidence, 'backupEvidence'),
backupEvidenceSha256: safeString(input.backupEvidenceSha256, 'backupEvidenceSha256'),
drillEvidence: safeString(input.drillEvidence, 'drillEvidence'),
drillEvidenceSha256: safeString(input.drillEvidenceSha256, 'drillEvidenceSha256'),
legacyPid: input.legacyPid as number,
legacyCommand: safeString(input.legacyCommand, 'legacyCommand'),
legacyIdentity: safeString(input.legacyIdentity, 'legacyIdentity'),
legacyStart: argv(input.legacyStart, 'legacyStart'),
stateDir: safeString(input.stateDir, 'stateDir'),
};
if (
![
plan.assetDir,
plan.backupEvidence,
plan.drillEvidence,
plan.stateDir,
plan.legacyStart[0],
].every(isAbsolute)
)
throw new Error('Cutover paths and legacy executable must be absolute');
if (!Number.isSafeInteger(plan.legacyPid) || plan.legacyPid <= 1)
throw new Error('legacyPid must be a safe PID greater than 1');
if (!DIGEST.test(plan.backupEvidenceSha256) || !DIGEST.test(plan.drillEvidenceSha256))
throw new Error('Evidence digests must be lowercase SHA-256');
if (!commandMatchesArgv(plan.legacyCommand, plan.legacyStart))
throw new Error('legacyStart must exactly reproduce legacyCommand');
return Object.freeze(plan);
}
function requireGatewayToken(gatewayToken: string): void {
const bytes = Buffer.byteLength(gatewayToken);
if (bytes < 32 || bytes > 512)
throw new Error('Gateway token must contain between 32 and 512 bytes');
}
async function verifyEvidence(path: string, digest: string, label: string): Promise<void> {
const info = await lstat(path);
if (!info.isFile()) throw new Error(`${label} evidence must be a regular file`);
const content = await readFile(path);
const actual = createHash('sha256').update(content).digest('hex');
if (actual !== digest) throw new Error(`${label} evidence SHA-256 mismatch`);
}
async function durableState(plan: CutoverPlan, state: CutoverState): Promise<void> {
await mkdir(plan.stateDir, { recursive: true, mode: 0o700 });
await chmod(plan.stateDir, 0o700);
const target = join(plan.stateDir, STATE_FILE);
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
const handle = await open(
temporary,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
0o600,
);
try {
await handle.writeFile(`${JSON.stringify(state)}\n`);
await handle.sync();
} finally {
await handle.close();
}
await rename(temporary, target);
const directory = await open(plan.stateDir, constants.O_RDONLY);
try {
await directory.sync();
} finally {
await directory.close();
}
}
function preparedState(plan: CutoverPlan): CutoverState {
return {
version: STATE_VERSION,
phase: 'prepared',
legacyPid: plan.legacyPid,
legacyCommand: plan.legacyCommand,
legacyIdentity: plan.legacyIdentity,
legacyStart: plan.legacyStart,
};
}
async function readState(plan: CutoverPlan): Promise<CutoverState> {
const path = join(plan.stateDir, STATE_FILE);
const info = await lstat(path);
if (!info.isFile() || (info.mode & 0o777) !== 0o600)
throw new Error('Cutover state must be a mode-0600 regular file');
const input = record(JSON.parse(await readFile(path, 'utf8')) as unknown, 'Cutover state');
const base = ['version', 'phase', 'legacyPid', 'legacyCommand', 'legacyIdentity', 'legacyStart'];
exactKeys(
input,
input.phase === 'active' ? [...base, 'gatewayPid', 'gatewayCommand', 'gatewayIdentity'] : base,
'Cutover state',
);
const state: CutoverState = {
version: input.version as 1,
phase: input.phase as 'prepared' | 'active',
legacyPid: input.legacyPid as number,
legacyCommand: safeString(input.legacyCommand, 'state legacyCommand'),
legacyIdentity: safeString(input.legacyIdentity, 'state legacyIdentity'),
legacyStart: argv(input.legacyStart, 'state legacyStart'),
...(input.phase === 'active'
? {
gatewayPid: input.gatewayPid as number,
gatewayCommand: safeString(input.gatewayCommand, 'state gatewayCommand'),
gatewayIdentity: safeString(input.gatewayIdentity, 'state gatewayIdentity'),
}
: {}),
};
if (state.version !== STATE_VERSION || !['prepared', 'active'].includes(state.phase))
throw new Error('Unsupported cutover state');
if (
!Number.isSafeInteger(state.legacyPid) ||
state.legacyPid <= 1 ||
(state.gatewayPid !== undefined &&
(!Number.isSafeInteger(state.gatewayPid) || state.gatewayPid <= 1))
)
throw new Error('Cutover state contains an invalid PID');
if (
state.legacyPid !== plan.legacyPid ||
state.legacyCommand !== plan.legacyCommand ||
state.legacyIdentity !== plan.legacyIdentity ||
state.legacyStart.length !== plan.legacyStart.length ||
state.legacyStart.some((item, index) => item !== plan.legacyStart[index])
)
throw new Error('Cutover state does not match the reviewed plan');
return state;
}
export async function preflight(
plan: CutoverPlan,
system: CutoverSystem,
gatewayToken: string,
): Promise<void> {
requireGatewayToken(gatewayToken);
if (!(await system.ready(8790, gatewayToken)))
throw new Error('Authenticated API health preflight failed on 127.0.0.1:8790/healthz');
const indexPath = join(await realpath(plan.assetDir), 'index.html');
const asset = await lstat(indexPath);
if (!asset.isFile()) throw new Error('assetDir/index.html must be a regular file');
await access(plan.legacyStart[0], constants.X_OK);
await verifyEvidence(plan.backupEvidence, plan.backupEvidenceSha256, 'Backup');
await verifyEvidence(plan.drillEvidence, plan.drillEvidenceSha256, 'Rollback drill');
const actual = await system.command(plan.legacyPid);
const identity = await system.identity(plan.legacyPid);
if (actual !== plan.legacyCommand || identity !== plan.legacyIdentity)
throw new Error(`Legacy PID identity mismatch; refusing to signal PID ${plan.legacyPid}`);
}
async function restoreLegacy(plan: CutoverPlan, system: CutoverSystem): Promise<number> {
return system.start(plan.legacyStart);
}
async function waitForGateway(
system: CutoverSystem,
gatewayPid: number,
gatewayToken: string,
): Promise<boolean> {
for (let attempt = 0; attempt < 100; attempt += 1) {
if ((await system.ownsProductionPort(gatewayPid)) && (await system.ready(8788, gatewayToken)))
return true;
await system.delay(100);
}
return false;
}
export async function cutover(
plan: CutoverPlan,
system: CutoverSystem,
gatewayToken: string,
): Promise<number> {
await preflight(plan, system, gatewayToken);
await durableState(plan, preparedState(plan));
let legacyStopped = false;
let gatewayPid: number | undefined;
let gatewayCommand: string | undefined;
let gatewayIdentity: string | undefined;
try {
if (
(await system.command(plan.legacyPid)) !== plan.legacyCommand ||
(await system.identity(plan.legacyPid)) !== plan.legacyIdentity
)
throw new Error(`Legacy PID identity mismatch; refusing to signal PID ${plan.legacyPid}`);
system.signal(plan.legacyPid, 'SIGTERM');
await system.waitGone(plan.legacyPid);
legacyStopped = true;
gatewayPid = system.start(GATEWAY_ARGV, {
...process.env,
CANARY_DIST_DIR: plan.assetDir,
MULTI_SIMADMIN_CUTOVER_ACK: PRODUCTION_CUTOVER_ACK,
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
...(process.env.MULTI_SIMADMIN_GATEWAY_HOST
? { MULTI_SIMADMIN_GATEWAY_HOST: process.env.MULTI_SIMADMIN_GATEWAY_HOST }
: {}),
...(process.env.MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS
? {
MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS: process.env.MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS,
}
: {}),
});
if (!(await waitForGateway(system, gatewayPid, gatewayToken)))
throw new Error(
'Production gateway failed authenticated ownership verification on port 8788',
);
gatewayCommand = await system.command(gatewayPid);
gatewayIdentity = await system.identity(gatewayPid);
await durableState(plan, {
...preparedState(plan),
phase: 'active',
gatewayPid,
gatewayCommand,
gatewayIdentity,
});
return gatewayPid;
} catch (error) {
if (gatewayPid !== undefined) {
try {
const actual = await system.command(gatewayPid);
const identity = await system.identity(gatewayPid);
if (
(gatewayCommand === undefined || actual === gatewayCommand) &&
(gatewayIdentity === undefined || identity === gatewayIdentity) &&
actual !== '' &&
identity !== ''
) {
system.signal(gatewayPid, 'SIGTERM');
await system.waitGone(gatewayPid);
}
} catch {
// Continue the safety-critical legacy restart even if gateway cleanup races its exit.
}
}
if (legacyStopped) {
if (gatewayPid !== undefined && (await system.ownsProductionPort(gatewayPid)))
throw new Error('Automatic rollback refused while production gateway still owns port 8788');
await restoreLegacy(plan, system);
}
throw error;
}
}
export async function rollback(plan: CutoverPlan, system: CutoverSystem): Promise<number> {
const state = await readState(plan);
if (
state.phase !== 'active' ||
state.gatewayPid === undefined ||
state.gatewayCommand === undefined ||
state.gatewayIdentity === undefined
)
throw new Error('Cutover state is not active; refusing rollback PID operations');
const actual = await system.command(state.gatewayPid);
const identity = await system.identity(state.gatewayPid);
if (
actual !== state.gatewayCommand ||
identity !== state.gatewayIdentity ||
!(await system.ownsProductionPort(state.gatewayPid))
)
throw new Error(`Gateway PID identity mismatch; refusing to signal PID ${state.gatewayPid}`);
system.signal(state.gatewayPid, 'SIGTERM');
await system.waitGone(state.gatewayPid);
const legacyPid = await restoreLegacy(plan, system);
await durableState(plan, {
version: STATE_VERSION,
phase: 'prepared',
legacyPid,
legacyCommand: plan.legacyCommand,
legacyIdentity: await system.identity(legacyPid),
legacyStart: plan.legacyStart,
});
return legacyPid;
}
+69
View File
@@ -0,0 +1,69 @@
export {
API_DEFAULT_HOST,
API_DEFAULT_PORT,
REDACT_PATHS,
buildApp,
createListenOptions,
} from './app.js';
export type { BuildAppOptions, ListenEnvironment, ListenOptions, ReadinessResult } from './app.js';
export { startApi } from './start.js';
export type { StartApiOptions } from './start.js';
export {
CANARY_DEFAULT_HOST,
CANARY_DEFAULT_PORT,
CANARY_DEFAULT_UPSTREAM_HOST,
CANARY_DEFAULT_UPSTREAM_PORT,
createCanaryGateway,
} from './canary-gateway.js';
export type { CanaryGateway, CanaryGatewayOptions } from './canary-gateway.js';
export {
activateRestoreCandidate,
backupDatabase,
prepareRestoreCandidate,
runRollbackDrill,
} from './infrastructure/database/backup.js';
export type { DatabaseArtifact, RollbackDrillRecord } from './infrastructure/database/backup.js';
export { openDatabase } from './infrastructure/database/database.js';
export {
LegacyImportError,
confirmLegacyImport,
previewLegacyImport,
} from './application/legacy-import/legacy-import.js';
export type {
LegacyImportInstancePreview,
LegacyImportPreview,
LegacyImportResult,
LegacyImportStatus,
} from './application/legacy-import/legacy-import.js';
export { runMigrationCli } from './application/legacy-import/migration-cli.js';
export type { MigrationCliIo } from './application/legacy-import/migration-cli.js';
export {
ActivationError,
activatePendingSecret,
} from './application/legacy-import/activate-pending-secret.js';
export type {
ActivatePendingSecretInput,
ActivatePendingSecretResult,
ActivationErrorCode,
} from './application/legacy-import/activate-pending-secret.js';
export {
MacOSKeychainSecretStore,
SecretStoreError,
SpawnCommandRunner,
parseKeychainReference,
} from './infrastructure/secrets/keychain-secret-store.js';
export type {
CommandInvocation,
CommandResult,
CommandRunner,
ParsedKeychainReference,
SecretStoreErrorCode,
} from './infrastructure/secrets/keychain-secret-store.js';
export type { SecretKey, SecretStore } from './infrastructure/secrets/secret-store.js';
export { InstanceService, InstanceServiceError } from './application/instances/instance-service.js';
export type {
InstanceServiceErrorCode,
InstanceServiceOptions,
} from './application/instances/instance-service.js';
export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js';
export * as databaseSchema from './infrastructure/database/schema.js';
@@ -0,0 +1,181 @@
import { execFile } from 'node:child_process';
import Database from 'better-sqlite3';
import { chmod, mkdtemp, open, readFile, rename, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import {
activateRestoreCandidate,
backupDatabase,
prepareRestoreCandidate,
runRollbackDrill,
} from './backup.js';
import { openDatabase, type SqliteDatabase } from './database.js';
import { migrateDatabase } from './migrations.js';
const databases: SqliteDatabase[] = [];
const execFileAsync = promisify(execFile);
afterEach(() => {
for (const database of databases.splice(0)) if (database.open) database.close();
});
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'phase9-backup-'));
const livePath = join(root, 'live.sqlite');
const backupPath = join(root, 'snapshots', 'before.sqlite');
const database = openDatabase(livePath);
databases.push(database);
migrateDatabase(database);
database
.prepare('INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)')
.run('fixture', '"before"', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
return { root, livePath, backupPath, database };
}
describe('verified backup, restore, and rollback foundation', () => {
it('creates a private WAL-safe snapshot containing committed WAL rows', async () => {
const { database, backupPath } = await fixture();
database
.prepare('INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)')
.run('wal', '"in-wal"', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
const snapshot = await backupDatabase(database, backupPath);
const copy = new Database(backupPath, { readonly: true });
expect(copy.prepare('SELECT value_json FROM app_settings ORDER BY rowid').all()).toEqual([
{ value_json: '"before"' },
{ value_json: '"in-wal"' },
]);
copy.close();
expect(snapshot.sha256).toMatch(/^[a-f0-9]{64}$/);
expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
});
it('verifies a separate candidate and digest-binds activation', async () => {
const { livePath, backupPath, database } = await fixture();
const snapshot = await backupDatabase(database, backupPath);
database.exec('UPDATE app_settings SET value_json = \'"after"\'');
database.close();
const candidate = await prepareRestoreCandidate(backupPath, `${livePath}.candidate`);
expect(candidate.sha256).toBe(snapshot.sha256);
await writeFile(backupPath, 'not sqlite');
await expect(activateRestoreCandidate(livePath, candidate, '0'.repeat(64))).rejects.toThrow(
/digest/i,
);
expect(new Database(livePath).prepare('SELECT value_json FROM app_settings').get()).toEqual({
value_json: '"after"',
});
await activateRestoreCandidate(livePath, candidate, candidate.sha256);
expect(new Database(livePath).prepare('SELECT value_json FROM app_settings').get()).toEqual({
value_json: '"before"',
});
});
it('rejects empty SQLite and application databases with missing migrations or FK violations', async () => {
const root = await mkdtemp(join(tmpdir(), 'phase9-invalid-'));
const empty = join(root, 'empty.sqlite');
new Database(empty).close();
await expect(prepareRestoreCandidate(empty, join(root, 'candidate'))).rejects.toThrow(
/schema|migration|application/i,
);
const { backupPath, database } = await fixture();
database.pragma('foreign_keys = OFF');
database.exec("INSERT INTO instance_tags VALUES ('missing','bad','now')");
await expect(backupDatabase(database, backupPath)).rejects.toThrow(/foreign key/i);
});
it('rejects an otherwise valid database with any extra sqlite_master object', async () => {
const { root, backupPath, database } = await fixture();
database.exec('CREATE VIEW unexpected_view AS SELECT 1 AS value');
await expect(backupDatabase(database, backupPath)).rejects.toThrow(/schema|unexpected|view/i);
database.exec(
'DROP VIEW unexpected_view; CREATE TRIGGER unexpected_trigger AFTER INSERT ON app_settings BEGIN SELECT 1; END',
);
await expect(backupDatabase(database, join(root, 'trigger.sqlite'))).rejects.toThrow(
/schema|unexpected|trigger/i,
);
});
it('binds activation to the exact staged inode and rejects a staged-path swap', async () => {
const { livePath, backupPath, database } = await fixture();
await backupDatabase(database, backupPath);
database.close();
const candidate = await prepareRestoreCandidate(backupPath, `${livePath}.candidate`);
const before = await readFile(livePath);
await expect(
activateRestoreCandidate(livePath, candidate, candidate.sha256, {
beforeActivation: async (stagedPath) => {
await rename(stagedPath, `${stagedPath}.original`);
await writeFile(stagedPath, await readFile(candidate.path), { mode: 0o400 });
},
}),
).rejects.toThrow(/staged|identity|changed/i);
expect(await readFile(livePath)).toEqual(before);
});
it('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
const { livePath, backupPath, database } = await fixture();
await backupDatabase(database, backupPath);
database.close();
const candidate = await prepareRestoreCandidate(backupPath, `${livePath}.candidate`);
const before = await readFile(livePath);
await expect(
activateRestoreCandidate(livePath, candidate, candidate.sha256, {
beforeActivation: async (stagedPath) => {
const original = await stat(stagedPath);
const originalTimes = await stat(stagedPath, { bigint: true });
await chmod(stagedPath, 0o600);
const handle = await open(stagedPath, 'r+');
try {
const byte = Buffer.alloc(1);
await handle.read(byte, 0, 1, 0);
byte[0] = byte[0]! ^ 0xff;
await handle.write(byte, 0, 1, 0);
await handle.sync();
} finally {
await handle.close();
}
await chmod(stagedPath, original.mode & 0o777);
await execFileAsync('python3', [
'-c',
'import os,sys; os.utime(sys.argv[1], ns=(int(sys.argv[2]), int(sys.argv[3])))',
stagedPath,
originalTimes.atimeNs.toString(),
originalTimes.mtimeNs.toString(),
]);
const restored = await stat(stagedPath);
expect(restored.dev).toBe(original.dev);
expect(restored.ino).toBe(original.ino);
expect(restored.size).toBe(original.size);
expect(restored.mode).toBe(original.mode);
expect(restored.mtimeMs).toBe(original.mtimeMs);
},
}),
).rejects.toThrow(/staged|digest|changed/i);
expect(await readFile(livePath)).toEqual(before);
});
it('runs a deterministic non-activating rollback drill with a reconciliation record', async () => {
const { root, livePath, backupPath, database } = await fixture();
const snapshot = await backupDatabase(database, backupPath);
database.close();
const before = await readFile(livePath);
const first = await runRollbackDrill(backupPath, join(root, 'drill-a'));
const second = await runRollbackDrill(backupPath, join(root, 'drill-b'));
expect(first).toMatchObject({ snapshotSha256: snapshot.sha256, integrity: 'ok' });
expect(second.schemaDigest).toBe(first.schemaDigest);
expect(second.rowCounts).toEqual(first.rowCounts);
expect(second.migrationsDigest).toBe(first.migrationsDigest);
expect(first.reconciliationDigest).toMatch(/^[a-f0-9]{64}$/);
expect(await readFile(livePath)).toEqual(before);
expect(JSON.stringify(first)).not.toContain('before');
});
});
@@ -0,0 +1,440 @@
import Database from 'better-sqlite3';
import { createHash, randomUUID } from 'node:crypto';
import type { FileHandle } from 'node:fs/promises';
import {
chmod,
copyFile,
lstat,
mkdir,
open,
readFile,
rename,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { beginRestore, getManagedDatabaseIdentity, type SqliteDatabase } from './database.js';
import { MIGRATIONS, migrateDatabase } from './migrations.js';
export interface DatabaseArtifact {
readonly path: string;
readonly sha256: string;
}
export interface RollbackDrillRecord {
readonly kind: 'rollback-drill';
readonly snapshotSha256: string;
readonly integrity: 'ok';
readonly schemaDigest: string;
readonly migrationsDigest: string;
readonly rowCounts: Readonly<Record<string, number>>;
readonly reconciliationDigest: string;
}
async function assertRegularFile(path: string, label: string): Promise<void> {
const details = await lstat(path);
if (details.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link`);
if (!details.isFile()) throw new Error(`${label} must be a regular file`);
}
async function assertDistinctFiles(databasePath: string, backupPath: string): Promise<void> {
if (resolve(databasePath) === resolve(backupPath))
throw new Error('Live and backup paths are the same');
await assertRegularFile(backupPath, 'Backup');
try {
await assertRegularFile(databasePath, 'Live database');
const [live, backup] = await Promise.all([stat(databasePath), stat(backupPath)]);
if (live.dev === backup.dev && live.ino === backup.ino)
throw new Error('Live and backup paths are aliases of the same file');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
async function reservePrivateFile(path: string): Promise<void> {
const handle = await open(path, 'wx', 0o600);
await handle.close();
}
async function assertValidDatabase(path: string): Promise<void> {
let database: Database.Database | undefined;
try {
database = new Database(path, { fileMustExist: true, readonly: true });
const integrity = database.pragma('integrity_check', { simple: true });
if (integrity !== 'ok')
throw new Error(`Database integrity check failed: ${String(integrity)}`);
assertExpectedSchema(database);
const migrations = database
.prepare('SELECT id,name,checksum FROM schema_migrations ORDER BY id')
.all() as { id: number; name: string; checksum: string }[];
if (migrations.length !== MIGRATIONS.length)
throw new Error('Application migration set incomplete');
for (const [index, migration] of MIGRATIONS.entries()) {
const actual = migrations[index];
const checksum = createHash('sha256')
.update(JSON.stringify(migration.statements))
.digest('hex');
if (
!actual ||
actual.id !== migration.id ||
actual.name !== migration.name ||
actual.checksum !== checksum
)
throw new Error(`Application migration ${migration.id} mismatch`);
}
const violations = database.pragma('foreign_key_check') as unknown[];
if (violations.length) throw new Error('Application database has foreign key violations');
} catch (error) {
throw new Error(
`Artifact is not a valid application SQLite database: ${(error as Error).message}`,
{
cause: error,
},
);
} finally {
database?.close();
}
}
interface SchemaObject {
readonly type: string;
readonly name: string;
readonly tableName: string;
readonly sql: string | null;
}
function readSchema(database: Database.Database): SchemaObject[] {
return database
.prepare(
`SELECT type,name,tbl_name AS tableName,sql
FROM sqlite_master
WHERE name NOT LIKE 'sqlite_%'
ORDER BY type,name`,
)
.all()
.map((object) => {
const value = object as SchemaObject;
return {
...value,
sql: value.sql?.trim().replaceAll(/\s+/g, ' ') ?? null,
};
}) as SchemaObject[];
}
let expectedSchema: readonly SchemaObject[] | undefined;
function getExpectedSchema(): readonly SchemaObject[] {
if (expectedSchema) return expectedSchema;
const reference = new Database(':memory:');
try {
migrateDatabase(reference);
expectedSchema = Object.freeze(readSchema(reference).map((object) => Object.freeze(object)));
return expectedSchema;
} finally {
reference.close();
}
}
function assertExpectedSchema(database: Database.Database): void {
const actual = readSchema(database);
const expected = getExpectedSchema();
if (JSON.stringify(actual) !== JSON.stringify(expected))
throw new Error('Application schema object set mismatch');
}
async function digestFile(path: string): Promise<string> {
return createHash('sha256')
.update(await readFile(path))
.digest('hex');
}
async function digestFileHandle(handle: FileHandle): Promise<string> {
const details = await handle.stat();
const bytes = Buffer.alloc(details.size);
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
if (bytesRead !== bytes.length)
throw new Error('Staged restore candidate could not be read fully');
return createHash('sha256').update(bytes).digest('hex');
}
async function syncFile(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
async function syncDirectory(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
export async function backupDatabase(
database: SqliteDatabase,
backupPath: string,
): Promise<DatabaseArtifact> {
const source = getManagedDatabaseIdentity(database);
const directory = dirname(backupPath);
await mkdir(directory, { recursive: true });
try {
const destination = await stat(backupPath);
if (`${destination.dev}:${destination.ino}` === source.deviceInode)
throw new Error('Backup destination aliases the live database identity');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const temporaryPath = `${backupPath}.${randomUUID()}.tmp`;
try {
await reservePrivateFile(temporaryPath);
// SQLite's online backup API includes committed WAL pages in one consistent snapshot.
await database.backup(temporaryPath);
await chmod(temporaryPath, 0o600);
await assertValidDatabase(temporaryPath);
await syncFile(temporaryPath);
await rename(temporaryPath, backupPath);
await chmod(backupPath, 0o600);
await syncDirectory(directory);
return Object.freeze({ path: backupPath, sha256: await digestFile(backupPath) });
} finally {
await rm(temporaryPath, { force: true });
}
}
export async function prepareRestoreCandidate(
backupPath: string,
candidatePath: string,
): Promise<DatabaseArtifact> {
await assertDistinctFiles(candidatePath, backupPath);
await assertValidDatabase(backupPath);
await mkdir(dirname(candidatePath), { recursive: true });
const temporaryPath = `${candidatePath}.${randomUUID()}.tmp`;
try {
await reservePrivateFile(temporaryPath);
await copyFile(backupPath, temporaryPath);
await chmod(temporaryPath, 0o600);
await assertValidDatabase(temporaryPath);
await syncFile(temporaryPath);
await rename(temporaryPath, candidatePath);
await syncDirectory(dirname(candidatePath));
return Object.freeze({ path: candidatePath, sha256: await digestFile(candidatePath) });
} finally {
await rm(temporaryPath, { force: true });
}
}
export async function activateRestoreCandidate(
databasePath: string,
candidate: DatabaseArtifact,
confirmedDigest: string,
hooks: { readonly beforeActivation?: (stagedPath: string) => Promise<void> } = {},
): Promise<void> {
if (confirmedDigest !== candidate.sha256) throw new Error('Restore candidate digest mismatch');
const endRestore = beginRestore(databasePath);
const directory = dirname(databasePath);
const stagedPath = `${databasePath}.${randomUUID()}.restore`;
try {
await assertDistinctFiles(databasePath, candidate.path);
await mkdir(directory, { recursive: true });
await reservePrivateFile(stagedPath);
const before = await stat(candidate.path);
await copyFile(candidate.path, stagedPath);
const after = await stat(candidate.path);
if (
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeMs !== after.mtimeMs
)
throw new Error('Restore candidate changed while being staged');
// Deny path-based writers after population, then hold the exact inode open through
// validation and the final path identity check.
await chmod(stagedPath, 0o400);
const stagedHandle = await open(stagedPath, 'r');
try {
const stagedIdentity = await stagedHandle.stat();
const stagedDigest = await digestFileHandle(stagedHandle);
if (stagedDigest !== candidate.sha256) throw new Error('Restore candidate digest mismatch');
await assertValidDatabase(stagedPath);
if ((await digestFileHandle(stagedHandle)) !== stagedDigest)
throw new Error('Staged restore candidate changed during validation');
await stagedHandle.sync();
await activateStagedDatabase(
databasePath,
stagedPath,
stagedHandle,
stagedIdentity,
confirmedDigest,
hooks.beforeActivation,
);
} finally {
await stagedHandle.close();
}
} finally {
await rm(stagedPath, { force: true });
endRestore();
}
}
/** Internal compatibility helper. Public callers must use prepare + digest-confirmed activation. */
export async function restoreDatabase(databasePath: string, backupPath: string): Promise<void> {
await assertDistinctFiles(databasePath, backupPath);
const candidatePath = `${databasePath}.${randomUUID()}.candidate`;
try {
const candidate = await prepareRestoreCandidate(backupPath, candidatePath);
await activateRestoreCandidate(databasePath, candidate, candidate.sha256);
} finally {
await rm(candidatePath, { force: true });
}
}
interface RestoreMarker {
readonly version: 1;
readonly quarantinedSidecars: readonly string[];
}
async function readPreviousRestoreMarker(markerPath: string): Promise<RestoreMarker | undefined> {
try {
await assertRegularFile(markerPath, 'Restore marker');
const value = JSON.parse(await readFile(markerPath, 'utf8')) as Partial<RestoreMarker>;
if (value.version !== 1 || !Array.isArray(value.quarantinedSidecars))
throw new Error('Restore marker is invalid; manual recovery is required');
return value as RestoreMarker;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
async function writeRestoreMarker(markerPath: string, marker: RestoreMarker): Promise<void> {
const temporary = `${markerPath}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, JSON.stringify(marker), { flag: 'wx', mode: 0o600 });
await syncFile(temporary);
await rename(temporary, markerPath);
await syncDirectory(dirname(markerPath));
} finally {
await rm(temporary, { force: true });
}
}
async function activateStagedDatabase(
databasePath: string,
stagedPath: string,
stagedHandle: FileHandle,
stagedIdentity: Awaited<ReturnType<FileHandle['stat']>>,
confirmedDigest: string,
beforeActivation?: (stagedPath: string) => Promise<void>,
): Promise<void> {
const directory = dirname(databasePath);
const markerPath = `${databasePath}.restore-in-progress`;
const previous = await readPreviousRestoreMarker(markerPath);
const transactionId = randomUUID();
const sidecars = [`${databasePath}-wal`, `${databasePath}-shm`];
const quarantined = sidecars.map((path) => `${path}.${transactionId}.quarantine`);
const allQuarantined = [...(previous?.quarantinedSidecars ?? []), ...quarantined];
await writeRestoreMarker(markerPath, { version: 1, quarantinedSidecars: allQuarantined });
try {
// Make old WAL state unreachable before replacing the main file. The durable marker
// forces managed opens to fail closed at every crash boundary.
for (const [index, sidecar] of sidecars.entries()) {
try {
await rename(sidecar, quarantined[index]!);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
await syncDirectory(directory);
await beforeActivation?.(stagedPath);
if ((await digestFileHandle(stagedHandle)) !== confirmedDigest)
throw new Error('Staged restore candidate digest changed before activation');
const [held, path] = await Promise.all([stagedHandle.stat(), lstat(stagedPath)]);
if (
!path.isFile() ||
path.isSymbolicLink() ||
(path.mode & 0o222) !== 0 ||
held.dev !== stagedIdentity.dev ||
held.ino !== stagedIdentity.ino ||
held.size !== stagedIdentity.size ||
held.mtimeMs !== stagedIdentity.mtimeMs ||
path.dev !== stagedIdentity.dev ||
path.ino !== stagedIdentity.ino ||
path.size !== stagedIdentity.size ||
path.mtimeMs !== stagedIdentity.mtimeMs
)
throw new Error('Staged restore candidate identity or bytes changed before activation');
await rename(stagedPath, databasePath);
await chmod(databasePath, 0o600);
await syncDirectory(directory);
for (const path of allQuarantined) await rm(path, { force: true });
await rm(markerPath);
await syncDirectory(directory);
} catch (error) {
throw new Error(
`Restore activation did not complete; ${markerPath} was retained to force recovery: ${(error as Error).message}`,
{ cause: error },
);
}
}
export async function runRollbackDrill(
backupPath: string,
candidatePath: string,
): Promise<RollbackDrillRecord> {
const candidate = await prepareRestoreCandidate(backupPath, candidatePath);
let database: Database.Database | undefined;
try {
database = new Database(candidate.path, { readonly: true, fileMustExist: true });
const integrity = database.pragma('integrity_check', { simple: true });
if (integrity !== 'ok') throw new Error('Rollback drill integrity check failed');
const schema = database
.prepare(
"SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name",
)
.all();
const tables = database
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
.all() as { name: string }[];
const rowCounts: Record<string, number> = {};
for (const { name } of tables) {
const quoted = `"${name.replaceAll('"', '""')}"`;
rowCounts[name] = (
database.prepare(`SELECT COUNT(*) AS count FROM ${quoted}`).get() as { count: number }
).count;
}
const schemaDigest = createHash('sha256').update(JSON.stringify(schema)).digest('hex');
const migrations = database
.prepare('SELECT id,name,checksum FROM schema_migrations ORDER BY id')
.all();
const migrationsDigest = createHash('sha256').update(JSON.stringify(migrations)).digest('hex');
const reconciliationDigest = createHash('sha256')
.update(
JSON.stringify({
snapshotSha256: candidate.sha256,
schemaDigest,
migrationsDigest,
rowCounts,
}),
)
.digest('hex');
return Object.freeze({
kind: 'rollback-drill',
snapshotSha256: candidate.sha256,
integrity: 'ok',
schemaDigest,
migrationsDigest,
rowCounts: Object.freeze(rowCounts),
reconciliationDigest,
});
} finally {
database?.close();
}
}
@@ -0,0 +1,422 @@
import { link, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { backupDatabase, restoreDatabase } from './backup.js';
import { openDatabase } from './database.js';
import { MIGRATIONS, migrateDatabase } from './migrations.js';
const directories: string[] = [];
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-database-'));
directories.push(directory);
return directory;
}
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
);
});
describe('database migrations', () => {
it('rejects duplicate, unordered, backfilled, and drifted migration catalogs', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
const one = { id: 1, name: 'one', statements: ['CREATE TABLE one (id TEXT)'] };
migrateDatabase(database, [one]);
expect(() =>
migrateDatabase(database, [{ ...one, statements: ['CREATE TABLE changed (id TEXT)'] }]),
).toThrow(/checksum|drift/i);
expect(() => migrateDatabase(database, [one, { ...one, name: 'two' }])).toThrow(
/duplicate.*id/i,
);
expect(() => migrateDatabase(database, [one, { id: 2, name: 'one', statements: [] }])).toThrow(
/duplicate.*name/i,
);
expect(() => migrateDatabase(database, [{ id: 2, name: 'two', statements: [] }, one])).toThrow(
/increasing/i,
);
migrateDatabase(database, [{ id: 2, name: 'two', statements: [] }]);
expect(() => migrateDatabase(database, [{ id: 0, name: 'backfill', statements: [] }])).toThrow(
/backfill/i,
);
database.close();
});
it('creates all business tables, migration metadata, indexes, foreign keys, and UTC timestamp fields', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
migrateDatabase(database);
const tables = database
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
.all()
.map((row) => (row as { name: string }).name);
expect(tables).toEqual([
'app_settings',
'audit_events',
'capabilities',
'event_journal',
'instance_tags',
'instances',
'job_attempts',
'job_items',
'jobs',
'operation_preparations',
'schema_migrations',
'secret_cleanup_tasks',
'secret_references',
'status_snapshots',
]);
expect(database.pragma('foreign_keys', { simple: true })).toBe(1);
const instanceColumns = database.pragma('table_info(instances)') as Array<{
name: string;
notnull: number;
}>;
expect(instanceColumns).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'config_revision', notnull: 1 }),
expect.objectContaining({ name: 'created_at', notnull: 1 }),
expect.objectContaining({ name: 'updated_at', notnull: 1 }),
]),
);
const foreignKeys = database.pragma('foreign_key_list(job_items)') as Array<{
from: string;
on_delete: string;
table: string;
}>;
expect(foreignKeys).toEqual(
expect.arrayContaining([
expect.objectContaining({ from: 'job_id', on_delete: 'CASCADE', table: 'jobs' }),
]),
);
expect(foreignKeys).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ from: 'instance_id', table: 'instances' }),
]),
);
const jobColumns = database.pragma('table_info(jobs)') as Array<{ name: string }>;
expect(jobColumns).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'root_job_id' }),
expect.objectContaining({ name: 'retry_of_job_id' }),
]),
);
const indexes = database
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%' ORDER BY name",
)
.all()
.map((row) => (row as { name: string }).name);
expect(indexes).toEqual(
expect.arrayContaining([
'idx_audit_events_created_at',
'idx_event_journal_sequence',
'idx_jobs_status_created_at',
'idx_status_snapshots_instance_observed_at',
]),
);
const now = '2026-07-16T00:00:00.000Z';
database
.prepare(
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
)
.run('cleanup-owner', 'Cleanup owner', 'http://10.0.0.9', 'password', 1, 1, now, now);
database
.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
)
.run(
'ref-one',
'cleanup-owner',
'instance-password',
'macos-keychain',
'opaque-one',
now,
now,
);
expect(() =>
database
.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
)
.run(
'ref-two',
'cleanup-owner',
'instance-password',
'macos-keychain',
'opaque-two',
now,
now,
),
).toThrow(/unique/i);
database
.prepare(
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
)
.run('opaque-one', 'cleanup-owner', 'instance-password', 'macos-keychain', now, now);
database.prepare('DELETE FROM instances WHERE id=?').run('cleanup-owner');
expect(database.prepare('SELECT reference FROM secret_cleanup_tasks').all()).toEqual([
{ reference: 'opaque-one' },
]);
database.close();
});
it('upgrades duplicate v1 secret references by retaining one canonical row and queuing the rest', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
migrateDatabase(database, [MIGRATIONS[0]!]);
const older = '2026-07-15T00:00:00.000Z';
const newer = '2026-07-16T00:00:00.000Z';
database
.prepare(
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
)
.run('owner', 'Owner', 'http://10.0.0.8', 'password', 1, 1, older, newer);
const reference = (slot: string) =>
`keychain://multi-simadmin/${Buffer.from(JSON.stringify(['owner', 'instance-password', slot]), 'utf8').toString('base64url')}`;
const insert = database.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
);
insert.run(
'old-ref',
'owner',
'instance-password',
'macos-keychain',
reference('old-slot'),
older,
older,
);
insert.run(
'new-ref',
'owner',
'instance-password',
'macos-keychain',
reference('new-slot'),
newer,
newer,
);
migrateDatabase(database);
expect(database.prepare('SELECT id FROM secret_references').all()).toEqual([{ id: 'new-ref' }]);
expect(
database
.prepare('SELECT reference,instance_id,purpose,provider FROM secret_cleanup_tasks')
.all(),
).toEqual([
{
reference: reference('old-slot'),
instance_id: 'owner',
purpose: 'instance-password',
provider: 'macos-keychain',
},
]);
expect(() => migrateDatabase(database)).not.toThrow();
database.close();
});
it('replays without resetting persisted data', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
migrateDatabase(database);
database
.prepare(
'INSERT INTO instances (id, name, base_url, config_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
)
.run(
'one',
'One',
'http://example.invalid',
7,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
migrateDatabase(database);
expect(
database.prepare('SELECT name, config_revision FROM instances WHERE id = ?').get('one'),
).toEqual({
config_revision: 7,
name: 'One',
});
expect(database.prepare('SELECT COUNT(*) AS count FROM schema_migrations').get()).toEqual({
count: MIGRATIONS.length,
});
database.close();
});
it('rolls back the entire migration when a statement fails', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
expect(() =>
migrateDatabase(database, [
{
id: 999,
name: 'deliberately-broken',
statements: ['CREATE TABLE should_rollback (id TEXT PRIMARY KEY)', 'THIS IS NOT SQL'],
},
]),
).toThrow();
expect(
database.prepare("SELECT name FROM sqlite_master WHERE name = 'should_rollback'").get(),
).toBeUndefined();
expect(
database.prepare("SELECT name FROM sqlite_master WHERE name = 'schema_migrations'").get(),
).toBeUndefined();
database.close();
});
});
describe('database backup and restore', () => {
it('honours a canonical restore marker when opening through a symlinked parent alias', async () => {
const directory = await temporaryDirectory();
const realDirectory = join(directory, 'real');
const aliasDirectory = join(directory, 'alias');
await mkdir(realDirectory);
const databasePath = join(realDirectory, 'app.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
database.close();
await writeFile(
`${databasePath}.restore-in-progress`,
JSON.stringify({ version: 1, quarantinedSidecars: [] }),
);
await symlink(realDirectory, aliasDirectory);
expect(() => openDatabase(join(aliasDirectory, 'app.sqlite'))).toThrow(/restore|recovery/i);
});
it('refuses restore while a managed live connection is open', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const backupPath = join(directory, 'backup.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
await backupDatabase(database, backupPath);
await expect(restoreDatabase(databasePath, backupPath)).rejects.toThrow(/open|connection/i);
database.close();
});
it('refuses restore through hardlink and symlinked-parent aliases while a managed connection is open', async () => {
const directory = await temporaryDirectory();
const realDirectory = join(directory, 'real');
const aliasDirectory = join(directory, 'alias');
await mkdir(realDirectory);
await symlink(realDirectory, aliasDirectory);
const databasePath = join(realDirectory, 'app.sqlite');
const hardlinkPath = join(directory, 'hardlink.sqlite');
const backupPath = join(directory, 'backup.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
await backupDatabase(database, backupPath);
await link(databasePath, hardlinkPath);
await expect(restoreDatabase(hardlinkPath, backupPath)).rejects.toThrow(
/open|connection|identity/i,
);
await expect(restoreDatabase(join(aliasDirectory, 'app.sqlite'), backupPath)).rejects.toThrow(
/open|connection|identity/i,
);
database.close();
});
it('rejects backup destinations that alias the managed live database', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const hardlinkPath = join(directory, 'hardlink.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
await link(databasePath, hardlinkPath);
await expect(backupDatabase(database, hardlinkPath)).rejects.toThrow(/same|alias|identity/i);
database.close();
});
it('creates backups with owner-only permissions', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
migrateDatabase(database);
const backupPath = join(directory, 'backup.sqlite');
await backupDatabase(database, backupPath);
expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
database.close();
});
it('rejects same-path, symlink, and hardlink restore aliases', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const backupPath = join(directory, 'backup.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
await backupDatabase(database, backupPath);
database.close();
await expect(restoreDatabase(databasePath, databasePath)).rejects.toThrow(/same|alias/i);
const symlinkPath = join(directory, 'backup-link.sqlite');
await symlink(backupPath, symlinkPath);
await expect(restoreDatabase(databasePath, symlinkPath)).rejects.toThrow(/symbolic|symlink/i);
const hardlinkPath = join(directory, 'live-hardlink.sqlite');
await link(databasePath, hardlinkPath);
await expect(restoreDatabase(databasePath, hardlinkPath)).rejects.toThrow(/same|alias/i);
});
it('backs up an active WAL database, then atomically restores its earlier state', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const backupPath = join(directory, 'backup.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
database
.prepare(
'INSERT INTO app_settings (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)',
)
.run('colour', '"blue"', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
await backupDatabase(database, backupPath);
database.prepare('UPDATE app_settings SET value_json = ? WHERE key = ?').run('"red"', 'colour');
database.close();
await restoreDatabase(databasePath, backupPath);
const restored = openDatabase(databasePath);
expect(
restored.prepare('SELECT value_json FROM app_settings WHERE key = ?').get('colour'),
).toEqual({ value_json: '"blue"' });
expect(restored.pragma('integrity_check', { simple: true })).toBe('ok');
restored.close();
});
it('rejects a corrupt backup without changing the live database', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const corruptPath = join(directory, 'corrupt.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
database
.prepare(
'INSERT INTO app_settings (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)',
)
.run('safe', 'true', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
database.close();
const before = await readFile(databasePath);
await writeFile(corruptPath, 'not a sqlite database');
await expect(restoreDatabase(databasePath, corruptPath)).rejects.toThrow(
/backup|integrity|database/i,
);
expect(await readFile(databasePath)).toEqual(before);
const unchanged = openDatabase(databasePath);
expect(
unchanged.prepare('SELECT value_json FROM app_settings WHERE key = ?').get('safe'),
).toEqual({ value_json: 'true' });
unchanged.close();
});
});
@@ -0,0 +1,119 @@
import Database from 'better-sqlite3';
import { existsSync, mkdirSync, realpathSync, statSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
export type SqliteDatabase = Database.Database;
export interface DatabaseIdentity {
readonly canonicalPath: string;
readonly deviceInode: string;
}
const openPaths = new Map<string, number>();
const openIdentities = new Map<string, number>();
const restorePaths = new Set<string>();
const restoreIdentities = new Set<string>();
const managedIdentities = new WeakMap<SqliteDatabase, DatabaseIdentity>();
function increment(map: Map<string, number>, key: string): void {
map.set(key, (map.get(key) ?? 0) + 1);
}
function decrement(map: Map<string, number>, key: string): void {
const remaining = (map.get(key) ?? 1) - 1;
if (remaining === 0) map.delete(key);
else map.set(key, remaining);
}
function canonicalPath(path: string): string {
const absolute = resolve(path);
return join(realpathSync(dirname(absolute)), basename(absolute));
}
function identityForExistingPath(path: string): DatabaseIdentity {
const canonical = realpathSync(path);
const details = statSync(canonical);
return {
canonicalPath: canonical,
deviceInode: `${details.dev}:${details.ino}`,
};
}
export function getManagedDatabaseIdentity(database: SqliteDatabase): DatabaseIdentity {
const identity = managedIdentities.get(database);
if (!identity) throw new Error('Database connection is not managed by openDatabase');
return identity;
}
export function hasOpenDatabase(path: string): boolean {
const canonical = canonicalPath(path);
if ((openPaths.get(canonical) ?? 0) > 0) return true;
try {
return (openIdentities.get(identityForExistingPath(path).deviceInode) ?? 0) > 0;
} catch {
return false;
}
}
export function beginRestore(path: string): () => void {
const identity = identityForExistingPath(path);
if (restorePaths.has(identity.canonicalPath) || restoreIdentities.has(identity.deviceInode)) {
throw new Error('A restore is already in progress for this database identity');
}
if (
(openPaths.get(identity.canonicalPath) ?? 0) > 0 ||
(openIdentities.get(identity.deviceInode) ?? 0) > 0
) {
throw new Error('Cannot restore database while a managed connection is open for this identity');
}
restorePaths.add(identity.canonicalPath);
restoreIdentities.add(identity.deviceInode);
return () => {
restorePaths.delete(identity.canonicalPath);
restoreIdentities.delete(identity.deviceInode);
};
}
export function openDatabase(path: string): SqliteDatabase {
const absolute = resolve(path);
mkdirSync(dirname(absolute), { recursive: true });
const preOpenPath = existsSync(absolute)
? identityForExistingPath(absolute).canonicalPath
: canonicalPath(absolute);
if (existsSync(`${preOpenPath}.restore-in-progress`))
throw new Error('Database restore recovery is required before opening this database');
if (restorePaths.has(preOpenPath))
throw new Error('Cannot open database while restore is in progress');
const database = new Database(absolute);
const identity = identityForExistingPath(absolute);
if (restorePaths.has(identity.canonicalPath) || restoreIdentities.has(identity.deviceInode)) {
database.close();
throw new Error('Cannot open database while restore is in progress for this identity');
}
increment(openPaths, identity.canonicalPath);
increment(openIdentities, identity.deviceInode);
managedIdentities.set(database, identity);
const close = database.close.bind(database);
let closed = false;
database.close = () => {
if (!closed) {
closed = true;
decrement(openPaths, identity.canonicalPath);
decrement(openIdentities, identity.deviceInode);
managedIdentities.delete(database);
}
close();
return database;
};
try {
database.pragma('journal_mode = WAL');
database.pragma('foreign_keys = ON');
database.pragma('busy_timeout = 5000');
return database;
} catch (error) {
database.close();
throw error;
}
}
@@ -0,0 +1,357 @@
import type { SqliteDatabase } from './database.js';
import { createHash } from 'node:crypto';
export interface Migration {
readonly id: number;
readonly name: string;
readonly statements: readonly string[];
}
export const MIGRATIONS: readonly Migration[] = [
{
id: 1,
name: 'initial-control-plane-schema',
statements: [
`CREATE TABLE instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL UNIQUE,
auth_mode TEXT NOT NULL DEFAULT 'password',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
config_revision INTEGER NOT NULL DEFAULT 1 CHECK (config_revision > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE instance_tags (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (instance_id, tag)
)`,
`CREATE TABLE capabilities (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
operation_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('supported', 'unsupported', 'auth-required', 'degraded', 'unknown')),
upstream_version TEXT,
detail_code TEXT,
observed_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_id, operation_id)
)`,
`CREATE TABLE status_snapshots (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
category TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('fresh', 'stale', 'expired', 'unknown')),
payload_json TEXT NOT NULL,
observed_at TEXT NOT NULL,
expires_at TEXT,
created_at TEXT NOT NULL
)`,
`CREATE TABLE jobs (
id TEXT PRIMARY KEY,
parent_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT,
operation_id TEXT NOT NULL,
risk_level TEXT NOT NULL CHECK (risk_level IN ('R0', 'R1', 'R2', 'R3')),
status TEXT NOT NULL,
requested_by TEXT NOT NULL,
request_id TEXT NOT NULL,
parameters_digest TEXT NOT NULL,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE job_items (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0),
status TEXT NOT NULL,
result_code TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
updated_at TEXT NOT NULL,
UNIQUE (job_id, instance_id, attempt_number)
)`,
`CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL,
actor TEXT NOT NULL,
operation_id TEXT NOT NULL,
risk_level TEXT NOT NULL CHECK (risk_level IN ('R0', 'R1', 'R2', 'R3')),
request_id TEXT NOT NULL,
parameters_summary_json TEXT NOT NULL,
body_digest TEXT,
result_code TEXT NOT NULL,
duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0),
created_at TEXT NOT NULL
)`,
`CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE secret_references (
id TEXT PRIMARY KEY,
instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
provider TEXT NOT NULL,
external_reference TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (provider, external_reference)
)`,
'CREATE INDEX idx_status_snapshots_instance_observed_at ON status_snapshots(instance_id, observed_at DESC)',
'CREATE INDEX idx_jobs_status_created_at ON jobs(status, created_at DESC)',
'CREATE INDEX idx_audit_events_created_at ON audit_events(created_at DESC)',
'CREATE INDEX idx_capabilities_state ON capabilities(state)',
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
],
},
{
id: 2,
name: 'secret-cleanup-task-outbox',
statements: [
`CREATE TABLE secret_cleanup_tasks (
reference TEXT PRIMARY KEY,
instance_id TEXT NOT NULL,
purpose TEXT NOT NULL,
provider TEXT NOT NULL,
generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0),
queued_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at)
SELECT external_reference,instance_id,purpose,provider,1,updated_at,updated_at
FROM (
SELECT external_reference,instance_id,purpose,provider,updated_at,
ROW_NUMBER() OVER (
PARTITION BY instance_id,purpose
ORDER BY updated_at DESC,created_at DESC,id DESC
) AS row_number
FROM secret_references
WHERE instance_id IS NOT NULL
) ranked
WHERE row_number > 1`,
`DELETE FROM secret_references
WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY instance_id,purpose
ORDER BY updated_at DESC,created_at DESC,id DESC
) AS row_number
FROM secret_references
WHERE instance_id IS NOT NULL
) ranked
WHERE row_number > 1
)`,
'CREATE UNIQUE INDEX uq_secret_references_instance_purpose ON secret_references(instance_id, purpose) WHERE instance_id IS NOT NULL',
'CREATE INDEX idx_secret_cleanup_tasks_queued_at ON secret_cleanup_tasks(queued_at)',
],
},
{
id: 3,
name: 'current-status-snapshot-lifecycle',
statements: [
`DELETE FROM status_snapshots
WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY instance_id, category
ORDER BY observed_at DESC, created_at DESC, id DESC
) AS row_number
FROM status_snapshots
) ranked
WHERE row_number > 1
)`,
'CREATE UNIQUE INDEX uq_status_snapshots_instance_category ON status_snapshots(instance_id, category)',
],
},
{
id: 4,
name: 'r3-preparations-and-durable-job-lineage',
statements: [
'ALTER TABLE jobs ADD COLUMN root_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT',
'ALTER TABLE jobs ADD COLUMN retry_of_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT',
'UPDATE jobs SET root_job_id=id WHERE root_job_id IS NULL',
`CREATE TABLE job_items_v4 (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
instance_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0),
status TEXT NOT NULL,
result_code TEXT,
error_json TEXT,
source_job_item_id TEXT REFERENCES job_items_v4(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
updated_at TEXT NOT NULL,
UNIQUE (job_id, instance_id, attempt_number)
)`,
`INSERT INTO job_items_v4 (id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at)
SELECT id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at FROM job_items`,
'DROP TABLE job_items',
'ALTER TABLE job_items_v4 RENAME TO job_items',
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
`CREATE TABLE job_attempts (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed','cancelled','unknown-result')),
started_at TEXT NOT NULL,
finished_at TEXT,
created_at TEXT NOT NULL
)`,
'CREATE INDEX idx_job_attempts_job_started_at ON job_attempts(job_id, started_at)',
`CREATE TABLE operation_preparations (
id TEXT PRIMARY KEY,
operation_id TEXT NOT NULL CHECK (operation_id = 'deleteInstance'),
risk_level TEXT NOT NULL CHECK (risk_level = 'R3'),
status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')),
target_instance_id TEXT NOT NULL,
target_revision INTEGER NOT NULL CHECK (target_revision > 0),
parameter_schema_id TEXT NOT NULL,
parameters_digest TEXT NOT NULL,
token_digest TEXT NOT NULL CHECK (length(token_digest) = 64),
requested_by TEXT NOT NULL,
request_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
],
},
{
id: 5,
name: 'generic-secure-operation-preparation-binding',
statements: [
`CREATE TABLE operation_preparations_v5 (
id TEXT PRIMARY KEY,
operation_id TEXT NOT NULL,
risk_level TEXT NOT NULL CHECK (risk_level IN ('R2','R3')),
status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')),
target_instance_id TEXT NOT NULL,
target_revision INTEGER NOT NULL CHECK (target_revision > 0),
target_origin TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
canonical_query TEXT NOT NULL,
body_digest TEXT NOT NULL CHECK (length(body_digest) = 64),
content_type TEXT NOT NULL,
parameter_schema_id TEXT NOT NULL,
parameters_digest TEXT NOT NULL CHECK (length(parameters_digest) = 64),
nonce TEXT NOT NULL UNIQUE,
token_digest TEXT NOT NULL CHECK (length(token_digest) = 64),
requested_by TEXT NOT NULL,
request_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`INSERT INTO operation_preparations_v5
(id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path,
canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest,
requested_by,request_id,expires_at,consumed_at,created_at,updated_at)
SELECT p.id,p.operation_id,p.risk_level,p.status,p.target_instance_id,p.target_revision,
COALESCE(i.base_url,''),'DELETE','/api/v1/instances/' || p.target_instance_id,'',
'${createHash('sha256').update('').digest('hex')}','',p.parameter_schema_id,p.parameters_digest,
'legacy-' || p.id,p.token_digest,p.requested_by,p.request_id,p.expires_at,p.consumed_at,p.created_at,p.updated_at
FROM operation_preparations p LEFT JOIN instances i ON i.id=p.target_instance_id`,
'DROP TABLE operation_preparations',
'ALTER TABLE operation_preparations_v5 RENAME TO operation_preparations',
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
],
},
{
id: 6,
name: 'durable-event-journal',
statements: [
`CREATE TABLE event_journal (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
envelope_json TEXT NOT NULL
)`,
'CREATE INDEX idx_event_journal_sequence ON event_journal(sequence)',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
)`;
function checksum(migration: Migration): string {
return createHash('sha256').update(JSON.stringify(migration.statements)).digest('hex');
}
function validateCatalog(migrations: readonly Migration[]): void {
const ids = new Set<number>();
const names = new Set<string>();
let previous = -Infinity;
for (const migration of migrations) {
if (!Number.isSafeInteger(migration.id) || migration.id < 0)
throw new Error(`Migration id ${migration.id} is invalid`);
if (ids.has(migration.id)) throw new Error(`Duplicate migration id ${migration.id}`);
if (names.has(migration.name)) throw new Error(`Duplicate migration name ${migration.name}`);
if (migration.id <= previous) throw new Error('Migration ids must be strictly increasing');
ids.add(migration.id);
names.add(migration.name);
previous = migration.id;
}
}
export function migrateDatabase(
database: SqliteDatabase,
migrations: readonly Migration[] = MIGRATIONS,
): void {
validateCatalog(migrations);
database.transaction(() => {
database.exec(
'CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)',
);
const maximum = database.prepare('SELECT MAX(id) AS id FROM schema_migrations').get() as {
id: number | null;
};
const applied = database.prepare('SELECT name, checksum FROM schema_migrations WHERE id = ?');
const appliedByName = database.prepare('SELECT id FROM schema_migrations WHERE name = ?');
const record = database.prepare(
'INSERT INTO schema_migrations (id, name, checksum, applied_at) VALUES (?, ?, ?, ?)',
);
for (const migration of migrations) {
const digest = checksum(migration);
const existing = applied.get(migration.id) as { name: string; checksum: string } | undefined;
if (existing) {
if (existing.name !== migration.name)
throw new Error(`Migration ${migration.id} name mismatch`);
if (existing.checksum !== digest)
throw new Error(`Migration ${migration.id} checksum drift detected`);
continue;
}
const existingName = appliedByName.get(migration.name) as { id: number } | undefined;
if (existingName)
throw new Error(
`Migration ${migration.name} drift detected: applied as id ${existingName.id}`,
);
if (maximum.id !== null && migration.id < maximum.id)
throw new Error(
`Migration ${migration.id} is an unapplied backfill below applied id ${maximum.id}`,
);
for (const statement of migration.statements) database.exec(statement);
record.run(migration.id, migration.name, digest, new Date().toISOString());
}
})();
}
// Kept separate so a failed first migration transaction removes metadata too.
export const migrationMetadataSql = createMigrationsTable;
@@ -0,0 +1,257 @@
import { desc, sql } from 'drizzle-orm';
import {
type AnySQLiteColumn,
check,
index,
integer,
primaryKey,
sqliteTable,
text,
unique,
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
const timestamps = {
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
};
export const instances = sqliteTable(
'instances',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
baseUrl: text('base_url').notNull(),
authMode: text('auth_mode').notNull().default('password'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
configRevision: integer('config_revision').notNull().default(1),
...timestamps,
},
(table) => [
unique('instances_base_url_unique').on(table.baseUrl),
check('instances_enabled_check', sql`${table.enabled} in (0, 1)`),
check('instances_config_revision_check', sql`${table.configRevision} > 0`),
],
);
export const instanceTags = sqliteTable(
'instance_tags',
{
instanceId: text('instance_id')
.notNull()
.references(() => instances.id, { onDelete: 'cascade' }),
tag: text('tag').notNull(),
createdAt: text('created_at').notNull(),
},
(table) => [primaryKey({ columns: [table.instanceId, table.tag] })],
);
export const capabilities = sqliteTable(
'capabilities',
{
instanceId: text('instance_id')
.notNull()
.references(() => instances.id, { onDelete: 'cascade' }),
operationId: text('operation_id').notNull(),
state: text('state').notNull(),
upstreamVersion: text('upstream_version'),
detailCode: text('detail_code'),
observedAt: text('observed_at').notNull(),
...timestamps,
},
(table) => [
primaryKey({ columns: [table.instanceId, table.operationId] }),
check(
'capabilities_state_check',
sql`${table.state} in ('supported', 'unsupported', 'auth-required', 'degraded', 'unknown')`,
),
index('idx_capabilities_state').on(table.state),
],
);
export const statusSnapshots = sqliteTable(
'status_snapshots',
{
id: text('id').primaryKey(),
instanceId: text('instance_id')
.notNull()
.references(() => instances.id, { onDelete: 'cascade' }),
category: text('category').notNull(),
state: text('state').notNull(),
payloadJson: text('payload_json').notNull(),
observedAt: text('observed_at').notNull(),
expiresAt: text('expires_at'),
createdAt: text('created_at').notNull(),
},
(table) => [
check(
'status_snapshots_state_check',
sql`${table.state} in ('fresh', 'stale', 'expired', 'unknown')`,
),
index('idx_status_snapshots_instance_observed_at').on(table.instanceId, desc(table.observedAt)),
uniqueIndex('uq_status_snapshots_instance_category').on(table.instanceId, table.category),
],
);
export const jobs = sqliteTable(
'jobs',
{
id: text('id').primaryKey(),
parentJobId: text('parent_job_id').references((): AnySQLiteColumn => jobs.id, {
onDelete: 'restrict',
}),
rootJobId: text('root_job_id').references((): AnySQLiteColumn => jobs.id, {
onDelete: 'restrict',
}),
retryOfJobId: text('retry_of_job_id').references((): AnySQLiteColumn => jobs.id, {
onDelete: 'restrict',
}),
operationId: text('operation_id').notNull(),
riskLevel: text('risk_level').notNull(),
status: text('status').notNull(),
requestedBy: text('requested_by').notNull(),
requestId: text('request_id').notNull(),
parametersDigest: text('parameters_digest').notNull(),
createdAt: text('created_at').notNull(),
startedAt: text('started_at'),
finishedAt: text('finished_at'),
updatedAt: text('updated_at').notNull(),
},
(table) => [
check('jobs_risk_level_check', sql`${table.riskLevel} in ('R0', 'R1', 'R2', 'R3')`),
index('idx_jobs_status_created_at').on(table.status, desc(table.createdAt)),
],
);
export const jobItems = sqliteTable(
'job_items',
{
id: text('id').primaryKey(),
jobId: text('job_id')
.notNull()
.references(() => jobs.id, { onDelete: 'cascade' }),
instanceId: text('instance_id').notNull(),
attemptNumber: integer('attempt_number').notNull().default(1),
status: text('status').notNull(),
resultCode: text('result_code'),
errorJson: text('error_json'),
sourceJobItemId: text('source_job_item_id').references((): AnySQLiteColumn => jobItems.id, {
onDelete: 'restrict',
}),
createdAt: text('created_at').notNull(),
startedAt: text('started_at'),
finishedAt: text('finished_at'),
updatedAt: text('updated_at').notNull(),
},
(table) => [
unique('job_items_job_instance_attempt_unique').on(
table.jobId,
table.instanceId,
table.attemptNumber,
),
check('job_items_attempt_number_check', sql`${table.attemptNumber} > 0`),
index('idx_job_items_job_status').on(table.jobId, table.status),
],
);
export const jobAttempts = sqliteTable(
'job_attempts',
{
id: text('id').primaryKey(),
jobId: text('job_id')
.notNull()
.references(() => jobs.id, { onDelete: 'cascade' }),
status: text('status').notNull(),
startedAt: text('started_at').notNull(),
finishedAt: text('finished_at'),
createdAt: text('created_at').notNull(),
},
(table) => [index('idx_job_attempts_job_started_at').on(table.jobId, table.startedAt)],
);
export const operationPreparations = sqliteTable(
'operation_preparations',
{
id: text('id').primaryKey(),
operationId: text('operation_id').notNull(),
riskLevel: text('risk_level').notNull(),
status: text('status').notNull(),
targetInstanceId: text('target_instance_id').notNull(),
targetRevision: integer('target_revision').notNull(),
parameterSchemaId: text('parameter_schema_id').notNull(),
parametersDigest: text('parameters_digest').notNull(),
tokenDigest: text('token_digest').notNull(),
requestedBy: text('requested_by').notNull(),
requestId: text('request_id').notNull(),
expiresAt: text('expires_at').notNull(),
consumedAt: text('consumed_at'),
...timestamps,
},
(table) => [
index('idx_operation_preparations_status_expires_at').on(table.status, table.expiresAt),
],
);
export const auditEvents = sqliteTable(
'audit_events',
{
id: text('id').primaryKey(),
instanceId: text('instance_id').references(() => instances.id, { onDelete: 'set null' }),
jobId: text('job_id').references(() => jobs.id, { onDelete: 'set null' }),
actor: text('actor').notNull(),
operationId: text('operation_id').notNull(),
riskLevel: text('risk_level').notNull(),
requestId: text('request_id').notNull(),
parametersSummaryJson: text('parameters_summary_json').notNull(),
bodyDigest: text('body_digest'),
resultCode: text('result_code').notNull(),
durationMs: integer('duration_ms').notNull(),
createdAt: text('created_at').notNull(),
},
(table) => [
check('audit_events_risk_level_check', sql`${table.riskLevel} in ('R0', 'R1', 'R2', 'R3')`),
check('audit_events_duration_ms_check', sql`${table.durationMs} >= 0`),
index('idx_audit_events_created_at').on(desc(table.createdAt)),
],
);
export const appSettings = sqliteTable('app_settings', {
key: text('key').primaryKey(),
valueJson: text('value_json').notNull(),
...timestamps,
});
export const secretReferences = sqliteTable(
'secret_references',
{
id: text('id').primaryKey(),
instanceId: text('instance_id').references(() => instances.id, { onDelete: 'cascade' }),
purpose: text('purpose').notNull(),
provider: text('provider').notNull(),
externalReference: text('external_reference').notNull(),
...timestamps,
},
(table) => [
unique('secret_references_provider_external_unique').on(
table.provider,
table.externalReference,
),
uniqueIndex('uq_secret_references_instance_purpose')
.on(table.instanceId, table.purpose)
.where(sql`${table.instanceId} is not null`),
],
);
export const secretCleanupTasks = sqliteTable(
'secret_cleanup_tasks',
{
reference: text('reference').primaryKey(),
instanceId: text('instance_id').notNull(),
purpose: text('purpose').notNull(),
provider: text('provider').notNull(),
generation: integer('generation').notNull().default(1),
queuedAt: text('queued_at').notNull(),
updatedAt: text('updated_at').notNull(),
},
(table) => [index('idx_secret_cleanup_tasks_queued_at').on(table.queuedAt)],
);
@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest';
import {
MacOSKeychainSecretStore,
SecretStoreError,
SpawnCommandRunner,
parseKeychainReference,
type CommandInvocation,
type CommandResult,
type CommandRunner,
} from './keychain-secret-store.js';
const secret = 's3cr3t with spaces';
class FakeRunner implements CommandRunner {
readonly calls: CommandInvocation[] = [];
readonly stdinSnapshots: (Buffer | undefined)[] = [];
results: (CommandResult | Error)[] = [];
async run(invocation: CommandInvocation): Promise<CommandResult> {
this.calls.push(invocation);
this.stdinSnapshots.push(
invocation.stdin === undefined ? undefined : Buffer.from(invocation.stdin),
);
const result = this.results.shift() ?? { exitCode: 0, stdout: '', stderr: '' };
if (result instanceof Error) throw result;
return result;
}
}
function serialized(value: unknown): string {
if (value instanceof Error) return JSON.stringify({ name: value.name, message: value.message });
return JSON.stringify(value);
}
describe('MacOSKeychainSecretStore', () => {
it('turns an early child exit while writing stdin into a controlled result or rejection', async () => {
const runner = new SpawnCommandRunner();
const outcome = await runner
.run({
executable: '/usr/bin/true',
args: [],
stdin: Buffer.alloc(65_536, 0x78),
env: {},
timeoutMs: 1_000,
maxOutputBytes: 1_024,
})
.catch((error: unknown) => error);
if (outcome instanceof Error) expect(outcome.message).toBe('COMMAND_FAILED');
else if (typeof outcome === 'object' && outcome !== null && 'exitCode' in outcome)
expect((outcome as { exitCode: unknown }).exitCode).toBe(0);
else throw new Error('Unexpected runner outcome');
});
it('sets through absolute security path with the secret only in exact stdin bytes', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
const reference = await store.set(
{ instanceId: 'instance.one', purpose: 'instance-password' },
secret,
);
expect(reference).toMatch(/^keychain:\/\/multi-simadmin\//);
expect(parseKeychainReference(reference)).toMatchObject({
service: 'multi-simadmin',
instanceId: 'instance.one',
purpose: 'instance-password',
});
expect(reference).not.toContain(secret);
expect(runner.calls).toHaveLength(1);
expect(runner.calls[0]).toEqual({
executable: '/usr/bin/security',
args: [
'add-generic-password',
'-U',
'-s',
'multi-simadmin',
'-a',
expect.stringMatching(/^[A-Za-z0-9_-]+$/),
'-w',
],
stdin: Buffer.alloc(Buffer.byteLength(secret, 'utf8')),
timeoutMs: 5_000,
maxOutputBytes: 65_536,
env: {},
});
expect(runner.stdinSnapshots[0]).toEqual(Buffer.from(secret, 'utf8'));
expect(runner.calls[0]?.stdin?.every((byte) => byte === 0)).toBe(true);
expect(JSON.stringify(runner.calls[0]?.args)).not.toContain(secret);
expect(JSON.stringify(runner.calls[0]?.env)).not.toContain(secret);
});
it('gets the stdout as the secret and deletes the same strictly parsed reference', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
const reference = await store.set(
{ instanceId: 'alpha', purpose: 'instance-password' },
'initial',
);
runner.calls.splice(0);
runner.results.push({ exitCode: 0, stdout: `${secret}\n`, stderr: '' });
expect(await store.get(reference)).toBe(secret);
runner.results.push({ exitCode: 0, stdout: '', stderr: '' });
expect(await store.delete(reference)).toBe(true);
expect(runner.calls.map(({ args }) => args[0])).toEqual([
'find-generic-password',
'delete-generic-password',
]);
expect(runner.calls[0]?.args).toContain('-w');
expect(runner.calls[1]?.args).not.toContain('-w');
});
it('maps keychain not-found to undefined/false', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
const reference = await store.set({ instanceId: 'alpha', purpose: 'instance-password' }, 'x');
runner.results.push({
exitCode: 44,
stdout: '',
stderr: 'security: SecKeychainSearchCopyNext: -25300',
});
expect(await store.get(reference)).toBeUndefined();
runner.results.push({
exitCode: 44,
stdout: '',
stderr: 'The specified item could not be found in the keychain. (-25300)',
});
expect(await store.delete(reference)).toBe(false);
});
it('never maps a set failure or forged item-not-found text to success', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
runner.results.push({ exitCode: 44, stdout: '', stderr: 'security: -25300' });
await expect(
store.set({ instanceId: 'alpha', purpose: 'instance-password' }, secret),
).rejects.toMatchObject({ code: 'COMMAND_FAILED' });
runner.results.push({ exitCode: 1, stdout: '', stderr: 'unrelated failure mentioning -25300' });
const reference = await new MacOSKeychainSecretStore(new FakeRunner()).set(
{ instanceId: 'alpha', purpose: 'password' },
'safe',
);
await expect(store.get(reference)).rejects.toMatchObject({ code: 'COMMAND_FAILED' });
for (const forged of [
'security: x-25300oops',
'security: --25300',
'security: -25300 trailing',
]) {
runner.results.push({ exitCode: 44, stdout: '', stderr: forged });
await expect(store.get(reference)).rejects.toMatchObject({ code: 'COMMAND_FAILED' });
}
});
it.each(['line\nbreak', 'line\rbreak', 'nul\0break', 'x'.repeat(4097)])(
'rejects non-line-safe or oversized secret input before invoking the runner',
async (value) => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
await expect(
store.set({ instanceId: 'alpha', purpose: 'instance-password' }, value),
).rejects.toMatchObject({ code: 'INVALID_SECRET' });
expect(runner.calls).toHaveLength(0);
},
);
it('maps runner timeout and malicious command output to stable redacted errors', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
runner.results.push(new Error(`timeout ${secret}`));
const timeout = await store
.set({ instanceId: 'alpha', purpose: 'instance-password' }, secret)
.catch((error: unknown) => error);
expect(timeout).toBeInstanceOf(SecretStoreError);
expect(timeout).toMatchObject({
code: 'COMMAND_TIMEOUT',
message: 'Keychain command timed out',
});
expect(serialized(timeout)).not.toContain(secret);
runner.results.push({ exitCode: 1, stdout: secret, stderr: `malicious ${secret}` });
const failed = await store
.set({ instanceId: 'alpha', purpose: 'instance-password' }, secret)
.catch((error: unknown) => error);
expect(failed).toMatchObject({ code: 'COMMAND_FAILED' });
expect(serialized(failed)).not.toContain(secret);
});
it('maps oversized stderr to a stable output-limit error without retaining output', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
runner.results.push(new Error(`STDERR_LIMIT ${secret}`));
const error = await store
.set({ instanceId: 'alpha', purpose: 'instance-password' }, secret)
.catch((reason: unknown) => reason);
expect(error).toMatchObject({ code: 'OUTPUT_LIMIT' });
expect(serialized(error)).not.toContain(secret);
});
it.each(['', ' ', 'a/b', 'line\nbreak', 'a'.repeat(129)])(
'rejects unsafe instance id %j before invoking the runner',
async (instanceId) => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
await expect(
store.set({ instanceId, purpose: 'instance-password' }, 'x'),
).rejects.toMatchObject({
code: 'INVALID_KEY',
});
expect(runner.calls).toHaveLength(0);
},
);
it('strictly parses references and rejects cross-service or injected variants', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
const reference = await store.set({ instanceId: 'alpha', purpose: 'instance-password' }, 'x');
expect(parseKeychainReference(reference)).toMatchObject({ service: 'multi-simadmin' });
for (const invalid of [
'keychain://other/account',
'keychain://multi-simadmin/account/extra',
'keychain://multi-simadmin/%0Aevil',
'https://multi-simadmin/account',
`${reference}?query=bad`,
]) {
expect(() => parseKeychainReference(invalid)).toThrowError(SecretStoreError);
}
});
});
@@ -0,0 +1,257 @@
import { spawn } from 'node:child_process';
import type { SecretKey, SecretStore } from './secret-store.js';
const SECURITY_PATH = '/usr/bin/security';
const SERVICE = 'multi-simadmin';
const TIMEOUT_MS = 5_000;
const MAX_STDOUT_BYTES = 65_536;
const safeComponent = /^[A-Za-z0-9_.-]+$/;
export type SecretStoreErrorCode =
| 'INVALID_KEY'
| 'INVALID_SECRET'
| 'INVALID_REFERENCE'
| 'COMMAND_TIMEOUT'
| 'OUTPUT_LIMIT'
| 'COMMAND_FAILED';
export class SecretStoreError extends Error {
constructor(
readonly code: SecretStoreErrorCode,
message: string,
) {
super(message);
this.name = 'SecretStoreError';
}
}
export interface CommandInvocation {
readonly executable: string;
readonly args: readonly string[];
readonly stdin?: Buffer;
readonly env: Readonly<Record<string, string>>;
readonly timeoutMs: number;
readonly maxOutputBytes: number;
}
export interface CommandResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
export interface CommandRunner {
run(invocation: CommandInvocation): Promise<CommandResult>;
}
export class SpawnCommandRunner implements CommandRunner {
run(invocation: CommandInvocation): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(invocation.executable, [...invocation.args], {
shell: false,
env: { ...invocation.env },
stdio: ['pipe', 'pipe', 'pipe'],
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
const fail = (error: Error): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.kill('SIGKILL');
reject(error);
};
const timer = setTimeout(() => fail(new Error('COMMAND_TIMEOUT')), invocation.timeoutMs);
child.stdout.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes > invocation.maxOutputBytes) fail(new Error('OUTPUT_LIMIT'));
else stdout.push(chunk);
});
child.stderr.on('data', (chunk: Buffer) => {
stderrBytes += chunk.length;
if (stderrBytes > invocation.maxOutputBytes) fail(new Error('OUTPUT_LIMIT'));
else stderr.push(chunk);
});
child.stdin.on('error', () => fail(new Error('COMMAND_FAILED')));
child.on('error', () => fail(new Error('COMMAND_FAILED')));
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
exitCode: code ?? 1,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
});
});
// The security CLI reads one line from stdin. Validation rejects framing characters.
child.stdin.end(invocation.stdin);
});
}
}
export interface ParsedKeychainReference {
readonly service: typeof SERVICE;
readonly account: string;
readonly instanceId: string;
readonly purpose: string;
readonly slot?: string;
}
function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): never {
throw new SecretStoreError(code, message);
}
function validateKey(key: SecretKey): void {
if (
typeof key.instanceId !== 'string' ||
key.instanceId.length === 0 ||
key.instanceId.length > 128 ||
!safeComponent.test(key.instanceId)
) {
invalid('INVALID_KEY', 'Secret key instance id is invalid');
}
if (
typeof key.purpose !== 'string' ||
key.purpose.length === 0 ||
key.purpose.length > 64 ||
!safeComponent.test(key.purpose)
) {
invalid('INVALID_KEY', 'Secret key purpose is invalid');
}
if (
key.slot !== undefined &&
(typeof key.slot !== 'string' ||
key.slot.length === 0 ||
key.slot.length > 64 ||
!safeComponent.test(key.slot))
) {
invalid('INVALID_KEY', 'Secret key slot is invalid');
}
}
function accountFor(key: SecretKey): string {
validateKey(key);
const components =
key.slot === undefined
? [key.instanceId, key.purpose]
: [key.instanceId, key.purpose, key.slot];
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
}
function referenceFor(account: string): string {
return `keychain://${SERVICE}/${account}`;
}
export function parseKeychainReference(reference: string): ParsedKeychainReference {
if (typeof reference !== 'string' || reference.length > 512)
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
const account = match[1];
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
} catch {
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
}
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
const [instanceId, purpose, slot] = decoded;
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
validateKey(key);
if (accountFor(key) !== account) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
return {
service: SERVICE,
account,
instanceId: key.instanceId,
purpose: key.purpose,
...(key.slot === undefined ? {} : { slot: key.slot }),
};
}
export class MacOSKeychainSecretStore implements SecretStore {
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
async set(key: SecretKey, value: string): Promise<string> {
if (
typeof value !== 'string' ||
value.length === 0 ||
/[\0\r\n]/.test(value) ||
Buffer.byteLength(value, 'utf8') > 4096
) {
throw new SecretStoreError('INVALID_SECRET', 'Secret is not valid for Keychain storage');
}
const account = accountFor(key);
await this.execute(
['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-w'],
Buffer.from(value, 'utf8'),
'set',
);
return referenceFor(account);
}
async get(reference: string): Promise<string | undefined> {
const { account } = parseKeychainReference(reference);
const result = await this.execute(
['find-generic-password', '-s', SERVICE, '-a', account, '-w'],
undefined,
'get',
);
if (result === undefined) return undefined;
if (result.stdout.endsWith('\r\n')) return result.stdout.slice(0, -2);
if (result.stdout.endsWith('\n')) return result.stdout.slice(0, -1);
return result.stdout;
}
async delete(reference: string): Promise<boolean> {
const { account } = parseKeychainReference(reference);
const result = await this.execute(
['delete-generic-password', '-s', SERVICE, '-a', account],
undefined,
'delete',
);
return result !== undefined;
}
private async execute(
args: readonly string[],
stdin: Buffer | undefined,
operation: 'set' | 'get' | 'delete',
): Promise<CommandResult | undefined> {
let result: CommandResult;
try {
result = await this.runner.run({
executable: SECURITY_PATH,
args,
...(stdin === undefined ? {} : { stdin }),
env: {},
timeoutMs: TIMEOUT_MS,
maxOutputBytes: MAX_STDOUT_BYTES,
});
} catch (error) {
const marker = error instanceof Error ? error.message : '';
if (marker.includes('OUTPUT_LIMIT') || marker.includes('STDERR_LIMIT'))
throw new SecretStoreError('OUTPUT_LIMIT', 'Keychain command output exceeded limit');
if (/timeout/i.test(marker))
throw new SecretStoreError('COMMAND_TIMEOUT', 'Keychain command timed out');
throw new SecretStoreError('COMMAND_FAILED', `Keychain ${operation} failed`);
} finally {
stdin?.fill(0);
}
if (result.exitCode !== 0) {
if (
operation !== 'set' &&
result.exitCode === 44 &&
/(?:^|\n)(?:[^\r\n]*: -25300|[^\r\n]*\(-25300\))\s*$/.test(result.stderr)
) {
return undefined;
}
throw new SecretStoreError('COMMAND_FAILED', `Keychain ${operation} failed`);
}
return result;
}
}
@@ -0,0 +1,11 @@
export interface SecretKey {
readonly instanceId: string;
readonly purpose: string;
readonly slot?: string;
}
export interface SecretStore {
set(key: SecretKey, value: string): Promise<string>;
get(reference: string): Promise<string | undefined>;
delete(reference: string): Promise<boolean>;
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { PinnedHttpRequester, type PinnedDispatchOptions } from './pinned-http-requester.js';
describe('PinnedHttpRequester', () => {
it('pins Node lookup to the validated address and sets bounded request options', async () => {
let captured: PinnedDispatchOptions | undefined;
const requester = new PinnedHttpRequester({
dispatch: async (options) => {
captured = options;
return { status: 200, headers: {}, body: '' };
},
});
await requester.request(
{ url: 'http://device.lan:8080/api/health', method: 'GET', headers: {} },
[{ address: '192.168.1.20', family: 4 }],
);
if (!captured) throw new Error('dispatch was not called');
const dispatchOptions = captured;
expect(dispatchOptions.followRedirects).toBe(false);
expect(dispatchOptions.timeoutMs).toBe(5000);
expect(dispatchOptions.maxBodyBytes).toBe(65536);
await expect(
new Promise((resolve, reject) =>
dispatchOptions.lookup('device.lan', { all: false }, (error, address, family) => {
if (error) reject(error);
else if (Array.isArray(address))
reject(new Error('unexpected all-address lookup result'));
else resolve({ a: address, f: family });
}),
),
).resolves.toEqual({ a: '192.168.1.20', f: 4 });
});
});
@@ -0,0 +1,110 @@
import * as http from 'node:http';
import * as https from 'node:https';
import type { LookupFunction } from 'node:net';
import type {
PinnedRequest,
ResolvedAddress,
TransportResponse,
} from './safe-instance-transport.js';
import { asUpstreamError, UpstreamError } from './upstream-error.js';
export interface PinnedDispatchOptions {
readonly url: URL;
readonly method: 'GET' | 'POST';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
readonly lookup: LookupFunction;
readonly followRedirects: false;
readonly timeoutMs: number;
readonly maxBodyBytes: number;
}
export interface PinnedHttpRequesterOptions {
readonly dispatch?: (options: PinnedDispatchOptions) => Promise<TransportResponse>;
readonly timeoutMs?: number;
readonly maxBodyBytes?: number;
}
const DEFAULT_TIMEOUT_MS = 5_000;
const DEFAULT_MAX_BODY_BYTES = 65_536;
const lookupFor = (addresses: readonly ResolvedAddress[]): LookupFunction => {
const first = addresses[0];
if (!first) throw new Error('No validated address');
return (_hostname, _options, callback) => callback(null, first.address, first.family);
};
export class PinnedHttpRequester {
private readonly dispatch: (options: PinnedDispatchOptions) => Promise<TransportResponse>;
private readonly timeoutMs: number;
private readonly maxBodyBytes: number;
constructor(options: PinnedHttpRequesterOptions = {}) {
this.dispatch = options.dispatch ?? dispatchNative;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
}
async request(
request: PinnedRequest,
addresses: readonly ResolvedAddress[],
): Promise<TransportResponse> {
try {
return await this.dispatch({
url: new URL(request.url),
method: request.method,
headers: request.headers,
...(request.body === undefined ? {} : { body: request.body }),
lookup: lookupFor(addresses),
followRedirects: false,
timeoutMs: this.timeoutMs,
maxBodyBytes: this.maxBodyBytes,
});
} catch (error) {
throw asUpstreamError(error);
}
}
}
const dispatchNative = (options: PinnedDispatchOptions): Promise<TransportResponse> =>
new Promise((resolve, reject) => {
const client = options.url.protocol === 'https:' ? https : http;
const request = client.request({
protocol: options.url.protocol,
hostname: options.url.hostname,
port: options.url.port || undefined,
path: `${options.url.pathname}${options.url.search}`,
method: options.method,
headers: options.headers,
lookup: options.lookup,
agent: false,
timeout: options.timeoutMs,
maxHeaderSize: 16_384,
...(options.url.protocol === 'https:' ? { servername: options.url.hostname } : {}),
});
let settled = false;
const fail = (error: Error): void => {
if (settled) return;
settled = true;
request.destroy(error);
reject(error);
};
request.once('timeout', () => fail(new UpstreamError('UPSTREAM_TIMEOUT')));
request.once('error', fail);
request.once('response', (response) => {
const chunks: Buffer[] = [];
let bytes = 0;
response.on('data', (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > options.maxBodyBytes) fail(new UpstreamError('UPSTREAM_RESPONSE_TOO_LARGE'));
else chunks.push(chunk);
});
response.once('error', fail);
response.once('end', () => {
if (settled) return;
settled = true;
const headers: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(response.headers))
headers[key] = Array.isArray(value) ? value.join(', ') : value;
resolve({
status: response.statusCode ?? 0,
headers,
body: Buffer.concat(chunks).toString('utf8'),
});
});
});
request.end(options.body);
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { createProductionUpstream } from './production-upstream.js';
describe('createProductionUpstream', () => {
it('passes every resolved address from its resolver to the pinned requester', async () => {
let received: unknown;
const upstream = createProductionUpstream({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async (request, addresses) => {
received = { request, addresses };
return { status: 200, headers: {}, body: '' };
},
});
await upstream.get('http://router.lan:8080/api/health');
expect(received).toMatchObject({
request: { method: 'GET', url: 'http://router.lan:8080/api/health' },
addresses: [{ address: '192.168.1.20', family: 4 }],
});
});
});
@@ -0,0 +1,33 @@
import { lookup as nodeLookup } from 'node:dns/promises';
import {
createSafeControlPlaneUpstream,
type SafeControlPlaneUpstream,
} from './safe-control-plane-upstream.js';
import { PinnedHttpRequester } from './pinned-http-requester.js';
import {
SafeInstanceTransport,
type PinnedRequest,
type ResolvedAddress,
type TransportResponse,
} from './safe-instance-transport.js';
export interface ProductionUpstreamOptions {
readonly resolve?: (hostname: string) => Promise<readonly ResolvedAddress[]>;
readonly request?: (
request: PinnedRequest,
addresses: readonly ResolvedAddress[],
) => Promise<TransportResponse>;
}
export function createProductionUpstream(
options: ProductionUpstreamOptions = {},
): SafeControlPlaneUpstream {
const requester = options.request ? { request: options.request } : new PinnedHttpRequester();
const transport = new SafeInstanceTransport({
resolve:
options.resolve ??
((hostname) =>
nodeLookup(hostname, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>),
request: (request, addresses) => requester.request(request, addresses),
});
return createSafeControlPlaneUpstream(transport);
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { createSafeControlPlaneUpstream } from './safe-control-plane-upstream.js';
describe('createSafeControlPlaneUpstream', () => {
it('routes health, login, and the audited R2 operation through the same safe transport', async () => {
const calls: unknown[] = [];
const upstream = createSafeControlPlaneUpstream({
get: async (url) => {
calls.push({ method: 'GET', url });
return { status: 401, headers: {}, body: '' };
},
post: async (url, headers, body) => {
calls.push({ method: 'POST', url, headers, body });
return { status: 200, headers: {}, body: '' };
},
});
await upstream.get('http://192.168.1.20/api/health');
await upstream.request({
url: 'https://192.168.1.20/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
body: '[REDACTED]',
});
await upstream.postNetworkRegisterAuto('http://192.168.1.20');
expect(calls).toEqual([
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
{
method: 'POST',
url: 'https://192.168.1.20/api/auth/login',
headers: { 'content-type': 'application/json' },
body: '{"password":"[REDACTED]"}',
},
{
method: 'POST',
url: 'http://192.168.1.20/api/network/register-auto',
headers: {},
body: '',
},
]);
});
});
@@ -0,0 +1,18 @@
import type { ConnectionTransport } from '../../application/connections/connection-probe.js';
import type { UpstreamSessionClientOptions } from '../../application/connections/upstream-session-client.js';
import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream-gateway.js';
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
}
export function createSafeControlPlaneUpstream(
transport: SafeUpstreamTransport,
): SafeControlPlaneUpstream {
const gateway = new SafeUpstreamGateway({ transport });
return {
get: (url) => transport.get(url),
request: (request) => gateway.request(request),
postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin),
};
}
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import { SafeInstanceTransport, TransportError } from './safe-instance-transport.js';
const allowed = new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async (request) => ({
status: 200,
headers: {},
body: `ok:${request.url}`,
}),
});
describe('SafeInstanceTransport', () => {
it('resolves a hostname immediately before requesting its health endpoint', async () => {
await expect(allowed.get('http://router.lan:3000/api/health')).resolves.toMatchObject({
status: 200,
body: 'ok:http://router.lan:3000/api/health',
});
});
it('supports HTTPS origins using the same validated address pinning path', async () => {
await expect(allowed.get('https://router.lan:3443/api/health')).resolves.toMatchObject({
status: 200,
body: 'ok:https://router.lan:3443/api/health',
});
});
it('pins IPv6 ULA literals without attempting DNS resolution', async () => {
let resolved = false;
let addresses: readonly { address: string; family: 4 | 6 }[] | undefined;
const transport = new SafeInstanceTransport({
resolve: async () => {
resolved = true;
return [];
},
request: async (_request, received) => {
addresses = received;
return { status: 200, headers: {}, body: '' };
},
});
await transport.get('http://[fd00::1]:8080/api/health');
expect(resolved).toBe(false);
expect(addresses).toEqual([{ address: 'fd00::1', family: 6 }]);
});
it.each([
{ name: 'loopback', address: '127.0.0.1' },
{ name: 'link-local', address: '169.254.169.254' },
{ name: 'unspecified', address: '0.0.0.0' },
{ name: 'multicast', address: '224.0.0.1' },
{ name: 'public', address: '8.8.8.8' },
])('rejects a DNS $name result before network I/O', async ({ address }) => {
let requested = false;
const transport = new SafeInstanceTransport({
resolve: async () => [{ address, family: 4 }],
request: async () => {
requested = true;
return { status: 200, headers: {}, body: '' };
},
});
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
code: 'UNSAFE_RESOLUTION',
} satisfies Partial<TransportError>);
expect(requested).toBe(false);
});
it('passes only the validated resolution set to POST requesters for pinning', async () => {
let received:
| { url: string; method: string; headers: Readonly<Record<string, string>>; body?: string }
| undefined;
let addresses: readonly { address: string; family: 4 | 6 }[] | undefined;
const transport = new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async (request, resolved) => {
received = request;
addresses = resolved;
return { status: 200, headers: {}, body: '' };
},
});
await transport.post(
'http://device.lan/api/auth/login',
{ 'content-type': 'application/json' },
'{"password":"[REDACTED]"}',
);
expect(received).toEqual({
url: 'http://device.lan/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"password":"[REDACTED]"}',
});
expect(addresses).toEqual([{ address: '192.168.1.20', family: 4 }]);
});
it('passes only the validated resolution set to the requester for pinning', async () => {
let received: readonly { address: string; family: 4 | 6 }[] | undefined;
const transport = new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async (request, addresses) => {
expect(request.method).toBe('GET');
received = addresses;
return { status: 200, headers: {}, body: '' };
},
});
await transport.get('http://device.lan/api/health');
expect(received).toEqual([{ address: '192.168.1.20', family: 4 }]);
});
it('rejects mixed safe and unsafe DNS answers to prevent rebinding races', async () => {
const transport = new SafeInstanceTransport({
resolve: async () => [
{ address: '192.168.1.20', family: 4 },
{ address: '127.0.0.1', family: 4 },
],
request: async () => ({ status: 200, headers: {}, body: '' }),
});
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
code: 'UNSAFE_RESOLUTION',
} satisfies Partial<TransportError>);
});
it('does not follow redirects across trust boundaries', async () => {
let calls = 0;
const transport = new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async () => {
calls += 1;
return { status: 302, headers: { location: 'http://127.0.0.1/private' }, body: '' };
},
});
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
code: 'REDIRECT_REJECTED',
} satisfies Partial<TransportError>);
expect(calls).toBe(1);
});
});
@@ -0,0 +1,107 @@
import { isIP } from 'node:net';
import { asUpstreamError, UpstreamError } from './upstream-error.js';
export interface ResolvedAddress {
readonly address: string;
readonly family: 4 | 6;
}
export interface TransportResponse {
readonly status: number;
readonly headers: Readonly<Record<string, string | undefined>>;
readonly body: string;
}
export interface PinnedRequest {
readonly url: string;
readonly method: 'GET' | 'POST';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
}
export interface SafeTransportOptions {
readonly resolve: (hostname: string) => Promise<readonly ResolvedAddress[]>;
readonly request: (
request: PinnedRequest,
addresses: readonly ResolvedAddress[],
) => Promise<TransportResponse>;
}
export class TransportError extends UpstreamError {
constructor(override readonly code: 'UNSAFE_ORIGIN' | 'UNSAFE_RESOLUTION' | 'REDIRECT_REJECTED') {
super(code);
this.name = 'TransportError';
}
}
const isPrivateV4 = (address: string): boolean => {
const parts = address.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
return false;
const [a, b, c] = parts;
if (a === undefined || b === undefined || c === undefined) return false;
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 192 && b === 0 && c === 0)
);
};
const isAllowedAddress = ({ address, family }: ResolvedAddress): boolean => {
if (family === 4) return isPrivateV4(address);
const normalized = address.toLowerCase();
return normalized.startsWith('fc') || normalized.startsWith('fd');
};
const origin = (raw: string): URL => {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new TransportError('UNSAFE_ORIGIN');
}
if (
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
parsed.username ||
parsed.password ||
parsed.hash
)
throw new TransportError('UNSAFE_ORIGIN');
if (!parsed.pathname.startsWith('/')) throw new TransportError('UNSAFE_ORIGIN');
return parsed;
};
export class SafeInstanceTransport {
constructor(private readonly options: SafeTransportOptions) {}
async get(
raw: string,
headers: Readonly<Record<string, string>> = {},
): Promise<TransportResponse> {
return this.send({ url: raw, method: 'GET', headers });
}
async post(
raw: string,
headers: Readonly<Record<string, string>>,
body: string,
): Promise<TransportResponse> {
return this.send({ url: raw, method: 'POST', headers, body });
}
private async send(request: PinnedRequest): Promise<TransportResponse> {
const parsed = origin(request.url);
const dialHost = parsed.hostname.replace(/^\[|\]$/g, '');
const literalFamily = isIP(dialHost);
let addresses: readonly ResolvedAddress[];
try {
addresses = literalFamily
? [{ address: dialHost, family: literalFamily as 4 | 6 }]
: await this.options.resolve(dialHost);
} catch (error) {
throw asUpstreamError(error);
}
if (addresses.length === 0 || addresses.some((entry) => !isAllowedAddress(entry)))
throw new TransportError('UNSAFE_RESOLUTION');
let response: TransportResponse;
try {
response = await this.options.request({ ...request, url: parsed.toString() }, addresses);
} catch (error) {
throw asUpstreamError(error);
}
if (response.status >= 300 && response.status < 400)
throw new TransportError('REDIRECT_REJECTED');
return response;
}
}
@@ -0,0 +1,164 @@
import { describe, expect, it, vi } from 'vitest';
import { SafeUpstreamGateway } from './safe-upstream-gateway.js';
describe('SafeUpstreamGateway', () => {
it('dispatches only the audited zero-body network registration operation through pinned POST', async () => {
const calls: unknown[] = [];
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async (url, headers, body) => {
calls.push({ url, headers, body });
return { status: 204, headers: {}, body: '' };
},
},
});
const response = await gateway.postNetworkRegisterAuto('http://192.168.1.20:8080');
expect(response.status).toBe(204);
expect(calls).toEqual([
{ url: 'http://192.168.1.20:8080/api/network/register-auto', headers: {}, body: '' },
]);
});
it('rejects malformed operation origins before calling transport', async () => {
const post = vi.fn(async () => ({ status: 204, headers: {}, body: '' }));
const gateway = new SafeUpstreamGateway({
transport: { get: async () => ({ status: 200, headers: {}, body: '' }), post },
});
await expect(
gateway.postNetworkRegisterAuto('http://192.168.1.20/base?next=x'),
).rejects.toMatchObject({ code: 'UPSTREAM_REQUEST_INVALID', dispatched: false });
expect(post).not.toHaveBeenCalled();
});
it('sends login password only as JSON through the pinned POST transport', async () => {
const calls: unknown[] = [];
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async (url, headers, body) => {
calls.push({ url, headers, body });
return { status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '' };
},
},
});
await gateway.request({
url: 'https://192.168.1.20:8080/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
body: '[REDACTED]',
});
expect(calls).toEqual([
{
url: 'https://192.168.1.20:8080/api/auth/login',
headers: { 'content-type': 'application/json' },
body: '{"password":"[REDACTED]"}',
},
]);
});
it('rejects HTTP login and logout so credentials and cookies are never sent in cleartext', async () => {
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async () => ({ status: 200, headers: {}, body: '' }),
},
});
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
body: '[REDACTED]',
}),
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/auth/logout',
method: 'POST',
headers: { cookie: 'simadmin_session=opaque' },
}),
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
});
it('allows audited resource GETs without a cookie for passwordless instances', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({
transport: { get, post: async () => ({ status: 200, headers: {}, body: '' }) },
});
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/stats',
method: 'GET',
headers: { accept: 'application/json' },
}),
).resolves.toMatchObject({ status: 200 });
expect(get).toHaveBeenCalledWith('http://192.168.1.20:8080/api/stats', {
accept: 'application/json',
});
});
it('allows only a bounded SMS list query and serializes an explicit SMS payload', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/list?limit=20&offset=0&direction=outgoing',
method: 'GET',
headers: { accept: 'application/json' },
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/send',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
sms: { phoneNumber: '+15550199', content: 'hello' },
});
expect(get).toHaveBeenCalledOnce();
expect(post).toHaveBeenCalledWith(
'http://192.168.1.20:8080/api/sms/send',
{ accept: 'application/json', 'content-type': 'application/json' },
'{"phone_number":"+15550199","content":"hello"}',
);
});
it('rejects unallowlisted SMS queries and malformed send payloads before transport', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
for (const url of [
'http://192.168.1.20/api/sms/list?limit=101&offset=0',
'http://192.168.1.20/api/sms/list?limit=10&offset=0&pdu=true',
'http://192.168.1.20/api/sms/list?offset=0&limit=10',
])
await expect(gateway.request({ url, method: 'GET', headers: {} })).rejects.toThrow(
'UPSTREAM_REQUEST_INVALID',
);
await expect(
gateway.request({
url: 'http://192.168.1.20/api/sms/send',
method: 'POST',
headers: {},
sms: { phoneNumber: 'bad\nnumber', content: 'hello' },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
expect(get).not.toHaveBeenCalled();
expect(post).not.toHaveBeenCalled();
});
it('does not allow a supplied redacted body marker to become a network request body', async () => {
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async () => ({ status: 200, headers: {}, body: '' }),
},
});
await expect(
gateway.request({
url: 'https://192.168.1.20:8080/api/auth/login',
method: 'POST',
headers: {},
body: '[REDACTED]',
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
});
});
@@ -0,0 +1,120 @@
import type {
UpstreamRequest,
UpstreamResponse,
} from '../../application/connections/upstream-session-client.js';
import type { TransportResponse } from './safe-instance-transport.js';
import { UpstreamError } from './upstream-error.js';
import { OperationNotDispatchedError } from '../../application/operations/secure-operation-execution.js';
export interface SafeUpstreamTransport {
get(url: string, headers?: Readonly<Record<string, string>>): Promise<TransportResponse>;
post(
url: string,
headers: Readonly<Record<string, string>>,
body: string,
): Promise<TransportResponse>;
}
export class SafeUpstreamGateway {
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
throw this.notDispatched();
}
if (
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
parsed.username ||
parsed.password ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash
)
throw this.notDispatched();
const url = `${parsed.origin}/api/network/register-auto`;
try {
return await this.options.transport.post(url, {}, '');
} catch (error) {
if (
error instanceof UpstreamError &&
(error.code === 'UNSAFE_ORIGIN' || error.code === 'UNSAFE_RESOLUTION')
)
throw new OperationNotDispatchedError(error.code);
throw error;
}
}
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
const url = new URL(request.url);
const headerKeys = Object.keys(request.headers).sort().join(',');
if (request.method === 'GET') {
const smsList = url.pathname === '/api/sms/list';
const smsQuery = smsList
? /^(?:limit=([1-9]\d?)|limit=100)&offset=(0|[1-9]\d{0,4})(?:&direction=(incoming|outgoing))?$/u.exec(
url.search.slice(1),
)
: null;
if (
request.secret !== undefined ||
request.body !== undefined ||
request.sms !== undefined ||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) ||
(!smsList && url.search) ||
url.hash ||
url.username ||
url.password ||
(request.headers.cookie !== undefined &&
(typeof request.headers.cookie !== 'string' ||
!/^simadmin_session=[^;\s,]+$/.test(request.headers.cookie)))
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.get(request.url, request.headers);
}
if (url.pathname === '/api/sms/send') {
const sms = request.sms;
if (
request.method !== 'POST' ||
request.secret !== undefined ||
request.body !== undefined ||
!sms ||
(headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') ||
!/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) ||
sms.content.length < 1 ||
sms.content.length > 1600 ||
Buffer.byteLength(sms.content, 'utf8') > 6400 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) ||
url.search ||
url.hash ||
url.username ||
url.password
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(
request.url,
request.headers,
JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }),
);
}
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.url.endsWith('/api/auth/login')) {
if (typeof request.secret !== 'string' || request.body !== '[REDACTED]')
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(
request.url,
request.headers,
JSON.stringify({ password: request.secret }),
);
}
if (request.url.endsWith('/api/auth/logout')) {
if (request.secret !== undefined || request.body !== undefined)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(request.url, request.headers, '');
}
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
}
private notDispatched(): OperationNotDispatchedError {
return new OperationNotDispatchedError('UPSTREAM_REQUEST_INVALID');
}
}
@@ -0,0 +1,24 @@
export type UpstreamErrorCode =
| 'UPSTREAM_UNAVAILABLE'
| 'UPSTREAM_TIMEOUT'
| 'UPSTREAM_RESPONSE_TOO_LARGE'
| 'UPSTREAM_INSECURE_AUTH'
| 'UPSTREAM_REQUEST_INVALID'
| 'UNSAFE_ORIGIN'
| 'UNSAFE_RESOLUTION'
| 'REDIRECT_REJECTED';
export class UpstreamError extends Error {
constructor(
readonly code: UpstreamErrorCode,
options?: { readonly cause?: unknown },
) {
super(code, options);
this.name = 'UpstreamError';
}
}
export const asUpstreamError = (error: unknown): UpstreamError =>
error instanceof UpstreamError
? error
: new UpstreamError('UPSTREAM_UNAVAILABLE', { cause: error });
@@ -0,0 +1,149 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import { AuditQueryService } from '../../application/audit/audit-query-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerAuditRoutes } from './audit-routes.js';
const dbs: Database.Database[] = [];
const timestamp = '2026-07-17T10:00:00.000Z';
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
function fixture() {
const db = new Database(':memory:');
migrateDatabase(db);
dbs.push(db);
const audit = new AuditQueryService(db);
const app = buildApp({ registerRoutes: (scope) => registerAuditRoutes(scope, { audit }) });
return { app, db, audit };
}
function insert(db: Database.Database, outcome = 'succeeded'): void {
db.prepare(
`INSERT INTO audit_events
(id, actor, operation_id, risk_level, request_id, parameters_summary_json,
result_code, duration_ms, created_at)
VALUES ('event-1', 'actor-1', 'op.one', 'R1', 'request-1', '[]', ?, 1, ?)`,
).run(outcome, timestamp);
}
function exactProblem(
response: {
headers: Record<string, string | string[] | number | undefined>;
json(): unknown;
},
values: Record<string, unknown>,
): void {
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toEqual(expect.any(String));
expect(response.json()).toEqual({
type: 'about:blank',
...values,
requestId: response.headers['x-request-id'],
});
}
describe('audit HTTP read routes', () => {
it('lists and gets audit events with exact defaults', async () => {
const { app, db, audit } = fixture();
insert(db);
const listCall = vi.spyOn(audit, 'list');
const list = await app.inject({ method: 'GET', url: '/api/v1/audit' });
expect(list.statusCode).toBe(200);
expect(listCall).toHaveBeenCalledWith({ page: 1, pageSize: 25, direction: 'asc' });
expect(list.json()).toEqual({
items: [
{
id: 'event-1',
occurredAt: timestamp,
actorId: 'actor-1',
action: 'op.one',
outcome: 'succeeded',
requestId: 'request-1',
parameterSummary: [],
},
],
page: { page: 1, pageSize: 25, total: 1 },
});
const get = await app.inject({ method: 'GET', url: '/api/v1/audit/event-1' });
expect(get.statusCode).toBe(200);
expect(get.json()).toEqual(list.json().items[0]);
await app.close();
});
it('parses every allowlisted scalar query', async () => {
const { app, audit } = fixture();
const call = vi.spyOn(audit, 'list');
const response = await app.inject({
method: 'GET',
url: `/api/v1/audit?page=2&pageSize=10&sort=action&direction=desc&actorId=a&instanceId=i&jobId=j&operationId=op&outcome=denied&occurredFrom=${timestamp}&occurredTo=${timestamp}&requestId=r`,
});
expect(response.statusCode).toBe(200);
expect(call).toHaveBeenCalledWith({
page: 2,
pageSize: 10,
sort: 'action',
direction: 'desc',
actorId: 'a',
instanceId: 'i',
jobId: 'j',
operationId: 'op',
outcome: 'denied',
occurredFrom: timestamp,
occurredTo: timestamp,
requestId: 'r',
});
await app.close();
});
it.each([
'unknown=x',
'page=1&page=2',
'actorId=a&actorId=b',
'page=0',
'page=01',
'page=1.5',
'page=9007199254740992',
'pageSize=101',
'sort=',
'sort=id',
'direction=ASC',
'outcome=ok',
'actorId=',
'operationId=',
'occurredFrom=bad',
`occurredFrom=2026-07-17T11%3A00%3A00.000Z&occurredTo=${timestamp}`,
])('rejects invalid, repeated, unknown, or noncanonical query: %s', async (query) => {
const { app } = fixture();
const response = await app.inject({ method: 'GET', url: `/api/v1/audit?${query}` });
expect(response.statusCode).toBe(400);
exactProblem(response, {
title: 'Bad Request',
status: 400,
code: 'VALIDATION_FAILED',
detail: 'The audit query is invalid.',
});
await app.close();
});
it('returns exact redacted 404 and 503 problems', async () => {
const { app, db } = fixture();
const missing = await app.inject({ method: 'GET', url: '/api/v1/audit/missing' });
exactProblem(missing, {
title: 'Not Found',
status: 404,
code: 'NOT_FOUND',
detail: 'The requested audit event does not exist.',
});
insert(db, 'secret-internal-result-code');
const unavailable = await app.inject({ method: 'GET', url: '/api/v1/audit/event-1' });
exactProblem(unavailable, {
title: 'Service Unavailable',
status: 503,
code: 'UNREPRESENTABLE',
detail: 'The requested audit data is temporarily unavailable.',
});
expect(unavailable.body).not.toContain('secret-internal-result-code');
await app.close();
});
});
+152
View File
@@ -0,0 +1,152 @@
import { AUDIT_OUTCOMES, type AuditOutcome, type AuditPageQuery } from '@multi-simadmin/contracts';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { AuditQueryError, AuditQueryService } from '../../application/audit/audit-query-service.js';
export interface AuditRoutesOptions {
readonly audit: AuditQueryService;
}
const QUERY_KEYS = new Set([
'page',
'pageSize',
'sort',
'direction',
'actorId',
'instanceId',
'jobId',
'operationId',
'outcome',
'occurredFrom',
'occurredTo',
'requestId',
]);
const CANONICAL_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
const MAX_STRING_LENGTH = 200;
const responses = {
VALIDATION_FAILED: {
status: 400,
title: 'Bad Request',
detail: 'The audit query is invalid.',
},
NOT_FOUND: {
status: 404,
title: 'Not Found',
detail: 'The requested audit event does not exist.',
},
UNREPRESENTABLE: {
status: 503,
title: 'Service Unavailable',
detail: 'The requested audit data is temporarily unavailable.',
},
} as const;
function invalid(): never {
throw new AuditQueryError('VALIDATION_FAILED', 'Invalid audit query');
}
function parseQuery(query: unknown): AuditPageQuery {
if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid();
const input = query as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!QUERY_KEYS.has(key) || typeof input[key] !== 'string') invalid();
}
const scalar = (key: string): string | undefined => {
const value = input[key];
return value === undefined ? undefined : (value as string);
};
const integer = (key: string, fallback: number, maximum: number): number => {
const value = scalar(key);
if (value === undefined) return fallback;
if (!/^[1-9]\d*$/u.test(value)) invalid();
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid();
return parsed;
};
const pageSize = integer('pageSize', 25, 100);
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
const result: {
page: number;
pageSize: number;
sort?: 'occurredAt' | 'action' | 'outcome';
direction: 'asc' | 'desc';
actorId?: string;
instanceId?: string;
jobId?: string;
operationId?: string;
outcome?: AuditOutcome;
occurredFrom?: string;
occurredTo?: string;
requestId?: string;
} = { page: integer('page', 1, maximumPage), pageSize, direction: 'asc' };
const sort = scalar('sort');
if (sort !== undefined) {
if (sort !== 'occurredAt' && sort !== 'action' && sort !== 'outcome') invalid();
result.sort = sort;
}
const direction = scalar('direction');
if (direction !== undefined) {
if (direction !== 'asc' && direction !== 'desc') invalid();
result.direction = direction;
}
const outcome = scalar('outcome');
if (outcome !== undefined) {
if (!(AUDIT_OUTCOMES as readonly string[]).includes(outcome)) invalid();
result.outcome = outcome as AuditOutcome;
}
for (const key of ['actorId', 'instanceId', 'jobId', 'operationId', 'requestId'] as const) {
const value = scalar(key);
if (value !== undefined) {
if (value.length < 1 || value.length > MAX_STRING_LENGTH) invalid();
result[key] = value;
}
}
for (const key of ['occurredFrom', 'occurredTo'] as const) {
const value = scalar(key);
if (value !== undefined) {
const parsed = new Date(value);
if (
!CANONICAL_TIMESTAMP.test(value) ||
!Number.isFinite(parsed.getTime()) ||
parsed.toISOString() !== value
)
invalid();
result[key] = value;
}
}
if (
result.occurredFrom !== undefined &&
result.occurredTo !== undefined &&
result.occurredFrom > result.occurredTo
)
invalid();
return result;
}
export function registerAuditRoutes(app: FastifyInstance, options: AuditRoutesOptions): void {
const handler =
<T>(action: (request: FastifyRequest) => T) =>
async (request: FastifyRequest, reply: FastifyReply) => {
try {
return action(request);
} catch (error) {
if (!(error instanceof AuditQueryError)) throw error;
const mapped = responses[error.code];
return reply.code(mapped.status).type('application/problem+json').send({
type: 'about:blank',
title: mapped.title,
status: mapped.status,
code: error.code,
detail: mapped.detail,
requestId: request.id,
});
}
};
app.get(
'/api/v1/audit',
handler((request) => options.audit.list(parseQuery(request.query))),
);
app.get(
'/api/v1/audit/:eventId',
handler((request) => options.audit.get((request.params as { eventId: string }).eventId)),
);
}
@@ -0,0 +1,189 @@
import http from 'node:http';
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import { EventJournal, type EventEnvelope } from '../../application/events/event-journal.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerEventRoutes } from './event-routes.js';
const databases: Database.Database[] = [];
const apps: ReturnType<typeof buildApp>[] = [];
const at = '2026-07-17T12:34:56.789Z';
const event = (id: string): EventEnvelope => ({
kind: 'job',
id,
occurredAt: at,
requestId: `request-${id}`,
jobId: 'job-1',
});
function fixture(authenticate: ((request: unknown) => boolean) | null = () => true) {
const database = new Database(':memory:');
migrateDatabase(database);
databases.push(database);
const journal = new EventJournal(database);
const app = buildApp({
registerRoutes: (scope) =>
registerEventRoutes(scope, {
journal,
...(authenticate === null ? {} : { authenticate }),
}),
});
apps.push(app);
return { app, journal };
}
afterEach(async () => {
await Promise.all(apps.splice(0).map(async (app) => app.close()));
for (const database of databases.splice(0)) database.close();
});
describe('events HTTP route', () => {
it('requires the route-local authentication dependency and does not silently expose events', async () => {
for (const authenticate of [null, () => false]) {
const { app } = fixture(authenticate);
const response = await app.inject({ method: 'GET', url: '/api/v1/events' });
expect(response.statusCode).toBe(401);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toBeTruthy();
expect(response.json()).toMatchObject({
type: 'about:blank',
title: 'Unauthorized',
status: 401,
code: 'UNAUTHORIZED',
requestId: response.headers['x-request-id'],
});
}
});
it.each(['', '\0bad'])('rejects a malformed Last-Event-ID value (%j)', async (lastEventId) => {
const { app } = fixture();
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: { 'last-event-id': lastEventId },
});
expect(response.statusCode).toBe(400);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
expect(response.json().requestId).toBe(response.headers['x-request-id']);
});
it('returns the frozen conflict Problem Details for an unavailable cursor', async () => {
const { app, journal } = fixture();
journal.append(event('known'));
const response = await app.inject({
method: 'GET',
url: '/api/v1/events',
headers: { 'last-event-id': 'expired-or-unknown' },
});
expect(response.statusCode).toBe(409);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toBeTruthy();
expect(response.json()).toEqual({
type: 'about:blank',
title: 'Conflict',
status: 409,
code: 'EVENT_POSITION_UNAVAILABLE',
detail: 'The requested event position is no longer available.',
requestId: response.headers['x-request-id'],
});
});
it('replays strictly after the cursor, then streams live events without an invented event field', async () => {
const { app, journal } = fixture();
journal.append(event('one'));
journal.append(event('two'));
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const chunks: string[] = [];
let response!: http.IncomingMessage;
await new Promise<void>((resolve, reject) => {
const request = http.get(
`${address}/api/v1/events`,
{ headers: { 'Last-Event-ID': 'one' } },
(incoming) => {
response = incoming;
incoming.setEncoding('utf8');
incoming.on('data', (chunk: string) => {
chunks.push(chunk);
if (chunks.join('').includes('id: three\n')) resolve();
});
incoming.on('error', reject);
journal.append(event('three'));
},
);
request.on('error', reject);
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.headers['cache-control']).toBe('no-cache');
expect(response.headers['x-request-id']).toBeTruthy();
const body = chunks.join('');
expect(body).toBe(
`id: two\ndata: ${JSON.stringify(event('two'))}\n\n` +
`id: three\ndata: ${JSON.stringify(event('three'))}\n\n`,
);
expect(body).not.toContain('\nevent:');
response.destroy();
});
it('subscribes before replay so an event arriving during the read is neither lost nor duplicated', async () => {
const { app, journal } = fixture();
journal.append(event('one'));
const originalRead = journal.readAfter.bind(journal);
vi.spyOn(journal, 'readAfter').mockImplementation((cursor) => {
journal.append(event('during-read'));
return originalRead(cursor);
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const body = await new Promise<string>((resolve, reject) => {
http
.get(`${address}/api/v1/events`, { headers: { 'Last-Event-ID': 'one' } }, (response) => {
response.setEncoding('utf8');
response.once('data', (chunk: string) => {
response.destroy();
resolve(chunk);
});
response.on('error', reject);
})
.on('error', reject);
});
const frame = `id: during-read\ndata: ${JSON.stringify(event('during-read'))}\n\n`;
expect(body).toBe(frame);
});
it('unsubscribes when the client disconnects', async () => {
const { app, journal } = fixture();
const unsubscribe = vi.fn();
const originalSubscribe = journal.subscribe.bind(journal);
vi.spyOn(journal, 'subscribe').mockImplementation((subscriber) => {
const originalUnsubscribe = originalSubscribe(subscriber);
return () => {
originalUnsubscribe();
unsubscribe();
};
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
await new Promise<void>((resolve, reject) => {
http
.get(`${address}/api/v1/events`, (response) => {
response.destroy();
resolve();
})
.on('error', reject);
});
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce());
});
});
+162
View File
@@ -0,0 +1,162 @@
import type { ServerResponse } from 'node:http';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
EventJournal,
formatServerSentEvent,
type JournalEvent,
} from '../../application/events/event-journal.js';
const FORBIDDEN_CURSOR_CHARACTERS = /[\r\n\0]/u;
export interface EventRoutesOptions {
readonly journal: EventJournal;
/**
* Route-local until the control plane supplies its cookie authentication hook.
* Deliberately fail closed when omitted rather than exposing the event stream.
*/
readonly authenticate?: (request: FastifyRequest) => boolean;
}
const problem = (
request: FastifyRequest,
status: 400 | 401 | 409,
code: string,
detail: string,
): Record<string, unknown> => ({
type: 'about:blank',
title: status === 400 ? 'Bad Request' : status === 401 ? 'Unauthorized' : 'Conflict',
status,
code,
detail,
requestId: request.id,
});
function cursor(request: FastifyRequest): string | undefined {
const value = request.headers['last-event-id'];
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.length === 0 || FORBIDDEN_CURSOR_CHARACTERS.test(value)) {
throw new TypeError('Last-Event-ID must be a non-empty event identifier');
}
return value;
}
function writeEvent(response: ServerResponse, event: JournalEvent, cleanup: () => void): void {
if (response.destroyed || response.writableEnded) {
cleanup();
return;
}
try {
response.write(formatServerSentEvent(event.envelope), (error) => {
if (error) cleanup();
});
} catch {
cleanup();
}
}
export function registerEventRoutes(app: FastifyInstance, options: EventRoutesOptions): void {
app.get('/api/v1/events', async (request, reply) => {
if (!options.authenticate?.(request)) {
return reply
.code(401)
.type('application/problem+json')
.send(
problem(
request,
401,
'UNAUTHORIZED',
'Authentication is required to access the event stream.',
),
);
}
let lastEventId: string | undefined;
try {
lastEventId = cursor(request);
} catch {
return reply
.code(400)
.type('application/problem+json')
.send(
problem(
request,
400,
'VALIDATION_FAILED',
'Last-Event-ID must be a non-empty valid event identifier.',
),
);
}
// Subscribe first. Events appended while the durable replay query runs are buffered;
// sequence de-duplication below closes the replay/subscription race.
const buffered: JournalEvent[] = [];
const stream: { response?: ServerResponse } = {};
let live = false;
let cleaned = false;
let unsubscribe = () => {};
const cleanup = () => {
if (cleaned) return;
cleaned = true;
unsubscribe();
const response = stream.response;
if (response && !response.destroyed && !response.writableEnded) response.destroy();
};
unsubscribe = options.journal.subscribe((event) => {
const response = stream.response;
if (!live || !response) {
buffered.push(event);
return;
}
writeEvent(response, event, cleanup);
});
let replay;
try {
replay = options.journal.readAfter(lastEventId);
} catch (error) {
cleanup();
throw error;
}
if (replay.status === 'unavailable') {
cleanup();
return reply
.code(409)
.type('application/problem+json')
.send(
problem(
request,
409,
'EVENT_POSITION_UNAVAILABLE',
'The requested event position is no longer available.',
),
);
}
reply.hijack();
const response = reply.raw;
stream.response = response;
response.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Request-Id': request.id,
});
response.flushHeaders();
response.on('close', cleanup);
response.on('error', cleanup);
const replayedSequences = new Set<number>();
for (const event of replay.events) {
replayedSequences.add(event.sequence);
writeEvent(response, event, cleanup);
}
for (const event of buffered) {
if (!replayedSequences.has(event.sequence)) writeEvent(response, event, cleanup);
}
buffered.length = 0;
live = true;
return reply;
});
}
@@ -0,0 +1,383 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import {
InstanceSessionStore,
UpstreamSessionClient,
} from '../../application/connections/upstream-session-client.js';
import { InstanceLoginService } from '../../application/connections/instance-login-service.js';
import { ConnectionProbe } from '../../application/connections/connection-probe.js';
import {
InstanceService,
InstanceServiceError,
type InstanceServiceErrorCode,
} from '../../application/instances/instance-service.js';
import {
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
DeleteInstanceOperation,
} from '../../application/operations/delete-instance-operation.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { registerInstanceRoutes } from './instance-routes.js';
class FakeStore implements SecretStore {
async set(key: { instanceId: string; purpose: string; slot?: string }) {
return `keychain://multi-simadmin/${Buffer.from(
JSON.stringify([key.instanceId, key.purpose, key.slot]),
'utf8',
).toString('base64url')}`;
}
async get() {
return undefined;
}
async delete() {
return false;
}
}
const dbs: Database.Database[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
const fixture = () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
let id = 0;
const instances = new InstanceService({
db,
store: new FakeStore(),
idFactory: () => `id-${++id}`,
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const connections = new ConnectionProbe({
db,
instances,
transport: { get: async () => ({ status: 401, headers: {}, body: '' }) },
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const sessionClient = new UpstreamSessionClient({
sessions: new InstanceSessionStore(),
request: async () => ({
status: 200,
headers: { 'set-cookie': 'simadmin_session=opaque' },
body: '',
}),
});
const login = new InstanceLoginService({
db,
client: sessionClient,
resolver: { resolve: async () => '[REDACTED]' },
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const deletion = new DeleteInstanceOperation({
db,
instances,
now: () => new Date('2026-07-16T12:00:00.000Z'),
idFactory: () => `operation-${++id}`,
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
});
const app = buildApp({
registerRoutes: (scope) =>
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
});
return { app, instances };
};
describe('instance HTTP routes', () => {
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
const { app } = fixture();
const create = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Alpha',
origin: 'http://192.168.1.10:8080',
tags: ['lab'],
password: { action: 'set', password: 'never-return-this' },
},
});
expect(create.statusCode).toBe(201);
expect(create.headers.etag).toBe('"rev-1"');
expect(JSON.stringify(create.json())).not.toContain('never-return-this');
expect(create.json()).toMatchObject({ id: 'id-1', revision: 1, credentialConfigured: true });
const list = await app.inject({ method: 'GET', url: '/api/v1/instances?page=1&pageSize=20' });
expect(list.statusCode).toBe(200);
expect(list.json()).toMatchObject({ items: [expect.objectContaining({ id: 'id-1' })] });
const get = await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' });
expect(get.statusCode).toBe(200);
expect(get.headers.etag).toBe('"rev-1"');
const session = await app.inject({
method: 'POST',
url: '/api/v1/instances/id-1/test-connection',
});
expect(session.statusCode).toBe(200);
expect(session.json()).toEqual({
instanceId: 'id-1',
authenticated: false,
checkedAt: '2026-07-16T12:00:00.000Z',
});
const login = await app.inject({
method: 'POST',
url: '/api/v1/instances/id-1/login',
payload: { password: 'one-shot-secret' },
});
expect(login.statusCode).toBe(200);
expect(login.json()).toEqual({
instanceId: 'id-1',
authenticated: true,
checkedAt: '2026-07-16T12:00:00.000Z',
});
expect(login.body).not.toContain('one-shot-secret');
const logout = await app.inject({ method: 'POST', url: '/api/v1/instances/id-1/logout' });
expect(logout.statusCode).toBe(200);
expect(logout.json()).toMatchObject({ instanceId: 'id-1', authenticated: false });
const stale = await app.inject({
method: 'PATCH',
url: '/api/v1/instances/id-1',
headers: { 'if-match': '"rev-2"' },
payload: { name: 'Beta' },
});
expect(stale.statusCode).toBe(412);
expect(stale.json()).toMatchObject({ code: 'REVISION_CONFLICT' });
const update = await app.inject({
method: 'PATCH',
url: '/api/v1/instances/id-1',
headers: { 'if-match': '"rev-1"' },
payload: { name: 'Beta', password: { action: 'preserve' } },
});
expect(update.statusCode).toBe(200);
expect(update.headers.etag).toBe('"rev-2"');
expect(update.json()).toMatchObject({ name: 'Beta', revision: 2 });
const directRemove = await app.inject({
method: 'DELETE',
url: '/api/v1/instances/id-1',
headers: { 'if-match': '"rev-2"' },
});
expect(directRemove.statusCode).toBe(400);
expect(directRemove.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
const preparation = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: {
operationId: 'deleteInstance',
targets: [{ instanceId: 'id-1', revision: 2 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
});
expect(preparation.statusCode).toBe(200);
const prepared = preparation.json() as { id: string; confirmationToken: string };
const remove = await app.inject({
method: 'DELETE',
url: '/api/v1/instances/id-1',
headers: {
'if-match': '"rev-2"',
'x-preparation-id': prepared.id,
'x-confirmation-token': prepared.confirmationToken,
},
});
expect(remove.statusCode).toBe(202);
expect(remove.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
const replay = await app.inject({
method: 'DELETE',
url: '/api/v1/instances/id-1',
headers: {
'if-match': '"rev-2"',
'x-preparation-id': prepared.id,
'x-confirmation-token': prepared.confirmationToken,
},
});
expect(replay.statusCode).toBe(400);
expect(replay.json()).toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect((await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' })).statusCode).toBe(
404,
);
await app.close();
});
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
const { app } = fixture();
const invalid = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: '' },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.headers['content-type']).toContain('application/problem+json');
expect(invalid.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
const missing = await app.inject({ method: 'GET', url: '/api/v1/instances/nope' });
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ code: 'NOT_FOUND' });
const nullCreate = await app.inject({
method: 'POST',
url: '/api/v1/instances',
headers: { 'content-type': 'application/json' },
payload: 'null',
});
expect(nullCreate.statusCode).toBe(400);
expect(nullCreate.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
const arrayPatch = await app.inject({
method: 'PATCH',
url: '/api/v1/instances/nope',
headers: { 'if-match': '"rev-1"' },
payload: [],
});
expect(arrayPatch.statusCode).toBe(400);
expect(arrayPatch.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
const strictCreateCases = [
{ name: 'Alpha', origin: 'http://192.168.1.10', unexpected: true },
{ name: 'Alpha', origin: 'http://192.168.1.10', tags: 'lab' },
{ name: 'Alpha', origin: 'http://192.168.1.10', password: { action: 'set' } },
];
for (const payload of strictCreateCases) {
const response = await app.inject({ method: 'POST', url: '/api/v1/instances', payload });
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Strict', origin: 'http://192.168.1.11' },
});
expect(created.statusCode).toBe(201);
const strictId = (created.json() as { id: string }).id;
for (const payload of [{}, { name: 123 }, { unexpected: true }]) {
const response = await app.inject({
method: 'PATCH',
url: `/api/v1/instances/${strictId}`,
headers: { 'if-match': '"rev-1"' },
payload,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
const unchanged = await app.inject({ method: 'GET', url: `/api/v1/instances/${strictId}` });
expect(unchanged.json()).toMatchObject({ name: 'Strict', revision: 1 });
for (const payload of [
{ password: { not: 'a string' } },
{ password: '' },
{ unexpected: true },
]) {
const response = await app.inject({
method: 'POST',
url: `/api/v1/instances/${strictId}/login`,
payload,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
const loginMissing = await app.inject({
method: 'POST',
url: '/api/v1/instances/nope/login',
payload: {},
});
expect(loginMissing.statusCode).toBe(404);
expect(loginMissing.json()).toMatchObject({ code: 'NOT_FOUND' });
await app.close();
});
it('requires an exact strong revision ETag in If-Match', async () => {
const { app } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Strict ETag', origin: 'http://192.168.1.12' },
});
const id = (created.json() as { id: string }).id;
for (const value of ['1', 'rev-1', 'W/"rev-1"', '"1"', '"rev-01"', '"rev-1", "rev-2"']) {
const response = await app.inject({
method: 'PATCH',
url: `/api/v1/instances/${id}`,
headers: { 'if-match': value },
payload: { name: 'Rejected' },
});
expect(response.statusCode, value).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
await app.close();
});
it('parses every frozen list filter and defaults pageSize to 25', async () => {
const { app, instances } = fixture();
const list = vi.spyOn(instances, 'list');
const response = await app.inject({
method: 'GET',
url: '/api/v1/instances?page=2&pageSize=30&sort=status&direction=desc&search=alpha&capabilityStatus=degraded&freshness=stale&tag=lab&credentialConfigured=false',
});
expect(response.statusCode).toBe(200);
expect(list).toHaveBeenCalledWith({
page: 2,
pageSize: 30,
sort: 'status',
direction: 'desc',
search: 'alpha',
capabilityStatus: 'degraded',
freshness: 'stale',
tag: 'lab',
credentialConfigured: false,
});
const defaults = await app.inject({ method: 'GET', url: '/api/v1/instances' });
expect(defaults.json()).toMatchObject({ page: { page: 1, pageSize: 25, total: 0 } });
await app.close();
});
it('rejects list query values outside the frozen OpenAPI constraints', async () => {
const { app } = fixture();
const invalidQueries = [
'page=0',
'page=1.5',
'pageSize=0',
'pageSize=101',
'pageSize=abc',
'sort=id',
'direction=sideways',
`search=${'x'.repeat(201)}`,
'capabilityStatus=online',
'freshness=old',
'credentialConfigured=1',
'credentialConfigured=TRUE',
];
for (const query of invalidQueries) {
const response = await app.inject({ method: 'GET', url: `/api/v1/instances?${query}` });
expect(response.statusCode, query).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
}
await app.close();
});
it('exhaustively maps and redacts instance service errors', async () => {
const expected: Record<InstanceServiceErrorCode, number> = {
VALIDATION_FAILED: 400,
NOT_FOUND: 404,
REVISION_CONFLICT: 412,
HAS_JOB_HISTORY: 409,
DUPLICATE_ID: 409,
DUPLICATE_ORIGIN: 409,
SECRET_STORE_FAILED: 500,
DATABASE_FAILED: 500,
SECRET_CLEANUP_FAILED: 500,
COMPENSATION_FAILED: 500,
COMPENSATION_PERSISTENCE_FAILED: 500,
};
for (const [code, status] of Object.entries(expected) as [InstanceServiceErrorCode, number][]) {
const { app, instances } = fixture();
vi.spyOn(instances, 'list').mockRejectedValue(
new InstanceServiceError(code, 'sensitive internal failure detail'),
);
const response = await app.inject({ method: 'GET', url: '/api/v1/instances' });
expect(response.statusCode, code).toBe(status);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ code, status });
expect(response.body).not.toContain('sensitive internal failure detail');
await app.close();
}
});
});
@@ -0,0 +1,498 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type {
InstanceInput,
InstancePageQuery,
InstancePatch,
PasswordUpdate,
Instance,
} from '@multi-simadmin/contracts';
import {
InstanceLoginService,
InstanceLoginServiceError,
} from '../../application/connections/instance-login-service.js';
import { ConnectionProbe } from '../../application/connections/connection-probe.js';
import {
InstanceService,
InstanceServiceError,
} from '../../application/instances/instance-service.js';
import {
DeleteInstanceOperation,
DeleteInstanceOperationError,
} from '../../application/operations/delete-instance-operation.js';
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
import {
MessageServiceError,
type InstanceMessageService,
} from '../../application/messages/instance-message-service.js';
export interface InstanceRoutesOptions {
readonly instances: InstanceService;
readonly connections?: ConnectionProbe;
readonly login?: InstanceLoginService;
readonly deletion?: DeleteInstanceOperation;
readonly resources?: InstanceResourceService;
readonly messages?: InstanceMessageService;
readonly registerDeletionPreparationRoute?: boolean;
}
const problem = (
request: FastifyRequest,
status: number,
code: string,
detail: string,
): Record<string, unknown> => ({
type: 'about:blank',
title:
status === 500
? 'Internal Server Error'
: status === 404
? 'Not Found'
: status === 412
? 'Precondition Failed'
: status === 409
? 'Conflict'
: 'Bad Request',
status,
code,
detail,
requestId: request.id,
});
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
const status = instanceErrorStatus[error.code];
return problem(
request,
status,
error.code,
'The requested instance operation could not be completed.',
);
};
const instanceErrorStatus = {
VALIDATION_FAILED: 400,
NOT_FOUND: 404,
REVISION_CONFLICT: 412,
HAS_JOB_HISTORY: 409,
DUPLICATE_ID: 409,
DUPLICATE_ORIGIN: 409,
SECRET_STORE_FAILED: 500,
DATABASE_FAILED: 500,
SECRET_CLEANUP_FAILED: 500,
COMPENSATION_FAILED: 500,
COMPENSATION_PERSISTENCE_FAILED: 500,
} as const satisfies Record<
import('../../application/instances/instance-service.js').InstanceServiceErrorCode,
number
>;
const etag = (instance: Instance): string => `"rev-${instance.revision}"`;
const revision = (request: FastifyRequest): number => {
const value = request.headers['if-match'];
const match = typeof value === 'string' ? /^"rev-([1-9]\d*)"$/.exec(value) : null;
if (!match) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision');
const parsed = Number(match[1]);
if (!Number.isSafeInteger(parsed))
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision');
return parsed;
};
const strings = (value: unknown): readonly string[] | undefined =>
Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
const record = (body: unknown): Record<string, unknown> => {
if (body === null || typeof body !== 'object' || Array.isArray(body))
throw new InstanceServiceError('VALIDATION_FAILED', 'Request body must be an object');
return body as Record<string, unknown>;
};
const bodyInput = (body: unknown): InstanceInput => {
const value = record(body);
const tags = strings(value.tags);
const patch: {
name: string;
origin: string;
tags?: readonly string[];
password?: PasswordUpdate;
} = {
name: typeof value.name === 'string' ? value.name : '',
origin: typeof value.origin === 'string' ? value.origin : '',
};
if (tags) patch.tags = tags;
if (value.password && typeof value.password === 'object')
patch.password = value.password as PasswordUpdate;
return patch;
};
const bodyPatch = (body: unknown): InstancePatch => {
const value = record(body);
const tags = strings(value.tags);
const patch: {
name?: string;
origin?: string;
tags?: readonly string[];
password?: PasswordUpdate;
} = {};
if (typeof value.name === 'string') patch.name = value.name;
if (typeof value.origin === 'string') patch.origin = value.origin;
if (tags) patch.tags = tags;
if (value.password && typeof value.password === 'object')
patch.password = value.password as PasswordUpdate;
return patch;
};
const page = (query: unknown): InstancePageQuery => {
const value = query as Record<string, unknown>;
const optionalString = (key: string): string | undefined => {
const item = value[key];
if (item === undefined) return undefined;
if (typeof item !== 'string')
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
return item;
};
const integer = (key: 'page' | 'pageSize', maximum?: number) => {
const item = optionalString(key);
if (item === undefined) return undefined;
if (!/^[1-9]\d*$/.test(item))
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
const parsed = Number(item);
if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum))
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
return parsed;
};
const queryValue: {
page?: number;
pageSize?: number;
sort?: 'name' | 'status' | 'freshness' | 'updatedAt';
direction?: 'asc' | 'desc';
search?: string;
tag?: string;
capabilityStatus?: 'supported' | 'unsupported' | 'auth-required' | 'degraded' | 'unknown';
freshness?: 'fresh' | 'stale' | 'expired' | 'unknown';
credentialConfigured?: boolean;
} = {};
const parsedPage = integer('page');
const parsedPageSize = integer('pageSize', 100);
if (parsedPage !== undefined) queryValue.page = parsedPage;
if (parsedPageSize !== undefined) queryValue.pageSize = parsedPageSize;
const sort = optionalString('sort');
if (sort !== undefined) {
if (!['name', 'status', 'freshness', 'updatedAt'].includes(sort))
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid sort');
queryValue.sort = sort as NonNullable<typeof queryValue.sort>;
}
const direction = optionalString('direction');
if (direction !== undefined) {
if (direction !== 'asc' && direction !== 'desc')
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid direction');
queryValue.direction = direction;
}
const search = optionalString('search');
if (search !== undefined) {
if (search.length > 200) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid search');
queryValue.search = search;
}
const tag = optionalString('tag');
if (tag !== undefined) queryValue.tag = tag;
const capabilityStatus = optionalString('capabilityStatus');
if (capabilityStatus !== undefined) {
if (
!['supported', 'unsupported', 'auth-required', 'degraded', 'unknown'].includes(
capabilityStatus,
)
)
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid capabilityStatus');
queryValue.capabilityStatus = capabilityStatus as NonNullable<
typeof queryValue.capabilityStatus
>;
}
const freshness = optionalString('freshness');
if (freshness !== undefined) {
if (!['fresh', 'stale', 'expired', 'unknown'].includes(freshness))
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid freshness');
queryValue.freshness = freshness as NonNullable<typeof queryValue.freshness>;
}
const credentialConfigured = optionalString('credentialConfigured');
if (credentialConfigured !== undefined) {
if (credentialConfigured !== 'true' && credentialConfigured !== 'false')
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid credentialConfigured');
queryValue.credentialConfigured = credentialConfigured === 'true';
}
return queryValue;
};
const passwordUpdateSchema = {
oneOf: [
{
type: 'object',
additionalProperties: false,
required: ['action'],
properties: { action: { const: 'preserve' } },
},
{
type: 'object',
additionalProperties: false,
required: ['action', 'password'],
properties: { action: { const: 'set' }, password: { type: 'string', minLength: 1 } },
},
{
type: 'object',
additionalProperties: false,
required: ['action'],
properties: { action: { const: 'clear' } },
},
],
} as const;
const instanceProperties = {
name: { type: 'string' },
origin: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
password: passwordUpdateSchema,
} as const;
const instanceInputSchema = {
type: 'object',
additionalProperties: false,
required: ['name', 'origin'],
properties: instanceProperties,
} as const;
const instancePatchSchema = {
type: 'object',
additionalProperties: false,
minProperties: 1,
properties: instanceProperties,
} as const;
const loginInputSchema = {
type: 'object',
additionalProperties: false,
properties: { password: { type: 'string', minLength: 1 } },
} as const;
export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRoutesOptions): void {
const wrap =
<T>(
handler: (
request: FastifyRequest,
reply: { header: (name: string, value: string) => unknown },
) => Promise<T>,
) =>
async (
request: FastifyRequest,
reply: {
code: (status: number) => typeof reply;
header: (name: string, value: string) => typeof reply;
type: (mime: string) => typeof reply;
send: (body: unknown) => unknown;
},
) => {
try {
return await handler(request, reply);
} catch (error) {
if (error instanceof InstanceServiceError)
return reply
.code(instanceErrorStatus[error.code])
.type('application/problem+json')
.send(domainProblem(request, error));
if (error instanceof DeleteInstanceOperationError) {
const status =
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 412 : 400;
return reply
.code(status)
.type('application/problem+json')
.send(
problem(
request,
status,
error.code,
'The destructive operation could not be confirmed.',
),
);
}
if (error instanceof InstanceLoginServiceError)
return reply
.code(error.code === 'NOT_FOUND' ? 404 : 409)
.type('application/problem+json')
.send(
problem(
request,
error.code === 'NOT_FOUND' ? 404 : 409,
error.code,
'The requested session operation could not be completed.',
),
);
if (error instanceof MessageServiceError) {
const status =
error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502;
return reply
.code(status)
.type('application/problem+json')
.send(
problem(
request,
status,
error.code,
'The requested message operation could not be completed.',
),
);
}
throw error;
}
};
app.get(
'/api/v1/instances',
wrap(async (request) => options.instances.list(page(request.query))),
);
app.post(
'/api/v1/instances',
{ schema: { body: instanceInputSchema } },
async (request, reply) => {
try {
const created = await options.instances.create(bodyInput(request.body));
return reply.header('ETag', etag(created)).code(201).send(created);
} catch (error) {
if (error instanceof InstanceServiceError)
return reply
.code(instanceErrorStatus[error.code])
.type('application/problem+json')
.send(domainProblem(request, error));
throw error;
}
},
);
app.get(
'/api/v1/instances/:instanceId',
wrap(async (request, reply) => {
const result = await options.instances.get(
(request.params as { instanceId: string }).instanceId,
);
if (!result) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
reply.header('ETag', etag(result));
return result;
}),
);
if (options.resources)
app.get(
'/api/v1/instances/:instanceId/resources',
wrap(async (request) =>
options.resources!.get((request.params as { instanceId: string }).instanceId),
),
);
if (options.messages) {
app.get(
'/api/v1/instances/:instanceId/messages',
wrap(async (request) => {
const query = request.query as Record<string, unknown>;
if (Object.keys(query).some((key) => !['limit', 'offset', 'direction'].includes(key)))
throw new MessageServiceError('VALIDATION_FAILED');
const integer = (key: 'limit' | 'offset', fallback: number): number => {
const raw = query[key];
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^\d+$/u.test(raw))
throw new MessageServiceError('VALIDATION_FAILED');
return Number(raw);
};
const direction = query.direction;
if (direction !== undefined && direction !== 'incoming' && direction !== 'outgoing')
throw new MessageServiceError('VALIDATION_FAILED');
return options.messages!.list((request.params as { instanceId: string }).instanceId, {
limit: integer('limit', 50),
offset: integer('offset', 0),
...(direction ? { direction } : {}),
});
}),
);
app.post(
'/api/v1/instances/:instanceId/messages/send',
{
bodyLimit: 8_192,
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['phoneNumber', 'content'],
properties: {
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
content: { type: 'string', minLength: 1, maxLength: 1600 },
},
},
},
},
wrap(async (request) => {
const value = record(request.body);
return options.messages!.send((request.params as { instanceId: string }).instanceId, {
phoneNumber: typeof value.phoneNumber === 'string' ? value.phoneNumber : '',
content: typeof value.content === 'string' ? value.content : '',
});
}),
);
}
app.patch(
'/api/v1/instances/:instanceId',
{ schema: { body: instancePatchSchema } },
wrap(async (request, reply) => {
const result = await options.instances.update(
(request.params as { instanceId: string }).instanceId,
revision(request),
bodyPatch(request.body),
);
reply.header('ETag', etag(result));
return result;
}),
);
if (options.connections)
app.post(
'/api/v1/instances/:instanceId/test-connection',
wrap(async (request) =>
options.connections!.test((request.params as { instanceId: string }).instanceId),
),
);
if (options.login) {
app.post(
'/api/v1/instances/:instanceId/login',
{ schema: { body: loginInputSchema } },
wrap(async (request) => {
const body = request.body as Record<string, unknown> | undefined;
if (body?.password !== undefined && typeof body.password !== 'string')
throw new InstanceServiceError('VALIDATION_FAILED', 'Password must be a string');
return options.login!.login(
(request.params as { instanceId: string }).instanceId,
body?.password,
);
}),
);
app.post(
'/api/v1/instances/:instanceId/logout',
wrap(async (request) =>
options.login!.logout((request.params as { instanceId: string }).instanceId),
),
);
}
if (options.deletion) {
if (options.registerDeletionPreparationRoute !== false) {
app.post(
'/api/v1/operations/prepare',
wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)),
);
}
app.delete('/api/v1/instances/:instanceId', async (request, reply) => {
const handled = await wrap(async (wrappedRequest) => {
const preparationId = wrappedRequest.headers['x-preparation-id'];
const confirmationToken = wrappedRequest.headers['x-confirmation-token'];
if (
typeof preparationId !== 'string' ||
preparationId.length === 0 ||
typeof confirmationToken !== 'string' ||
confirmationToken.length < 20
)
throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmation headers are required');
return options.deletion!.executeDelete({
instanceId: (wrappedRequest.params as { instanceId: string }).instanceId,
revision: revision(wrappedRequest),
preparationId,
confirmationToken,
actor: 'loopback-control-plane',
requestId: wrappedRequest.id,
});
})(request, reply);
if (reply.sent) return;
return reply.code(202).send(handled);
});
} else {
app.delete(
'/api/v1/instances/:instanceId',
wrap(async () => {
throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmed deletion is unavailable');
}),
);
}
}
@@ -0,0 +1,207 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import { JobQueryService } from '../../application/jobs/job-query-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerJobRoutes } from './job-routes.js';
const dbs: Database.Database[] = [];
const timestamp = '2026-07-17T10:00:00.000Z';
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const jobs = new JobQueryService(db);
const app = buildApp({ registerRoutes: (scope) => registerJobRoutes(scope, { jobs }) });
return { app, db, jobs };
}
function insertJob(db: Database.Database, id = 'job-1', status = 'succeeded'): void {
db.prepare(
`INSERT INTO jobs
(id, root_job_id, operation_id, risk_level, status, requested_by, request_id,
parameters_digest, created_at, updated_at)
VALUES (?, ?, 'network.register-auto', 'R1', ?, 'actor', 'request-1',
'digest', ?, ?)`,
).run(id, id, status, timestamp, timestamp);
}
const exactProblem = (
response: { headers: Record<string, string | string[] | number | undefined>; json(): unknown },
values: { title: string; status: number; code: string; detail: string },
) => {
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toEqual(expect.any(String));
expect(response.json()).toEqual({
type: 'about:blank',
...values,
requestId: response.headers['x-request-id'],
});
};
describe('job HTTP read routes', () => {
it('serves active jobs without exposing internal nonterminal items', async () => {
const { app, db } = fixture();
insertJob(db, 'job-1', 'running');
db.prepare(
`INSERT INTO job_items
(id, job_id, instance_id, attempt_number, status, created_at, updated_at)
VALUES ('item-pending', 'job-1', 'instance-1', 1, 'running', ?, ?)`,
).run(timestamp, timestamp);
db.prepare(
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
VALUES ('attempt-1', 'job-1', 'running', ?, NULL, ?)`,
).run(timestamp, timestamp);
const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
status: 'running',
items: [],
attempts: [{ id: 'attempt-1', state: 'running', startedAt: timestamp }],
});
await app.close();
});
it('maps an overbound child collection to the stable redacted 503 problem', async () => {
const { app, db } = fixture();
insertJob(db);
const insert = db.prepare(
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
VALUES (?, 'job-1', 'succeeded', ?, ?, ?)`,
);
db.transaction(() => {
for (let index = 1; index <= 1001; index += 1)
insert.run(`attempt-${index}`, timestamp, timestamp, timestamp);
})();
const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' });
expect(response.statusCode).toBe(503);
exactProblem(response, {
title: 'Service Unavailable',
status: 503,
code: 'UNREPRESENTABLE',
detail: 'The requested job data is temporarily unavailable.',
});
expect(response.body).not.toContain('1000');
await app.close();
});
it('lists and gets jobs and supplies the documented list defaults', async () => {
const { app, db, jobs } = fixture();
insertJob(db);
const listCall = vi.spyOn(jobs, 'list');
const list = await app.inject({ method: 'GET', url: '/api/v1/jobs' });
expect(list.statusCode).toBe(200);
expect(listCall).toHaveBeenCalledWith({ page: 1, pageSize: 25, direction: 'asc' });
expect(list.json()).toEqual({
items: [
{
id: 'job-1',
operationId: 'network.register-auto',
status: 'succeeded',
rootJobId: 'job-1',
items: [],
attempts: [],
createdAt: timestamp,
},
],
page: { page: 1, pageSize: 25, total: 1 },
});
const get = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' });
expect(get.statusCode).toBe(200);
expect(get.json()).toMatchObject({ id: 'job-1', status: 'succeeded' });
await app.close();
});
it('parses every allowed scalar query exactly', async () => {
const { app, jobs } = fixture();
const listCall = vi.spyOn(jobs, 'list');
const response = await app.inject({
method: 'GET',
url: '/api/v1/jobs?page=2&pageSize=10&sort=operationId&direction=desc&status=failed&operationId=op.one&rootJobId=root-1&instanceId=instance-1',
});
expect(response.statusCode).toBe(200);
expect(listCall).toHaveBeenCalledWith({
page: 2,
pageSize: 10,
sort: 'operationId',
direction: 'desc',
status: 'failed',
operationId: 'op.one',
rootJobId: 'root-1',
instanceId: 'instance-1',
});
await app.close();
});
it.each([
'unknown=value',
'page=1&page=2',
'status=failed&status=succeeded',
'page=0',
'page=01',
'page=1.5',
'page=9007199254740992',
'pageSize=0',
'pageSize=101',
'sort=',
'sort=id',
'direction=ASC',
'status=complete',
'status=',
'operationId=',
'rootJobId=',
'instanceId=',
])('rejects invalid, unknown, empty, or repeated query input: %s', async (query) => {
const { app } = fixture();
const response = await app.inject({ method: 'GET', url: `/api/v1/jobs?${query}` });
expect(response.statusCode).toBe(400);
exactProblem(response, {
title: 'Bad Request',
status: 400,
code: 'VALIDATION_FAILED',
detail: 'The jobs query is invalid.',
});
await app.close();
});
it('maps a missing job to an exact redacted 404 problem', async () => {
const { app } = fixture();
const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/missing' });
expect(response.statusCode).toBe(404);
exactProblem(response, {
title: 'Not Found',
status: 404,
code: 'NOT_FOUND',
detail: 'The requested job does not exist.',
});
await app.close();
});
it('maps unrepresentable persisted data to an exact stable redacted 503 problem', async () => {
const { app, db } = fixture();
insertJob(db);
db.prepare(`UPDATE jobs SET created_at = 'not-a-secret-but-invalid' WHERE id = 'job-1'`).run();
const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' });
expect(response.statusCode).toBe(503);
exactProblem(response, {
title: 'Service Unavailable',
status: 503,
code: 'UNREPRESENTABLE',
detail: 'The requested job data is temporarily unavailable.',
});
expect(response.body).not.toContain('not-a-secret-but-invalid');
expect(response.body).not.toContain('canonical');
await app.close();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { JOB_STATUSES, type JobPageQuery, type JobStatus } from '@multi-simadmin/contracts';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobQueryError, JobQueryService } from '../../application/jobs/job-query-service.js';
export interface JobRoutesOptions {
readonly jobs: JobQueryService;
}
const QUERY_KEYS = new Set([
'page',
'pageSize',
'sort',
'direction',
'status',
'operationId',
'rootJobId',
'instanceId',
]);
const errorResponse = {
VALIDATION_FAILED: {
status: 400,
title: 'Bad Request',
detail: 'The jobs query is invalid.',
},
NOT_FOUND: {
status: 404,
title: 'Not Found',
detail: 'The requested job does not exist.',
},
UNREPRESENTABLE: {
status: 503,
title: 'Service Unavailable',
detail: 'The requested job data is temporarily unavailable.',
},
} as const;
function invalid(): never {
throw new JobQueryError('VALIDATION_FAILED', 'Invalid jobs query');
}
function parseQuery(query: unknown): JobPageQuery {
if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid();
const input = query as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!QUERY_KEYS.has(key)) invalid();
if (typeof input[key] !== 'string') invalid();
}
const scalar = (key: string): string | undefined => {
const value = input[key];
return value === undefined ? undefined : (value as string);
};
const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => {
const value = scalar(key);
if (value === undefined) return fallback;
if (!/^[1-9]\d*$/u.test(value)) invalid();
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid();
return parsed;
};
const pageSize = integer('pageSize', 25, 100);
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
const result: {
page: number;
pageSize: number;
sort?: 'createdAt' | 'status' | 'operationId';
direction: 'asc' | 'desc';
status?: JobStatus;
operationId?: string;
rootJobId?: string;
instanceId?: string;
} = {
page: integer('page', 1, maximumPage),
pageSize,
direction: 'asc',
};
const sort = scalar('sort');
if (sort !== undefined) {
if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId') invalid();
result.sort = sort;
}
const direction = scalar('direction');
if (direction !== undefined) {
if (direction !== 'asc' && direction !== 'desc') invalid();
result.direction = direction;
}
const status = scalar('status');
if (status !== undefined) {
if (!(JOB_STATUSES as readonly string[]).includes(status)) invalid();
result.status = status as JobStatus;
}
for (const key of ['operationId', 'rootJobId', 'instanceId'] as const) {
const value = scalar(key);
if (value !== undefined) {
if (value.length === 0) invalid();
result[key] = value;
}
}
return result;
}
export function registerJobRoutes(app: FastifyInstance, options: JobRoutesOptions): void {
const handler =
<T>(action: (request: FastifyRequest) => T) =>
async (request: FastifyRequest, reply: FastifyReply) => {
try {
return action(request);
} catch (error) {
if (!(error instanceof JobQueryError)) throw error;
const mapped = errorResponse[error.code];
return reply.code(mapped.status).type('application/problem+json').send({
type: 'about:blank',
title: mapped.title,
status: mapped.status,
code: error.code,
detail: mapped.detail,
requestId: request.id,
});
}
};
app.get(
'/api/v1/jobs',
handler((request) => options.jobs.list(parseQuery(request.query))),
);
app.get(
'/api/v1/jobs/:jobId',
handler((request) => options.jobs.get((request.params as { jobId: string }).jobId)),
);
}
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildApp } from '../../app.js';
import { operationCatalogRegistry } from '../../application/operations/operation-catalog-data.js';
import { registerOperationRoutes } from './operation-routes.js';
const apps: ReturnType<typeof buildApp>[] = [];
afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close())));
const app = () => {
const instance = buildApp({
registerRoutes: (server) => registerOperationRoutes(server, operationCatalogRegistry),
});
apps.push(instance);
return instance;
};
describe('GET /api/v1/operations', () => {
it('returns only the safe catalog projection', async () => {
const response = await app().inject('/api/v1/operations?pageSize=1');
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ page: { page: 1, pageSize: 1, total: 117 } });
expect(Object.keys(response.json().items[0])).toEqual([
'operationId',
'title',
'risk',
'capability',
'batchable',
'parameterSchemaId',
]);
for (const forbidden of [
'method',
'pathTemplate',
'sourceEvidence',
'sensitiveFields',
'handler',
])
expect(response.body).not.toContain(`\"${forbidden}\"`);
});
it.each([
'unknown=x',
'page=1&page=2',
'page[]=1',
'page=0',
'page=01',
'page=1.5',
'pageSize=101',
'risk=R4',
'capability=bogus',
'batchable=True',
'batchable=1',
'sort=title',
'direction=down',
`search=${'x'.repeat(201)}`,
])('rejects invalid strict scalar query: %s', async (query) => {
const response = await app().inject(`/api/v1/operations?${query}`);
expect(response.statusCode).toBe(400);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED', status: 400 });
});
it('accepts every frozen capability including job', async () => {
const response = await app().inject('/api/v1/operations?capability=job&pageSize=100');
expect(response.statusCode).toBe(200);
expect(response.json().items.length).toBeGreaterThan(0);
expect(
response.json().items.every((item: { capability: string }) => item.capability === 'job'),
).toBe(true);
});
});
@@ -0,0 +1,186 @@
import type {
ExecuteOperationRequest,
OperationPageQuery,
PrepareOperationRequest,
} from '@multi-simadmin/contracts';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { OperationCatalogService } from '../../application/operations/operation-catalog-service.js';
import type { OperationCatalogRegistry } from '../../application/operations/operation-catalog-service.js';
import {
DeleteInstanceOperation,
DeleteInstanceOperationError,
} from '../../application/operations/delete-instance-operation.js';
import {
SecureOperationExecution,
SecureOperationExecutionError,
} from '../../application/operations/secure-operation-execution.js';
const capabilities = new Set(['query', 'command', 'job']);
const keys = new Set([
'page',
'pageSize',
'sort',
'direction',
'risk',
'capability',
'batchable',
'search',
]);
class CatalogValidationError extends Error {}
function scalarQuery(request: FastifyRequest): Record<string, string> {
const raw = request.query;
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
throw new CatalogValidationError();
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(raw)) {
if (!keys.has(key) || typeof value !== 'string') throw new CatalogValidationError();
result[key] = value;
}
return result;
}
function parseQuery(request: FastifyRequest): OperationPageQuery {
const query = scalarQuery(request);
const result: {
page?: number;
pageSize?: number;
sort?: 'operationId' | 'risk' | 'capability';
direction?: 'asc' | 'desc';
risk?: 'R0' | 'R1' | 'R2' | 'R3';
capability?: string;
batchable?: boolean;
search?: string;
} = {};
const integer = (key: 'page' | 'pageSize', maximum?: number): void => {
const value = query[key];
if (value === undefined) return;
if (!/^[1-9]\d*$/.test(value)) throw new CatalogValidationError();
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum))
throw new CatalogValidationError();
result[key] = parsed;
};
integer('page');
integer('pageSize', 100);
const sort = query.sort;
if (sort !== undefined) {
if (sort !== 'operationId' && sort !== 'risk' && sort !== 'capability')
throw new CatalogValidationError();
result.sort = sort;
}
const direction = query.direction;
if (direction !== undefined) {
if (direction !== 'asc' && direction !== 'desc') throw new CatalogValidationError();
result.direction = direction;
}
const risk = query.risk;
if (risk !== undefined) {
if (risk !== 'R0' && risk !== 'R1' && risk !== 'R2' && risk !== 'R3')
throw new CatalogValidationError();
result.risk = risk;
}
const capability = query.capability;
if (capability !== undefined) {
if (!capabilities.has(capability)) throw new CatalogValidationError();
result.capability = capability;
}
const batchable = query.batchable;
if (batchable !== undefined) {
if (batchable !== 'true' && batchable !== 'false') throw new CatalogValidationError();
result.batchable = batchable === 'true';
}
const search = query.search;
if (search !== undefined) {
if (search.length > 200) throw new CatalogValidationError();
result.search = search;
}
return result;
}
export function registerOperationRoutes(
app: FastifyInstance,
registry: OperationCatalogRegistry,
execution?: SecureOperationExecution,
deletion?: DeleteInstanceOperation,
): void {
const service = new OperationCatalogService(registry);
app.get('/api/v1/operations', async (request, reply) => {
try {
return service.list(parseQuery(request));
} catch (error) {
if (!(error instanceof CatalogValidationError)) throw error;
return reply.code(400).type('application/problem+json').send({
type: 'about:blank',
title: 'Bad Request',
status: 400,
code: 'VALIDATION_FAILED',
detail: 'The request did not satisfy the endpoint schema.',
requestId: request.id,
});
}
});
if (!execution) return;
const problem = (
request: FastifyRequest,
reply: FastifyReply,
error: SecureOperationExecutionError,
) => {
const status =
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 409 : 400;
const title = status === 404 ? 'Not Found' : status === 409 ? 'Conflict' : 'Bad Request';
return reply.code(status).type('application/problem+json').send({
type: 'about:blank',
title,
status,
code: error.code,
detail: error.message,
requestId: request.id,
});
};
app.post('/api/v1/operations/prepare', async (request, reply) => {
try {
const body = request.body as { operationId?: unknown };
if (body && typeof body === 'object' && body.operationId === 'deleteInstance' && deletion)
return reply.code(200).send(await deletion.prepare(request.body as never, request.id));
return reply
.code(200)
.send(await execution.prepare(request.body as PrepareOperationRequest, request.id));
} catch (error) {
if (error instanceof DeleteInstanceOperationError) {
const status =
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 412 : 400;
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title:
status === 404 ? 'Not Found' : status === 412 ? 'Precondition Failed' : 'Bad Request',
status,
code: error.code,
detail: 'The requested operation could not be prepared.',
requestId: request.id,
});
}
if (error instanceof SecureOperationExecutionError) return problem(request, reply, error);
throw error;
}
});
app.post('/api/v1/operations/execute', async (request, reply) => {
try {
return reply
.code(202)
.send(
await execution.execute(
request.body as ExecuteOperationRequest,
'loopback-control-plane',
request.id,
),
);
} catch (error) {
if (error instanceof SecureOperationExecutionError) return problem(request, reply, error);
throw error;
}
});
}
@@ -0,0 +1,121 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
SecureOperationExecution,
secureOperationRegistry,
} from '../../application/operations/secure-operation-execution.js';
import { operationCatalogRegistry } from '../../application/operations/operation-catalog-data.js';
import { registerOperationRoutes } from './operation-routes.js';
const resources: Array<{ close(): unknown }> = [];
afterEach(async () => {
for (const resource of resources.splice(0)) await resource.close();
});
function fixture() {
const db = new Database(':memory:');
resources.push(db);
db.pragma('foreign_keys=ON');
migrateDatabase(db);
db.prepare(
`INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at)
VALUES ('i','one','http://192.168.1.10','none',1,1,'2026-07-17T00:00:00.000Z','2026-07-17T00:00:00.000Z')`,
).run();
const request = vi.fn(async () => ({ status: 200 }));
const execution = new SecureOperationExecution({
db,
registry: secureOperationRegistry,
transport: { request },
idFactory: (() => {
let n = 0;
return () => `id-${++n}`;
})(),
tokenFactory: () => Buffer.alloc(32, 4).toString('base64url'),
});
const app = buildApp({
registerRoutes: (server) =>
registerOperationRoutes(server, operationCatalogRegistry, execution),
});
resources.push(app);
return { app, db, request };
}
const prepareBody = {
operationId: 'postNetworkRegisterAuto',
targets: [{ instanceId: 'i', revision: 1 }],
parameters: {
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
fields: [],
},
};
describe('secure operation routes', () => {
it('returns 200 Preparation then 202 terminal Job with request IDs', async () => {
const { app, db, request } = fixture();
const prepared = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: prepareBody,
});
expect(prepared.statusCode).toBe(200);
expect(prepared.headers['x-request-id']).toBeTruthy();
const preparation = prepared.json();
expect(db.prepare('SELECT count(*) count FROM jobs').get()).toEqual({ count: 0 });
const executed = await app.inject({
method: 'POST',
url: '/api/v1/operations/execute',
payload: {
preparationId: preparation.id,
confirmationToken: preparation.confirmationToken,
},
});
expect(executed.statusCode).toBe(202);
expect(executed.headers['x-request-id']).toBeTruthy();
expect(executed.json()).toMatchObject({
operationId: 'postNetworkRegisterAuto',
status: 'succeeded',
});
expect(request).toHaveBeenCalledOnce();
});
it.each([
['/api/v1/operations/prepare', { ...prepareBody, extra: true }],
[
'/api/v1/operations/prepare',
{ ...prepareBody, targets: [{ instanceId: 'i', revision: 1, extra: true }] },
],
[
'/api/v1/operations/prepare',
{ ...prepareBody, parameters: { ...prepareBody.parameters, extra: true } },
],
['/api/v1/operations/execute', { preparationId: 'x', confirmationToken: 'x', extra: true }],
])('strictly rejects additional properties at %s', async (url, payload) => {
const { app } = fixture();
const response = await app.inject({ method: 'POST', url, payload });
expect(response.statusCode).toBe(400);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.headers['x-request-id']).toBeTruthy();
expect(response.json()).toMatchObject({
status: 400,
code: 'VALIDATION_FAILED',
requestId: response.headers['x-request-id'],
});
});
it('returns Problem Details and does not consume a wrong confirmation', async () => {
const { app, db } = fixture();
const preparation = (
await app.inject({ method: 'POST', url: '/api/v1/operations/prepare', payload: prepareBody })
).json();
const response = await app.inject({
method: 'POST',
url: '/api/v1/operations/execute',
payload: { preparationId: preparation.id, confirmationToken: 'wrong' },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ status: 400, code: 'CONFIRMATION_INVALID' });
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(preparation.id),
).toEqual({ status: 'prepared' });
});
});
+59
View File
@@ -0,0 +1,59 @@
import { lstat, readdir } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { buildProductionControlPlane } from './production-control-plane.js';
import { createRuntimeConfig, GATEWAY_AUTH_HEADER } from './runtime-config.js';
import { installBoundedShutdown } from './shutdown.js';
async function regularFile(path: string): Promise<boolean> {
try {
const value = await lstat(path);
return value.isFile() && !value.isSymbolicLink();
} catch {
return false;
}
}
async function recoveryMarkers(databasePath: string): Promise<number> {
const directory = dirname(databasePath);
const name = basename(databasePath);
const names = await readdir(directory).catch(() => []);
return names.filter(
(entry) =>
entry === `${name}.restore-in-progress` ||
(entry.startsWith(`${name}.`) &&
(entry.endsWith('.restore') || entry.endsWith('.quarantine'))),
).length;
}
async function gatewayStaticReachable(token: string): Promise<boolean> {
try {
const response = await fetch('http://127.0.0.1:8789/', {
headers: { [GATEWAY_AUTH_HEADER]: token },
signal: AbortSignal.timeout(2_000),
});
await response.body?.cancel();
return response.status === 200;
} catch {
return false;
}
}
try {
const config = createRuntimeConfig(process.env);
const app = buildProductionControlPlane({
databasePath: config.databasePath,
gatewayToken: config.gatewayToken,
assetsCheck: () => regularFile(join(config.webDist, 'index.html')),
recoveryMarkersCheck: () => recoveryMarkers(config.databasePath),
upstreamGatewayCheck: () => gatewayStaticReachable(config.gatewayToken),
});
await app.listen({ host: config.api.host, port: config.api.port });
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;
}
@@ -0,0 +1,73 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
buildProductionControlPlane,
type ProductionControlPlaneOptions,
} from './production-control-plane.js';
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
const cleanup: Array<() => Promise<void>> = [];
const gatewayToken = 'production-gateway-token-with-at-least-32-bytes';
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
const directory = await mkdtemp(join(tmpdir(), 'production-control-plane-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
return {
databasePath: join(directory, 'db.sqlite'),
gatewayToken,
store: {
set: async () => '',
get: async () => undefined,
delete: async () => false,
},
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
},
keychainMetadataCheck: async () => true,
};
}
describe('production control-plane authentication composition', () => {
it('keeps the event route fail-closed when trusted-gateway auth is not configured', async () => {
const options = await fixtureOptions();
const app = buildProductionControlPlane({
databasePath: options.databasePath,
store: options.store!,
upstream: options.upstream!,
keychainMetadataCheck: options.keychainMetadataCheck!,
});
cleanup.push(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/events' });
expect(response.statusCode).toBe(401);
expect(response.json()).toMatchObject({ code: 'UNAUTHORIZED' });
});
it('opens SSE only after mandatory trusted-gateway authentication succeeds', async () => {
const app = buildProductionControlPlane(await fixtureOptions());
cleanup.push(() => app.close());
const direct = await app.inject({ method: 'GET', url: '/api/v1/events' });
expect(direct.statusCode).toBe(401);
expect(direct.json()).toMatchObject({ code: 'GATEWAY_AUTH_REQUIRED' });
await app.listen({ host: '127.0.0.1', port: 0 });
const address = app.server.address();
if (!address || typeof address === 'string') throw new Error('Expected TCP server address');
const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/events`, {
headers: { [GATEWAY_AUTH_HEADER]: gatewayToken },
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toMatch(/^text\/event-stream/);
await response.body?.cancel();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { isAbsolute } from 'node:path';
import {
buildControlPlaneApp,
type ControlPlaneApp,
type SafeControlPlaneUpstream,
} from './control-plane.js';
import { openDatabase } from './infrastructure/database/database.js';
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>;
readonly assetsCheck?: () => boolean | Promise<boolean>;
readonly recoveryMarkersCheck?: () => number | Promise<number>;
readonly upstreamGatewayCheck?: () => boolean | Promise<boolean>;
}
export function buildProductionControlPlane(
options: ProductionControlPlaneOptions,
): ControlPlaneApp {
if (
typeof options.databasePath !== 'string' ||
options.databasePath.length === 0 ||
!isAbsolute(options.databasePath)
) {
throw new Error('An explicit absolute SQLite database path is required');
}
const db = openDatabase(options.databasePath);
try {
migrateDatabase(db);
const store = options.store ?? new MacOSKeychainSecretStore();
const app = buildControlPlaneApp({
db,
store,
upstream: options.upstream ?? createProductionUpstream(),
// This route-local decision is safe only in this composition: when a token is
// configured, buildApp's global onRequest hook rejects the request first.
// Keep all other control-plane compositions fail-closed by default.
...(options.gatewayToken ? { authenticateEventStream: () => true } : {}),
app: {
logger: {},
...(options.gatewayToken ? { gatewayToken: options.gatewayToken } : {}),
readiness: createProductionReadiness({
db,
gatewayToken: options.gatewayToken ?? '',
keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
...(options.assetsCheck ? { assetsCheck: options.assetsCheck } : {}),
...(options.recoveryMarkersCheck
? { recoveryMarkersCheck: options.recoveryMarkersCheck }
: {}),
...(options.upstreamGatewayCheck
? { upstreamGatewayCheck: options.upstreamGatewayCheck }
: {}),
}),
},
});
app.addHook('onClose', async () => {
if (db.open) db.close();
});
return app;
} catch (error) {
if (db.open) db.close();
throw error;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { createCanaryGateway } from './canary-gateway.js';
import { readProductionGatewayRuntimeOptions } from './canary-runtime.js';
const gateway = createCanaryGateway(readProductionGatewayRuntimeOptions(process.env));
let stopping = false;
async function stop(signal: string): Promise<void> {
if (stopping) return;
stopping = true;
process.stdout.write(`Stopping production gateway (${signal})\n`);
await gateway.stop();
}
process.once('SIGINT', () => void stop('SIGINT'));
process.once('SIGTERM', () => void stop('SIGTERM'));
try {
await gateway.start();
process.stdout.write(`Production gateway listening at ${gateway.origin}\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
+195
View File
@@ -0,0 +1,195 @@
import { createHash } from 'node:crypto';
import { chmod, lstat, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { createCanaryGateway } from './canary-gateway.js';
import { PRODUCTION_CUTOVER_ACK, readProductionGatewayRuntimeOptions } from './canary-runtime.js';
import {
cutover,
parseCutoverPlan,
preflight,
rollback,
type CutoverPlan,
type CutoverSystem,
} from './cutover-orchestrator.js';
const token = 'synthetic-production-gateway-token-32-bytes';
const dirs: string[] = [];
afterEach(() =>
Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))),
);
function environment(ack?: string) {
return ack === undefined
? { MULTI_SIMADMIN_GATEWAY_TOKEN: token }
: { MULTI_SIMADMIN_GATEWAY_TOKEN: token, MULTI_SIMADMIN_CUTOVER_ACK: ack };
}
async function fixture(name = 'cutover'): Promise<CutoverPlan> {
const root = await mkdtemp(join(tmpdir(), `${name}-`));
dirs.push(root);
const assets = join(root, 'assets');
await mkdir(assets);
await writeFile(join(assets, 'index.html'), 'asset');
const backup = join(root, 'backup');
const drill = join(root, 'drill');
const backupContent = 'verified backup artifact\n';
const drillContent = 'successful rollback drill\n';
await writeFile(backup, backupContent);
await writeFile(drill, drillContent);
return {
assetDir: assets,
backupEvidence: backup,
backupEvidenceSha256: createHash('sha256').update(backupContent).digest('hex'),
drillEvidence: drill,
drillEvidenceSha256: createHash('sha256').update(drillContent).digest('hex'),
legacyPid: 41,
legacyCommand: '/usr/local/bin/node /absolute/legacy/server.js',
legacyIdentity: 'legacy-start-1',
legacyStart: ['/usr/local/bin/node', '/absolute/legacy/server.js'],
stateDir: join(root, 'state'),
};
}
interface FakeOptions {
gatewayOwnsPort?: boolean;
gatewayReady?: boolean;
}
function fakeSystem(plan: CutoverPlan, options: FakeOptions = {}) {
const commands = new Map<number, string>([[plan.legacyPid, plan.legacyCommand]]);
const identities = new Map<number, string>([[plan.legacyPid, plan.legacyIdentity]]);
const signals: number[] = [];
const starts: Array<{ argv: string[]; environment?: NodeJS.ProcessEnv }> = [];
let nextPid = 42;
const system: CutoverSystem = {
ready: async (port, suppliedToken) =>
suppliedToken === token && (port === 8790 || options.gatewayReady !== false),
command: async (pid) => commands.get(pid) ?? '',
identity: async (pid) => identities.get(pid) ?? '',
ownsProductionPort: async (pid) => pid === 42 && options.gatewayOwnsPort !== false,
signal: (pid) => {
signals.push(pid);
commands.delete(pid);
identities.delete(pid);
},
waitGone: async () => {},
start: (argv, environment) => {
starts.push({ argv: [...argv], ...(environment === undefined ? {} : { environment }) });
const pid = nextPid++;
commands.set(pid, argv.join(' '));
identities.set(pid, `start-${pid}`);
return pid;
},
delay: async () => {},
};
return { system, signals, starts, commands, identities };
}
describe('production gateway ownership', () => {
it('requires the exact strong acknowledgement and ignores a supplied canary port', () => {
expect(() => readProductionGatewayRuntimeOptions(environment())).toThrow(/CUTOVER_ACK/);
expect(() => readProductionGatewayRuntimeOptions(environment('yes'))).toThrow(/CUTOVER_ACK/);
const options = readProductionGatewayRuntimeOptions({
...environment(PRODUCTION_CUTOVER_ACK),
CANARY_PORT: '1234',
});
expect(options).toMatchObject({ port: 8788, ownsProductionPort: true });
});
it('keeps ordinary canary construction unable to claim 8788', () => {
expect(() => createCanaryGateway({ distDir: '/tmp', port: 8788 })).toThrow(/reserved/);
});
});
describe('cutover validation and reversible orchestration', () => {
it('rejects extra untrusted JSON fields and non-absolute executables', async () => {
const plan = await fixture('cutover-schema');
expect(() => parseCutoverPlan({ ...plan, command: 'sh -c evil' })).toThrow(/schema/);
expect(() => parseCutoverPlan({ ...plan, legacyStart: ['sh', '-c', 'evil'] })).toThrow(
/absolute/,
);
});
it('authenticates readiness and verifies evidence digests and regular assets', async () => {
const plan = await fixture('cutover-preflight');
const { system } = fakeSystem(plan);
await expect(preflight(plan, system, '')).rejects.toThrow(/Gateway token/);
await writeFile(plan.backupEvidence, 'tampered');
await expect(preflight(plan, system, token)).rejects.toThrow(/SHA-256 mismatch/);
await rm(plan.backupEvidence);
await mkdir(plan.backupEvidence);
await expect(preflight(plan, system, token)).rejects.toThrow(/regular file/);
});
it('durably records mode-0600 state, verifies 8788 ownership, and restores exact argv', async () => {
const plan = await fixture();
const { system, signals, starts } = fakeSystem(plan);
expect(await cutover(plan, system, token)).toBe(42);
expect(signals).toEqual([41]);
expect(starts[0]?.environment?.MULTI_SIMADMIN_GATEWAY_TOKEN).toBe(token);
const statePath = join(plan.stateDir, 'cutover-state.json');
expect((await lstat(statePath)).mode & 0o777).toBe(0o600);
expect(await readFile(statePath, 'utf8')).not.toContain(token);
expect(await rollback(plan, system)).toBe(43);
expect(signals).toEqual([41, 42]);
expect(starts[1]?.argv).toEqual(['/usr/local/bin/node', '/absolute/legacy/server.js']);
});
it('automatically restarts legacy if the gateway fails to own authenticated 8788', async () => {
const plan = await fixture('cutover-failure');
const { system, signals, starts } = fakeSystem(plan, { gatewayOwnsPort: false });
await expect(cutover(plan, system, token)).rejects.toThrow(/ownership verification/);
expect(signals).toEqual([41, 42]);
expect(starts.map((start) => start.argv)).toEqual([
[
process.execPath,
'--import',
resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'),
resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
],
['/usr/local/bin/node', '/absolute/legacy/server.js'],
]);
const state = JSON.parse(await readFile(join(plan.stateDir, 'cutover-state.json'), 'utf8')) as {
phase: string;
};
expect(state.phase).toBe('prepared');
});
it('never signals mismatched legacy or gateway PID identities', async () => {
const plan = await fixture('cutover-mismatch');
const first = fakeSystem(plan);
first.commands.set(plan.legacyPid, 'foreign');
await expect(cutover(plan, first.system, token)).rejects.toThrow(/identity mismatch/);
expect(first.signals).toEqual([]);
const second = fakeSystem(plan);
await cutover(plan, second.system, token);
second.commands.set(42, 'foreign');
await expect(rollback(plan, second.system)).rejects.toThrow(/identity mismatch/);
expect(second.signals).toEqual([41]);
});
it('refuses rollback state with permissive permissions or invalid schema', async () => {
const plan = await fixture('cutover-state-validation');
const first = fakeSystem(plan);
await cutover(plan, first.system, token);
const statePath = join(plan.stateDir, 'cutover-state.json');
await chmod(statePath, 0o644);
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
expect(first.signals).toEqual([41]);
await chmod(statePath, 0o600);
const state = JSON.parse(await readFile(statePath, 'utf8')) as Record<string, unknown>;
await writeFile(
statePath,
JSON.stringify({ ...state, injectedCommand: ['sh', '-c', 'evil'] }),
{
mode: 0o600,
},
);
await expect(rollback(plan, first.system)).rejects.toThrow(/schema/);
expect(first.signals).toEqual([41]);
});
});
@@ -0,0 +1,67 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from './infrastructure/database/migrations.js';
import { createProductionReadiness } from './production-readiness.js';
function fixture() {
const db = new Database(':memory:');
migrateDatabase(db);
return db;
}
describe('production readiness release gates', () => {
it('fails closed on pending credentials, recovery markers, missing assets and configured gateway failure', async () => {
const db = fixture();
db.prepare(
"INSERT INTO app_settings(key,value_json,created_at,updated_at) VALUES('legacyImport.pendingCredentials','2','x','x')",
).run();
const readiness = createProductionReadiness({
db,
gatewayToken: 'x'.repeat(32),
keychainMetadataCheck: async () => true,
assetsCheck: async () => false,
recoveryMarkersCheck: async () => 1,
upstreamGatewayCheck: async () => false,
});
await expect(readiness()).resolves.toEqual({
ready: false,
checks: {
database: 'ready',
migrations: 'ready',
gatewayAuth: 'ready',
keychain: 'ready',
pendingCredentials: 'pending:2',
recoveryMarkers: 'present:1',
assets: 'unavailable',
upstreamGateway: 'unavailable',
},
});
db.close();
});
it('requires explicit release checks and reports ready only when every gate passes', async () => {
const db = fixture();
const missing = await createProductionReadiness({
db,
gatewayToken: 'x'.repeat(32),
keychainMetadataCheck: async () => true,
})();
expect(missing.ready).toBe(false);
expect(missing.checks).toMatchObject({
assets: 'unconfigured',
recoveryMarkers: 'unconfigured',
upstreamGateway: 'unconfigured',
pendingCredentials: 'ready',
});
const ready = await createProductionReadiness({
db,
gatewayToken: 'x'.repeat(32),
keychainMetadataCheck: async () => true,
assetsCheck: async () => true,
recoveryMarkersCheck: async () => 0,
upstreamGatewayCheck: async () => true,
})();
expect(ready.ready).toBe(true);
db.close();
});
});
+117
View File
@@ -0,0 +1,117 @@
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>;
/** Release gates are deliberately required: omitting one fails readiness closed. */
readonly assetsCheck?: () => boolean | Promise<boolean>;
readonly recoveryMarkersCheck?: () => number | Promise<number>;
readonly upstreamGatewayCheck?: () => 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;
try {
const row = options.db
.prepare(
"SELECT value_json FROM app_settings WHERE key = 'legacyImport.pendingCredentials'",
)
.get() as { value_json: string } | undefined;
const value: unknown = row === undefined ? 0 : JSON.parse(row.value_json);
checks.pendingCredentials =
Number.isSafeInteger(value) && (value as number) >= 0
? value === 0
? 'ready'
: `pending:${String(value)}`
: 'unavailable';
} catch {
checks.pendingCredentials = 'unavailable';
}
if (checks.pendingCredentials !== 'ready') ready = false;
if (options.recoveryMarkersCheck === undefined) {
checks.recoveryMarkers = 'unconfigured';
} else {
try {
const count = await options.recoveryMarkersCheck();
checks.recoveryMarkers =
Number.isSafeInteger(count) && count >= 0
? count === 0
? 'ready'
: `present:${String(count)}`
: 'unavailable';
} catch {
checks.recoveryMarkers = 'unavailable';
}
}
if (checks.recoveryMarkers !== 'ready') ready = false;
for (const [name, check] of [
['assets', options.assetsCheck],
['upstreamGateway', options.upstreamGatewayCheck],
] as const) {
if (check === undefined) {
checks[name] = 'unconfigured';
} else {
try {
checks[name] = (await check()) ? 'ready' : 'unavailable';
} catch {
checks[name] = 'unavailable';
}
}
if (checks[name] !== 'ready') ready = false;
}
return { ready, checks };
};
}
+128
View File
@@ -0,0 +1,128 @@
import Database from 'better-sqlite3';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from './infrastructure/database/migrations.js';
import {
appendReleaseEvidence,
compareShadowSnapshot,
sha256File,
verifyReleaseEvidenceLog,
} from './release-evidence.js';
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => Promise.all(cleanup.splice(0).map((fn) => fn())));
async function directory(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'release-evidence-'));
cleanup.push(() => rm(path, { recursive: true, force: true }));
return path;
}
describe('durable redacted release evidence', () => {
it('atomically appends hash-chained, digest-bound evidence without origins or secrets', async () => {
const root = await directory();
const log = join(root, 'evidence.jsonl');
const record = await appendReleaseEvidence(log, {
releaseSha: 'a'.repeat(40),
webAssetDigest: 'b'.repeat(64),
databaseBackupDigest: 'c'.repeat(64),
rollbackDrill: {
performedAt: '2026-07-18T00:00:00.000Z',
result: 'passed',
recordDigest: 'd'.repeat(64),
},
pendingCredentialCount: 0,
readiness: { ready: true, checks: { assets: 'ready' } },
shadow: {
inputDigest: 'e'.repeat(64),
matches: true,
legacy: { instances: { total: 2, enabled: 2 }, statuses: { fresh: 2 } },
current: { instances: { total: 2, enabled: 2 }, statuses: { fresh: 2 } },
differences: [],
},
recordedAt: '2026-07-18T01:00:00.000Z',
});
expect(record.sequence).toBe(1);
expect(record.recordDigest).toMatch(/^[a-f0-9]{64}$/);
expect(await verifyReleaseEvidenceLog(log)).toEqual({
valid: true,
records: 1,
lastDigest: record.recordDigest,
});
const persisted = await readFile(log, 'utf8');
expect(persisted).not.toContain('https://');
expect(persisted).not.toContain('token');
await expect(
appendReleaseEvidence(log, {
releaseSha: 'a'.repeat(40),
webAssetDigest: 'b'.repeat(64),
databaseBackupDigest: 'c'.repeat(64),
rollbackDrill: {
performedAt: '2026-07-18T00:00:00.000Z',
result: 'passed',
recordDigest: 'd'.repeat(64),
},
pendingCredentialCount: 0,
readiness: { ready: true, checks: { upstream: 'https://secret.example' } },
shadow: {
inputDigest: 'e'.repeat(64),
matches: true,
legacy: { instances: { total: 0, enabled: 0 }, statuses: {} },
current: { instances: { total: 0, enabled: 0 }, statuses: {} },
differences: [],
},
}),
).rejects.toThrow(/unsafe evidence/i);
});
it('detects evidence tampering', async () => {
const root = await directory();
const log = join(root, 'evidence.jsonl');
await writeFile(log, '{"sequence":1,"recordDigest":"bad"}\n');
expect((await verifyReleaseEvidenceLog(log)).valid).toBe(false);
});
});
describe('read-only digest-bound shadow reconciliation', () => {
it('compares only safe counts/status projections and does not mutate the DB', async () => {
const root = await directory();
const snapshot = join(root, 'legacy.json');
await writeFile(
snapshot,
JSON.stringify({
instances: [
{ name: 'one', url: 'https://secret.invalid', enabled: true, status: 'fresh' },
{ name: 'two', password: 'never-log', enabled: false, status: 'stale' },
],
}),
);
const db = new Database(':memory:');
migrateDatabase(db);
const now = '2026-07-18T00:00:00.000Z';
db.prepare(
'INSERT INTO instances(id,name,base_url,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?)',
).run('1', 'one', 'https://new.invalid', 1, now, now);
db.prepare(
'INSERT INTO instances(id,name,base_url,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?)',
).run('2', 'two', 'https://new2.invalid', 0, now, now);
db.prepare(
'INSERT INTO status_snapshots(id,instance_id,category,state,payload_json,observed_at,created_at) VALUES(?,?,?,?,?,?,?)',
).run('s1', '1', 'health', 'fresh', '{}', now, now);
db.prepare(
'INSERT INTO status_snapshots(id,instance_id,category,state,payload_json,observed_at,created_at) VALUES(?,?,?,?,?,?,?)',
).run('s2', '2', 'health', 'stale', '{}', now, now);
const before = db.serialize();
const result = await compareShadowSnapshot(snapshot, db);
expect(result).toMatchObject({
inputDigest: await sha256File(snapshot),
matches: true,
legacy: { instances: { total: 2, enabled: 1 }, statuses: { fresh: 1, stale: 1 } },
});
expect(JSON.stringify(result)).not.toContain('invalid');
expect(db.serialize()).toEqual(before);
db.close();
});
});
+328
View File
@@ -0,0 +1,328 @@
import type Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { open, readFile, rename, rm, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
const DIGEST = /^[a-f0-9]{64}$/u;
const SHA = /^[a-f0-9]{40}$/u;
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u;
const SAFE_VALUE = /^[a-zA-Z0-9 _.:+-]{1,128}$/u;
export interface SafeProjection {
readonly instances: { readonly total: number; readonly enabled: number };
readonly statuses: Readonly<Record<string, number>>;
}
export interface ShadowComparison {
readonly inputDigest: string;
readonly matches: boolean;
readonly legacy: SafeProjection;
readonly current: SafeProjection;
readonly differences: readonly string[];
}
export interface ReleaseEvidenceInput {
readonly releaseSha: string;
readonly webAssetDigest: string;
readonly databaseBackupDigest: string;
readonly rollbackDrill: {
readonly performedAt: string;
readonly result: 'passed';
readonly recordDigest: string;
};
readonly pendingCredentialCount: number;
readonly readiness: {
readonly ready: boolean;
readonly checks: Readonly<Record<string, string>>;
};
readonly shadow: ShadowComparison;
readonly recordedAt?: string;
}
export interface ReleaseEvidenceRecord extends ReleaseEvidenceInput {
readonly sequence: number;
readonly previousDigest: string | null;
readonly recordedAt: string;
readonly recordDigest: string;
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`)
.join(',')}}`;
}
return JSON.stringify(value);
}
function digest(value: unknown): string {
return createHash('sha256').update(canonical(value), 'utf8').digest('hex');
}
function safeCount(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[]): void {
const actual = Object.keys(value).sort();
const wanted = [...expected].sort();
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index]))
throw new Error('Unsafe evidence schema');
}
function validateProjection(value: SafeProjection): void {
exactKeys(value as unknown as Record<string, unknown>, ['instances', 'statuses']);
exactKeys(value.instances as unknown as Record<string, unknown>, ['enabled', 'total']);
if (!safeCount(value.instances.total) || !safeCount(value.instances.enabled))
throw new Error('Unsafe evidence projection count');
if (value.instances.enabled > value.instances.total)
throw new Error('Unsafe evidence projection');
for (const [key, count] of Object.entries(value.statuses)) {
if (!/^[a-z0-9_-]{1,64}$/u.test(key) || !safeCount(count))
throw new Error('Unsafe evidence status projection');
}
}
function validateInput(input: ReleaseEvidenceInput): void {
exactKeys(input as unknown as Record<string, unknown>, [
'databaseBackupDigest',
'pendingCredentialCount',
'readiness',
'releaseSha',
'rollbackDrill',
'shadow',
'webAssetDigest',
...(input.recordedAt === undefined ? [] : ['recordedAt']),
]);
exactKeys(input.rollbackDrill as unknown as Record<string, unknown>, [
'performedAt',
'recordDigest',
'result',
]);
exactKeys(input.readiness as unknown as Record<string, unknown>, ['checks', 'ready']);
exactKeys(input.shadow as unknown as Record<string, unknown>, [
'current',
'differences',
'inputDigest',
'legacy',
'matches',
]);
if (
!SHA.test(input.releaseSha) ||
!DIGEST.test(input.webAssetDigest) ||
!DIGEST.test(input.databaseBackupDigest)
)
throw new Error('Unsafe evidence digest');
if (
!ISO.test(input.rollbackDrill.performedAt) ||
input.rollbackDrill.result !== 'passed' ||
!DIGEST.test(input.rollbackDrill.recordDigest)
)
throw new Error('Unsafe evidence rollback drill');
if (!safeCount(input.pendingCredentialCount)) throw new Error('Unsafe evidence credential count');
for (const [key, value] of Object.entries(input.readiness.checks)) {
if (!/^[a-zA-Z][a-zA-Z0-9]{0,63}$/u.test(key) || !SAFE_VALUE.test(value))
throw new Error('Unsafe evidence readiness value');
}
if (!DIGEST.test(input.shadow.inputDigest)) throw new Error('Unsafe evidence shadow digest');
validateProjection(input.shadow.legacy);
validateProjection(input.shadow.current);
if (input.shadow.differences.some((value) => !SAFE_VALUE.test(value)))
throw new Error('Unsafe evidence difference');
if (input.recordedAt !== undefined && !ISO.test(input.recordedAt))
throw new Error('Unsafe evidence timestamp');
}
export async function sha256File(path: string): Promise<string> {
const metadata = await stat(path);
if (!metadata.isFile()) throw new Error('Digest input must be a regular file');
return createHash('sha256')
.update(await readFile(path))
.digest('hex');
}
function parseRecords(text: string): ReleaseEvidenceRecord[] {
if (text === '') return [];
const lines = text.split('\n');
if (lines.at(-1) !== '') throw new Error('Evidence log is not newline terminated');
lines.pop();
return lines.map((line) => JSON.parse(line) as ReleaseEvidenceRecord);
}
function validateRecord(
record: ReleaseEvidenceRecord,
index: number,
previous: string | null,
): boolean {
const { recordDigest, ...unsigned } = record;
try {
exactKeys(record as unknown as Record<string, unknown>, [
'databaseBackupDigest',
'pendingCredentialCount',
'previousDigest',
'readiness',
'recordDigest',
'recordedAt',
'releaseSha',
'rollbackDrill',
'sequence',
'shadow',
'webAssetDigest',
]);
const evidenceInput = {
releaseSha: record.releaseSha,
webAssetDigest: record.webAssetDigest,
databaseBackupDigest: record.databaseBackupDigest,
rollbackDrill: record.rollbackDrill,
pendingCredentialCount: record.pendingCredentialCount,
readiness: record.readiness,
shadow: record.shadow,
recordedAt: record.recordedAt,
};
validateInput(evidenceInput);
} catch {
return false;
}
return (
record.sequence === index + 1 &&
record.previousDigest === previous &&
DIGEST.test(recordDigest) &&
digest(unsigned) === recordDigest
);
}
export async function verifyReleaseEvidenceLog(path: string): Promise<{
readonly valid: boolean;
readonly records: number;
readonly lastDigest: string | null;
}> {
try {
const records = parseRecords(await readFile(path, 'utf8'));
let previous: string | null = null;
for (const [index, record] of records.entries()) {
if (!validateRecord(record, index, previous))
return { valid: false, records: index, lastDigest: previous };
previous = record.recordDigest;
}
return { valid: true, records: records.length, lastDigest: previous };
} catch {
return { valid: false, records: 0, lastDigest: null };
}
}
export async function appendReleaseEvidence(
path: string,
input: ReleaseEvidenceInput,
): Promise<ReleaseEvidenceRecord> {
validateInput(input);
const absolute = resolve(path);
const parent = dirname(absolute);
const parentInfo = await stat(parent);
if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink())
throw new Error('Unsafe evidence directory');
const lockPath = `${absolute}.lock`;
let lock;
try {
lock = await open(lockPath, 'wx', 0o600);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST')
throw new Error('Release evidence log is locked by another writer');
throw error;
}
try {
let existing = '';
try {
const info = await stat(absolute);
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0)
throw new Error('Unsafe evidence log');
existing = await readFile(absolute, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const records = parseRecords(existing);
let previous: string | null = null;
for (const [index, record] of records.entries()) {
if (!validateRecord(record, index, previous)) throw new Error('Unsafe evidence log chain');
previous = record.recordDigest;
}
const unsigned = {
...input,
recordedAt: input.recordedAt ?? new Date().toISOString(),
sequence: records.length + 1,
previousDigest: previous,
};
const record: ReleaseEvidenceRecord = { ...unsigned, recordDigest: digest(unsigned) };
const temporary = `${absolute}.${process.pid}.${Date.now()}.tmp`;
const handle = await open(temporary, 'wx', 0o600);
try {
await handle.writeFile(`${existing}${canonical(record)}\n`, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temporary, absolute);
const directory = await open(parent, 'r');
try {
await directory.sync();
} finally {
await directory.close();
}
return Object.freeze(record);
} finally {
await lock.close();
await rm(lockPath, { force: true });
}
}
function projectionFromLegacy(value: unknown): SafeProjection {
if (value === null || typeof value !== 'object' || Array.isArray(value))
throw new Error('Invalid shadow snapshot');
const instances = (value as { instances?: unknown }).instances;
if (!Array.isArray(instances)) throw new Error('Invalid shadow snapshot');
const statuses: Record<string, number> = {};
let enabled = 0;
for (const item of instances) {
if (item === null || typeof item !== 'object' || Array.isArray(item))
throw new Error('Invalid shadow snapshot');
const record = item as Record<string, unknown>;
if (
typeof record.enabled !== 'boolean' ||
typeof record.status !== 'string' ||
!/^[a-z0-9_-]{1,64}$/u.test(record.status)
)
throw new Error('Invalid shadow snapshot');
if (record.enabled) enabled += 1;
statuses[record.status] = (statuses[record.status] ?? 0) + 1;
}
return { instances: { total: instances.length, enabled }, statuses };
}
function projectionFromDatabase(db: Database.Database): SafeProjection {
const instances = db
.prepare('SELECT COUNT(*) AS total, COALESCE(SUM(enabled), 0) AS enabled FROM instances')
.get() as { total: number; enabled: number };
const rows = db
.prepare('SELECT state, COUNT(*) AS count FROM status_snapshots GROUP BY state')
.all() as Array<{ state: string; count: number }>;
const statuses: Record<string, number> = {};
for (const row of rows) {
if (!/^[a-z0-9_-]{1,64}$/u.test(row.state) || !safeCount(row.count))
throw new Error('Unsafe database status');
statuses[row.state] = row.count;
}
return { instances: { total: instances.total, enabled: instances.enabled }, statuses };
}
export async function compareShadowSnapshot(
path: string,
db: Database.Database,
): Promise<ShadowComparison> {
const inputDigest = await sha256File(path);
const legacy = projectionFromLegacy(JSON.parse(await readFile(path, 'utf8')) as unknown);
const current = projectionFromDatabase(db);
const differences: string[] = [];
if (legacy.instances.total !== current.instances.total)
differences.push('instance total differs');
if (legacy.instances.enabled !== current.instances.enabled)
differences.push('enabled instance count differs');
if (canonical(legacy.statuses) !== canonical(current.statuses))
differences.push('status counts differ');
return Object.freeze({
inputDigest,
matches: differences.length === 0,
legacy,
current,
differences,
});
}
+78
View File
@@ -0,0 +1,78 @@
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;
readonly MULTI_SIMADMIN_WEB_DIST?: string;
}
export interface RuntimeConfig {
readonly api: { readonly host: '127.0.0.1'; readonly port: 8790 };
readonly dataRoot: string;
readonly databasePath: string;
readonly gatewayToken: string;
readonly webDist: 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');
const webDistInput = environment.MULTI_SIMADMIN_WEB_DIST;
if (!webDistInput || !isAbsolute(webDistInput))
throw new Error('An explicit absolute web distribution path is required');
const webDist = resolve(webDistInput);
return Object.freeze({
api: Object.freeze({ host: '127.0.0.1' as const, port: 8790 as const }),
dataRoot,
databasePath,
gatewayToken,
webDist,
});
}

Some files were not shown because too many files have changed in this diff Show More