diff --git a/apps/api/package.json b/apps/api/package.json index 16142a0..c5df477 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d31dbf7..5e889fd 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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'; diff --git a/apps/api/src/application/connections/connection-probe.test.ts b/apps/api/src/application/connections/connection-probe.test.ts new file mode 100644 index 0000000..1188c3c --- /dev/null +++ b/apps/api/src/application/connections/connection-probe.test.ts @@ -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' }); + }); +}); diff --git a/apps/api/src/application/connections/connection-probe.ts b/apps/api/src/application/connections/connection-probe.ts new file mode 100644 index 0000000..326bb1a --- /dev/null +++ b/apps/api/src/application/connections/connection-probe.ts @@ -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; +} +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 { + 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 }; + } +} diff --git a/apps/api/src/application/connections/instance-credential-resolver.test.ts b/apps/api/src/application/connections/instance-credential-resolver.test.ts new file mode 100644 index 0000000..a9a6b63 --- /dev/null +++ b/apps/api/src/application/connections/instance-credential-resolver.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/application/connections/instance-credential-resolver.ts b/apps/api/src/application/connections/instance-credential-resolver.ts new file mode 100644 index 0000000..c8d7cc6 --- /dev/null +++ b/apps/api/src/application/connections/instance-credential-resolver.ts @@ -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 { + 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'); + } + } +} diff --git a/apps/api/src/application/connections/instance-login-service.test.ts b/apps/api/src/application/connections/instance-login-service.test.ts new file mode 100644 index 0000000..015f7cf --- /dev/null +++ b/apps/api/src/application/connections/instance-login-service.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/application/connections/instance-login-service.ts b/apps/api/src/application/connections/instance-login-service.ts new file mode 100644 index 0000000..9bf8c28 --- /dev/null +++ b/apps/api/src/application/connections/instance-login-service.ts @@ -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; +} +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 { + 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 { + 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; + } +} diff --git a/apps/api/src/application/connections/upstream-session-client.test.ts b/apps/api/src/application/connections/upstream-session-client.test.ts new file mode 100644 index 0000000..329ca0d --- /dev/null +++ b/apps/api/src/application/connections/upstream-session-client.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/application/connections/upstream-session-client.ts b/apps/api/src/application/connections/upstream-session-client.ts new file mode 100644 index 0000000..6d9bd82 --- /dev/null +++ b/apps/api/src/application/connections/upstream-session-client.ts @@ -0,0 +1,81 @@ +export interface UpstreamRequest { + readonly url: string; + readonly method: 'POST'; + readonly headers: Readonly>; + /** 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>; + readonly body: string; +} +export interface UpstreamSessionClientOptions { + readonly sessions: InstanceSessionStore; + readonly request: (request: UpstreamRequest) => Promise; +} +export interface InstanceSession { + readonly origin: string; + readonly cookie: string; +} +export class InstanceSessionStore { + private readonly sessions = new Map(); + 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 { + 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); + } + } +} diff --git a/apps/api/src/application/instances/instance-service.test.ts b/apps/api/src/application/instances/instance-service.test.ts new file mode 100644 index 0000000..9dba6ed --- /dev/null +++ b/apps/api/src/application/instances/instance-service.test.ts @@ -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(); + 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) => { + 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(); + }); +}); diff --git a/apps/api/src/application/instances/instance-service.ts b/apps/api/src/application/instances/instance-service.ts new file mode 100644 index 0000000..9163e6e --- /dev/null +++ b/apps/api/src/application/instances/instance-service.ts @@ -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 = { + degraded: 0, + 'auth-required': 1, + unsupported: 2, + supported: 3, + unknown: 4, +}; +const freshnessRank: Record = { + 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 { + 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 { + const rows = this.loadRows('WHERE i.id = ?', [instanceId]); + return rows[0] ? this.toInstance(rows[0]) : undefined; + } + + async list(query: InstancePageQuery = {}): Promise { + 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 { + 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 { + 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 { + 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 { + 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) + ); +} diff --git a/apps/api/src/application/operations/delete-instance-operation.test.ts b/apps/api/src/application/operations/delete-instance-operation.test.ts new file mode 100644 index 0000000..ee188b3 --- /dev/null +++ b/apps/api/src/application/operations/delete-instance-operation.test.ts @@ -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 }); + }); +}); diff --git a/apps/api/src/application/operations/delete-instance-operation.ts b/apps/api/src/application/operations/delete-instance-operation.ts new file mode 100644 index 0000000..cca0580 --- /dev/null +++ b/apps/api/src/application/operations/delete-instance-operation.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/control-plane.test.ts b/apps/api/src/control-plane.test.ts new file mode 100644 index 0000000..6c85dba --- /dev/null +++ b/apps/api/src/control-plane.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts new file mode 100644 index 0000000..c6a39fc --- /dev/null +++ b/apps/api/src/control-plane.ts @@ -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; +} +export interface ControlPlaneApp extends FastifyInstance { + retryPendingSecretCleanup(): Promise; +} +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; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ad1a85d..5434d03 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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'; diff --git a/apps/api/src/infrastructure/database/database.test.ts b/apps/api/src/infrastructure/database/database.test.ts index ac0f050..5657841 100644 --- a/apps/api/src/infrastructure/database/database.test.ts +++ b/apps/api/src/infrastructure/database/database.test.ts @@ -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(); }); diff --git a/apps/api/src/infrastructure/database/migrations.ts b/apps/api/src/infrastructure/database/migrations.ts index 3d2bc02..fa47c9e 100644 --- a/apps/api/src/infrastructure/database/migrations.ts +++ b/apps/api/src/infrastructure/database/migrations.ts @@ -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 ( diff --git a/apps/api/src/infrastructure/database/schema.ts b/apps/api/src/infrastructure/database/schema.ts index 68b4320..712e13b 100644 --- a/apps/api/src/infrastructure/database/schema.ts +++ b/apps/api/src/infrastructure/database/schema.ts @@ -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)], +); diff --git a/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts b/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts index 1365878..e20b324 100644 --- a/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts +++ b/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts @@ -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({ diff --git a/apps/api/src/infrastructure/secrets/keychain-secret-store.ts b/apps/api/src/infrastructure/secrets/keychain-secret-store.ts index a56802b..2d9bf6c 100644 --- a/apps/api/src/infrastructure/secrets/keychain-secret-store.ts +++ b/apps/api/src/infrastructure/secrets/keychain-secret-store.ts @@ -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 { diff --git a/apps/api/src/infrastructure/transport/pinned-http-requester.test.ts b/apps/api/src/infrastructure/transport/pinned-http-requester.test.ts new file mode 100644 index 0000000..5a5fb6d --- /dev/null +++ b/apps/api/src/infrastructure/transport/pinned-http-requester.test.ts @@ -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 }); + }); +}); diff --git a/apps/api/src/infrastructure/transport/pinned-http-requester.ts b/apps/api/src/infrastructure/transport/pinned-http-requester.ts new file mode 100644 index 0000000..2c40a8a --- /dev/null +++ b/apps/api/src/infrastructure/transport/pinned-http-requester.ts @@ -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>; + readonly body?: string; + readonly lookup: LookupFunction; + readonly followRedirects: false; + readonly timeoutMs: number; + readonly maxBodyBytes: number; +} +export interface PinnedHttpRequesterOptions { + readonly dispatch?: (options: PinnedDispatchOptions) => Promise; + 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; + 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 { + 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 => + 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 = {}; + 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); + }); diff --git a/apps/api/src/infrastructure/transport/production-upstream.test.ts b/apps/api/src/infrastructure/transport/production-upstream.test.ts new file mode 100644 index 0000000..c680fe3 --- /dev/null +++ b/apps/api/src/infrastructure/transport/production-upstream.test.ts @@ -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 }], + }); + }); +}); diff --git a/apps/api/src/infrastructure/transport/production-upstream.ts b/apps/api/src/infrastructure/transport/production-upstream.ts new file mode 100644 index 0000000..be82783 --- /dev/null +++ b/apps/api/src/infrastructure/transport/production-upstream.ts @@ -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 request?: ( + request: PinnedRequest, + addresses: readonly ResolvedAddress[], + ) => Promise; +} +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), + request: (request, addresses) => requester.request(request, addresses), + }); + return createSafeControlPlaneUpstream(transport); +} diff --git a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.test.ts b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.test.ts new file mode 100644 index 0000000..377fa9e --- /dev/null +++ b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.test.ts @@ -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]"}', + }, + ]); + }); +}); diff --git a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts new file mode 100644 index 0000000..77f1bd2 --- /dev/null +++ b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts @@ -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), + }; +} diff --git a/apps/api/src/infrastructure/transport/safe-instance-transport.test.ts b/apps/api/src/infrastructure/transport/safe-instance-transport.test.ts new file mode 100644 index 0000000..a45aca3 --- /dev/null +++ b/apps/api/src/infrastructure/transport/safe-instance-transport.test.ts @@ -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); + 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>; 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); + }); + + 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); + expect(calls).toBe(1); + }); +}); diff --git a/apps/api/src/infrastructure/transport/safe-instance-transport.ts b/apps/api/src/infrastructure/transport/safe-instance-transport.ts new file mode 100644 index 0000000..844ca48 --- /dev/null +++ b/apps/api/src/infrastructure/transport/safe-instance-transport.ts @@ -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>; + readonly body: string; +} +export interface PinnedRequest { + readonly url: string; + readonly method: 'GET' | 'POST'; + readonly headers: Readonly>; + readonly body?: string; +} +export interface SafeTransportOptions { + readonly resolve: (hostname: string) => Promise; + readonly request: ( + request: PinnedRequest, + addresses: readonly ResolvedAddress[], + ) => Promise; +} +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 { + return this.send({ url: raw, method: 'GET', headers: {} }); + } + async post( + raw: string, + headers: Readonly>, + body: string, + ): Promise { + return this.send({ url: raw, method: 'POST', headers, body }); + } + private async send(request: PinnedRequest): Promise { + 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; + } +} diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts new file mode 100644 index 0000000..cf69ecb --- /dev/null +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts new file mode 100644 index 0000000..4ea95df --- /dev/null +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts @@ -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; + post( + url: string, + headers: Readonly>, + body: string, + ): Promise; +} +export class SafeUpstreamGateway { + constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {} + async request(request: UpstreamRequest): Promise { + 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'); + } +} diff --git a/apps/api/src/infrastructure/transport/upstream-error.ts b/apps/api/src/infrastructure/transport/upstream-error.ts new file mode 100644 index 0000000..7360b55 --- /dev/null +++ b/apps/api/src/infrastructure/transport/upstream-error.ts @@ -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 }); diff --git a/apps/api/src/interface/http/instance-routes.test.ts b/apps/api/src/interface/http/instance-routes.test.ts new file mode 100644 index 0000000..cd8bb48 --- /dev/null +++ b/apps/api/src/interface/http/instance-routes.test.ts @@ -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 = { + 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(); + } + }); +}); diff --git a/apps/api/src/interface/http/instance-routes.ts b/apps/api/src/interface/http/instance-routes.ts new file mode 100644 index 0000000..9343003 --- /dev/null +++ b/apps/api/src/interface/http/instance-routes.ts @@ -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 => ({ + 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 => { + if (body === null || typeof body !== 'object' || Array.isArray(body)) + throw new InstanceServiceError('VALIDATION_FAILED', 'Request body must be an object'); + return body as Record; +}; +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; + 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; + } + 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; + } + 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 = + ( + handler: ( + request: FastifyRequest, + reply: { header: (name: string, value: string) => unknown }, + ) => Promise, + ) => + 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 | 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'); + }), + ); + } +} diff --git a/apps/api/src/production-control-plane.ts b/apps/api/src/production-control-plane.ts new file mode 100644 index 0000000..441ea23 --- /dev/null +++ b/apps/api/src/production-control-plane.ts @@ -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; + } +} diff --git a/apps/api/src/start.test.ts b/apps/api/src/start.test.ts index e09964a..a6b894d 100644 --- a/apps/api/src/start.test.ts +++ b/apps/api/src/start.test.ts @@ -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((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(); + }); }); diff --git a/apps/api/src/start.ts b/apps/api/src/start.ts index 55911ad..a12e7fd 100644 --- a/apps/api/src/start.ts +++ b/apps/api/src/start.ts @@ -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; + readonly databasePath?: string; + readonly app?: { + listen(options: { readonly host: string; readonly port: number }): Promise; + close(): Promise; + retryPendingSecretCleanup?: () => Promise; + }; } export async function startApi(options: StartApiOptions = {}): Promise { - 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. + } + } } diff --git a/package.json b/package.json index a2d0845..51cd8af 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,6 @@ "prettier": "3.8.1", "typescript": "5.9.3", "typescript-eslint": "8.53.0", - "vitest": "4.0.18" + "vitest": "4.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fed0e2..6624f69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,11 +31,14 @@ importers: specifier: 8.53.0 version: 8.53.0(eslint@9.39.2)(typescript@5.9.3) vitest: - specifier: 4.0.18 - version: 4.0.18(@types/node@24.13.3) + specifier: 4.1.0 + version: 4.1.0(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)) apps/api: dependencies: + '@multi-simadmin/contracts': + specifier: workspace:* + version: link:../../packages/contracts better-sqlite3: specifier: 12.11.1 version: 12.11.1 @@ -532,34 +535,34 @@ packages: resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -668,6 +671,9 @@ packages: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -803,8 +809,8 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -1290,8 +1296,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1416,20 +1422,21 @@ packages: yaml: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -1848,43 +1855,45 @@ snapshots: '@typescript-eslint/types': 8.53.0 eslint-visitor-keys: 4.2.1 - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.0': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(vite@7.3.6(@types/node@24.13.3))': + '@vitest/mocker@4.1.0(vite@7.3.6(@types/node@24.13.3))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.6(@types/node@24.13.3) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.0': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.0': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.0 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.0': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 tinyrainbow: 3.1.0 abstract-logging@2.0.1: {} @@ -1988,6 +1997,8 @@ snapshots: content-disposition@1.1.0: {} + convert-source-map@2.0.0: {} + cookie@1.1.1: {} cross-spawn@7.0.6: @@ -2023,7 +2034,7 @@ snapshots: dependencies: once: 1.4.0 - es-module-lexer@1.7.0: {} + es-module-lexer@2.3.1: {} esbuild@0.28.1: optionalDependencies: @@ -2546,7 +2557,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.2.0: {} string_decoder@1.3.0: dependencies: @@ -2639,22 +2650,22 @@ snapshots: '@types/node': 24.13.3 fsevents: 2.3.3 - vitest@4.0.18(@types/node@24.13.3): + vitest@4.1.0(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.6(@types/node@24.13.3)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@7.3.6(@types/node@24.13.3)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 picomatch: 4.0.5 - std-env: 3.10.0 + std-env: 4.2.0 tinybench: 2.9.0 tinyexec: 1.2.4 tinyglobby: 0.2.17 @@ -2664,17 +2675,7 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml which@2.0.2: dependencies: