feat(api): complete phase 2.4 control plane
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@ import Fastify, {
|
||||
type FastifyServerOptions,
|
||||
type FastifyRequest,
|
||||
} from 'fastify';
|
||||
import { UpstreamError } from './infrastructure/transport/upstream-error.js';
|
||||
|
||||
export const API_DEFAULT_HOST = '127.0.0.1' as const;
|
||||
export const API_DEFAULT_PORT = 8790 as const;
|
||||
@@ -144,6 +145,7 @@ export function createListenOptions(environment: ListenEnvironment): ListenOptio
|
||||
throw new Error('API port must be an integer between 1 and 65535');
|
||||
}
|
||||
if (port === LEGACY_PORT) throw new Error('API port 8788 is reserved for the legacy service');
|
||||
if (port !== API_DEFAULT_PORT) throw new Error('API port is fixed at 8790');
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
@@ -161,6 +163,12 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
|
||||
const fastifyOptions: FastifyServerOptions = {
|
||||
genReqId: () => randomUUID(),
|
||||
logger,
|
||||
ajv: {
|
||||
customOptions: {
|
||||
removeAdditional: false,
|
||||
coerceTypes: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const app = Fastify(fastifyOptions);
|
||||
const readiness = options.readiness ?? (() => ({ ready: true, checks: { bootstrap: 'ready' } }));
|
||||
@@ -216,6 +224,26 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (error instanceof UpstreamError && error.code !== 'UPSTREAM_REQUEST_INVALID') {
|
||||
const status =
|
||||
error.code === 'UPSTREAM_INSECURE_AUTH'
|
||||
? 400
|
||||
: error.code === 'UPSTREAM_TIMEOUT'
|
||||
? 504
|
||||
: 502;
|
||||
const title =
|
||||
status === 400 ? 'Bad Request' : status === 504 ? 'Gateway Timeout' : 'Bad Gateway';
|
||||
const detail =
|
||||
status === 400
|
||||
? 'Authentication requires an HTTPS instance origin.'
|
||||
: status === 504
|
||||
? 'The upstream instance did not respond in time.'
|
||||
: 'The upstream instance could not complete the request.';
|
||||
return reply
|
||||
.code(status)
|
||||
.type('application/problem+json')
|
||||
.send(sendProblem(request, status, title, error.code, detail));
|
||||
}
|
||||
const status = clientStatus(error);
|
||||
if (status !== undefined) {
|
||||
const title = CLIENT_ERROR_TITLES[status] ?? 'Bad Request';
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { InstanceService } from '../instances/instance-service.js';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import type { TransportResponse } from '../../infrastructure/transport/safe-instance-transport.js';
|
||||
import { ConnectionProbe } from './connection-probe.js';
|
||||
|
||||
class Store implements SecretStore {
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||
return `keychain://multi-simadmin/${Buffer.from(JSON.stringify([key.instanceId, key.purpose, key.slot])).toString('base64url')}`;
|
||||
}
|
||||
async get() {
|
||||
return undefined;
|
||||
}
|
||||
async delete() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const dbs: Database.Database[] = [];
|
||||
afterEach(() => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
const fixture = (response: TransportResponse) => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
const instances = new InstanceService({
|
||||
db,
|
||||
store: new Store(),
|
||||
idFactory: () => 'instance-1',
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
});
|
||||
const transport = {
|
||||
get: async (url: string) => {
|
||||
expect(url).toBe('http://192.168.1.20:3000/api/health');
|
||||
return response;
|
||||
},
|
||||
};
|
||||
return {
|
||||
db,
|
||||
instances,
|
||||
probe: new ConnectionProbe({
|
||||
db,
|
||||
instances,
|
||||
transport,
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe('ConnectionProbe', () => {
|
||||
it('uses the unauthenticated health endpoint and records a reachable unauthenticated instance', async () => {
|
||||
const { instances, probe } = fixture({ status: 200, headers: {}, body: '{}' });
|
||||
await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' });
|
||||
await expect(probe.test('instance-1')).resolves.toEqual({
|
||||
instanceId: 'instance-1',
|
||||
authenticated: true,
|
||||
checkedAt: '2026-07-16T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
it('treats 401 as reachable but not authenticated rather than offline', async () => {
|
||||
const { instances, probe } = fixture({ status: 401, headers: {}, body: '{}' });
|
||||
await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' });
|
||||
await expect(probe.test('instance-1')).resolves.toMatchObject({ authenticated: false });
|
||||
});
|
||||
it('persists only a redacted connection freshness snapshot, not a blanket capability claim', async () => {
|
||||
const { db, instances, probe } = fixture({ status: 401, headers: {}, body: '{}' });
|
||||
await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' });
|
||||
await probe.test('instance-1');
|
||||
await probe.test('instance-1');
|
||||
const snapshots = db
|
||||
.prepare('SELECT category, state, payload_json, expires_at FROM status_snapshots')
|
||||
.all() as {
|
||||
category: string;
|
||||
state: string;
|
||||
payload_json: string;
|
||||
expires_at: string;
|
||||
}[];
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(snapshots[0]).toMatchObject({ category: 'connection', state: 'fresh' });
|
||||
expect(JSON.parse(snapshots[0]!.payload_json)).toEqual({
|
||||
reachable: true,
|
||||
authenticated: false,
|
||||
});
|
||||
expect(snapshots[0]!.expires_at).toBe('2026-07-16T12:00:30.000Z');
|
||||
expect(db.prepare('SELECT * FROM capabilities').all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not invoke transport for an unknown instance', async () => {
|
||||
const { probe } = fixture({ status: 200, headers: {}, body: '{}' });
|
||||
await expect(probe.test('missing')).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { SessionMetadata } from '@multi-simadmin/contracts';
|
||||
import { InstanceService, InstanceServiceError } from '../instances/instance-service.js';
|
||||
import type { TransportResponse } from '../../infrastructure/transport/safe-instance-transport.js';
|
||||
|
||||
export interface ConnectionTransport {
|
||||
readonly get: (url: string) => Promise<TransportResponse>;
|
||||
}
|
||||
export interface ConnectionProbeOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly instances: InstanceService;
|
||||
readonly transport: ConnectionTransport;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
const CONNECTION_SNAPSHOT_TTL_MS = 30_000;
|
||||
|
||||
export class ConnectionProbe {
|
||||
private readonly now: () => Date;
|
||||
constructor(private readonly options: ConnectionProbeOptions) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
}
|
||||
async test(instanceId: string): Promise<SessionMetadata> {
|
||||
const instance = await this.options.instances.get(instanceId);
|
||||
if (!instance) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
||||
const response = await this.options.transport.get(`${instance.origin}/api/health`);
|
||||
const observed = this.now();
|
||||
const observedAt = observed.toISOString();
|
||||
const expiresAt = new Date(observed.getTime() + CONNECTION_SNAPSHOT_TTL_MS).toISOString();
|
||||
const authenticated = response.status >= 200 && response.status < 300;
|
||||
this.options.db
|
||||
.prepare(
|
||||
`INSERT INTO status_snapshots (id, instance_id, category, state, payload_json, observed_at, expires_at, created_at)
|
||||
VALUES (?, ?, 'connection', 'fresh', ?, ?, ?, ?)
|
||||
ON CONFLICT(instance_id, category) DO UPDATE SET
|
||||
id=excluded.id,
|
||||
state=excluded.state,
|
||||
payload_json=excluded.payload_json,
|
||||
observed_at=excluded.observed_at,
|
||||
expires_at=excluded.expires_at,
|
||||
created_at=excluded.created_at`,
|
||||
)
|
||||
.run(
|
||||
randomUUID(),
|
||||
instanceId,
|
||||
JSON.stringify({ reachable: true, authenticated }),
|
||||
observedAt,
|
||||
expiresAt,
|
||||
observedAt,
|
||||
);
|
||||
return { instanceId, authenticated, checkedAt: observedAt };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { InstanceCredentialResolver } from './instance-credential-resolver.js';
|
||||
|
||||
const refs = (instanceId: string) =>
|
||||
`keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify([instanceId, 'instance-password', 'slot-1']),
|
||||
).toString('base64url')}`;
|
||||
const dbs: Database.Database[] = [];
|
||||
afterEach(() => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
const fixture = (store: SecretStore) => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
db.prepare(
|
||||
"INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES ('i1','N','http://192.168.1.20:3000','password',1,1,'t','t')",
|
||||
).run();
|
||||
return { db, resolver: new InstanceCredentialResolver({ db, store }) };
|
||||
};
|
||||
|
||||
describe('InstanceCredentialResolver', () => {
|
||||
it('resolves only the canonical password reference for the requested existing instance', async () => {
|
||||
let asked: string | undefined;
|
||||
const { db, resolver } = fixture({
|
||||
set: async () => '',
|
||||
get: async (reference) => {
|
||||
asked = reference;
|
||||
return '[REDACTED]';
|
||||
},
|
||||
delete: async () => false,
|
||||
});
|
||||
const reference = refs('i1');
|
||||
db.prepare(
|
||||
"INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES ('s1','i1','instance-password','macos-keychain',?,'t','t')",
|
||||
).run(reference);
|
||||
await expect(resolver.resolve('i1')).resolves.toBe('[REDACTED]');
|
||||
expect(asked).toBe(reference);
|
||||
});
|
||||
it('does not call SecretStore when no saved credential exists', async () => {
|
||||
let calls = 0;
|
||||
const { resolver } = fixture({
|
||||
set: async () => '',
|
||||
get: async () => {
|
||||
calls += 1;
|
||||
return '[REDACTED]';
|
||||
},
|
||||
delete: async () => false,
|
||||
});
|
||||
await expect(resolver.resolve('i1')).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' });
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
it('rejects a reference bound to another instance before SecretStore access', async () => {
|
||||
let calls = 0;
|
||||
const { db, resolver } = fixture({
|
||||
set: async () => '',
|
||||
get: async () => {
|
||||
calls += 1;
|
||||
return '[REDACTED]';
|
||||
},
|
||||
delete: async () => false,
|
||||
});
|
||||
db.prepare(
|
||||
"INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES ('s1','i1','instance-password','macos-keychain',?,'t','t')",
|
||||
).run(refs('other'));
|
||||
await expect(resolver.resolve('i1')).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' });
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
|
||||
const PURPOSE = 'instance-password';
|
||||
const PROVIDER = 'macos-keychain';
|
||||
export class CredentialResolverError extends Error {
|
||||
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export interface InstanceCredentialResolverOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly store: SecretStore;
|
||||
}
|
||||
export class InstanceCredentialResolver {
|
||||
constructor(private readonly options: InstanceCredentialResolverOptions) {}
|
||||
async resolve(instanceId: string): Promise<string> {
|
||||
const instance = this.options.db
|
||||
.prepare('SELECT id FROM instances WHERE id=?')
|
||||
.get(instanceId) as { id: string } | undefined;
|
||||
if (!instance) throw new CredentialResolverError('NOT_FOUND');
|
||||
const row = this.options.db
|
||||
.prepare(
|
||||
'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?',
|
||||
)
|
||||
.get(instanceId, PURPOSE, PROVIDER) as { external_reference: string } | undefined;
|
||||
if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
|
||||
try {
|
||||
const parsed = parseKeychainReference(row.external_reference);
|
||||
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot)
|
||||
throw new Error('invalid binding');
|
||||
const secret = await this.options.store.get(row.external_reference);
|
||||
if (!secret) throw new Error('missing secret');
|
||||
return secret;
|
||||
} catch {
|
||||
throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import { InstanceSessionStore, UpstreamSessionClient } from './upstream-session-client.js';
|
||||
import { InstanceLoginService } from './instance-login-service.js';
|
||||
|
||||
const dbs: Database.Database[] = [];
|
||||
afterEach(() => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
const fixture = () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
db.prepare(
|
||||
"INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES ('i1','N','http://192.168.1.20:3000','password',1,1,'t','t')",
|
||||
).run();
|
||||
const calls: unknown[] = [];
|
||||
const sessions = new InstanceSessionStore();
|
||||
const client = new UpstreamSessionClient({
|
||||
sessions,
|
||||
request: async (request) => {
|
||||
calls.push(request);
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'set-cookie': 'simadmin_session=opaque; HttpOnly' },
|
||||
body: '',
|
||||
};
|
||||
},
|
||||
});
|
||||
let resolverCalls = 0;
|
||||
const resolver = {
|
||||
resolve: async () => {
|
||||
resolverCalls += 1;
|
||||
return '[REDACTED]';
|
||||
},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
sessions,
|
||||
resolverCalls: () => resolverCalls,
|
||||
service: new InstanceLoginService({
|
||||
db,
|
||||
client,
|
||||
resolver,
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
}),
|
||||
};
|
||||
};
|
||||
describe('InstanceLoginService', () => {
|
||||
it('uses a supplied one-shot password without invoking saved-credential resolution', async () => {
|
||||
const { service, resolverCalls } = fixture();
|
||||
await expect(service.login('i1', '[REDACTED]')).resolves.toEqual({
|
||||
instanceId: 'i1',
|
||||
authenticated: true,
|
||||
checkedAt: '2026-07-16T12:00:00.000Z',
|
||||
});
|
||||
expect(resolverCalls()).toBe(0);
|
||||
});
|
||||
it('uses resolver only when the password input is omitted', async () => {
|
||||
const { service } = fixture();
|
||||
await expect(service.login('i1')).resolves.toMatchObject({ authenticated: true });
|
||||
});
|
||||
it('returns unauthenticated metadata after logout and clears the local session', async () => {
|
||||
const { service, sessions } = fixture();
|
||||
await service.login('i1', '[REDACTED]');
|
||||
await expect(service.logout('i1')).resolves.toEqual({
|
||||
instanceId: 'i1',
|
||||
authenticated: false,
|
||||
checkedAt: '2026-07-16T12:00:00.000Z',
|
||||
});
|
||||
expect(sessions.cookieFor('i1')).toBeUndefined();
|
||||
});
|
||||
it('does not contact upstream for an unknown instance', async () => {
|
||||
const { service, calls } = fixture();
|
||||
await expect(service.login('missing', '[REDACTED]')).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { SessionMetadata } from '@multi-simadmin/contracts';
|
||||
import { CredentialResolverError } from './instance-credential-resolver.js';
|
||||
import { UpstreamSessionClient } from './upstream-session-client.js';
|
||||
|
||||
export interface CredentialResolver {
|
||||
resolve(instanceId: string): Promise<string>;
|
||||
}
|
||||
export class InstanceLoginServiceError extends Error {
|
||||
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export interface InstanceLoginServiceOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly client: UpstreamSessionClient;
|
||||
readonly resolver: CredentialResolver;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
export class InstanceLoginService {
|
||||
private readonly now: () => Date;
|
||||
constructor(private readonly options: InstanceLoginServiceOptions) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
}
|
||||
async login(instanceId: string, password?: string): Promise<SessionMetadata> {
|
||||
const origin = this.origin(instanceId);
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = password ?? (await this.options.resolver.resolve(instanceId));
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialResolverError) throw new InstanceLoginServiceError(error.code);
|
||||
throw error;
|
||||
}
|
||||
const result = await this.options.client.login(instanceId, origin, resolved);
|
||||
return { instanceId, authenticated: result.authenticated, checkedAt: this.now().toISOString() };
|
||||
}
|
||||
async logout(instanceId: string): Promise<SessionMetadata> {
|
||||
await this.options.client.logout(instanceId, this.origin(instanceId));
|
||||
return { instanceId, authenticated: false, checkedAt: this.now().toISOString() };
|
||||
}
|
||||
private origin(instanceId: string): string {
|
||||
const row = this.options.db
|
||||
.prepare('SELECT base_url FROM instances WHERE id=?')
|
||||
.get(instanceId) as { base_url: string } | undefined;
|
||||
if (!row) throw new InstanceLoginServiceError('NOT_FOUND');
|
||||
return row.base_url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { InstanceSessionStore, UpstreamSessionClient } from './upstream-session-client.js';
|
||||
|
||||
describe('UpstreamSessionClient', () => {
|
||||
it('keeps only the simadmin_session cookie in memory after successful login', async () => {
|
||||
const sessions = new InstanceSessionStore();
|
||||
const client = new UpstreamSessionClient({
|
||||
sessions,
|
||||
request: async (request) => {
|
||||
expect(request.url).toBe('http://192.168.1.20:3000/api/auth/login');
|
||||
expect(request.method).toBe('POST');
|
||||
expect(request.headers['content-type']).toBe('application/json');
|
||||
expect(request.body).toBe('[REDACTED]');
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'set-cookie': 'simadmin_session=opaque; HttpOnly; Path=/, another=value' },
|
||||
body: '{}',
|
||||
};
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
client.login('instance-1', 'http://192.168.1.20:3000', '[REDACTED]'),
|
||||
).resolves.toEqual({ authenticated: true });
|
||||
expect(sessions.cookieFor('instance-1')).toBe('simadmin_session=opaque');
|
||||
});
|
||||
it('does not create a session when login is rejected', async () => {
|
||||
const sessions = new InstanceSessionStore();
|
||||
const client = new UpstreamSessionClient({
|
||||
sessions,
|
||||
request: async () => ({ status: 401, headers: {}, body: '' }),
|
||||
});
|
||||
await expect(
|
||||
client.login('instance-1', 'http://192.168.1.20:3000', '[REDACTED]'),
|
||||
).resolves.toEqual({ authenticated: false });
|
||||
expect(sessions.cookieFor('instance-1')).toBeUndefined();
|
||||
});
|
||||
it('does not authenticate a successful response that omits simadmin_session', async () => {
|
||||
const sessions = new InstanceSessionStore();
|
||||
const client = new UpstreamSessionClient({
|
||||
sessions,
|
||||
request: async () => ({
|
||||
status: 200,
|
||||
headers: { 'set-cookie': 'another=value; HttpOnly' },
|
||||
body: '',
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
client.login('instance-1', 'http://192.168.1.20:3000', '[REDACTED]'),
|
||||
).resolves.toEqual({ authenticated: false });
|
||||
expect(sessions.cookieFor('instance-1')).toBeUndefined();
|
||||
});
|
||||
it('does not send a cookie after the instance origin changes', async () => {
|
||||
const sessions = new InstanceSessionStore();
|
||||
const requests: unknown[] = [];
|
||||
const client = new UpstreamSessionClient({
|
||||
sessions,
|
||||
request: async (request) => {
|
||||
requests.push(request);
|
||||
return { status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '' };
|
||||
},
|
||||
});
|
||||
await client.login('one', 'http://192.168.1.10:8080', '[REDACTED]');
|
||||
await client.logout('one', 'http://192.168.1.11:8080');
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(sessions.cookieFor('one')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends the stored cookie only to the matching instance logout endpoint, then clears it', async () => {
|
||||
const sessions = new InstanceSessionStore();
|
||||
sessions.set('instance-1', 'http://192.168.1.20:3000', 'simadmin_session=opaque');
|
||||
const client = new UpstreamSessionClient({
|
||||
sessions,
|
||||
request: async (request) => {
|
||||
expect(request.url).toBe('http://192.168.1.20:3000/api/auth/logout');
|
||||
expect(request.headers.cookie).toBe('simadmin_session=opaque');
|
||||
return { status: 200, headers: {}, body: '' };
|
||||
},
|
||||
});
|
||||
await client.logout('instance-1', 'http://192.168.1.20:3000');
|
||||
expect(sessions.cookieFor('instance-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface UpstreamRequest {
|
||||
readonly url: string;
|
||||
readonly method: 'POST';
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
/** One-shot secret. Request implementations must not log or persist this field. */
|
||||
readonly secret?: string;
|
||||
/** Redacted representation suitable for observability, never the password. */
|
||||
readonly body?: '[REDACTED]';
|
||||
}
|
||||
export interface UpstreamResponse {
|
||||
readonly status: number;
|
||||
readonly headers: Readonly<Record<string, string | undefined>>;
|
||||
readonly body: string;
|
||||
}
|
||||
export interface UpstreamSessionClientOptions {
|
||||
readonly sessions: InstanceSessionStore;
|
||||
readonly request: (request: UpstreamRequest) => Promise<UpstreamResponse>;
|
||||
}
|
||||
export interface InstanceSession {
|
||||
readonly origin: string;
|
||||
readonly cookie: string;
|
||||
}
|
||||
export class InstanceSessionStore {
|
||||
private readonly sessions = new Map<string, InstanceSession>();
|
||||
set(instanceId: string, origin: string, cookie: string): void {
|
||||
this.sessions.set(instanceId, { origin, cookie });
|
||||
}
|
||||
sessionFor(instanceId: string): InstanceSession | undefined {
|
||||
return this.sessions.get(instanceId);
|
||||
}
|
||||
cookieFor(instanceId: string): string | undefined {
|
||||
return this.sessions.get(instanceId)?.cookie;
|
||||
}
|
||||
clear(instanceId: string): void {
|
||||
this.sessions.delete(instanceId);
|
||||
}
|
||||
}
|
||||
const sessionCookie = (header: string | undefined): string | undefined => {
|
||||
if (!header) return undefined;
|
||||
const match = /(?:^|,\s*)simadmin_session=([^;\s,]+)/.exec(header);
|
||||
return match ? `simadmin_session=${match[1]}` : undefined;
|
||||
};
|
||||
export class UpstreamSessionClient {
|
||||
constructor(private readonly options: UpstreamSessionClientOptions) {}
|
||||
async login(
|
||||
instanceId: string,
|
||||
origin: string,
|
||||
password: string,
|
||||
): Promise<{ authenticated: boolean }> {
|
||||
const response = await this.options.request({
|
||||
url: `${origin}/api/auth/login`,
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
secret: password,
|
||||
body: '[REDACTED]',
|
||||
});
|
||||
const cookie =
|
||||
response.status >= 200 && response.status < 300
|
||||
? sessionCookie(response.headers['set-cookie'])
|
||||
: undefined;
|
||||
if (!cookie) {
|
||||
this.options.sessions.clear(instanceId);
|
||||
return { authenticated: false };
|
||||
}
|
||||
this.options.sessions.set(instanceId, origin, cookie);
|
||||
return { authenticated: true };
|
||||
}
|
||||
async logout(instanceId: string, origin: string): Promise<void> {
|
||||
const session = this.options.sessions.sessionFor(instanceId);
|
||||
try {
|
||||
if (session?.origin === origin)
|
||||
await this.options.request({
|
||||
url: `${origin}/api/auth/logout`,
|
||||
method: 'POST',
|
||||
headers: { cookie: session.cookie },
|
||||
});
|
||||
} finally {
|
||||
this.options.sessions.clear(instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import type { Instance } from '@multi-simadmin/contracts';
|
||||
import { InstanceService, InstanceServiceError } from './instance-service.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly values = new Map<string, string>();
|
||||
readonly deletes: string[] = [];
|
||||
readonly sets: Array<{
|
||||
key: { instanceId: string; purpose: string; slot?: string };
|
||||
value: string;
|
||||
}> = [];
|
||||
onDelete?: (reference: string) => void;
|
||||
overrideReference?: string;
|
||||
failSet = false;
|
||||
failDelete = false;
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
||||
if (this.failSet) throw new Error(`keychain leaked ${value}`);
|
||||
const account = Buffer.from(
|
||||
JSON.stringify([key.instanceId, key.purpose, key.slot]),
|
||||
'utf8',
|
||||
).toString('base64url');
|
||||
const ref = this.overrideReference ?? `keychain://multi-simadmin/${account}`;
|
||||
this.sets.push({ key, value });
|
||||
this.values.set(ref, value);
|
||||
return ref;
|
||||
}
|
||||
async get(reference: string) {
|
||||
return this.values.get(reference);
|
||||
}
|
||||
async delete(reference: string) {
|
||||
this.deletes.push(reference);
|
||||
this.onDelete?.(reference);
|
||||
if (this.failDelete) throw new Error(`cannot delete ${this.values.get(reference)}`);
|
||||
return this.values.delete(reference);
|
||||
}
|
||||
}
|
||||
const dbs: Database.Database[] = [];
|
||||
afterEach(() => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
function fixture(ids = ['id-1', 'slot-1', 'ref-1', 'id-2', 'slot-2', 'ref-2']) {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
const store = new MemorySecrets();
|
||||
let index = 0;
|
||||
const service = new InstanceService({
|
||||
db,
|
||||
store,
|
||||
idFactory: () => ids[index++]!,
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
});
|
||||
return { db, store, service };
|
||||
}
|
||||
const basic = { name: ' Alpha ', origin: 'http://192.168.1.10:8080/', tags: [' z ', 'a', 'a'] };
|
||||
const code = async (promise: Promise<unknown>) => {
|
||||
try {
|
||||
await promise;
|
||||
} catch (e) {
|
||||
return (e as InstanceServiceError).code;
|
||||
}
|
||||
throw new Error('expected rejection');
|
||||
};
|
||||
|
||||
describe('InstanceService', () => {
|
||||
it('starts RED until the repository module exists', () =>
|
||||
expect(InstanceService).toBeTypeOf('function'));
|
||||
|
||||
it('creates and gets normalized instances without reading or persisting a secret', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
const created = await service.create(basic);
|
||||
expect(created).toEqual({
|
||||
id: 'id-1',
|
||||
name: 'Alpha',
|
||||
origin: 'http://192.168.1.10:8080',
|
||||
tags: ['a', 'z'],
|
||||
revision: 1,
|
||||
capabilityStatus: 'unknown',
|
||||
freshness: 'unknown',
|
||||
credentialConfigured: false,
|
||||
});
|
||||
expect(await service.get('id-1')).toEqual(created);
|
||||
expect(store.sets).toHaveLength(0);
|
||||
expect(
|
||||
JSON.stringify(
|
||||
db.prepare('select * from instances join instance_tags on id=instance_id').all(),
|
||||
),
|
||||
).not.toContain('password');
|
||||
});
|
||||
|
||||
it('stores a password under a unique slot and SQLite contains only its opaque reference', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
const secret = 'super-secret-2.4A';
|
||||
const result = await service.create({
|
||||
...basic,
|
||||
password: { action: 'set', password: secret },
|
||||
});
|
||||
expect(result.credentialConfigured).toBe(true);
|
||||
expect(store.sets[0]).toMatchObject({
|
||||
key: { instanceId: 'id-1', purpose: 'instance-password', slot: 'slot-1' },
|
||||
value: secret,
|
||||
});
|
||||
const allText = JSON.stringify(
|
||||
db
|
||||
.prepare('select id,instance_id,purpose,provider,external_reference from secret_references')
|
||||
.all(),
|
||||
);
|
||||
expect(allText).toContain('macos-keychain');
|
||||
expect(allText).not.toContain(secret);
|
||||
expect(JSON.stringify(result)).not.toContain(secret);
|
||||
expect(store.get).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('rejects a secret-store reference not bound to the requested instance and slot', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
store.overrideReference = `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify(['other-instance', 'instance-password', 'other-slot']),
|
||||
'utf8',
|
||||
).toString('base64url')}`;
|
||||
expect(
|
||||
await code(service.create({ ...basic, password: { action: 'set', password: 'secret' } })),
|
||||
).toBe('SECRET_STORE_FAILED');
|
||||
expect(db.prepare('SELECT * FROM secret_references').all()).toEqual([]);
|
||||
expect(store.deletes).toEqual([store.overrideReference]);
|
||||
expect(store.values.size).toBe(0);
|
||||
});
|
||||
|
||||
it('fails closed when an invalid returned secret reference cannot be removed', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
store.overrideReference = `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify(['other-instance', 'instance-password', 'other-slot']),
|
||||
'utf8',
|
||||
).toString('base64url')}`;
|
||||
store.failDelete = true;
|
||||
expect(
|
||||
await code(service.create({ ...basic, password: { action: 'set', password: 'secret' } })),
|
||||
).toBe('COMPENSATION_PERSISTENCE_FAILED');
|
||||
expect(db.prepare('SELECT * FROM secret_references').all()).toEqual([]);
|
||||
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects duplicate normalized origins and duplicate generated ids with stable errors', async () => {
|
||||
const x = fixture(['same', 'same', 'same']);
|
||||
await x.service.create(basic);
|
||||
expect(await code(x.service.create({ ...basic, name: 'B' }))).toBe('DUPLICATE_ORIGIN');
|
||||
expect(await code(x.service.create({ ...basic, origin: 'http://10.1.2.3' }))).toBe(
|
||||
'DUPLICATE_ID',
|
||||
);
|
||||
});
|
||||
|
||||
it('filters, sorts with id tie-breaks, and paginates aggregate output', async () => {
|
||||
const { db, service } = fixture(['b', 'a']);
|
||||
await service.create({ name: 'Same', origin: 'http://10.0.0.2', tags: ['blue'] });
|
||||
await service.create({
|
||||
name: 'Same',
|
||||
origin: 'http://10.0.0.3',
|
||||
tags: ['red'],
|
||||
password: { action: 'preserve' },
|
||||
});
|
||||
db.prepare('insert into capabilities values (?,?,?,?,?,?,?,?)').run(
|
||||
'b',
|
||||
'x',
|
||||
'supported',
|
||||
null,
|
||||
null,
|
||||
'2026-07-16T11:00:00Z',
|
||||
'2026-07-16T11:00:00Z',
|
||||
'2026-07-16T11:00:00Z',
|
||||
);
|
||||
db.prepare('insert into capabilities values (?,?,?,?,?,?,?,?)').run(
|
||||
'b',
|
||||
'y',
|
||||
'degraded',
|
||||
null,
|
||||
null,
|
||||
'2026-07-16T11:00:00Z',
|
||||
'2026-07-16T11:00:00Z',
|
||||
'2026-07-16T11:00:00Z',
|
||||
);
|
||||
db.prepare('insert into status_snapshots values (?,?,?,?,?,?,?,?)').run(
|
||||
's',
|
||||
'b',
|
||||
'health',
|
||||
'fresh',
|
||||
'{}',
|
||||
'2026-07-16T11:00:00Z',
|
||||
'2026-07-16T13:00:00Z',
|
||||
'2026-07-16T11:00:00Z',
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await service.list({
|
||||
search: 'b',
|
||||
tag: 'blue',
|
||||
capabilityStatus: 'degraded',
|
||||
freshness: 'fresh',
|
||||
})
|
||||
).items.map((x: Instance) => x.id),
|
||||
).toEqual(['b']);
|
||||
expect((await service.list({ sort: 'name', page: 1, pageSize: 1 })).items[0]?.id).toBe('a');
|
||||
expect((await service.list({ sort: 'name', page: 2, pageSize: 1 })).page).toEqual({
|
||||
page: 2,
|
||||
pageSize: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates with revision CAS, normalized fields, and one revision increment', async () => {
|
||||
const { service } = fixture();
|
||||
await service.create(basic);
|
||||
expect(await code(service.update('id-1', 9, { name: 'No' }))).toBe('REVISION_CONFLICT');
|
||||
const result = await service.update('id-1', 1, { name: ' Beta ', tags: ['x'] });
|
||||
expect(result).toMatchObject({
|
||||
name: 'Beta',
|
||||
tags: ['x'],
|
||||
revision: 2,
|
||||
credentialConfigured: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves, replaces and clears credentials without exposing them', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
await service.create({ ...basic, password: { action: 'set', password: 'first-secret' } });
|
||||
await service.update('id-1', 1, { password: { action: 'preserve' } });
|
||||
expect(store.sets).toHaveLength(1);
|
||||
const replaced = await service.update('id-1', 2, {
|
||||
password: { action: 'set', password: 'second-secret' },
|
||||
});
|
||||
expect(replaced.credentialConfigured).toBe(true);
|
||||
expect(store.deletes).toEqual([
|
||||
expect.stringMatching(/^keychain:\/\/multi-simadmin\/[A-Za-z0-9_-]+$/),
|
||||
]);
|
||||
const cleared = await service.update('id-1', 3, { password: { action: 'clear' } });
|
||||
expect(cleared.credentialConfigured).toBe(false);
|
||||
expect(db.prepare('select * from secret_references').all()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('compensates a newly stored secret when the DB transaction fails', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
await service.create(basic);
|
||||
const occupiedReference = `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify(['id-1', 'instance-password', 'slot-1']),
|
||||
'utf8',
|
||||
).toString('base64url')}`;
|
||||
db.prepare('insert into secret_references values (?,?,?,?,?,?,?)').run(
|
||||
'occupied',
|
||||
null,
|
||||
'other',
|
||||
'macos-keychain',
|
||||
occupiedReference,
|
||||
'now',
|
||||
'now',
|
||||
);
|
||||
const error = await code(
|
||||
service.update('id-1', 1, { password: { action: 'set', password: 'super-secret' } }),
|
||||
);
|
||||
expect(error).toBe('DATABASE_FAILED');
|
||||
expect(store.values.size).toBe(0);
|
||||
});
|
||||
|
||||
it('surfaces an explicit fail-closed error when compensation cleanup cannot be persisted', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
await service.create(basic);
|
||||
const occupiedReference = `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify(['id-1', 'instance-password', 'slot-1']),
|
||||
'utf8',
|
||||
).toString('base64url')}`;
|
||||
db.prepare('insert into secret_references values (?,?,?,?,?,?,?)').run(
|
||||
'occupied',
|
||||
null,
|
||||
'other',
|
||||
'macos-keychain',
|
||||
occupiedReference,
|
||||
'now',
|
||||
'now',
|
||||
);
|
||||
db.exec(
|
||||
`CREATE TRIGGER fail_cleanup_outbox BEFORE INSERT ON secret_cleanup_tasks BEGIN SELECT RAISE(ABORT, 'outbox unavailable'); END`,
|
||||
);
|
||||
store.failDelete = true;
|
||||
expect(
|
||||
await code(
|
||||
service.update('id-1', 1, { password: { action: 'set', password: 'super-secret' } }),
|
||||
),
|
||||
).toBe('COMPENSATION_PERSISTENCE_FAILED');
|
||||
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletes with CAS, cascades dependent state, then cleans the secret', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
await service.create({ ...basic, password: { action: 'set', password: 'gone' } });
|
||||
db.prepare('insert into instance_tags values (?,?,?) on conflict do nothing').run(
|
||||
'id-1',
|
||||
'extra',
|
||||
'now',
|
||||
);
|
||||
expect(await code(service.delete('id-1', 2))).toBe('REVISION_CONFLICT');
|
||||
await service.delete('id-1', 1);
|
||||
expect(db.prepare('select * from instances').all()).toHaveLength(0);
|
||||
expect(db.prepare('select * from secret_references').all()).toHaveLength(0);
|
||||
expect(store.values.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://localhost',
|
||||
'http://127.0.0.1',
|
||||
'http://169.254.169.254',
|
||||
'http://0.0.0.0',
|
||||
'http://224.0.0.1',
|
||||
'https://8.8.8.8',
|
||||
'ftp://10.0.0.1',
|
||||
'http://u:p@10.0.0.1',
|
||||
'http://10.0.0.1/x?y=1',
|
||||
'http://10.0.0.1/#x',
|
||||
])('rejects unsafe origin %s', async (origin) => {
|
||||
const { service } = fixture();
|
||||
expect(await code(service.create({ name: 'x', origin }))).toBe('VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
it('validates bounded names/tags/password and accepts private IPv4 plus IPv6 ULA', async () => {
|
||||
const { service } = fixture(['one', 'two']);
|
||||
await service.create({ name: 'x', origin: 'https://172.16.0.1', tags: [' ok '] });
|
||||
await service.create({ name: 'y', origin: 'http://[fd00::1]:80' });
|
||||
for (const input of [
|
||||
{ name: ' ', origin: 'http://10.0.0.1' },
|
||||
{ name: 'x', origin: 'http://10.0.0.2', tags: [' '] },
|
||||
{ name: 'x', origin: 'http://10.0.0.3', password: { action: 'set', password: '' } as const },
|
||||
])
|
||||
expect(await code(service.create(input))).toBe('VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
it('returns redacted stable errors for secret-store failures and keeps committed cleanup durable', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
store.failSet = true;
|
||||
const setErr = await service
|
||||
.create({ ...basic, password: { action: 'set', password: 'never-print-me' } })
|
||||
.then(
|
||||
() => {
|
||||
throw new Error('expected set rejection');
|
||||
},
|
||||
(e) => e as InstanceServiceError,
|
||||
);
|
||||
expect(setErr.code).toBe('SECRET_STORE_FAILED');
|
||||
expect(JSON.stringify(setErr)).not.toContain('never-print-me');
|
||||
store.failSet = false;
|
||||
const created = await service.create({
|
||||
...basic,
|
||||
origin: 'http://10.0.0.2',
|
||||
password: { action: 'set', password: 'cleanup-secret' },
|
||||
});
|
||||
store.failDelete = true;
|
||||
await expect(service.delete(created.id, 1)).resolves.toBeUndefined();
|
||||
expect(await service.get(created.id)).toBeUndefined();
|
||||
const pending = db
|
||||
.prepare('SELECT reference,instance_id,purpose,provider FROM secret_cleanup_tasks')
|
||||
.all();
|
||||
expect(pending).toEqual([
|
||||
expect.objectContaining({
|
||||
instance_id: created.id,
|
||||
purpose: 'instance-password',
|
||||
provider: 'macos-keychain',
|
||||
reference: expect.stringContaining('keychain://multi-simadmin/'),
|
||||
}),
|
||||
]);
|
||||
store.failDelete = false;
|
||||
expect(await service.retryPendingSecretCleanup()).toEqual({
|
||||
attempted: 1,
|
||||
cleaned: 1,
|
||||
remaining: 0,
|
||||
});
|
||||
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists cleanup work for replacement and clear until explicit retry succeeds', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
await service.create({ ...basic, password: { action: 'set', password: 'first-secret' } });
|
||||
store.failDelete = true;
|
||||
const replacement = await service.update('id-1', 1, {
|
||||
password: { action: 'set', password: 'second-secret' },
|
||||
});
|
||||
expect(replacement).toMatchObject({ revision: 2, credentialConfigured: true });
|
||||
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toHaveLength(1);
|
||||
store.failDelete = false;
|
||||
expect(await service.retryPendingSecretCleanup()).toMatchObject({ remaining: 0 });
|
||||
const updated = await service.get('id-1');
|
||||
store.failDelete = true;
|
||||
const cleared = await service.update('id-1', updated!.revision, {
|
||||
password: { action: 'clear' },
|
||||
});
|
||||
expect(cleared).toMatchObject({ credentialConfigured: false });
|
||||
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects forged cleanup references and treats already absent credentials as cleaned', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
const now = '2026-07-16T12:00:00.000Z';
|
||||
const encoded = (parts: string[]) =>
|
||||
`keychain://multi-simadmin/${Buffer.from(JSON.stringify(parts), 'utf8').toString('base64url')}`;
|
||||
const forged = encoded(['other-instance', 'instance-password', 'slot']);
|
||||
db.prepare(
|
||||
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
|
||||
).run(forged, 'id-1', 'instance-password', 'macos-keychain', now, now);
|
||||
expect(await service.retryPendingSecretCleanup()).toEqual({
|
||||
attempted: 1,
|
||||
cleaned: 0,
|
||||
remaining: 1,
|
||||
});
|
||||
expect(store.deletes).toEqual([]);
|
||||
db.prepare('DELETE FROM secret_cleanup_tasks').run();
|
||||
|
||||
const absent = encoded(['id-1', 'instance-password', 'slot']);
|
||||
db.prepare(
|
||||
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
|
||||
).run(absent, 'id-1', 'instance-password', 'macos-keychain', now, now);
|
||||
expect(await service.retryPendingSecretCleanup()).toEqual({
|
||||
attempted: 1,
|
||||
cleaned: 1,
|
||||
remaining: 0,
|
||||
});
|
||||
expect(db.prepare('SELECT * FROM secret_cleanup_tasks').all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('isolates malformed cleanup rows while processing valid work', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
const now = '2026-07-16T12:00:00.000Z';
|
||||
const valid = `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify(['id-1', 'instance-password', 'slot']),
|
||||
'utf8',
|
||||
).toString('base64url')}`;
|
||||
for (const [reference, instanceId] of [
|
||||
['not-a-reference', 'id-1'],
|
||||
[valid, 'id-1'],
|
||||
])
|
||||
db.prepare(
|
||||
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
|
||||
).run(reference, instanceId, 'instance-password', 'macos-keychain', now, now);
|
||||
expect(await service.retryPendingSecretCleanup()).toEqual({
|
||||
attempted: 2,
|
||||
cleaned: 1,
|
||||
remaining: 1,
|
||||
});
|
||||
expect(store.deletes).toEqual([valid]);
|
||||
});
|
||||
|
||||
it('never deletes a cleanup reference that is active again', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
await service.create({ ...basic, password: { action: 'set', password: 'active' } });
|
||||
const active = db.prepare('SELECT external_reference FROM secret_references').get() as {
|
||||
external_reference: string;
|
||||
};
|
||||
const now = '2026-07-16T12:00:00.000Z';
|
||||
db.prepare(
|
||||
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
|
||||
).run(active.external_reference, 'id-1', 'instance-password', 'macos-keychain', now, now);
|
||||
expect(await service.retryPendingSecretCleanup()).toEqual({
|
||||
attempted: 1,
|
||||
cleaned: 0,
|
||||
remaining: 1,
|
||||
});
|
||||
expect(store.deletes).toEqual([]);
|
||||
expect(store.values.has(active.external_reference)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not ACK a requeued cleanup task with an obsolete generation', async () => {
|
||||
const { db, store, service } = fixture();
|
||||
await service.create({ ...basic, password: { action: 'set', password: 'first' } });
|
||||
store.failDelete = true;
|
||||
await service.update('id-1', 1, { password: { action: 'set', password: 'second' } });
|
||||
store.failDelete = false;
|
||||
const reference = db.prepare('SELECT reference FROM secret_cleanup_tasks').get() as {
|
||||
reference: string;
|
||||
};
|
||||
store.onDelete = () => {
|
||||
db.prepare('UPDATE secret_cleanup_tasks SET generation=generation+1 WHERE reference=?').run(
|
||||
reference.reference,
|
||||
);
|
||||
delete store.onDelete;
|
||||
};
|
||||
const result = await service.retryPendingSecretCleanup();
|
||||
expect(result).toMatchObject({ attempted: 1, cleaned: 0, remaining: 1 });
|
||||
expect(db.prepare('SELECT generation FROM secret_cleanup_tasks').get()).toEqual({
|
||||
generation: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates freshness across categories by the worst observed state', async () => {
|
||||
const { db, service } = fixture();
|
||||
const created = await service.create(basic);
|
||||
const now = '2026-07-16T12:00:00.000Z';
|
||||
db.prepare(
|
||||
'INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
).run('snapshot-fresh', created.id, 'cellular', 'fresh', '{}', now, null, now);
|
||||
db.prepare(
|
||||
'INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
).run('snapshot-stale', created.id, 'network', 'stale', '{}', now, null, now);
|
||||
expect((await service.get(created.id))?.freshness).toBe('stale');
|
||||
});
|
||||
|
||||
it('rejects control separators in tags rather than corrupting tag round-trips', async () => {
|
||||
const { service } = fixture();
|
||||
expect(
|
||||
await code(service.create({ ...basic, tags: ['safe', `bad${String.fromCharCode(31)}tag`] })),
|
||||
).toBe('VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
it('orders names and tags by stable code points instead of host locale rules', async () => {
|
||||
const { service } = fixture(['z-id', 'slot-1', 'ref-1', 'a-id', 'slot-2', 'ref-2']);
|
||||
await service.create({ name: 'a', origin: 'http://10.0.0.1', tags: ['a', 'Z'] });
|
||||
await service.create({ name: 'Z', origin: 'http://10.0.0.2', tags: [] });
|
||||
expect((await service.get('z-id'))?.tags).toEqual(['Z', 'a']);
|
||||
expect((await service.list({ sort: 'name' })).items.map(({ id }) => id)).toEqual([
|
||||
'slot-1',
|
||||
'z-id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects page and revision values that would overflow subsequent calculations', async () => {
|
||||
const { service } = fixture();
|
||||
await service.create(basic);
|
||||
expect(await code(service.list({ page: Number.MAX_SAFE_INTEGER, pageSize: 100 }))).toBe(
|
||||
'VALIDATION_FAILED',
|
||||
);
|
||||
expect(await code(service.update('id-1', Number.MAX_SAFE_INTEGER, { name: 'x' }))).toBe(
|
||||
'VALIDATION_FAILED',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a stable history conflict when deleting an instance referenced by a job item', async () => {
|
||||
const { db, service } = fixture();
|
||||
const created = await service.create(basic);
|
||||
const now = '2026-07-16T12:00:00.000Z';
|
||||
db.prepare(
|
||||
'INSERT INTO jobs (id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||
).run('job-1', 'system.health', 'R0', 'completed', 'test', 'request-1', 'digest', now, now);
|
||||
db.prepare(
|
||||
'INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
).run('job-item-1', 'job-1', created.id, 1, 'completed', now, now);
|
||||
expect(await code(service.delete(created.id, 1))).toBe('HAS_JOB_HISTORY');
|
||||
expect(await service.get(created.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,602 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
CapabilityStatus,
|
||||
Instance,
|
||||
InstanceInput,
|
||||
InstancePage,
|
||||
InstancePageQuery,
|
||||
InstancePatch,
|
||||
SnapshotFreshness,
|
||||
} from '@multi-simadmin/contracts';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
|
||||
|
||||
const PURPOSE = 'instance-password';
|
||||
const PROVIDER = 'macos-keychain';
|
||||
const MAX_TAGS = 50;
|
||||
const MAX_TAG_LENGTH = 100;
|
||||
|
||||
export type InstanceServiceErrorCode =
|
||||
| 'VALIDATION_FAILED'
|
||||
| 'NOT_FOUND'
|
||||
| 'REVISION_CONFLICT'
|
||||
| 'HAS_JOB_HISTORY'
|
||||
| 'DUPLICATE_ID'
|
||||
| 'DUPLICATE_ORIGIN'
|
||||
| 'SECRET_STORE_FAILED'
|
||||
| 'DATABASE_FAILED'
|
||||
| 'SECRET_CLEANUP_FAILED'
|
||||
| 'COMPENSATION_FAILED'
|
||||
| 'COMPENSATION_PERSISTENCE_FAILED';
|
||||
|
||||
export class InstanceServiceError extends Error {
|
||||
constructor(
|
||||
readonly code: InstanceServiceErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'InstanceServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstanceServiceOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly store: SecretStore;
|
||||
readonly idFactory?: () => string;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
interface InstanceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
config_revision: number;
|
||||
updated_at: string;
|
||||
}
|
||||
interface SecretRow {
|
||||
id: string;
|
||||
external_reference: string;
|
||||
}
|
||||
interface CleanupEntry {
|
||||
readonly instanceId: string;
|
||||
readonly reference: string;
|
||||
readonly purpose: string;
|
||||
readonly provider: string;
|
||||
readonly generation: number;
|
||||
readonly queuedAt: string;
|
||||
}
|
||||
interface AggregateRow extends InstanceRow {
|
||||
tags: string | null;
|
||||
credential: number;
|
||||
}
|
||||
|
||||
function validation(message: string): never {
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', message);
|
||||
}
|
||||
function normalizeName(value: unknown): string {
|
||||
if (typeof value !== 'string') return validation('Name must be a string');
|
||||
const result = value.trim();
|
||||
if (!result || result.length > 200) return validation('Name must contain 1 to 200 characters');
|
||||
return result;
|
||||
}
|
||||
function normalizeTags(value: readonly string[] | undefined): string[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > MAX_TAGS) return validation('Tags are invalid');
|
||||
const tags = value.map((tag) => {
|
||||
if (typeof tag !== 'string') return validation('Tag must be a string');
|
||||
const normalized = tag.trim();
|
||||
if (!normalized || normalized.length > MAX_TAG_LENGTH || /[\x00-\x1F\x7F]/.test(normalized))
|
||||
return validation('Tag is invalid');
|
||||
return normalized;
|
||||
});
|
||||
const unique = [...new Set(tags)].sort(codePointCompare);
|
||||
if (unique.length > MAX_TAGS) return validation('Too many tags');
|
||||
return unique;
|
||||
}
|
||||
function privateIpv4(host: string): boolean {
|
||||
const parts = host.split('.');
|
||||
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false;
|
||||
const octets = parts.map(Number);
|
||||
if (octets.some((part) => part > 255)) return false;
|
||||
const [a, b] = octets as [number, number, number, number];
|
||||
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
||||
}
|
||||
function privateIpv6(host: string): boolean {
|
||||
const normalized = host.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
return /^(fc|fd)[0-9a-f]{2}:/.test(normalized);
|
||||
}
|
||||
function normalizeOrigin(value: unknown): string {
|
||||
if (typeof value !== 'string') return validation('Origin must be a URL');
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return validation('Origin must be a valid URL');
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
||||
return validation('Origin must use HTTP or HTTPS');
|
||||
if (url.username || url.password || url.search || url.hash)
|
||||
return validation('Origin must not contain credentials, query, or fragment');
|
||||
if (url.pathname !== '/' && url.pathname !== '')
|
||||
return validation('Origin must not contain a path');
|
||||
if (!privateIpv4(url.hostname) && !privateIpv6(url.hostname))
|
||||
return validation('Origin must target a private LAN address');
|
||||
return url.origin;
|
||||
}
|
||||
function validatePassword(value: InstanceInput['password'] | InstancePatch['password']): void {
|
||||
if (value === undefined || value.action === 'preserve' || value.action === 'clear') return;
|
||||
if (value.action !== 'set' || typeof value.password !== 'string' || value.password.length === 0)
|
||||
validation('Password update is invalid');
|
||||
}
|
||||
|
||||
const capabilityRank: Record<CapabilityStatus, number> = {
|
||||
degraded: 0,
|
||||
'auth-required': 1,
|
||||
unsupported: 2,
|
||||
supported: 3,
|
||||
unknown: 4,
|
||||
};
|
||||
const freshnessRank: Record<SnapshotFreshness, number> = {
|
||||
expired: 0,
|
||||
stale: 1,
|
||||
fresh: 2,
|
||||
unknown: 3,
|
||||
};
|
||||
|
||||
export class InstanceService {
|
||||
private readonly db: Database.Database;
|
||||
private readonly store: SecretStore;
|
||||
private readonly id: () => string;
|
||||
private readonly clock: () => Date;
|
||||
|
||||
constructor(options: InstanceServiceOptions) {
|
||||
this.db = options.db;
|
||||
this.store = options.store;
|
||||
this.id = options.idFactory ?? randomUUID;
|
||||
this.clock = options.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async create(input: InstanceInput): Promise<Instance> {
|
||||
const name = normalizeName(input.name);
|
||||
const origin = normalizeOrigin(input.origin);
|
||||
const tags = normalizeTags(input.tags);
|
||||
validatePassword(input.password);
|
||||
const instanceId = this.id();
|
||||
let newSecret: { id: string; external: string } | undefined;
|
||||
if (input.password?.action === 'set')
|
||||
newSecret = await this.storeSecret(instanceId, input.password.password);
|
||||
const now = this.clock().toISOString();
|
||||
try {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?, ?,1,1,?,?)',
|
||||
)
|
||||
.run(instanceId, name, origin, newSecret ? 'password' : 'none', now, now);
|
||||
this.replaceTags(instanceId, tags, now);
|
||||
if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now);
|
||||
})();
|
||||
} catch (error) {
|
||||
if (newSecret) await this.compensate(instanceId, newSecret.external);
|
||||
if (isUnique(error, 'base_url'))
|
||||
throw new InstanceServiceError('DUPLICATE_ORIGIN', 'Instance origin already exists');
|
||||
if (isUnique(error, 'instances.id'))
|
||||
throw new InstanceServiceError('DUPLICATE_ID', 'Instance id already exists');
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Could not create instance');
|
||||
}
|
||||
return (await this.get(instanceId))!;
|
||||
}
|
||||
|
||||
async get(instanceId: string): Promise<Instance | undefined> {
|
||||
const rows = this.loadRows('WHERE i.id = ?', [instanceId]);
|
||||
return rows[0] ? this.toInstance(rows[0]) : undefined;
|
||||
}
|
||||
|
||||
async list(query: InstancePageQuery = {}): Promise<InstancePage> {
|
||||
const pageSize = integerInRange(query.pageSize ?? 25, 1, 100, 'pageSize');
|
||||
const page = integerInRange(
|
||||
query.page ?? 1,
|
||||
1,
|
||||
Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1,
|
||||
'page',
|
||||
);
|
||||
if (query.direction !== undefined && query.direction !== 'asc' && query.direction !== 'desc')
|
||||
validation('Direction is invalid');
|
||||
const validSort = ['name', 'status', 'freshness', 'updatedAt'];
|
||||
if (query.sort !== undefined && !validSort.includes(query.sort)) validation('Sort is invalid');
|
||||
let values = this.loadRows('', []).map((row) => ({
|
||||
value: this.toInstance(row),
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
const search = query.search?.trim().toLowerCase();
|
||||
if (search)
|
||||
values = values.filter(
|
||||
({ value }) =>
|
||||
value.name.toLowerCase().includes(search) || value.id.toLowerCase().includes(search),
|
||||
);
|
||||
if (query.tag !== undefined)
|
||||
values = values.filter(({ value }) => value.tags.includes(query.tag!.trim()));
|
||||
if (query.credentialConfigured !== undefined)
|
||||
values = values.filter(
|
||||
({ value }) => value.credentialConfigured === query.credentialConfigured,
|
||||
);
|
||||
if (query.capabilityStatus !== undefined)
|
||||
values = values.filter(({ value }) => value.capabilityStatus === query.capabilityStatus);
|
||||
if (query.freshness !== undefined)
|
||||
values = values.filter(({ value }) => value.freshness === query.freshness);
|
||||
const direction = query.direction === 'desc' ? -1 : 1;
|
||||
const sort = query.sort ?? 'name';
|
||||
values.sort((left, right) => {
|
||||
let result: number;
|
||||
if (sort === 'status')
|
||||
result =
|
||||
capabilityRank[left.value.capabilityStatus]! -
|
||||
capabilityRank[right.value.capabilityStatus]!;
|
||||
else if (sort === 'freshness')
|
||||
result = freshnessRank[left.value.freshness]! - freshnessRank[right.value.freshness]!;
|
||||
else if (sort === 'updatedAt') result = codePointCompare(left.updatedAt, right.updatedAt);
|
||||
else result = codePointCompare(left.value.name, right.value.name);
|
||||
return result === 0 ? codePointCompare(left.value.id, right.value.id) : result * direction;
|
||||
});
|
||||
const total = values.length;
|
||||
const start = (page - 1) * pageSize;
|
||||
return {
|
||||
items: values.slice(start, start + pageSize).map(({ value }) => value),
|
||||
page: { page, pageSize, total },
|
||||
};
|
||||
}
|
||||
|
||||
async update(instanceId: string, revision: number, patch: InstancePatch): Promise<Instance> {
|
||||
integerInRange(revision, 1, Number.MAX_SAFE_INTEGER - 1, 'revision');
|
||||
validatePassword(patch.password);
|
||||
const current = this.row(instanceId);
|
||||
if (!current) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
||||
if (current.config_revision !== revision)
|
||||
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
||||
const name = patch.name === undefined ? current.name : normalizeName(patch.name);
|
||||
const origin = patch.origin === undefined ? current.base_url : normalizeOrigin(patch.origin);
|
||||
const tags = patch.tags === undefined ? undefined : normalizeTags(patch.tags);
|
||||
let committedOldSecret: SecretRow | undefined;
|
||||
let newSecret: { id: string; external: string } | undefined;
|
||||
if (patch.password?.action === 'set')
|
||||
newSecret = await this.storeSecret(instanceId, patch.password.password);
|
||||
const now = this.clock().toISOString();
|
||||
try {
|
||||
this.db.transaction(() => {
|
||||
const transactionCurrent = this.row(instanceId);
|
||||
if (!transactionCurrent)
|
||||
throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
||||
if (transactionCurrent.config_revision !== revision)
|
||||
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
||||
const transactionOldSecret = this.secret(instanceId);
|
||||
const changed = this.db
|
||||
.prepare(
|
||||
'UPDATE instances SET name=?,base_url=?,auth_mode=?,config_revision=config_revision+1,updated_at=? WHERE id=? AND config_revision=?',
|
||||
)
|
||||
.run(
|
||||
name,
|
||||
origin,
|
||||
newSecret || (transactionOldSecret && patch.password?.action !== 'clear')
|
||||
? 'password'
|
||||
: 'none',
|
||||
now,
|
||||
instanceId,
|
||||
revision,
|
||||
);
|
||||
if (changed.changes !== 1)
|
||||
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
||||
if (tags) this.replaceTags(instanceId, tags, now);
|
||||
if (patch.password?.action === 'clear' || newSecret)
|
||||
this.db
|
||||
.prepare('DELETE FROM secret_references WHERE instance_id=? AND purpose=?')
|
||||
.run(instanceId, PURPOSE);
|
||||
if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now);
|
||||
if (transactionOldSecret && (patch.password?.action === 'clear' || newSecret)) {
|
||||
this.queueCleanup(instanceId, transactionOldSecret.external_reference, now);
|
||||
committedOldSecret = transactionOldSecret;
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
if (newSecret) await this.compensate(instanceId, newSecret.external);
|
||||
if (error instanceof InstanceServiceError) throw error;
|
||||
if (isUnique(error, 'base_url'))
|
||||
throw new InstanceServiceError('DUPLICATE_ORIGIN', 'Instance origin already exists');
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Could not update instance');
|
||||
}
|
||||
if (committedOldSecret)
|
||||
await this.tryCleanup(instanceId, committedOldSecret.external_reference);
|
||||
return (await this.get(instanceId))!;
|
||||
}
|
||||
|
||||
async delete(
|
||||
instanceId: string,
|
||||
revision: number,
|
||||
options?: { allowJobHistory?: boolean },
|
||||
): Promise<void> {
|
||||
integerInRange(revision, 1, Number.MAX_SAFE_INTEGER - 1, 'revision');
|
||||
const allowJobHistory = options?.allowJobHistory === true;
|
||||
let committedOldSecret: SecretRow | undefined;
|
||||
try {
|
||||
this.db.transaction(() => {
|
||||
const transactionCurrent = this.row(instanceId);
|
||||
if (!transactionCurrent)
|
||||
throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
||||
if (transactionCurrent.config_revision !== revision)
|
||||
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
||||
if (
|
||||
!allowJobHistory &&
|
||||
this.db.prepare('SELECT 1 FROM job_items WHERE instance_id=? LIMIT 1').get(instanceId)
|
||||
)
|
||||
throw new InstanceServiceError('HAS_JOB_HISTORY', 'Instance has immutable job history');
|
||||
const transactionOldSecret = this.secret(instanceId);
|
||||
if (transactionOldSecret) {
|
||||
this.queueCleanup(
|
||||
instanceId,
|
||||
transactionOldSecret.external_reference,
|
||||
this.clock().toISOString(),
|
||||
);
|
||||
committedOldSecret = transactionOldSecret;
|
||||
}
|
||||
const deleted = this.db
|
||||
.prepare('DELETE FROM instances WHERE id=? AND config_revision=?')
|
||||
.run(instanceId, revision);
|
||||
if (deleted.changes !== 1)
|
||||
throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match');
|
||||
})();
|
||||
} catch (error) {
|
||||
if (error instanceof InstanceServiceError) throw error;
|
||||
if (
|
||||
!allowJobHistory &&
|
||||
this.db.prepare('SELECT 1 FROM job_items WHERE instance_id=? LIMIT 1').get(instanceId)
|
||||
)
|
||||
throw new InstanceServiceError('HAS_JOB_HISTORY', 'Instance has immutable job history');
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Could not delete instance');
|
||||
}
|
||||
if (committedOldSecret)
|
||||
await this.tryCleanup(instanceId, committedOldSecret.external_reference);
|
||||
}
|
||||
|
||||
async retryPendingSecretCleanup(): Promise<{
|
||||
attempted: number;
|
||||
cleaned: number;
|
||||
remaining: number;
|
||||
}> {
|
||||
const entries = this.cleanupEntries();
|
||||
let cleaned = 0;
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
this.validateCleanupEntry(entry);
|
||||
if (this.isActiveReference(entry.reference)) continue;
|
||||
await this.store.delete(entry.reference);
|
||||
if (this.removeCleanup(entry.reference, entry.generation)) cleaned += 1;
|
||||
} catch {
|
||||
// Retain the opaque reference for a later explicit retry.
|
||||
}
|
||||
}
|
||||
return { attempted: entries.length, cleaned, remaining: this.cleanupEntries().length };
|
||||
}
|
||||
|
||||
private row(id: string): InstanceRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT id,name,base_url,config_revision,updated_at FROM instances WHERE id=?')
|
||||
.get(id) as InstanceRow | undefined;
|
||||
}
|
||||
private secret(id: string): SecretRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
'SELECT id,external_reference FROM secret_references WHERE instance_id=? AND purpose=?',
|
||||
)
|
||||
.get(id, PURPOSE) as SecretRow | undefined;
|
||||
}
|
||||
private replaceTags(id: string, tags: readonly string[], now: string): void {
|
||||
this.db.prepare('DELETE FROM instance_tags WHERE instance_id=?').run(id);
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)',
|
||||
);
|
||||
for (const tag of tags) insert.run(id, tag, now);
|
||||
}
|
||||
private insertReference(id: string, instanceId: string, external: string, now: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(id, instanceId, PURPOSE, PROVIDER, external, now, now);
|
||||
}
|
||||
private async storeSecret(
|
||||
instanceId: string,
|
||||
password: string,
|
||||
): Promise<{ id: string; external: string }> {
|
||||
const slot = this.id();
|
||||
let external: string;
|
||||
try {
|
||||
external = await this.store.set({ instanceId, purpose: PURPOSE, slot }, password);
|
||||
} catch {
|
||||
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
|
||||
}
|
||||
try {
|
||||
const parsed = parseKeychainReference(external);
|
||||
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
|
||||
throw new Error('secret store returned a reference with an invalid binding');
|
||||
} catch {
|
||||
try {
|
||||
await this.store.delete(external);
|
||||
} catch {
|
||||
throw new InstanceServiceError(
|
||||
'COMPENSATION_PERSISTENCE_FAILED',
|
||||
'Credential binding was invalid and cleanup could not be confirmed',
|
||||
);
|
||||
}
|
||||
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
|
||||
}
|
||||
return { id: this.id(), external };
|
||||
}
|
||||
private async compensate(instanceId: string, reference: string): Promise<void> {
|
||||
try {
|
||||
this.validateCleanupReference(instanceId, reference);
|
||||
await this.store.delete(reference);
|
||||
} catch {
|
||||
try {
|
||||
this.queueCleanup(instanceId, reference, this.clock().toISOString());
|
||||
} catch {
|
||||
throw new InstanceServiceError(
|
||||
'COMPENSATION_PERSISTENCE_FAILED',
|
||||
'Database operation failed and credential cleanup could not be persisted',
|
||||
);
|
||||
}
|
||||
throw new InstanceServiceError(
|
||||
'COMPENSATION_FAILED',
|
||||
'Database operation failed and new credential cleanup failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
private async tryCleanup(instanceId: string, reference: string): Promise<void> {
|
||||
const generation = this.cleanupGeneration(reference);
|
||||
try {
|
||||
this.validateCleanupReference(instanceId, reference);
|
||||
await this.store.delete(reference);
|
||||
if (generation !== undefined) this.removeCleanup(reference, generation);
|
||||
} catch {
|
||||
// The durable outbox is authoritative after the business transaction commits.
|
||||
}
|
||||
}
|
||||
private cleanupEntries(): CleanupEntry[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
'SELECT reference,instance_id,purpose,provider,generation,queued_at FROM secret_cleanup_tasks ORDER BY queued_at ASC,reference ASC',
|
||||
)
|
||||
.all()
|
||||
.map((row) => {
|
||||
const task = row as {
|
||||
reference: string;
|
||||
instance_id: string;
|
||||
purpose: string;
|
||||
provider: string;
|
||||
generation: number;
|
||||
queued_at: string;
|
||||
};
|
||||
return {
|
||||
reference: task.reference,
|
||||
instanceId: task.instance_id,
|
||||
purpose: task.purpose,
|
||||
provider: task.provider,
|
||||
generation: task.generation,
|
||||
queuedAt: task.queued_at,
|
||||
};
|
||||
});
|
||||
}
|
||||
private queueCleanup(instanceId: string, reference: string, now: string): void {
|
||||
this.validateCleanupReference(instanceId, reference);
|
||||
const queued = this.db
|
||||
.prepare(
|
||||
`INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at) VALUES (?,?,?,?,1,?,?)
|
||||
ON CONFLICT(reference) DO UPDATE SET
|
||||
generation=secret_cleanup_tasks.generation+1,
|
||||
updated_at=excluded.updated_at
|
||||
WHERE secret_cleanup_tasks.instance_id=excluded.instance_id
|
||||
AND secret_cleanup_tasks.purpose=excluded.purpose
|
||||
AND secret_cleanup_tasks.provider=excluded.provider`,
|
||||
)
|
||||
.run(reference, instanceId, PURPOSE, PROVIDER, now, now);
|
||||
if (queued.changes !== 1)
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
|
||||
}
|
||||
private cleanupGeneration(reference: string): number | undefined {
|
||||
const row = this.db
|
||||
.prepare('SELECT generation FROM secret_cleanup_tasks WHERE reference=?')
|
||||
.get(reference) as { generation: number } | undefined;
|
||||
return row?.generation;
|
||||
}
|
||||
private removeCleanup(reference: string, generation: number): boolean {
|
||||
return (
|
||||
this.db
|
||||
.prepare('DELETE FROM secret_cleanup_tasks WHERE reference=? AND generation=?')
|
||||
.run(reference, generation).changes === 1
|
||||
);
|
||||
}
|
||||
private isActiveReference(reference: string): boolean {
|
||||
return Boolean(
|
||||
this.db
|
||||
.prepare('SELECT 1 FROM secret_references WHERE external_reference=? LIMIT 1')
|
||||
.get(reference),
|
||||
);
|
||||
}
|
||||
private validateCleanupEntry(entry: CleanupEntry): void {
|
||||
if (entry.purpose !== PURPOSE || entry.provider !== PROVIDER)
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
|
||||
this.validateCleanupReference(entry.instanceId, entry.reference);
|
||||
}
|
||||
private validateCleanupReference(instanceId: string, reference: string): void {
|
||||
try {
|
||||
const parsed = parseKeychainReference(reference);
|
||||
if (
|
||||
parsed.instanceId !== instanceId ||
|
||||
parsed.purpose !== PURPOSE ||
|
||||
parsed.slot === undefined
|
||||
)
|
||||
throw new Error('mismatch');
|
||||
} catch {
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
|
||||
}
|
||||
}
|
||||
private loadRows(where: string, parameters: unknown[]): AggregateRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT i.id,i.name,i.base_url,i.config_revision,i.updated_at,
|
||||
group_concat(t.tag, char(31)) tags,
|
||||
CASE WHEN EXISTS(SELECT 1 FROM secret_references r WHERE r.instance_id=i.id AND r.purpose=?) THEN 1 ELSE 0 END credential
|
||||
FROM instances i LEFT JOIN instance_tags t ON t.instance_id=i.id ${where} GROUP BY i.id`,
|
||||
)
|
||||
.all(PURPOSE, ...parameters) as AggregateRow[];
|
||||
}
|
||||
private toInstance(row: AggregateRow): Instance {
|
||||
const capabilityRows = this.db
|
||||
.prepare('SELECT state FROM capabilities WHERE instance_id=?')
|
||||
.all(row.id) as { state: CapabilityStatus }[];
|
||||
const capabilityStatus =
|
||||
capabilityRows.length === 0
|
||||
? 'unknown'
|
||||
: capabilityRows
|
||||
.map((x) => x.state)
|
||||
.sort((a, b) => capabilityRank[a]! - capabilityRank[b]!)[0]!;
|
||||
const snapshots = this.db
|
||||
.prepare('SELECT state,expires_at FROM status_snapshots WHERE instance_id=?')
|
||||
.all(row.id) as { state: SnapshotFreshness; expires_at: string | null }[];
|
||||
const freshness: SnapshotFreshness =
|
||||
snapshots.length === 0
|
||||
? 'unknown'
|
||||
: snapshots
|
||||
.map((snapshot) =>
|
||||
snapshot.expires_at && snapshot.expires_at <= this.clock().toISOString()
|
||||
? 'expired'
|
||||
: snapshot.state,
|
||||
)
|
||||
.sort((left, right) => freshnessRank[left]! - freshnessRank[right]!)[0]!;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
origin: row.base_url,
|
||||
tags: row.tags ? row.tags.split(String.fromCharCode(31)).sort(codePointCompare) : [],
|
||||
revision: row.config_revision,
|
||||
capabilityStatus,
|
||||
freshness,
|
||||
credentialConfigured: row.credential === 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function integerInRange(value: number, minimum: number, maximum: number, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
|
||||
validation(`${name} is invalid`);
|
||||
return value;
|
||||
}
|
||||
function codePointCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
function isUnique(error: unknown, fragment: string): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message.includes('UNIQUE constraint failed') &&
|
||||
error.message.includes(fragment)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { InstanceService } from '../instances/instance-service.js';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import {
|
||||
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
||||
DeleteInstanceOperation,
|
||||
DeleteInstanceOperationError,
|
||||
} from './delete-instance-operation.js';
|
||||
|
||||
class Store implements SecretStore {
|
||||
async set() {
|
||||
return '';
|
||||
}
|
||||
async get() {
|
||||
return undefined;
|
||||
}
|
||||
async delete() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const databases: Database.Database[] = [];
|
||||
afterEach(() => {
|
||||
for (const database of databases.splice(0)) database.close();
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
databases.push(db);
|
||||
let sequence = 0;
|
||||
const now = () => new Date('2026-07-16T12:00:00.000Z');
|
||||
const instances = new InstanceService({
|
||||
db,
|
||||
store: new Store(),
|
||||
idFactory: () => `instance-${++sequence}`,
|
||||
now,
|
||||
});
|
||||
const operation = new DeleteInstanceOperation({
|
||||
db,
|
||||
instances,
|
||||
now,
|
||||
idFactory: () => `operation-${++sequence}`,
|
||||
tokenFactory: () => Buffer.alloc(32, 7).toString('base64url'),
|
||||
});
|
||||
return { db, instances, operation };
|
||||
}
|
||||
|
||||
describe('DeleteInstanceOperation', () => {
|
||||
it('prepares only the fixed delete shape and persists only a token digest with TTL', async () => {
|
||||
const { db, instances, operation } = fixture();
|
||||
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||
const prepared = await operation.prepare({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
});
|
||||
expect(prepared).toMatchObject({
|
||||
status: 'prepared',
|
||||
operationId: 'deleteInstance',
|
||||
risk: 'R3',
|
||||
targetCount: 1,
|
||||
});
|
||||
expect(Buffer.from(prepared.confirmationToken, 'base64url')).toHaveLength(32);
|
||||
expect(prepared.expiresAt).toBe('2026-07-16T12:05:00.000Z');
|
||||
const row = db
|
||||
.prepare('SELECT token_digest, expires_at FROM operation_preparations WHERE id=?')
|
||||
.get(prepared.id) as { token_digest: string; expires_at: string };
|
||||
expect(row.token_digest).toBe(
|
||||
createHash('sha256').update(prepared.confirmationToken).digest('hex'),
|
||||
);
|
||||
expect(JSON.stringify(db.prepare('SELECT * FROM operation_preparations').all())).not.toContain(
|
||||
prepared.confirmationToken,
|
||||
);
|
||||
|
||||
for (const input of [
|
||||
{
|
||||
operationId: 'other',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
},
|
||||
{
|
||||
operationId: 'deleteInstance',
|
||||
targets: [],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
},
|
||||
{
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 0 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
},
|
||||
{
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: 'wrong', fields: [] },
|
||||
},
|
||||
{
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: {
|
||||
parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
||||
fields: [{ fieldId: 'x', kind: 'string' as const, value: 'x' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
extra: true,
|
||||
},
|
||||
null,
|
||||
])
|
||||
await expect(operation.prepare(input as never)).rejects.toBeInstanceOf(
|
||||
DeleteInstanceOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes once, creates immutable history, deletes safely, and returns a frozen Job shape', async () => {
|
||||
const { db, instances, operation } = fixture();
|
||||
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||
const prepared = await operation.prepare({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
});
|
||||
const job = await operation.executeDelete({
|
||||
instanceId: instance.id,
|
||||
revision: 1,
|
||||
preparationId: prepared.id,
|
||||
confirmationToken: prepared.confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: 'request-1',
|
||||
});
|
||||
expect(job).toEqual({
|
||||
id: expect.any(String),
|
||||
operationId: 'deleteInstance',
|
||||
status: 'succeeded',
|
||||
rootJobId: expect.any(String),
|
||||
items: [{ id: expect.any(String), targetId: instance.id, state: 'succeeded' }],
|
||||
attempts: [
|
||||
{
|
||||
id: expect.any(String),
|
||||
state: 'succeeded',
|
||||
startedAt: expect.any(String),
|
||||
finishedAt: expect.any(String),
|
||||
},
|
||||
],
|
||||
createdAt: expect.any(String),
|
||||
});
|
||||
expect(job.rootJobId).toBe(job.id);
|
||||
expect(await instances.get(instance.id)).toBeUndefined();
|
||||
expect(db.prepare('SELECT instance_id FROM job_items').get()).toEqual({
|
||||
instance_id: instance.id,
|
||||
});
|
||||
await expect(
|
||||
operation.executeDelete({
|
||||
instanceId: instance.id,
|
||||
revision: 1,
|
||||
preparationId: prepared.id,
|
||||
confirmationToken: prepared.confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: 'request-2',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 1 });
|
||||
});
|
||||
|
||||
it('does not consume a preparation when the token is wrong', async () => {
|
||||
const { db, instances, operation } = fixture();
|
||||
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||
const prepared = await operation.prepare({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
});
|
||||
await expect(
|
||||
operation.executeDelete({
|
||||
instanceId: instance.id,
|
||||
revision: 1,
|
||||
preparationId: prepared.id,
|
||||
confirmationToken: 'x'.repeat(20),
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: 'request-1',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||
expect(
|
||||
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
||||
).toEqual({ status: 'prepared' });
|
||||
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it('returns typed target errors after consuming a correctly bound confirmation', async () => {
|
||||
const { db, instances, operation } = fixture();
|
||||
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||
const prepared = await operation.prepare({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
});
|
||||
await instances.update(instance.id, 1, { name: 'B' });
|
||||
await expect(
|
||||
operation.executeDelete({
|
||||
instanceId: instance.id,
|
||||
revision: 1,
|
||||
preparationId: prepared.id,
|
||||
confirmationToken: prepared.confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: 'stale',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'REVISION_CONFLICT' });
|
||||
expect(
|
||||
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
||||
).toEqual({ status: 'consumed' });
|
||||
|
||||
const current = await instances.get(instance.id);
|
||||
const missing = await operation.prepare({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: current!.revision }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
});
|
||||
await instances.delete(instance.id, current!.revision);
|
||||
await expect(
|
||||
operation.executeDelete({
|
||||
instanceId: instance.id,
|
||||
revision: current!.revision,
|
||||
preparationId: missing.id,
|
||||
confirmationToken: missing.confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: 'missing',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('reconciles interrupted running delete jobs to unknown-result', async () => {
|
||||
const { db, instances, operation } = fixture();
|
||||
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||
const now = '2026-07-16T12:00:00.000Z';
|
||||
db.prepare(
|
||||
"INSERT INTO jobs (id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at) VALUES ('j','j','deleteInstance','R3','running','a','r','d',?,?,?)",
|
||||
).run(now, now, now);
|
||||
db.prepare(
|
||||
"INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES ('a','j','running',?,?)",
|
||||
).run(now, now);
|
||||
db.prepare(
|
||||
"INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at) VALUES ('i','j',?,1,'running',?,?,?)",
|
||||
).run(instance.id, now, now, now);
|
||||
expect(operation.reconcileInterruptedJobs()).toBe(1);
|
||||
expect(db.prepare("SELECT status FROM jobs WHERE id='j'").get()).toEqual({
|
||||
status: 'unknown-result',
|
||||
});
|
||||
expect(db.prepare("SELECT status FROM job_items WHERE id='i'").get()).toEqual({
|
||||
status: 'unknown-result',
|
||||
});
|
||||
expect(db.prepare("SELECT status FROM job_attempts WHERE id='a'").get()).toEqual({
|
||||
status: 'unknown-result',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete when durable Job history cannot be created at execution entry', async () => {
|
||||
const { db, instances, operation } = fixture();
|
||||
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||
const prepared = await operation.prepare({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
});
|
||||
db.exec(
|
||||
"CREATE TRIGGER reject_delete_job_item BEFORE INSERT ON job_items BEGIN SELECT RAISE(ABORT, 'blocked'); END",
|
||||
);
|
||||
await expect(
|
||||
operation.executeDelete({
|
||||
instanceId: instance.id,
|
||||
revision: 1,
|
||||
preparationId: prepared.id,
|
||||
confirmationToken: prepared.confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: 'request-1',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(await instances.get(instance.id)).toBeDefined();
|
||||
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it('rejects an expired preparation once and never creates a Job', async () => {
|
||||
const { db, instances } = fixture();
|
||||
let current = new Date('2026-07-16T12:00:00.000Z');
|
||||
let sequence = 100;
|
||||
const operation = new DeleteInstanceOperation({
|
||||
db,
|
||||
instances,
|
||||
now: () => current,
|
||||
idFactory: () => `expiry-${++sequence}`,
|
||||
tokenFactory: () => Buffer.alloc(32, 9).toString('base64url'),
|
||||
});
|
||||
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||
const prepared = await operation.prepare({
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
});
|
||||
current = new Date('2026-07-16T12:05:00.001Z');
|
||||
await expect(
|
||||
operation.executeDelete({
|
||||
instanceId: instance.id,
|
||||
revision: 1,
|
||||
preparationId: prepared.id,
|
||||
confirmationToken: prepared.confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: 'request-expired',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||
await expect(
|
||||
operation.executeDelete({
|
||||
instanceId: instance.id,
|
||||
revision: 1,
|
||||
preparationId: prepared.id,
|
||||
confirmationToken: prepared.confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: 'request-replay',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import type { Job, Preparation, PrepareOperationRequest } from '@multi-simadmin/contracts';
|
||||
import { InstanceService, InstanceServiceError } from '../instances/instance-service.js';
|
||||
|
||||
export const DELETE_INSTANCE_PARAMETER_SCHEMA_ID = 'deleteInstance.parameters.v1' as const;
|
||||
const OPERATION_ID = 'deleteInstance';
|
||||
const ACTOR = 'loopback-control-plane';
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
const PARAMETERS_DIGEST = createHash('sha256')
|
||||
.update(JSON.stringify({ parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] }))
|
||||
.digest('hex');
|
||||
|
||||
export type DeleteInstanceOperationErrorCode =
|
||||
| 'VALIDATION_FAILED'
|
||||
| 'NOT_FOUND'
|
||||
| 'REVISION_CONFLICT'
|
||||
| 'CONFIRMATION_INVALID';
|
||||
|
||||
export class DeleteInstanceOperationError extends Error {
|
||||
constructor(
|
||||
readonly code: DeleteInstanceOperationErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DeleteInstanceOperationError';
|
||||
}
|
||||
}
|
||||
|
||||
interface PreparationRow {
|
||||
operation_id: string;
|
||||
status: string;
|
||||
target_instance_id: string;
|
||||
target_revision: number;
|
||||
parameter_schema_id: string;
|
||||
parameters_digest: string;
|
||||
token_digest: string;
|
||||
expires_at: string;
|
||||
}
|
||||
interface ExecuteInput {
|
||||
readonly instanceId: string;
|
||||
readonly revision: number;
|
||||
readonly preparationId: string;
|
||||
readonly confirmationToken: string;
|
||||
readonly actor: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
interface Options {
|
||||
readonly db: Database.Database;
|
||||
readonly instances: InstanceService;
|
||||
readonly now?: () => Date;
|
||||
readonly idFactory?: () => string;
|
||||
readonly tokenFactory?: () => string;
|
||||
}
|
||||
|
||||
const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
const equalDigest = (left: string, right: string): boolean => {
|
||||
const a = Buffer.from(left, 'hex');
|
||||
const b = Buffer.from(right, 'hex');
|
||||
return a.length === 32 && b.length === 32 && timingSafeEqual(a, b);
|
||||
};
|
||||
const validRevision = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
|
||||
export class DeleteInstanceOperation {
|
||||
private readonly db: Database.Database;
|
||||
private readonly instances: InstanceService;
|
||||
private readonly clock: () => Date;
|
||||
private readonly id: () => string;
|
||||
private readonly token: () => string;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.db = options.db;
|
||||
this.instances = options.instances;
|
||||
this.clock = options.now ?? (() => new Date());
|
||||
this.id = options.idFactory ?? randomUUID;
|
||||
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
|
||||
}
|
||||
|
||||
async prepare(input: PrepareOperationRequest, requestId = this.id()): Promise<Preparation> {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
Object.keys(input).some((key) => !['operationId', 'targets', 'parameters'].includes(key)) ||
|
||||
input.operationId !== OPERATION_ID ||
|
||||
!Array.isArray(input.targets) ||
|
||||
input.targets.length !== 1 ||
|
||||
!input.targets[0] ||
|
||||
typeof input.targets[0] !== 'object' ||
|
||||
Array.isArray(input.targets[0]) ||
|
||||
Object.keys(input.targets[0]).some((key) => !['instanceId', 'revision'].includes(key)) ||
|
||||
typeof input.targets[0].instanceId !== 'string' ||
|
||||
input.targets[0].instanceId.length === 0 ||
|
||||
!validRevision(input.targets[0].revision) ||
|
||||
!input.parameters ||
|
||||
typeof input.parameters !== 'object' ||
|
||||
Array.isArray(input.parameters) ||
|
||||
Object.keys(input.parameters).some((key) => !['parameterSchemaId', 'fields'].includes(key)) ||
|
||||
input.parameters.parameterSchemaId !== DELETE_INSTANCE_PARAMETER_SCHEMA_ID ||
|
||||
!Array.isArray(input.parameters.fields) ||
|
||||
input.parameters.fields.length !== 0
|
||||
) {
|
||||
throw new DeleteInstanceOperationError('VALIDATION_FAILED', 'Invalid delete preparation');
|
||||
}
|
||||
const target = input.targets[0]!;
|
||||
const current = await this.instances.get(target.instanceId);
|
||||
if (!current) throw new DeleteInstanceOperationError('NOT_FOUND', 'Instance was not found');
|
||||
if (current.revision !== target.revision)
|
||||
throw new DeleteInstanceOperationError(
|
||||
'REVISION_CONFLICT',
|
||||
'Instance revision does not match',
|
||||
);
|
||||
|
||||
const token = this.token();
|
||||
let tokenBytes: Buffer;
|
||||
try {
|
||||
tokenBytes = Buffer.from(token, 'base64url');
|
||||
} catch {
|
||||
tokenBytes = Buffer.alloc(0);
|
||||
}
|
||||
if (tokenBytes.length < 32 || token.length < 20 || !/^[A-Za-z0-9_-]+$/.test(token))
|
||||
throw new Error('Confirmation token factory returned an unsafe token');
|
||||
const id = this.id();
|
||||
const created = this.clock();
|
||||
const now = created.toISOString();
|
||||
const expiresAt = new Date(created.getTime() + TTL_MS).toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO operation_preparations
|
||||
(id,operation_id,risk_level,status,target_instance_id,target_revision,parameter_schema_id,parameters_digest,token_digest,requested_by,request_id,expires_at,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
OPERATION_ID,
|
||||
'R3',
|
||||
'prepared',
|
||||
target.instanceId,
|
||||
target.revision,
|
||||
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
||||
PARAMETERS_DIGEST,
|
||||
digest(token),
|
||||
ACTOR,
|
||||
requestId,
|
||||
expiresAt,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
status: 'prepared',
|
||||
operationId: OPERATION_ID,
|
||||
risk: 'R3',
|
||||
expiresAt,
|
||||
confirmationToken: token,
|
||||
confirmationPrompt: `Delete instance ${target.instanceId}? This action cannot be undone.`,
|
||||
targetCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async executeDelete(input: ExecuteInput): Promise<Job> {
|
||||
if (!validRevision(input.revision))
|
||||
throw new DeleteInstanceOperationError('VALIDATION_FAILED', 'Invalid revision');
|
||||
const now = this.clock().toISOString();
|
||||
const ids = { job: this.id(), item: this.id(), attempt: this.id() };
|
||||
let accepted = false;
|
||||
let targetError: 'NOT_FOUND' | 'REVISION_CONFLICT' | undefined;
|
||||
this.db.transaction(() => {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT operation_id,status,target_instance_id,target_revision,parameter_schema_id,
|
||||
parameters_digest,token_digest,expires_at
|
||||
FROM operation_preparations WHERE id=?`,
|
||||
)
|
||||
.get(input.preparationId) as PreparationRow | undefined;
|
||||
if (!row || row.status !== 'prepared')
|
||||
throw new DeleteInstanceOperationError(
|
||||
'CONFIRMATION_INVALID',
|
||||
'Confirmation could not be accepted',
|
||||
);
|
||||
|
||||
const secretValid =
|
||||
row.expires_at > now &&
|
||||
equalDigest(row.token_digest, digest(input.confirmationToken)) &&
|
||||
row.operation_id === OPERATION_ID &&
|
||||
row.target_instance_id === input.instanceId &&
|
||||
row.target_revision === input.revision &&
|
||||
row.parameter_schema_id === DELETE_INSTANCE_PARAMETER_SCHEMA_ID &&
|
||||
row.parameters_digest === PARAMETERS_DIGEST;
|
||||
if (!secretValid) return;
|
||||
|
||||
// A correctly authenticated confirmation is one-shot, including stale/missing target results.
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE operation_preparations SET status='consumed',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
|
||||
)
|
||||
.run(now, now, input.preparationId);
|
||||
const current = this.db
|
||||
.prepare('SELECT config_revision FROM instances WHERE id=?')
|
||||
.get(input.instanceId) as { config_revision: number } | undefined;
|
||||
if (!current) {
|
||||
targetError = 'NOT_FOUND';
|
||||
return;
|
||||
}
|
||||
if (current.config_revision !== input.revision) {
|
||||
targetError = 'REVISION_CONFLICT';
|
||||
return;
|
||||
}
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO jobs
|
||||
(id,parent_job_id,root_job_id,retry_of_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at)
|
||||
VALUES (?,NULL,?,NULL,?,'R3','running',?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
ids.job,
|
||||
ids.job,
|
||||
OPERATION_ID,
|
||||
input.actor,
|
||||
input.requestId,
|
||||
PARAMETERS_DIGEST,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES (?,?,'running',?,?)`,
|
||||
)
|
||||
.run(ids.attempt, ids.job, now, now);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO job_items
|
||||
(id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at)
|
||||
VALUES (?,?,?,1,'running',?,?,?)`,
|
||||
)
|
||||
.run(ids.item, ids.job, input.instanceId, now, now, now);
|
||||
accepted = true;
|
||||
})();
|
||||
if (targetError)
|
||||
throw new DeleteInstanceOperationError(
|
||||
targetError,
|
||||
targetError === 'NOT_FOUND' ? 'Instance was not found' : 'Instance revision does not match',
|
||||
);
|
||||
if (!accepted)
|
||||
throw new DeleteInstanceOperationError(
|
||||
'CONFIRMATION_INVALID',
|
||||
'Confirmation could not be accepted',
|
||||
);
|
||||
|
||||
try {
|
||||
await this.instances.delete(input.instanceId, input.revision, { allowJobHistory: true });
|
||||
const finished = this.clock().toISOString();
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE job_items
|
||||
SET status='succeeded',result_code='INSTANCE_DELETED',finished_at=?,updated_at=?
|
||||
WHERE id=? AND status='running'`,
|
||||
)
|
||||
.run(finished, finished, ids.item);
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE job_attempts SET status='succeeded',finished_at=? WHERE id=? AND status='running'",
|
||||
)
|
||||
.run(finished, ids.attempt);
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE jobs SET status='succeeded',finished_at=?,updated_at=? WHERE id=? AND status='running'",
|
||||
)
|
||||
.run(finished, finished, ids.job);
|
||||
})();
|
||||
} catch (error) {
|
||||
const finished = this.clock().toISOString();
|
||||
const state = error instanceof InstanceServiceError ? 'failed' : 'unknown-result';
|
||||
const code = error instanceof InstanceServiceError ? error.code : 'DELETE_RESULT_UNKNOWN';
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE job_items
|
||||
SET status=?,result_code=?,finished_at=?,updated_at=?
|
||||
WHERE id=? AND status='running'`,
|
||||
)
|
||||
.run(state, code, finished, finished, ids.item);
|
||||
this.db
|
||||
.prepare('UPDATE job_attempts SET status=?,finished_at=? WHERE id=?')
|
||||
.run(state, finished, ids.attempt);
|
||||
this.db
|
||||
.prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?')
|
||||
.run(state, finished, finished, ids.job);
|
||||
})();
|
||||
}
|
||||
return this.job(ids.job);
|
||||
}
|
||||
|
||||
reconcileInterruptedJobs(): number {
|
||||
const finished = this.clock().toISOString();
|
||||
return this.db.transaction(() => {
|
||||
const jobs = this.db
|
||||
.prepare("SELECT id FROM jobs WHERE operation_id=? AND status='running'")
|
||||
.all(OPERATION_ID) as Array<{ id: string }>;
|
||||
for (const { id } of jobs) {
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE job_items SET status='unknown-result',result_code='INTERRUPTED',finished_at=?,updated_at=? WHERE job_id=? AND status='running'",
|
||||
)
|
||||
.run(finished, finished, id);
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE job_attempts SET status='unknown-result',finished_at=? WHERE job_id=? AND status='running'",
|
||||
)
|
||||
.run(finished, id);
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE jobs SET status='unknown-result',finished_at=?,updated_at=? WHERE id=? AND status='running'",
|
||||
)
|
||||
.run(finished, finished, id);
|
||||
}
|
||||
return jobs.length;
|
||||
})();
|
||||
}
|
||||
|
||||
private job(id: string): Job {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
'SELECT operation_id,status,root_job_id,retry_of_job_id,created_at FROM jobs WHERE id=?',
|
||||
)
|
||||
.get(id) as {
|
||||
operation_id: string;
|
||||
status: Job['status'];
|
||||
root_job_id: string;
|
||||
retry_of_job_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
const items = this.db
|
||||
.prepare('SELECT id,instance_id,status FROM job_items WHERE job_id=? ORDER BY created_at,id')
|
||||
.all(id) as Array<{
|
||||
id: string;
|
||||
instance_id: string;
|
||||
status: 'succeeded' | 'failed' | 'unknown-result';
|
||||
}>;
|
||||
const attempts = this.db
|
||||
.prepare(
|
||||
'SELECT id,status,started_at,finished_at FROM job_attempts WHERE job_id=? ORDER BY started_at,id',
|
||||
)
|
||||
.all(id) as Array<{
|
||||
id: string;
|
||||
status: 'succeeded' | 'failed' | 'unknown-result';
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
}>;
|
||||
return {
|
||||
id,
|
||||
operationId: row.operation_id,
|
||||
status: row.status,
|
||||
...(row.retry_of_job_id ? { retryOfJobId: row.retry_of_job_id } : {}),
|
||||
rootJobId: row.root_job_id,
|
||||
items: items.map((item) => ({ id: item.id, targetId: item.instance_id, state: item.status })),
|
||||
attempts: attempts.map((attempt) => ({
|
||||
id: attempt.id,
|
||||
state: attempt.status,
|
||||
startedAt: attempt.started_at,
|
||||
...(attempt.finished_at ? { finishedAt: attempt.finished_at } : {}),
|
||||
})),
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buildApp, type BuildAppOptions } from './app.js';
|
||||
import {
|
||||
ConnectionProbe,
|
||||
type ConnectionTransport,
|
||||
} from './application/connections/connection-probe.js';
|
||||
import { InstanceCredentialResolver } from './application/connections/instance-credential-resolver.js';
|
||||
import { InstanceLoginService } from './application/connections/instance-login-service.js';
|
||||
import {
|
||||
InstanceSessionStore,
|
||||
UpstreamSessionClient,
|
||||
type UpstreamSessionClientOptions,
|
||||
} from './application/connections/upstream-session-client.js';
|
||||
import { InstanceService } from './application/instances/instance-service.js';
|
||||
import { DeleteInstanceOperation } from './application/operations/delete-instance-operation.js';
|
||||
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
||||
import { registerInstanceRoutes } from './interface/http/instance-routes.js';
|
||||
|
||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||
request: UpstreamSessionClientOptions['request'];
|
||||
}
|
||||
export interface ControlPlaneOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly store: SecretStore;
|
||||
readonly upstream: SafeControlPlaneUpstream;
|
||||
readonly now?: () => Date;
|
||||
readonly app?: Omit<BuildAppOptions, 'registerRoutes'>;
|
||||
}
|
||||
export interface ControlPlaneApp extends FastifyInstance {
|
||||
retryPendingSecretCleanup(): Promise<unknown>;
|
||||
}
|
||||
export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp {
|
||||
const instances = options.now
|
||||
? new InstanceService({ db: options.db, store: options.store, now: options.now })
|
||||
: new InstanceService({ db: options.db, store: options.store });
|
||||
const connections = options.now
|
||||
? new ConnectionProbe({
|
||||
db: options.db,
|
||||
instances,
|
||||
transport: options.upstream,
|
||||
now: options.now,
|
||||
})
|
||||
: new ConnectionProbe({ db: options.db, instances, transport: options.upstream });
|
||||
const sessions = new InstanceSessionStore();
|
||||
const client = new UpstreamSessionClient({ sessions, request: options.upstream.request });
|
||||
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
|
||||
const login = options.now
|
||||
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
|
||||
: new InstanceLoginService({ db: options.db, client, resolver });
|
||||
const deletion = options.now
|
||||
? new DeleteInstanceOperation({ db: options.db, instances, now: options.now })
|
||||
: new DeleteInstanceOperation({ db: options.db, instances });
|
||||
deletion.reconcileInterruptedJobs();
|
||||
const app = buildApp({
|
||||
...options.app,
|
||||
registerRoutes: (app) =>
|
||||
registerInstanceRoutes(app, { instances, connections, login, deletion }),
|
||||
});
|
||||
Object.assign(app, {
|
||||
retryPendingSecretCleanup: () => instances.retryPendingSecretCleanup(),
|
||||
});
|
||||
return app as ControlPlaneApp;
|
||||
}
|
||||
@@ -44,5 +44,10 @@ export type {
|
||||
SecretStoreErrorCode,
|
||||
} from './infrastructure/secrets/keychain-secret-store.js';
|
||||
export type { SecretKey, SecretStore } from './infrastructure/secrets/secret-store.js';
|
||||
export { InstanceService, InstanceServiceError } from './application/instances/instance-service.js';
|
||||
export type {
|
||||
InstanceServiceErrorCode,
|
||||
InstanceServiceOptions,
|
||||
} from './application/instances/instance-service.js';
|
||||
export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js';
|
||||
export * as databaseSchema from './infrastructure/database/schema.js';
|
||||
|
||||
@@ -64,9 +64,12 @@ describe('database migrations', () => {
|
||||
'capabilities',
|
||||
'instance_tags',
|
||||
'instances',
|
||||
'job_attempts',
|
||||
'job_items',
|
||||
'jobs',
|
||||
'operation_preparations',
|
||||
'schema_migrations',
|
||||
'secret_cleanup_tasks',
|
||||
'secret_references',
|
||||
'status_snapshots',
|
||||
]);
|
||||
@@ -91,7 +94,18 @@ describe('database migrations', () => {
|
||||
expect(foreignKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: 'job_id', on_delete: 'CASCADE', table: 'jobs' }),
|
||||
expect.objectContaining({ from: 'instance_id', on_delete: 'RESTRICT', table: 'instances' }),
|
||||
]),
|
||||
);
|
||||
expect(foreignKeys).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: 'instance_id', table: 'instances' }),
|
||||
]),
|
||||
);
|
||||
const jobColumns = database.pragma('table_info(jobs)') as Array<{ name: string }>;
|
||||
expect(jobColumns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'root_job_id' }),
|
||||
expect.objectContaining({ name: 'retry_of_job_id' }),
|
||||
]),
|
||||
);
|
||||
const indexes = database
|
||||
@@ -107,6 +121,103 @@ describe('database migrations', () => {
|
||||
'idx_status_snapshots_instance_observed_at',
|
||||
]),
|
||||
);
|
||||
const now = '2026-07-16T00:00:00.000Z';
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run('cleanup-owner', 'Cleanup owner', 'http://10.0.0.9', 'password', 1, 1, now, now);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
'ref-one',
|
||||
'cleanup-owner',
|
||||
'instance-password',
|
||||
'macos-keychain',
|
||||
'opaque-one',
|
||||
now,
|
||||
now,
|
||||
);
|
||||
expect(() =>
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
'ref-two',
|
||||
'cleanup-owner',
|
||||
'instance-password',
|
||||
'macos-keychain',
|
||||
'opaque-two',
|
||||
now,
|
||||
now,
|
||||
),
|
||||
).toThrow(/unique/i);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
|
||||
)
|
||||
.run('opaque-one', 'cleanup-owner', 'instance-password', 'macos-keychain', now, now);
|
||||
database.prepare('DELETE FROM instances WHERE id=?').run('cleanup-owner');
|
||||
expect(database.prepare('SELECT reference FROM secret_cleanup_tasks').all()).toEqual([
|
||||
{ reference: 'opaque-one' },
|
||||
]);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('upgrades duplicate v1 secret references by retaining one canonical row and queuing the rest', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const database = openDatabase(join(directory, 'app.sqlite'));
|
||||
migrateDatabase(database, [MIGRATIONS[0]!]);
|
||||
const older = '2026-07-15T00:00:00.000Z';
|
||||
const newer = '2026-07-16T00:00:00.000Z';
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run('owner', 'Owner', 'http://10.0.0.8', 'password', 1, 1, older, newer);
|
||||
const reference = (slot: string) =>
|
||||
`keychain://multi-simadmin/${Buffer.from(JSON.stringify(['owner', 'instance-password', slot]), 'utf8').toString('base64url')}`;
|
||||
const insert = database.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
);
|
||||
insert.run(
|
||||
'old-ref',
|
||||
'owner',
|
||||
'instance-password',
|
||||
'macos-keychain',
|
||||
reference('old-slot'),
|
||||
older,
|
||||
older,
|
||||
);
|
||||
insert.run(
|
||||
'new-ref',
|
||||
'owner',
|
||||
'instance-password',
|
||||
'macos-keychain',
|
||||
reference('new-slot'),
|
||||
newer,
|
||||
newer,
|
||||
);
|
||||
|
||||
migrateDatabase(database);
|
||||
|
||||
expect(database.prepare('SELECT id FROM secret_references').all()).toEqual([{ id: 'new-ref' }]);
|
||||
expect(
|
||||
database
|
||||
.prepare('SELECT reference,instance_id,purpose,provider FROM secret_cleanup_tasks')
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
reference: reference('old-slot'),
|
||||
instance_id: 'owner',
|
||||
purpose: 'instance-password',
|
||||
provider: 'macos-keychain',
|
||||
},
|
||||
]);
|
||||
expect(() => migrateDatabase(database)).not.toThrow();
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -113,6 +113,121 @@ export const MIGRATIONS: readonly Migration[] = [
|
||||
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'secret-cleanup-task-outbox',
|
||||
statements: [
|
||||
`CREATE TABLE secret_cleanup_tasks (
|
||||
reference TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0),
|
||||
queued_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at)
|
||||
SELECT external_reference,instance_id,purpose,provider,1,updated_at,updated_at
|
||||
FROM (
|
||||
SELECT external_reference,instance_id,purpose,provider,updated_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY instance_id,purpose
|
||||
ORDER BY updated_at DESC,created_at DESC,id DESC
|
||||
) AS row_number
|
||||
FROM secret_references
|
||||
WHERE instance_id IS NOT NULL
|
||||
) ranked
|
||||
WHERE row_number > 1`,
|
||||
`DELETE FROM secret_references
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY instance_id,purpose
|
||||
ORDER BY updated_at DESC,created_at DESC,id DESC
|
||||
) AS row_number
|
||||
FROM secret_references
|
||||
WHERE instance_id IS NOT NULL
|
||||
) ranked
|
||||
WHERE row_number > 1
|
||||
)`,
|
||||
'CREATE UNIQUE INDEX uq_secret_references_instance_purpose ON secret_references(instance_id, purpose) WHERE instance_id IS NOT NULL',
|
||||
'CREATE INDEX idx_secret_cleanup_tasks_queued_at ON secret_cleanup_tasks(queued_at)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'current-status-snapshot-lifecycle',
|
||||
statements: [
|
||||
`DELETE FROM status_snapshots
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY instance_id, category
|
||||
ORDER BY observed_at DESC, created_at DESC, id DESC
|
||||
) AS row_number
|
||||
FROM status_snapshots
|
||||
) ranked
|
||||
WHERE row_number > 1
|
||||
)`,
|
||||
'CREATE UNIQUE INDEX uq_status_snapshots_instance_category ON status_snapshots(instance_id, category)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'r3-preparations-and-durable-job-lineage',
|
||||
statements: [
|
||||
'ALTER TABLE jobs ADD COLUMN root_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT',
|
||||
'ALTER TABLE jobs ADD COLUMN retry_of_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT',
|
||||
'UPDATE jobs SET root_job_id=id WHERE root_job_id IS NULL',
|
||||
`CREATE TABLE job_items_v4 (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
instance_id TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0),
|
||||
status TEXT NOT NULL,
|
||||
result_code TEXT,
|
||||
error_json TEXT,
|
||||
source_job_item_id TEXT REFERENCES job_items_v4(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (job_id, instance_id, attempt_number)
|
||||
)`,
|
||||
`INSERT INTO job_items_v4 (id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at)
|
||||
SELECT id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at FROM job_items`,
|
||||
'DROP TABLE job_items',
|
||||
'ALTER TABLE job_items_v4 RENAME TO job_items',
|
||||
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
|
||||
`CREATE TABLE job_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed','cancelled','unknown-result')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
'CREATE INDEX idx_job_attempts_job_started_at ON job_attempts(job_id, started_at)',
|
||||
`CREATE TABLE operation_preparations (
|
||||
id TEXT PRIMARY KEY,
|
||||
operation_id TEXT NOT NULL CHECK (operation_id = 'deleteInstance'),
|
||||
risk_level TEXT NOT NULL CHECK (risk_level = 'R3'),
|
||||
status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')),
|
||||
target_instance_id TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL CHECK (target_revision > 0),
|
||||
parameter_schema_id TEXT NOT NULL,
|
||||
parameters_digest TEXT NOT NULL,
|
||||
token_digest TEXT NOT NULL CHECK (length(token_digest) = 64),
|
||||
requested_by TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sqliteTable,
|
||||
text,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
|
||||
const timestamps = {
|
||||
@@ -88,6 +89,7 @@ export const statusSnapshots = sqliteTable(
|
||||
sql`${table.state} in ('fresh', 'stale', 'expired', 'unknown')`,
|
||||
),
|
||||
index('idx_status_snapshots_instance_observed_at').on(table.instanceId, desc(table.observedAt)),
|
||||
uniqueIndex('uq_status_snapshots_instance_category').on(table.instanceId, table.category),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -98,6 +100,12 @@ export const jobs = sqliteTable(
|
||||
parentJobId: text('parent_job_id').references((): AnySQLiteColumn => jobs.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
rootJobId: text('root_job_id').references((): AnySQLiteColumn => jobs.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
retryOfJobId: text('retry_of_job_id').references((): AnySQLiteColumn => jobs.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
operationId: text('operation_id').notNull(),
|
||||
riskLevel: text('risk_level').notNull(),
|
||||
status: text('status').notNull(),
|
||||
@@ -122,12 +130,14 @@ export const jobItems = sqliteTable(
|
||||
jobId: text('job_id')
|
||||
.notNull()
|
||||
.references(() => jobs.id, { onDelete: 'cascade' }),
|
||||
instanceId: text('instance_id')
|
||||
.notNull()
|
||||
.references(() => instances.id, { onDelete: 'restrict' }),
|
||||
instanceId: text('instance_id').notNull(),
|
||||
attemptNumber: integer('attempt_number').notNull().default(1),
|
||||
status: text('status').notNull(),
|
||||
resultCode: text('result_code'),
|
||||
errorJson: text('error_json'),
|
||||
sourceJobItemId: text('source_job_item_id').references((): AnySQLiteColumn => jobItems.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
createdAt: text('created_at').notNull(),
|
||||
startedAt: text('started_at'),
|
||||
finishedAt: text('finished_at'),
|
||||
@@ -144,6 +154,44 @@ export const jobItems = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const jobAttempts = sqliteTable(
|
||||
'job_attempts',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
jobId: text('job_id')
|
||||
.notNull()
|
||||
.references(() => jobs.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull(),
|
||||
startedAt: text('started_at').notNull(),
|
||||
finishedAt: text('finished_at'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
},
|
||||
(table) => [index('idx_job_attempts_job_started_at').on(table.jobId, table.startedAt)],
|
||||
);
|
||||
|
||||
export const operationPreparations = sqliteTable(
|
||||
'operation_preparations',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
operationId: text('operation_id').notNull(),
|
||||
riskLevel: text('risk_level').notNull(),
|
||||
status: text('status').notNull(),
|
||||
targetInstanceId: text('target_instance_id').notNull(),
|
||||
targetRevision: integer('target_revision').notNull(),
|
||||
parameterSchemaId: text('parameter_schema_id').notNull(),
|
||||
parametersDigest: text('parameters_digest').notNull(),
|
||||
tokenDigest: text('token_digest').notNull(),
|
||||
requestedBy: text('requested_by').notNull(),
|
||||
requestId: text('request_id').notNull(),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
consumedAt: text('consumed_at'),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
index('idx_operation_preparations_status_expires_at').on(table.status, table.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const auditEvents = sqliteTable(
|
||||
'audit_events',
|
||||
{
|
||||
@@ -188,5 +236,22 @@ export const secretReferences = sqliteTable(
|
||||
table.provider,
|
||||
table.externalReference,
|
||||
),
|
||||
uniqueIndex('uq_secret_references_instance_purpose')
|
||||
.on(table.instanceId, table.purpose)
|
||||
.where(sql`${table.instanceId} is not null`),
|
||||
],
|
||||
);
|
||||
|
||||
export const secretCleanupTasks = sqliteTable(
|
||||
'secret_cleanup_tasks',
|
||||
{
|
||||
reference: text('reference').primaryKey(),
|
||||
instanceId: text('instance_id').notNull(),
|
||||
purpose: text('purpose').notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
generation: integer('generation').notNull().default(1),
|
||||
queuedAt: text('queued_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
},
|
||||
(table) => [index('idx_secret_cleanup_tasks_queued_at').on(table.queuedAt)],
|
||||
);
|
||||
|
||||
@@ -61,6 +61,11 @@ describe('MacOSKeychainSecretStore', () => {
|
||||
);
|
||||
|
||||
expect(reference).toMatch(/^keychain:\/\/multi-simadmin\//);
|
||||
expect(parseKeychainReference(reference)).toMatchObject({
|
||||
service: 'multi-simadmin',
|
||||
instanceId: 'instance.one',
|
||||
purpose: 'instance-password',
|
||||
});
|
||||
expect(reference).not.toContain(secret);
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(runner.calls[0]).toEqual({
|
||||
|
||||
@@ -96,6 +96,9 @@ export class SpawnCommandRunner implements CommandRunner {
|
||||
export interface ParsedKeychainReference {
|
||||
readonly service: typeof SERVICE;
|
||||
readonly account: string;
|
||||
readonly instanceId: string;
|
||||
readonly purpose: string;
|
||||
readonly slot?: string;
|
||||
}
|
||||
|
||||
function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): never {
|
||||
@@ -161,7 +164,13 @@ export function parseKeychainReference(reference: string): ParsedKeychainReferen
|
||||
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
|
||||
validateKey(key);
|
||||
if (accountFor(key) !== account) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
return { service: SERVICE, account };
|
||||
return {
|
||||
service: SERVICE,
|
||||
account,
|
||||
instanceId: key.instanceId,
|
||||
purpose: key.purpose,
|
||||
...(key.slot === undefined ? {} : { slot: key.slot }),
|
||||
};
|
||||
}
|
||||
|
||||
export class MacOSKeychainSecretStore implements SecretStore {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PinnedHttpRequester, type PinnedDispatchOptions } from './pinned-http-requester.js';
|
||||
|
||||
describe('PinnedHttpRequester', () => {
|
||||
it('pins Node lookup to the validated address and sets bounded request options', async () => {
|
||||
let captured: PinnedDispatchOptions | undefined;
|
||||
const requester = new PinnedHttpRequester({
|
||||
dispatch: async (options) => {
|
||||
captured = options;
|
||||
return { status: 200, headers: {}, body: '' };
|
||||
},
|
||||
});
|
||||
await requester.request(
|
||||
{ url: 'http://device.lan:8080/api/health', method: 'GET', headers: {} },
|
||||
[{ address: '192.168.1.20', family: 4 }],
|
||||
);
|
||||
if (!captured) throw new Error('dispatch was not called');
|
||||
const dispatchOptions = captured;
|
||||
expect(dispatchOptions.followRedirects).toBe(false);
|
||||
expect(dispatchOptions.timeoutMs).toBe(5000);
|
||||
expect(dispatchOptions.maxBodyBytes).toBe(65536);
|
||||
await expect(
|
||||
new Promise((resolve, reject) =>
|
||||
dispatchOptions.lookup('device.lan', { all: false }, (error, address, family) => {
|
||||
if (error) reject(error);
|
||||
else if (Array.isArray(address))
|
||||
reject(new Error('unexpected all-address lookup result'));
|
||||
else resolve({ a: address, f: family });
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ a: '192.168.1.20', f: 4 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import type {
|
||||
PinnedRequest,
|
||||
ResolvedAddress,
|
||||
TransportResponse,
|
||||
} from './safe-instance-transport.js';
|
||||
import { asUpstreamError, UpstreamError } from './upstream-error.js';
|
||||
|
||||
export interface PinnedDispatchOptions {
|
||||
readonly url: URL;
|
||||
readonly method: 'GET' | 'POST';
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body?: string;
|
||||
readonly lookup: LookupFunction;
|
||||
readonly followRedirects: false;
|
||||
readonly timeoutMs: number;
|
||||
readonly maxBodyBytes: number;
|
||||
}
|
||||
export interface PinnedHttpRequesterOptions {
|
||||
readonly dispatch?: (options: PinnedDispatchOptions) => Promise<TransportResponse>;
|
||||
readonly timeoutMs?: number;
|
||||
readonly maxBodyBytes?: number;
|
||||
}
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_MAX_BODY_BYTES = 65_536;
|
||||
const lookupFor = (addresses: readonly ResolvedAddress[]): LookupFunction => {
|
||||
const first = addresses[0];
|
||||
if (!first) throw new Error('No validated address');
|
||||
return (_hostname, _options, callback) => callback(null, first.address, first.family);
|
||||
};
|
||||
export class PinnedHttpRequester {
|
||||
private readonly dispatch: (options: PinnedDispatchOptions) => Promise<TransportResponse>;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly maxBodyBytes: number;
|
||||
constructor(options: PinnedHttpRequesterOptions = {}) {
|
||||
this.dispatch = options.dispatch ?? dispatchNative;
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
||||
}
|
||||
async request(
|
||||
request: PinnedRequest,
|
||||
addresses: readonly ResolvedAddress[],
|
||||
): Promise<TransportResponse> {
|
||||
try {
|
||||
return await this.dispatch({
|
||||
url: new URL(request.url),
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
...(request.body === undefined ? {} : { body: request.body }),
|
||||
lookup: lookupFor(addresses),
|
||||
followRedirects: false,
|
||||
timeoutMs: this.timeoutMs,
|
||||
maxBodyBytes: this.maxBodyBytes,
|
||||
});
|
||||
} catch (error) {
|
||||
throw asUpstreamError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
const dispatchNative = (options: PinnedDispatchOptions): Promise<TransportResponse> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const client = options.url.protocol === 'https:' ? https : http;
|
||||
const request = client.request({
|
||||
protocol: options.url.protocol,
|
||||
hostname: options.url.hostname,
|
||||
port: options.url.port || undefined,
|
||||
path: `${options.url.pathname}${options.url.search}`,
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
lookup: options.lookup,
|
||||
agent: false,
|
||||
timeout: options.timeoutMs,
|
||||
maxHeaderSize: 16_384,
|
||||
...(options.url.protocol === 'https:' ? { servername: options.url.hostname } : {}),
|
||||
});
|
||||
let settled = false;
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
request.destroy(error);
|
||||
reject(error);
|
||||
};
|
||||
request.once('timeout', () => fail(new UpstreamError('UPSTREAM_TIMEOUT')));
|
||||
request.once('error', fail);
|
||||
request.once('response', (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > options.maxBodyBytes) fail(new UpstreamError('UPSTREAM_RESPONSE_TOO_LARGE'));
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
response.once('error', fail);
|
||||
response.once('end', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const headers: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(response.headers))
|
||||
headers[key] = Array.isArray(value) ? value.join(', ') : value;
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
request.end(options.body);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createProductionUpstream } from './production-upstream.js';
|
||||
|
||||
describe('createProductionUpstream', () => {
|
||||
it('passes every resolved address from its resolver to the pinned requester', async () => {
|
||||
let received: unknown;
|
||||
const upstream = createProductionUpstream({
|
||||
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
|
||||
request: async (request, addresses) => {
|
||||
received = { request, addresses };
|
||||
return { status: 200, headers: {}, body: '' };
|
||||
},
|
||||
});
|
||||
await upstream.get('http://router.lan:8080/api/health');
|
||||
expect(received).toMatchObject({
|
||||
request: { method: 'GET', url: 'http://router.lan:8080/api/health' },
|
||||
addresses: [{ address: '192.168.1.20', family: 4 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { lookup as nodeLookup } from 'node:dns/promises';
|
||||
import {
|
||||
createSafeControlPlaneUpstream,
|
||||
type SafeControlPlaneUpstream,
|
||||
} from './safe-control-plane-upstream.js';
|
||||
import { PinnedHttpRequester } from './pinned-http-requester.js';
|
||||
import {
|
||||
SafeInstanceTransport,
|
||||
type PinnedRequest,
|
||||
type ResolvedAddress,
|
||||
type TransportResponse,
|
||||
} from './safe-instance-transport.js';
|
||||
|
||||
export interface ProductionUpstreamOptions {
|
||||
readonly resolve?: (hostname: string) => Promise<readonly ResolvedAddress[]>;
|
||||
readonly request?: (
|
||||
request: PinnedRequest,
|
||||
addresses: readonly ResolvedAddress[],
|
||||
) => Promise<TransportResponse>;
|
||||
}
|
||||
export function createProductionUpstream(
|
||||
options: ProductionUpstreamOptions = {},
|
||||
): SafeControlPlaneUpstream {
|
||||
const requester = options.request ? { request: options.request } : new PinnedHttpRequester();
|
||||
const transport = new SafeInstanceTransport({
|
||||
resolve:
|
||||
options.resolve ??
|
||||
((hostname) =>
|
||||
nodeLookup(hostname, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>),
|
||||
request: (request, addresses) => requester.request(request, addresses),
|
||||
});
|
||||
return createSafeControlPlaneUpstream(transport);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSafeControlPlaneUpstream } from './safe-control-plane-upstream.js';
|
||||
|
||||
describe('createSafeControlPlaneUpstream', () => {
|
||||
it('routes both health GET and login POST through the same safe transport', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const upstream = createSafeControlPlaneUpstream({
|
||||
get: async (url) => {
|
||||
calls.push({ method: 'GET', url });
|
||||
return { status: 401, headers: {}, body: '' };
|
||||
},
|
||||
post: async (url, headers, body) => {
|
||||
calls.push({ method: 'POST', url, headers, body });
|
||||
return { status: 200, headers: {}, body: '' };
|
||||
},
|
||||
});
|
||||
await upstream.get('http://192.168.1.20/api/health');
|
||||
await upstream.request({
|
||||
url: 'https://192.168.1.20/api/auth/login',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
secret: '[REDACTED]',
|
||||
body: '[REDACTED]',
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://192.168.1.20/api/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"password":"[REDACTED]"}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ConnectionTransport } from '../../application/connections/connection-probe.js';
|
||||
import type { UpstreamSessionClientOptions } from '../../application/connections/upstream-session-client.js';
|
||||
import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream-gateway.js';
|
||||
|
||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||
request: UpstreamSessionClientOptions['request'];
|
||||
}
|
||||
export function createSafeControlPlaneUpstream(
|
||||
transport: SafeUpstreamTransport,
|
||||
): SafeControlPlaneUpstream {
|
||||
const gateway = new SafeUpstreamGateway({ transport });
|
||||
return {
|
||||
get: (url) => transport.get(url),
|
||||
request: (request) => gateway.request(request),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SafeInstanceTransport, TransportError } from './safe-instance-transport.js';
|
||||
|
||||
const allowed = new SafeInstanceTransport({
|
||||
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
|
||||
request: async (request) => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: `ok:${request.url}`,
|
||||
}),
|
||||
});
|
||||
|
||||
describe('SafeInstanceTransport', () => {
|
||||
it('resolves a hostname immediately before requesting its health endpoint', async () => {
|
||||
await expect(allowed.get('http://router.lan:3000/api/health')).resolves.toMatchObject({
|
||||
status: 200,
|
||||
body: 'ok:http://router.lan:3000/api/health',
|
||||
});
|
||||
});
|
||||
|
||||
it('supports HTTPS origins using the same validated address pinning path', async () => {
|
||||
await expect(allowed.get('https://router.lan:3443/api/health')).resolves.toMatchObject({
|
||||
status: 200,
|
||||
body: 'ok:https://router.lan:3443/api/health',
|
||||
});
|
||||
});
|
||||
|
||||
it('pins IPv6 ULA literals without attempting DNS resolution', async () => {
|
||||
let resolved = false;
|
||||
let addresses: readonly { address: string; family: 4 | 6 }[] | undefined;
|
||||
const transport = new SafeInstanceTransport({
|
||||
resolve: async () => {
|
||||
resolved = true;
|
||||
return [];
|
||||
},
|
||||
request: async (_request, received) => {
|
||||
addresses = received;
|
||||
return { status: 200, headers: {}, body: '' };
|
||||
},
|
||||
});
|
||||
await transport.get('http://[fd00::1]:8080/api/health');
|
||||
expect(resolved).toBe(false);
|
||||
expect(addresses).toEqual([{ address: 'fd00::1', family: 6 }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'loopback', address: '127.0.0.1' },
|
||||
{ name: 'link-local', address: '169.254.169.254' },
|
||||
{ name: 'unspecified', address: '0.0.0.0' },
|
||||
{ name: 'multicast', address: '224.0.0.1' },
|
||||
{ name: 'public', address: '8.8.8.8' },
|
||||
])('rejects a DNS $name result before network I/O', async ({ address }) => {
|
||||
let requested = false;
|
||||
const transport = new SafeInstanceTransport({
|
||||
resolve: async () => [{ address, family: 4 }],
|
||||
request: async () => {
|
||||
requested = true;
|
||||
return { status: 200, headers: {}, body: '' };
|
||||
},
|
||||
});
|
||||
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
|
||||
code: 'UNSAFE_RESOLUTION',
|
||||
} satisfies Partial<TransportError>);
|
||||
expect(requested).toBe(false);
|
||||
});
|
||||
|
||||
it('passes only the validated resolution set to POST requesters for pinning', async () => {
|
||||
let received:
|
||||
| { url: string; method: string; headers: Readonly<Record<string, string>>; body?: string }
|
||||
| undefined;
|
||||
let addresses: readonly { address: string; family: 4 | 6 }[] | undefined;
|
||||
const transport = new SafeInstanceTransport({
|
||||
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
|
||||
request: async (request, resolved) => {
|
||||
received = request;
|
||||
addresses = resolved;
|
||||
return { status: 200, headers: {}, body: '' };
|
||||
},
|
||||
});
|
||||
await transport.post(
|
||||
'http://device.lan/api/auth/login',
|
||||
{ 'content-type': 'application/json' },
|
||||
'{"password":"[REDACTED]"}',
|
||||
);
|
||||
expect(received).toEqual({
|
||||
url: 'http://device.lan/api/auth/login',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"password":"[REDACTED]"}',
|
||||
});
|
||||
expect(addresses).toEqual([{ address: '192.168.1.20', family: 4 }]);
|
||||
});
|
||||
|
||||
it('passes only the validated resolution set to the requester for pinning', async () => {
|
||||
let received: readonly { address: string; family: 4 | 6 }[] | undefined;
|
||||
const transport = new SafeInstanceTransport({
|
||||
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
|
||||
request: async (request, addresses) => {
|
||||
expect(request.method).toBe('GET');
|
||||
received = addresses;
|
||||
return { status: 200, headers: {}, body: '' };
|
||||
},
|
||||
});
|
||||
await transport.get('http://device.lan/api/health');
|
||||
expect(received).toEqual([{ address: '192.168.1.20', family: 4 }]);
|
||||
});
|
||||
|
||||
it('rejects mixed safe and unsafe DNS answers to prevent rebinding races', async () => {
|
||||
const transport = new SafeInstanceTransport({
|
||||
resolve: async () => [
|
||||
{ address: '192.168.1.20', family: 4 },
|
||||
{ address: '127.0.0.1', family: 4 },
|
||||
],
|
||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
});
|
||||
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
|
||||
code: 'UNSAFE_RESOLUTION',
|
||||
} satisfies Partial<TransportError>);
|
||||
});
|
||||
|
||||
it('does not follow redirects across trust boundaries', async () => {
|
||||
let calls = 0;
|
||||
const transport = new SafeInstanceTransport({
|
||||
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
|
||||
request: async () => {
|
||||
calls += 1;
|
||||
return { status: 302, headers: { location: 'http://127.0.0.1/private' }, body: '' };
|
||||
},
|
||||
});
|
||||
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
|
||||
code: 'REDIRECT_REJECTED',
|
||||
} satisfies Partial<TransportError>);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { isIP } from 'node:net';
|
||||
import { asUpstreamError, UpstreamError } from './upstream-error.js';
|
||||
|
||||
export interface ResolvedAddress {
|
||||
readonly address: string;
|
||||
readonly family: 4 | 6;
|
||||
}
|
||||
export interface TransportResponse {
|
||||
readonly status: number;
|
||||
readonly headers: Readonly<Record<string, string | undefined>>;
|
||||
readonly body: string;
|
||||
}
|
||||
export interface PinnedRequest {
|
||||
readonly url: string;
|
||||
readonly method: 'GET' | 'POST';
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body?: string;
|
||||
}
|
||||
export interface SafeTransportOptions {
|
||||
readonly resolve: (hostname: string) => Promise<readonly ResolvedAddress[]>;
|
||||
readonly request: (
|
||||
request: PinnedRequest,
|
||||
addresses: readonly ResolvedAddress[],
|
||||
) => Promise<TransportResponse>;
|
||||
}
|
||||
export class TransportError extends UpstreamError {
|
||||
constructor(override readonly code: 'UNSAFE_ORIGIN' | 'UNSAFE_RESOLUTION' | 'REDIRECT_REJECTED') {
|
||||
super(code);
|
||||
this.name = 'TransportError';
|
||||
}
|
||||
}
|
||||
const isPrivateV4 = (address: string): boolean => {
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
|
||||
return false;
|
||||
const [a, b, c] = parts;
|
||||
if (a === undefined || b === undefined || c === undefined) return false;
|
||||
return (
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 192 && b === 0 && c === 0)
|
||||
);
|
||||
};
|
||||
const isAllowedAddress = ({ address, family }: ResolvedAddress): boolean => {
|
||||
if (family === 4) return isPrivateV4(address);
|
||||
const normalized = address.toLowerCase();
|
||||
return normalized.startsWith('fc') || normalized.startsWith('fd');
|
||||
};
|
||||
const origin = (raw: string): URL => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new TransportError('UNSAFE_ORIGIN');
|
||||
}
|
||||
if (
|
||||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash
|
||||
)
|
||||
throw new TransportError('UNSAFE_ORIGIN');
|
||||
if (!parsed.pathname.startsWith('/')) throw new TransportError('UNSAFE_ORIGIN');
|
||||
return parsed;
|
||||
};
|
||||
export class SafeInstanceTransport {
|
||||
constructor(private readonly options: SafeTransportOptions) {}
|
||||
async get(raw: string): Promise<TransportResponse> {
|
||||
return this.send({ url: raw, method: 'GET', headers: {} });
|
||||
}
|
||||
async post(
|
||||
raw: string,
|
||||
headers: Readonly<Record<string, string>>,
|
||||
body: string,
|
||||
): Promise<TransportResponse> {
|
||||
return this.send({ url: raw, method: 'POST', headers, body });
|
||||
}
|
||||
private async send(request: PinnedRequest): Promise<TransportResponse> {
|
||||
const parsed = origin(request.url);
|
||||
const dialHost = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
const literalFamily = isIP(dialHost);
|
||||
let addresses: readonly ResolvedAddress[];
|
||||
try {
|
||||
addresses = literalFamily
|
||||
? [{ address: dialHost, family: literalFamily as 4 | 6 }]
|
||||
: await this.options.resolve(dialHost);
|
||||
} catch (error) {
|
||||
throw asUpstreamError(error);
|
||||
}
|
||||
if (addresses.length === 0 || addresses.some((entry) => !isAllowedAddress(entry)))
|
||||
throw new TransportError('UNSAFE_RESOLUTION');
|
||||
let response: TransportResponse;
|
||||
try {
|
||||
response = await this.options.request({ ...request, url: parsed.toString() }, addresses);
|
||||
} catch (error) {
|
||||
throw asUpstreamError(error);
|
||||
}
|
||||
if (response.status >= 300 && response.status < 400)
|
||||
throw new TransportError('REDIRECT_REJECTED');
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SafeUpstreamGateway } from './safe-upstream-gateway.js';
|
||||
|
||||
describe('SafeUpstreamGateway', () => {
|
||||
it('sends login password only as JSON through the pinned POST transport', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const gateway = new SafeUpstreamGateway({
|
||||
transport: {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
post: async (url, headers, body) => {
|
||||
calls.push({ url, headers, body });
|
||||
return { status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '' };
|
||||
},
|
||||
},
|
||||
});
|
||||
await gateway.request({
|
||||
url: 'https://192.168.1.20:8080/api/auth/login',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
secret: '[REDACTED]',
|
||||
body: '[REDACTED]',
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
url: 'https://192.168.1.20:8080/api/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"password":"[REDACTED]"}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('rejects HTTP login and logout so credentials and cookies are never sent in cleartext', async () => {
|
||||
const gateway = new SafeUpstreamGateway({
|
||||
transport: {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
post: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
gateway.request({
|
||||
url: 'http://192.168.1.20:8080/api/auth/login',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
secret: '[REDACTED]',
|
||||
body: '[REDACTED]',
|
||||
}),
|
||||
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
|
||||
await expect(
|
||||
gateway.request({
|
||||
url: 'http://192.168.1.20:8080/api/auth/logout',
|
||||
method: 'POST',
|
||||
headers: { cookie: 'simadmin_session=opaque' },
|
||||
}),
|
||||
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
|
||||
});
|
||||
|
||||
it('does not allow a supplied redacted body marker to become a network request body', async () => {
|
||||
const gateway = new SafeUpstreamGateway({
|
||||
transport: {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
post: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
gateway.request({
|
||||
url: 'https://192.168.1.20:8080/api/auth/login',
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: '[REDACTED]',
|
||||
}),
|
||||
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
UpstreamRequest,
|
||||
UpstreamResponse,
|
||||
} from '../../application/connections/upstream-session-client.js';
|
||||
import type { TransportResponse } from './safe-instance-transport.js';
|
||||
import { UpstreamError } from './upstream-error.js';
|
||||
|
||||
export interface SafeUpstreamTransport {
|
||||
get(url: string): Promise<TransportResponse>;
|
||||
post(
|
||||
url: string,
|
||||
headers: Readonly<Record<string, string>>,
|
||||
body: string,
|
||||
): Promise<TransportResponse>;
|
||||
}
|
||||
export class SafeUpstreamGateway {
|
||||
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
|
||||
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
||||
const url = new URL(request.url);
|
||||
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
|
||||
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
if (request.url.endsWith('/api/auth/login')) {
|
||||
if (typeof request.secret !== 'string' || request.body !== '[REDACTED]')
|
||||
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
return this.options.transport.post(
|
||||
request.url,
|
||||
request.headers,
|
||||
JSON.stringify({ password: request.secret }),
|
||||
);
|
||||
}
|
||||
if (request.url.endsWith('/api/auth/logout')) {
|
||||
if (request.secret !== undefined || request.body !== undefined)
|
||||
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
return this.options.transport.post(request.url, request.headers, '');
|
||||
}
|
||||
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type UpstreamErrorCode =
|
||||
| 'UPSTREAM_UNAVAILABLE'
|
||||
| 'UPSTREAM_TIMEOUT'
|
||||
| 'UPSTREAM_RESPONSE_TOO_LARGE'
|
||||
| 'UPSTREAM_INSECURE_AUTH'
|
||||
| 'UPSTREAM_REQUEST_INVALID'
|
||||
| 'UNSAFE_ORIGIN'
|
||||
| 'UNSAFE_RESOLUTION'
|
||||
| 'REDIRECT_REJECTED';
|
||||
|
||||
export class UpstreamError extends Error {
|
||||
constructor(
|
||||
readonly code: UpstreamErrorCode,
|
||||
options?: { readonly cause?: unknown },
|
||||
) {
|
||||
super(code, options);
|
||||
this.name = 'UpstreamError';
|
||||
}
|
||||
}
|
||||
|
||||
export const asUpstreamError = (error: unknown): UpstreamError =>
|
||||
error instanceof UpstreamError
|
||||
? error
|
||||
: new UpstreamError('UPSTREAM_UNAVAILABLE', { cause: error });
|
||||
@@ -0,0 +1,383 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildApp } from '../../app.js';
|
||||
import {
|
||||
InstanceSessionStore,
|
||||
UpstreamSessionClient,
|
||||
} from '../../application/connections/upstream-session-client.js';
|
||||
import { InstanceLoginService } from '../../application/connections/instance-login-service.js';
|
||||
import { ConnectionProbe } from '../../application/connections/connection-probe.js';
|
||||
import {
|
||||
InstanceService,
|
||||
InstanceServiceError,
|
||||
type InstanceServiceErrorCode,
|
||||
} from '../../application/instances/instance-service.js';
|
||||
import {
|
||||
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
||||
DeleteInstanceOperation,
|
||||
} from '../../application/operations/delete-instance-operation.js';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { registerInstanceRoutes } from './instance-routes.js';
|
||||
|
||||
class FakeStore implements SecretStore {
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||
return `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify([key.instanceId, key.purpose, key.slot]),
|
||||
'utf8',
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
async get() {
|
||||
return undefined;
|
||||
}
|
||||
async delete() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const dbs: Database.Database[] = [];
|
||||
afterEach(() => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
const fixture = () => {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
let id = 0;
|
||||
const instances = new InstanceService({
|
||||
db,
|
||||
store: new FakeStore(),
|
||||
idFactory: () => `id-${++id}`,
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
});
|
||||
const connections = new ConnectionProbe({
|
||||
db,
|
||||
instances,
|
||||
transport: { get: async () => ({ status: 401, headers: {}, body: '' }) },
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
});
|
||||
const sessionClient = new UpstreamSessionClient({
|
||||
sessions: new InstanceSessionStore(),
|
||||
request: async () => ({
|
||||
status: 200,
|
||||
headers: { 'set-cookie': 'simadmin_session=opaque' },
|
||||
body: '',
|
||||
}),
|
||||
});
|
||||
const login = new InstanceLoginService({
|
||||
db,
|
||||
client: sessionClient,
|
||||
resolver: { resolve: async () => '[REDACTED]' },
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
});
|
||||
const deletion = new DeleteInstanceOperation({
|
||||
db,
|
||||
instances,
|
||||
now: () => new Date('2026-07-16T12:00:00.000Z'),
|
||||
idFactory: () => `operation-${++id}`,
|
||||
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
|
||||
});
|
||||
const app = buildApp({
|
||||
registerRoutes: (scope) =>
|
||||
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
|
||||
});
|
||||
return { app, instances };
|
||||
};
|
||||
|
||||
describe('instance HTTP routes', () => {
|
||||
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
|
||||
const { app } = fixture();
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: {
|
||||
name: 'Alpha',
|
||||
origin: 'http://192.168.1.10:8080',
|
||||
tags: ['lab'],
|
||||
password: { action: 'set', password: 'never-return-this' },
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(201);
|
||||
expect(create.headers.etag).toBe('"rev-1"');
|
||||
expect(JSON.stringify(create.json())).not.toContain('never-return-this');
|
||||
expect(create.json()).toMatchObject({ id: 'id-1', revision: 1, credentialConfigured: true });
|
||||
|
||||
const list = await app.inject({ method: 'GET', url: '/api/v1/instances?page=1&pageSize=20' });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json()).toMatchObject({ items: [expect.objectContaining({ id: 'id-1' })] });
|
||||
const get = await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' });
|
||||
expect(get.statusCode).toBe(200);
|
||||
expect(get.headers.etag).toBe('"rev-1"');
|
||||
|
||||
const session = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances/id-1/test-connection',
|
||||
});
|
||||
expect(session.statusCode).toBe(200);
|
||||
expect(session.json()).toEqual({
|
||||
instanceId: 'id-1',
|
||||
authenticated: false,
|
||||
checkedAt: '2026-07-16T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances/id-1/login',
|
||||
payload: { password: 'one-shot-secret' },
|
||||
});
|
||||
expect(login.statusCode).toBe(200);
|
||||
expect(login.json()).toEqual({
|
||||
instanceId: 'id-1',
|
||||
authenticated: true,
|
||||
checkedAt: '2026-07-16T12:00:00.000Z',
|
||||
});
|
||||
expect(login.body).not.toContain('one-shot-secret');
|
||||
const logout = await app.inject({ method: 'POST', url: '/api/v1/instances/id-1/logout' });
|
||||
expect(logout.statusCode).toBe(200);
|
||||
expect(logout.json()).toMatchObject({ instanceId: 'id-1', authenticated: false });
|
||||
|
||||
const stale = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: { 'if-match': '"rev-2"' },
|
||||
payload: { name: 'Beta' },
|
||||
});
|
||||
expect(stale.statusCode).toBe(412);
|
||||
expect(stale.json()).toMatchObject({ code: 'REVISION_CONFLICT' });
|
||||
|
||||
const update = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: { 'if-match': '"rev-1"' },
|
||||
payload: { name: 'Beta', password: { action: 'preserve' } },
|
||||
});
|
||||
expect(update.statusCode).toBe(200);
|
||||
expect(update.headers.etag).toBe('"rev-2"');
|
||||
expect(update.json()).toMatchObject({ name: 'Beta', revision: 2 });
|
||||
|
||||
const directRemove = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: { 'if-match': '"rev-2"' },
|
||||
});
|
||||
expect(directRemove.statusCode).toBe(400);
|
||||
expect(directRemove.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const preparation = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/operations/prepare',
|
||||
payload: {
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: 'id-1', revision: 2 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
},
|
||||
});
|
||||
expect(preparation.statusCode).toBe(200);
|
||||
const prepared = preparation.json() as { id: string; confirmationToken: string };
|
||||
const remove = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: {
|
||||
'if-match': '"rev-2"',
|
||||
'x-preparation-id': prepared.id,
|
||||
'x-confirmation-token': prepared.confirmationToken,
|
||||
},
|
||||
});
|
||||
expect(remove.statusCode).toBe(202);
|
||||
expect(remove.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
|
||||
const replay = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/instances/id-1',
|
||||
headers: {
|
||||
'if-match': '"rev-2"',
|
||||
'x-preparation-id': prepared.id,
|
||||
'x-confirmation-token': prepared.confirmationToken,
|
||||
},
|
||||
});
|
||||
expect(replay.statusCode).toBe(400);
|
||||
expect(replay.json()).toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' })).statusCode).toBe(
|
||||
404,
|
||||
);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
|
||||
const { app } = fixture();
|
||||
const invalid = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: '' },
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(invalid.headers['content-type']).toContain('application/problem+json');
|
||||
expect(invalid.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const missing = await app.inject({ method: 'GET', url: '/api/v1/instances/nope' });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
expect(missing.json()).toMatchObject({ code: 'NOT_FOUND' });
|
||||
const nullCreate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: 'null',
|
||||
});
|
||||
expect(nullCreate.statusCode).toBe(400);
|
||||
expect(nullCreate.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const arrayPatch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/instances/nope',
|
||||
headers: { 'if-match': '"rev-1"' },
|
||||
payload: [],
|
||||
});
|
||||
expect(arrayPatch.statusCode).toBe(400);
|
||||
expect(arrayPatch.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
const strictCreateCases = [
|
||||
{ name: 'Alpha', origin: 'http://192.168.1.10', unexpected: true },
|
||||
{ name: 'Alpha', origin: 'http://192.168.1.10', tags: 'lab' },
|
||||
{ name: 'Alpha', origin: 'http://192.168.1.10', password: { action: 'set' } },
|
||||
];
|
||||
for (const payload of strictCreateCases) {
|
||||
const response = await app.inject({ method: 'POST', url: '/api/v1/instances', payload });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: 'Strict', origin: 'http://192.168.1.11' },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const strictId = (created.json() as { id: string }).id;
|
||||
for (const payload of [{}, { name: 123 }, { unexpected: true }]) {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/instances/${strictId}`,
|
||||
headers: { 'if-match': '"rev-1"' },
|
||||
payload,
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
const unchanged = await app.inject({ method: 'GET', url: `/api/v1/instances/${strictId}` });
|
||||
expect(unchanged.json()).toMatchObject({ name: 'Strict', revision: 1 });
|
||||
|
||||
for (const payload of [
|
||||
{ password: { not: 'a string' } },
|
||||
{ password: '' },
|
||||
{ unexpected: true },
|
||||
]) {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/instances/${strictId}/login`,
|
||||
payload,
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
const loginMissing = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances/nope/login',
|
||||
payload: {},
|
||||
});
|
||||
expect(loginMissing.statusCode).toBe(404);
|
||||
expect(loginMissing.json()).toMatchObject({ code: 'NOT_FOUND' });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('requires an exact strong revision ETag in If-Match', async () => {
|
||||
const { app } = fixture();
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: 'Strict ETag', origin: 'http://192.168.1.12' },
|
||||
});
|
||||
const id = (created.json() as { id: string }).id;
|
||||
for (const value of ['1', 'rev-1', 'W/"rev-1"', '"1"', '"rev-01"', '"rev-1", "rev-2"']) {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/instances/${id}`,
|
||||
headers: { 'if-match': value },
|
||||
payload: { name: 'Rejected' },
|
||||
});
|
||||
expect(response.statusCode, value).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('parses every frozen list filter and defaults pageSize to 25', async () => {
|
||||
const { app, instances } = fixture();
|
||||
const list = vi.spyOn(instances, 'list');
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/instances?page=2&pageSize=30&sort=status&direction=desc&search=alpha&capabilityStatus=degraded&freshness=stale&tag=lab&credentialConfigured=false',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(list).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
pageSize: 30,
|
||||
sort: 'status',
|
||||
direction: 'desc',
|
||||
search: 'alpha',
|
||||
capabilityStatus: 'degraded',
|
||||
freshness: 'stale',
|
||||
tag: 'lab',
|
||||
credentialConfigured: false,
|
||||
});
|
||||
const defaults = await app.inject({ method: 'GET', url: '/api/v1/instances' });
|
||||
expect(defaults.json()).toMatchObject({ page: { page: 1, pageSize: 25, total: 0 } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects list query values outside the frozen OpenAPI constraints', async () => {
|
||||
const { app } = fixture();
|
||||
const invalidQueries = [
|
||||
'page=0',
|
||||
'page=1.5',
|
||||
'pageSize=0',
|
||||
'pageSize=101',
|
||||
'pageSize=abc',
|
||||
'sort=id',
|
||||
'direction=sideways',
|
||||
`search=${'x'.repeat(201)}`,
|
||||
'capabilityStatus=online',
|
||||
'freshness=old',
|
||||
'credentialConfigured=1',
|
||||
'credentialConfigured=TRUE',
|
||||
];
|
||||
for (const query of invalidQueries) {
|
||||
const response = await app.inject({ method: 'GET', url: `/api/v1/instances?${query}` });
|
||||
expect(response.statusCode, query).toBe(400);
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('exhaustively maps and redacts instance service errors', async () => {
|
||||
const expected: Record<InstanceServiceErrorCode, number> = {
|
||||
VALIDATION_FAILED: 400,
|
||||
NOT_FOUND: 404,
|
||||
REVISION_CONFLICT: 412,
|
||||
HAS_JOB_HISTORY: 409,
|
||||
DUPLICATE_ID: 409,
|
||||
DUPLICATE_ORIGIN: 409,
|
||||
SECRET_STORE_FAILED: 500,
|
||||
DATABASE_FAILED: 500,
|
||||
SECRET_CLEANUP_FAILED: 500,
|
||||
COMPENSATION_FAILED: 500,
|
||||
COMPENSATION_PERSISTENCE_FAILED: 500,
|
||||
};
|
||||
for (const [code, status] of Object.entries(expected) as [InstanceServiceErrorCode, number][]) {
|
||||
const { app, instances } = fixture();
|
||||
vi.spyOn(instances, 'list').mockRejectedValue(
|
||||
new InstanceServiceError(code, 'sensitive internal failure detail'),
|
||||
);
|
||||
const response = await app.inject({ method: 'GET', url: '/api/v1/instances' });
|
||||
expect(response.statusCode, code).toBe(status);
|
||||
expect(response.headers['content-type']).toContain('application/problem+json');
|
||||
expect(response.json()).toMatchObject({ code, status });
|
||||
expect(response.body).not.toContain('sensitive internal failure detail');
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type {
|
||||
InstanceInput,
|
||||
InstancePageQuery,
|
||||
InstancePatch,
|
||||
PasswordUpdate,
|
||||
Instance,
|
||||
} from '@multi-simadmin/contracts';
|
||||
import {
|
||||
InstanceLoginService,
|
||||
InstanceLoginServiceError,
|
||||
} from '../../application/connections/instance-login-service.js';
|
||||
import { ConnectionProbe } from '../../application/connections/connection-probe.js';
|
||||
import {
|
||||
InstanceService,
|
||||
InstanceServiceError,
|
||||
} from '../../application/instances/instance-service.js';
|
||||
import {
|
||||
DeleteInstanceOperation,
|
||||
DeleteInstanceOperationError,
|
||||
} from '../../application/operations/delete-instance-operation.js';
|
||||
|
||||
export interface InstanceRoutesOptions {
|
||||
readonly instances: InstanceService;
|
||||
readonly connections?: ConnectionProbe;
|
||||
readonly login?: InstanceLoginService;
|
||||
readonly deletion?: DeleteInstanceOperation;
|
||||
}
|
||||
const problem = (
|
||||
request: FastifyRequest,
|
||||
status: number,
|
||||
code: string,
|
||||
detail: string,
|
||||
): Record<string, unknown> => ({
|
||||
type: 'about:blank',
|
||||
title:
|
||||
status === 500
|
||||
? 'Internal Server Error'
|
||||
: status === 404
|
||||
? 'Not Found'
|
||||
: status === 412
|
||||
? 'Precondition Failed'
|
||||
: status === 409
|
||||
? 'Conflict'
|
||||
: 'Bad Request',
|
||||
status,
|
||||
code,
|
||||
detail,
|
||||
requestId: request.id,
|
||||
});
|
||||
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
|
||||
const status = instanceErrorStatus[error.code];
|
||||
return problem(
|
||||
request,
|
||||
status,
|
||||
error.code,
|
||||
'The requested instance operation could not be completed.',
|
||||
);
|
||||
};
|
||||
const instanceErrorStatus = {
|
||||
VALIDATION_FAILED: 400,
|
||||
NOT_FOUND: 404,
|
||||
REVISION_CONFLICT: 412,
|
||||
HAS_JOB_HISTORY: 409,
|
||||
DUPLICATE_ID: 409,
|
||||
DUPLICATE_ORIGIN: 409,
|
||||
SECRET_STORE_FAILED: 500,
|
||||
DATABASE_FAILED: 500,
|
||||
SECRET_CLEANUP_FAILED: 500,
|
||||
COMPENSATION_FAILED: 500,
|
||||
COMPENSATION_PERSISTENCE_FAILED: 500,
|
||||
} as const satisfies Record<
|
||||
import('../../application/instances/instance-service.js').InstanceServiceErrorCode,
|
||||
number
|
||||
>;
|
||||
const etag = (instance: Instance): string => `"rev-${instance.revision}"`;
|
||||
const revision = (request: FastifyRequest): number => {
|
||||
const value = request.headers['if-match'];
|
||||
const match = typeof value === 'string' ? /^"rev-([1-9]\d*)"$/.exec(value) : null;
|
||||
if (!match) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision');
|
||||
const parsed = Number(match[1]);
|
||||
if (!Number.isSafeInteger(parsed))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision');
|
||||
return parsed;
|
||||
};
|
||||
const strings = (value: unknown): readonly string[] | undefined =>
|
||||
Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
|
||||
const record = (body: unknown): Record<string, unknown> => {
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Request body must be an object');
|
||||
return body as Record<string, unknown>;
|
||||
};
|
||||
const bodyInput = (body: unknown): InstanceInput => {
|
||||
const value = record(body);
|
||||
const tags = strings(value.tags);
|
||||
const patch: {
|
||||
name: string;
|
||||
origin: string;
|
||||
tags?: readonly string[];
|
||||
password?: PasswordUpdate;
|
||||
} = {
|
||||
name: typeof value.name === 'string' ? value.name : '',
|
||||
origin: typeof value.origin === 'string' ? value.origin : '',
|
||||
};
|
||||
if (tags) patch.tags = tags;
|
||||
if (value.password && typeof value.password === 'object')
|
||||
patch.password = value.password as PasswordUpdate;
|
||||
return patch;
|
||||
};
|
||||
const bodyPatch = (body: unknown): InstancePatch => {
|
||||
const value = record(body);
|
||||
const tags = strings(value.tags);
|
||||
const patch: {
|
||||
name?: string;
|
||||
origin?: string;
|
||||
tags?: readonly string[];
|
||||
password?: PasswordUpdate;
|
||||
} = {};
|
||||
if (typeof value.name === 'string') patch.name = value.name;
|
||||
if (typeof value.origin === 'string') patch.origin = value.origin;
|
||||
if (tags) patch.tags = tags;
|
||||
if (value.password && typeof value.password === 'object')
|
||||
patch.password = value.password as PasswordUpdate;
|
||||
return patch;
|
||||
};
|
||||
const page = (query: unknown): InstancePageQuery => {
|
||||
const value = query as Record<string, unknown>;
|
||||
const optionalString = (key: string): string | undefined => {
|
||||
const item = value[key];
|
||||
if (item === undefined) return undefined;
|
||||
if (typeof item !== 'string')
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
||||
return item;
|
||||
};
|
||||
const integer = (key: 'page' | 'pageSize', maximum?: number) => {
|
||||
const item = optionalString(key);
|
||||
if (item === undefined) return undefined;
|
||||
if (!/^[1-9]\d*$/.test(item))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
||||
const parsed = Number(item);
|
||||
if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
||||
return parsed;
|
||||
};
|
||||
const queryValue: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: 'name' | 'status' | 'freshness' | 'updatedAt';
|
||||
direction?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
tag?: string;
|
||||
capabilityStatus?: 'supported' | 'unsupported' | 'auth-required' | 'degraded' | 'unknown';
|
||||
freshness?: 'fresh' | 'stale' | 'expired' | 'unknown';
|
||||
credentialConfigured?: boolean;
|
||||
} = {};
|
||||
const parsedPage = integer('page');
|
||||
const parsedPageSize = integer('pageSize', 100);
|
||||
if (parsedPage !== undefined) queryValue.page = parsedPage;
|
||||
if (parsedPageSize !== undefined) queryValue.pageSize = parsedPageSize;
|
||||
const sort = optionalString('sort');
|
||||
if (sort !== undefined) {
|
||||
if (!['name', 'status', 'freshness', 'updatedAt'].includes(sort))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid sort');
|
||||
queryValue.sort = sort as NonNullable<typeof queryValue.sort>;
|
||||
}
|
||||
const direction = optionalString('direction');
|
||||
if (direction !== undefined) {
|
||||
if (direction !== 'asc' && direction !== 'desc')
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid direction');
|
||||
queryValue.direction = direction;
|
||||
}
|
||||
const search = optionalString('search');
|
||||
if (search !== undefined) {
|
||||
if (search.length > 200) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid search');
|
||||
queryValue.search = search;
|
||||
}
|
||||
const tag = optionalString('tag');
|
||||
if (tag !== undefined) queryValue.tag = tag;
|
||||
const capabilityStatus = optionalString('capabilityStatus');
|
||||
if (capabilityStatus !== undefined) {
|
||||
if (
|
||||
!['supported', 'unsupported', 'auth-required', 'degraded', 'unknown'].includes(
|
||||
capabilityStatus,
|
||||
)
|
||||
)
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid capabilityStatus');
|
||||
queryValue.capabilityStatus = capabilityStatus as NonNullable<
|
||||
typeof queryValue.capabilityStatus
|
||||
>;
|
||||
}
|
||||
const freshness = optionalString('freshness');
|
||||
if (freshness !== undefined) {
|
||||
if (!['fresh', 'stale', 'expired', 'unknown'].includes(freshness))
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid freshness');
|
||||
queryValue.freshness = freshness as NonNullable<typeof queryValue.freshness>;
|
||||
}
|
||||
const credentialConfigured = optionalString('credentialConfigured');
|
||||
if (credentialConfigured !== undefined) {
|
||||
if (credentialConfigured !== 'true' && credentialConfigured !== 'false')
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid credentialConfigured');
|
||||
queryValue.credentialConfigured = credentialConfigured === 'true';
|
||||
}
|
||||
return queryValue;
|
||||
};
|
||||
const passwordUpdateSchema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action'],
|
||||
properties: { action: { const: 'preserve' } },
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action', 'password'],
|
||||
properties: { action: { const: 'set' }, password: { type: 'string', minLength: 1 } },
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action'],
|
||||
properties: { action: { const: 'clear' } },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
const instanceProperties = {
|
||||
name: { type: 'string' },
|
||||
origin: { type: 'string' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
password: passwordUpdateSchema,
|
||||
} as const;
|
||||
const instanceInputSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['name', 'origin'],
|
||||
properties: instanceProperties,
|
||||
} as const;
|
||||
const instancePatchSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
minProperties: 1,
|
||||
properties: instanceProperties,
|
||||
} as const;
|
||||
const loginInputSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { password: { type: 'string', minLength: 1 } },
|
||||
} as const;
|
||||
|
||||
export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRoutesOptions): void {
|
||||
const wrap =
|
||||
<T>(
|
||||
handler: (
|
||||
request: FastifyRequest,
|
||||
reply: { header: (name: string, value: string) => unknown },
|
||||
) => Promise<T>,
|
||||
) =>
|
||||
async (
|
||||
request: FastifyRequest,
|
||||
reply: {
|
||||
code: (status: number) => typeof reply;
|
||||
header: (name: string, value: string) => typeof reply;
|
||||
type: (mime: string) => typeof reply;
|
||||
send: (body: unknown) => unknown;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
return await handler(request, reply);
|
||||
} catch (error) {
|
||||
if (error instanceof InstanceServiceError)
|
||||
return reply
|
||||
.code(instanceErrorStatus[error.code])
|
||||
.type('application/problem+json')
|
||||
.send(domainProblem(request, error));
|
||||
if (error instanceof DeleteInstanceOperationError) {
|
||||
const status =
|
||||
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 412 : 400;
|
||||
return reply
|
||||
.code(status)
|
||||
.type('application/problem+json')
|
||||
.send(
|
||||
problem(
|
||||
request,
|
||||
status,
|
||||
error.code,
|
||||
'The destructive operation could not be confirmed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (error instanceof InstanceLoginServiceError)
|
||||
return reply
|
||||
.code(error.code === 'NOT_FOUND' ? 404 : 409)
|
||||
.type('application/problem+json')
|
||||
.send(
|
||||
problem(
|
||||
request,
|
||||
error.code === 'NOT_FOUND' ? 404 : 409,
|
||||
error.code,
|
||||
'The requested session operation could not be completed.',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
app.get(
|
||||
'/api/v1/instances',
|
||||
wrap(async (request) => options.instances.list(page(request.query))),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/instances',
|
||||
{ schema: { body: instanceInputSchema } },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const created = await options.instances.create(bodyInput(request.body));
|
||||
return reply.header('ETag', etag(created)).code(201).send(created);
|
||||
} catch (error) {
|
||||
if (error instanceof InstanceServiceError)
|
||||
return reply
|
||||
.code(instanceErrorStatus[error.code])
|
||||
.type('application/problem+json')
|
||||
.send(domainProblem(request, error));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/api/v1/instances/:instanceId',
|
||||
wrap(async (request, reply) => {
|
||||
const result = await options.instances.get(
|
||||
(request.params as { instanceId: string }).instanceId,
|
||||
);
|
||||
if (!result) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
||||
reply.header('ETag', etag(result));
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
app.patch(
|
||||
'/api/v1/instances/:instanceId',
|
||||
{ schema: { body: instancePatchSchema } },
|
||||
wrap(async (request, reply) => {
|
||||
const result = await options.instances.update(
|
||||
(request.params as { instanceId: string }).instanceId,
|
||||
revision(request),
|
||||
bodyPatch(request.body),
|
||||
);
|
||||
reply.header('ETag', etag(result));
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
if (options.connections)
|
||||
app.post(
|
||||
'/api/v1/instances/:instanceId/test-connection',
|
||||
wrap(async (request) =>
|
||||
options.connections!.test((request.params as { instanceId: string }).instanceId),
|
||||
),
|
||||
);
|
||||
|
||||
if (options.login) {
|
||||
app.post(
|
||||
'/api/v1/instances/:instanceId/login',
|
||||
{ schema: { body: loginInputSchema } },
|
||||
wrap(async (request) => {
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
if (body?.password !== undefined && typeof body.password !== 'string')
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Password must be a string');
|
||||
return options.login!.login(
|
||||
(request.params as { instanceId: string }).instanceId,
|
||||
body?.password,
|
||||
);
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/instances/:instanceId/logout',
|
||||
wrap(async (request) =>
|
||||
options.login!.logout((request.params as { instanceId: string }).instanceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.deletion) {
|
||||
app.post(
|
||||
'/api/v1/operations/prepare',
|
||||
wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)),
|
||||
);
|
||||
app.delete('/api/v1/instances/:instanceId', async (request, reply) => {
|
||||
const handled = await wrap(async (wrappedRequest) => {
|
||||
const preparationId = wrappedRequest.headers['x-preparation-id'];
|
||||
const confirmationToken = wrappedRequest.headers['x-confirmation-token'];
|
||||
if (
|
||||
typeof preparationId !== 'string' ||
|
||||
preparationId.length === 0 ||
|
||||
typeof confirmationToken !== 'string' ||
|
||||
confirmationToken.length < 20
|
||||
)
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmation headers are required');
|
||||
return options.deletion!.executeDelete({
|
||||
instanceId: (wrappedRequest.params as { instanceId: string }).instanceId,
|
||||
revision: revision(wrappedRequest),
|
||||
preparationId,
|
||||
confirmationToken,
|
||||
actor: 'loopback-control-plane',
|
||||
requestId: wrappedRequest.id,
|
||||
});
|
||||
})(request, reply);
|
||||
if (reply.sent) return;
|
||||
return reply.code(202).send(handled);
|
||||
});
|
||||
} else {
|
||||
app.delete(
|
||||
'/api/v1/instances/:instanceId',
|
||||
wrap(async () => {
|
||||
throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmed deletion is unavailable');
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ describe('API startup boundary', () => {
|
||||
calls.push(options);
|
||||
return 'http://127.0.0.1:8790';
|
||||
},
|
||||
close: async () => undefined,
|
||||
},
|
||||
});
|
||||
expect(calls).toEqual([{ host: API_DEFAULT_HOST, port: API_DEFAULT_PORT }]);
|
||||
@@ -26,9 +27,56 @@ describe('API startup boundary', () => {
|
||||
called = true;
|
||||
return '';
|
||||
},
|
||||
close: async () => undefined,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/reserved|legacy/i);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('validates listen options before composing production resources', async () => {
|
||||
await expect(startApi({ environment: { API_PORT: '8788' } })).rejects.toThrow(/8788.*legacy/i);
|
||||
});
|
||||
|
||||
it('closes an injected app when listen fails and preserves the listen error', async () => {
|
||||
const listenError = new Error('address already in use');
|
||||
let closed = false;
|
||||
await expect(
|
||||
startApi({
|
||||
app: {
|
||||
listen: async () => {
|
||||
throw listenError;
|
||||
},
|
||||
close: async () => {
|
||||
closed = true;
|
||||
throw new Error('close also failed');
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(listenError);
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
it('schedules cleanup only after a successful listen without waiting for it', async () => {
|
||||
const events: string[] = [];
|
||||
let release!: () => void;
|
||||
const cleanup = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await startApi({
|
||||
app: {
|
||||
listen: async () => {
|
||||
events.push('listen');
|
||||
return '';
|
||||
},
|
||||
close: async () => undefined,
|
||||
retryPendingSecretCleanup: async () => {
|
||||
events.push('cleanup');
|
||||
await cleanup;
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(events).toEqual(['listen', 'cleanup']);
|
||||
release();
|
||||
});
|
||||
});
|
||||
|
||||
+28
-5
@@ -1,12 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buildApp, createListenOptions, type ListenEnvironment } from './app.js';
|
||||
import { createListenOptions, type ListenEnvironment } from './app.js';
|
||||
import { buildProductionControlPlane } from './production-control-plane.js';
|
||||
|
||||
export interface StartApiOptions {
|
||||
readonly environment?: ListenEnvironment;
|
||||
readonly app?: Pick<FastifyInstance, 'listen'>;
|
||||
readonly databasePath?: string;
|
||||
readonly app?: {
|
||||
listen(options: { readonly host: string; readonly port: number }): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
retryPendingSecretCleanup?: () => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function startApi(options: StartApiOptions = {}): Promise<void> {
|
||||
const app = options.app ?? buildApp({ logger: {} });
|
||||
await app.listen(createListenOptions(options.environment ?? {}));
|
||||
const listenOptions = createListenOptions(options.environment ?? {});
|
||||
const app =
|
||||
options.app ?? buildProductionControlPlane({ databasePath: options.databasePath ?? '' });
|
||||
try {
|
||||
await app.listen(listenOptions);
|
||||
} catch (listenError) {
|
||||
try {
|
||||
await app.close();
|
||||
} catch {
|
||||
// Preserve the listen failure; startup cleanup is best-effort.
|
||||
}
|
||||
throw listenError;
|
||||
}
|
||||
if (app.retryPendingSecretCleanup) {
|
||||
try {
|
||||
void app.retryPendingSecretCleanup().catch(() => undefined);
|
||||
} catch {
|
||||
// Cleanup is durable and can be retried after a later successful listen.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+63
-62
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user