feat(api): complete phase 2.4 control plane

This commit is contained in:
chick
2026-07-17 00:37:11 +08:00
parent c2702b5fc6
commit 7b91dbbad1
40 changed files with 4526 additions and 74 deletions
+2 -1
View File
@@ -5,10 +5,11 @@
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts apps/api/src/infrastructure/database/database.test.ts apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts apps/api/src/application/legacy-import/legacy-import.test.ts apps/api/src/application/legacy-import/activate-pending-secret.test.ts",
"test": "vitest run --root ../.. apps/api/src",
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"@multi-simadmin/contracts": "workspace:*",
"better-sqlite3": "12.11.1",
"drizzle-orm": "0.45.2",
"fastify": "5.10.0"
+28
View File
@@ -5,6 +5,7 @@ import Fastify, {
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;
@@ -144,6 +145,7 @@ export function createListenOptions(environment: ListenEnvironment): ListenOptio
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 };
}
@@ -161,6 +163,12 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
const fastifyOptions: FastifyServerOptions = {
genReqId: () => randomUUID(),
logger,
ajv: {
customOptions: {
removeAdditional: false,
coerceTypes: false,
},
},
};
const app = Fastify(fastifyOptions);
const readiness = options.readiness ?? (() => ({ ready: true, checks: { bootstrap: 'ready' } }));
@@ -216,6 +224,26 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
),
);
}
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';
@@ -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,81 @@
export interface UpstreamRequest {
readonly url: string;
readonly method: '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]';
}
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,545 @@
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 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[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
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 };
}
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('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,327 @@
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('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,370 @@
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;
status: string;
target_instance_id: string;
target_revision: number;
parameter_schema_id: string;
parameters_digest: 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,parameter_schema_id,parameters_digest,token_digest,requested_by,request_id,expires_at,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
.run(
id,
OPERATION_ID,
'R3',
'prepared',
target.instanceId,
target.revision,
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
PARAMETERS_DIGEST,
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,status,target_instance_id,target_revision,parameter_schema_id,
parameters_digest,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 secretValid =
row.expires_at > now &&
equalDigest(row.token_digest, digest(input.confirmationToken)) &&
row.operation_id === OPERATION_ID &&
row.target_instance_id === input.instanceId &&
row.target_revision === input.revision &&
row.parameter_schema_id === DELETE_INSTANCE_PARAMETER_SCHEMA_ID &&
row.parameters_digest === PARAMETERS_DIGEST;
if (!secretValid) 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);
const current = this.db
.prepare('SELECT config_revision FROM instances WHERE id=?')
.get(input.instanceId) as { config_revision: number } | undefined;
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,
};
}
}
+112
View File
@@ -0,0 +1,112 @@
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('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: '' }),
},
});
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();
});
});
+64
View File
@@ -0,0 +1,64 @@
import type Database from 'better-sqlite3';
import type { FastifyInstance } 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';
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
}
export interface ControlPlaneOptions {
readonly db: Database.Database;
readonly store: SecretStore;
readonly upstream: SafeControlPlaneUpstream;
readonly now?: () => Date;
readonly app?: Omit<BuildAppOptions, 'registerRoutes'>;
}
export interface ControlPlaneApp extends FastifyInstance {
retryPendingSecretCleanup(): Promise<unknown>;
}
export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp {
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 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 app = buildApp({
...options.app,
registerRoutes: (app) =>
registerInstanceRoutes(app, { instances, connections, login, deletion }),
});
Object.assign(app, {
retryPendingSecretCleanup: () => instances.retryPendingSecretCleanup(),
});
return app as ControlPlaneApp;
}
+5
View File
@@ -44,5 +44,10 @@ export type {
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';
@@ -64,9 +64,12 @@ describe('database migrations', () => {
'capabilities',
'instance_tags',
'instances',
'job_attempts',
'job_items',
'jobs',
'operation_preparations',
'schema_migrations',
'secret_cleanup_tasks',
'secret_references',
'status_snapshots',
]);
@@ -91,7 +94,18 @@ describe('database migrations', () => {
expect(foreignKeys).toEqual(
expect.arrayContaining([
expect.objectContaining({ from: 'job_id', on_delete: 'CASCADE', table: 'jobs' }),
expect.objectContaining({ from: 'instance_id', on_delete: 'RESTRICT', table: 'instances' }),
]),
);
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
@@ -107,6 +121,103 @@ describe('database migrations', () => {
'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();
});
@@ -113,6 +113,121 @@ export const MIGRATIONS: readonly Migration[] = [
'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)',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
+68 -3
View File
@@ -8,6 +8,7 @@ import {
sqliteTable,
text,
unique,
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
const timestamps = {
@@ -88,6 +89,7 @@ export const statusSnapshots = sqliteTable(
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),
],
);
@@ -98,6 +100,12 @@ export const jobs = sqliteTable(
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(),
@@ -122,12 +130,14 @@ export const jobItems = sqliteTable(
jobId: text('job_id')
.notNull()
.references(() => jobs.id, { onDelete: 'cascade' }),
instanceId: text('instance_id')
.notNull()
.references(() => instances.id, { onDelete: 'restrict' }),
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'),
@@ -144,6 +154,44 @@ export const jobItems = sqliteTable(
],
);
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',
{
@@ -188,5 +236,22 @@ export const secretReferences = sqliteTable(
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)],
);
@@ -61,6 +61,11 @@ describe('MacOSKeychainSecretStore', () => {
);
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({
@@ -96,6 +96,9 @@ export class SpawnCommandRunner implements CommandRunner {
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 {
@@ -161,7 +164,13 @@ export function parseKeychainReference(reference: string): ParsedKeychainReferen
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 };
return {
service: SERVICE,
account,
instanceId: key.instanceId,
purpose: key.purpose,
...(key.slot === undefined ? {} : { slot: key.slot }),
};
}
export class MacOSKeychainSecretStore implements SecretStore {
@@ -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,35 @@
import { describe, expect, it } from 'vitest';
import { createSafeControlPlaneUpstream } from './safe-control-plane-upstream.js';
describe('createSafeControlPlaneUpstream', () => {
it('routes both health GET and login POST 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]',
});
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]"}',
},
]);
});
});
@@ -0,0 +1,16 @@
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'];
}
export function createSafeControlPlaneUpstream(
transport: SafeUpstreamTransport,
): SafeControlPlaneUpstream {
const gateway = new SafeUpstreamGateway({ transport });
return {
get: (url) => transport.get(url),
request: (request) => gateway.request(request),
};
}
@@ -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,105 @@
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.search ||
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): 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,72 @@
import { describe, expect, it } from 'vitest';
import { SafeUpstreamGateway } from './safe-upstream-gateway.js';
describe('SafeUpstreamGateway', () => {
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('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,38 @@
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';
export interface SafeUpstreamTransport {
get(url: 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 request(request: UpstreamRequest): Promise<UpstreamResponse> {
const url = new URL(request.url);
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');
}
}
@@ -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,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,417 @@
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';
export interface InstanceRoutesOptions {
readonly instances: InstanceService;
readonly connections?: ConnectionProbe;
readonly login?: InstanceLoginService;
readonly deletion?: DeleteInstanceOperation;
}
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.',
),
);
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;
}),
);
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) {
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');
}),
);
}
}
+46
View File
@@ -0,0 +1,46 @@
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';
export interface ProductionControlPlaneOptions {
readonly databasePath: string;
readonly store?: SecretStore;
readonly upstream?: SafeControlPlaneUpstream;
}
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 app = buildControlPlaneApp({
db,
store: options.store ?? new MacOSKeychainSecretStore(),
upstream: options.upstream ?? createProductionUpstream(),
app: { logger: {} },
});
app.addHook('onClose', async () => {
if (db.open) db.close();
});
return app;
} catch (error) {
if (db.open) db.close();
throw error;
}
}
+48
View File
@@ -11,6 +11,7 @@ describe('API startup boundary', () => {
calls.push(options);
return 'http://127.0.0.1:8790';
},
close: async () => undefined,
},
});
expect(calls).toEqual([{ host: API_DEFAULT_HOST, port: API_DEFAULT_PORT }]);
@@ -26,9 +27,56 @@ describe('API startup boundary', () => {
called = true;
return '';
},
close: async () => undefined,
},
}),
).rejects.toThrow(/reserved|legacy/i);
expect(called).toBe(false);
});
it('validates listen options before composing production resources', async () => {
await expect(startApi({ environment: { API_PORT: '8788' } })).rejects.toThrow(/8788.*legacy/i);
});
it('closes an injected app when listen fails and preserves the listen error', async () => {
const listenError = new Error('address already in use');
let closed = false;
await expect(
startApi({
app: {
listen: async () => {
throw listenError;
},
close: async () => {
closed = true;
throw new Error('close also failed');
},
},
}),
).rejects.toBe(listenError);
expect(closed).toBe(true);
});
it('schedules cleanup only after a successful listen without waiting for it', async () => {
const events: string[] = [];
let release!: () => void;
const cleanup = new Promise<void>((resolve) => {
release = resolve;
});
await startApi({
app: {
listen: async () => {
events.push('listen');
return '';
},
close: async () => undefined,
retryPendingSecretCleanup: async () => {
events.push('cleanup');
await cleanup;
},
},
});
expect(events).toEqual(['listen', 'cleanup']);
release();
});
});
+28 -5
View File
@@ -1,12 +1,35 @@
import type { FastifyInstance } from 'fastify';
import { buildApp, createListenOptions, type ListenEnvironment } from './app.js';
import { createListenOptions, type ListenEnvironment } from './app.js';
import { buildProductionControlPlane } from './production-control-plane.js';
export interface StartApiOptions {
readonly environment?: ListenEnvironment;
readonly app?: Pick<FastifyInstance, 'listen'>;
readonly databasePath?: string;
readonly app?: {
listen(options: { readonly host: string; readonly port: number }): Promise<string>;
close(): Promise<void>;
retryPendingSecretCleanup?: () => Promise<unknown>;
};
}
export async function startApi(options: StartApiOptions = {}): Promise<void> {
const app = options.app ?? buildApp({ logger: {} });
await app.listen(createListenOptions(options.environment ?? {}));
const listenOptions = createListenOptions(options.environment ?? {});
const app =
options.app ?? buildProductionControlPlane({ databasePath: options.databasePath ?? '' });
try {
await app.listen(listenOptions);
} catch (listenError) {
try {
await app.close();
} catch {
// Preserve the listen failure; startup cleanup is best-effort.
}
throw listenError;
}
if (app.retryPendingSecretCleanup) {
try {
void app.retryPendingSecretCleanup().catch(() => undefined);
} catch {
// Cleanup is durable and can be retried after a later successful listen.
}
}
}