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 { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js'; const PURPOSE = 'instance-password'; 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; group_id: string | null; } interface SecretRow { id: string; external_reference: string; } interface CleanupEntry { readonly instanceId: string; readonly reference: string; readonly purpose: string; readonly provider: string; readonly generation: number; readonly queuedAt: string; } interface AggregateRow extends InstanceRow { tags: string | null; credential: number; } function validation(message: string): never { throw new InstanceServiceError('VALIDATION_FAILED', message); } function normalizeName(value: unknown): string { if (typeof value !== 'string') return validation('Name must be a string'); const result = value.trim(); if (!result || result.length > 200) return validation('Name must contain 1 to 200 characters'); return result; } function normalizeTags(value: readonly string[] | undefined): string[] { if (value === undefined) return []; if (!Array.isArray(value) || value.length > MAX_TAGS) return validation('Tags are invalid'); const tags = value.map((tag) => { if (typeof tag !== 'string') return validation('Tag must be a string'); const normalized = tag.trim(); if (!normalized || normalized.length > MAX_TAG_LENGTH || /[\x00-\x1F\x7F]/.test(normalized)) return validation('Tag is invalid'); return normalized; }); const unique = [...new Set(tags)].sort(codePointCompare); if (unique.length > MAX_TAGS) return validation('Too many tags'); return unique; } function privateIpv4(host: string): boolean { const parts = host.split('.'); if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false; const octets = parts.map(Number); if (octets.some((part) => part > 255)) return false; const [a, b] = octets as [number, number, number, number]; return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); } function privateIpv6(host: string): boolean { const normalized = host.toLowerCase().replace(/^\[|\]$/g, ''); return /^(fc|fd)[0-9a-f]{2}:/.test(normalized); } function normalizeOrigin(value: unknown): string { if (typeof value !== 'string') return validation('Origin must be a URL'); let url: URL; try { url = new URL(value); } catch { return validation('Origin must be a valid URL'); } if (url.protocol !== 'http:' && url.protocol !== 'https:') return validation('Origin must use HTTP or HTTPS'); if (url.username || url.password || url.search || url.hash) return validation('Origin must not contain credentials, query, or fragment'); if (url.pathname !== '/' && url.pathname !== '') return validation('Origin must not contain a path'); if (!privateIpv4(url.hostname) && !privateIpv6(url.hostname)) return validation('Origin must target a private LAN address'); return url.origin; } function validatePassword(value: InstanceInput['password'] | InstancePatch['password']): void { if (value === undefined || value.action === 'preserve' || value.action === 'clear') return; if (value.action !== 'set' || typeof value.password !== 'string' || value.password.length === 0) validation('Password update is invalid'); } const capabilityRank: Record = { degraded: 0, 'auth-required': 1, unsupported: 2, supported: 3, unknown: 4, }; const freshnessRank: Record = { expired: 0, stale: 1, fresh: 2, unknown: 3, }; export class InstanceService { private readonly db: Database.Database; private readonly store: SecretStore; private readonly id: () => string; private readonly clock: () => Date; constructor(options: InstanceServiceOptions) { this.db = options.db; this.store = options.store; this.id = options.idFactory ?? randomUUID; this.clock = options.now ?? (() => new Date()); } async create(input: InstanceInput): Promise { const name = normalizeName(input.name); const origin = normalizeOrigin(input.origin); const tags = normalizeTags(input.tags); const groupId = this.normalizeGroupId(input.groupId); 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,group_id) VALUES (?,?,?, ?,1,1,?,?,?)', ) .run(instanceId, name, origin, newSecret ? 'password' : 'none', now, now, groupId); this.replaceTags(instanceId, tags, now); if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now); })(); } catch (error) { if (newSecret) await this.compensate(instanceId, newSecret.external); if (isUnique(error, 'base_url')) throw new InstanceServiceError('DUPLICATE_ORIGIN', 'Instance origin already exists'); if (isUnique(error, 'instances.id')) throw new InstanceServiceError('DUPLICATE_ID', 'Instance id already exists'); throw new InstanceServiceError('DATABASE_FAILED', 'Could not create instance'); } return (await this.get(instanceId))!; } async get(instanceId: string): Promise { const rows = this.loadRows('WHERE i.id = ?', [instanceId]); return rows[0] ? this.toInstance(rows[0]) : undefined; } async list(query: InstancePageQuery = {}): Promise { const pageSize = integerInRange(query.pageSize ?? 25, 1, 100, 'pageSize'); const page = integerInRange( query.page ?? 1, 1, Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1, 'page', ); if (query.direction !== undefined && query.direction !== 'asc' && query.direction !== 'desc') validation('Direction is invalid'); const validSort = ['name', 'status', 'freshness', 'updatedAt']; if (query.sort !== undefined && !validSort.includes(query.sort)) validation('Sort is invalid'); let values = this.loadRows('', []).map((row) => ({ value: this.toInstance(row), updatedAt: row.updated_at, })); const search = query.search?.trim().toLowerCase(); if (search) values = values.filter( ({ value }) => value.name.toLowerCase().includes(search) || value.id.toLowerCase().includes(search), ); if (query.tag !== undefined) values = values.filter(({ value }) => value.tags.includes(query.tag!.trim())); if (query.groupId !== undefined) values = values.filter(({ value }) => value.groupId === query.groupId!.trim()); if (query.credentialConfigured !== undefined) values = values.filter( ({ value }) => value.credentialConfigured === query.credentialConfigured, ); if (query.capabilityStatus !== undefined) values = values.filter(({ value }) => value.capabilityStatus === query.capabilityStatus); if (query.freshness !== undefined) values = values.filter(({ value }) => value.freshness === query.freshness); const direction = query.direction === 'desc' ? -1 : 1; const sort = query.sort ?? 'name'; values.sort((left, right) => { let result: number; if (sort === 'status') result = capabilityRank[left.value.capabilityStatus]! - capabilityRank[right.value.capabilityStatus]!; else if (sort === 'freshness') result = freshnessRank[left.value.freshness]! - freshnessRank[right.value.freshness]!; else if (sort === 'updatedAt') result = codePointCompare(left.updatedAt, right.updatedAt); else result = codePointCompare(left.value.name, right.value.name); return result === 0 ? codePointCompare(left.value.id, right.value.id) : result * direction; }); const total = values.length; const start = (page - 1) * pageSize; return { items: values.slice(start, start + pageSize).map(({ value }) => value), page: { page, pageSize, total }, }; } async update(instanceId: string, revision: number, patch: InstancePatch): Promise { integerInRange(revision, 1, Number.MAX_SAFE_INTEGER - 1, 'revision'); validatePassword(patch.password); const current = this.row(instanceId); if (!current) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found'); if (current.config_revision !== revision) throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match'); const name = patch.name === undefined ? current.name : normalizeName(patch.name); const origin = patch.origin === undefined ? current.base_url : normalizeOrigin(patch.origin); const tags = patch.tags === undefined ? undefined : normalizeTags(patch.tags); const groupId = patch.groupId === undefined ? current.group_id : this.normalizeGroupId(patch.groupId); 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=?,group_id=? WHERE id=? AND config_revision=?', ) .run( name, origin, newSecret || (transactionOldSecret && patch.password?.action !== 'clear') ? 'password' : 'none', now, groupId, instanceId, revision, ); if (changed.changes !== 1) throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match'); if (tags) this.replaceTags(instanceId, tags, now); if (patch.password?.action === 'clear' || newSecret) this.db .prepare('DELETE FROM secret_references WHERE instance_id=? AND purpose=?') .run(instanceId, PURPOSE); if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now); if (transactionOldSecret && (patch.password?.action === 'clear' || newSecret)) { this.queueCleanup(instanceId, transactionOldSecret.external_reference, now); committedOldSecret = transactionOldSecret; } })(); } catch (error) { if (newSecret) await this.compensate(instanceId, newSecret.external); if (error instanceof InstanceServiceError) throw error; if (isUnique(error, 'base_url')) throw new InstanceServiceError('DUPLICATE_ORIGIN', 'Instance origin already exists'); throw new InstanceServiceError('DATABASE_FAILED', 'Could not update instance'); } if (committedOldSecret) await this.tryCleanup(instanceId, committedOldSecret.external_reference); return (await this.get(instanceId))!; } async delete( instanceId: string, revision: number, options?: { allowJobHistory?: boolean }, ): Promise { integerInRange(revision, 1, Number.MAX_SAFE_INTEGER - 1, 'revision'); const allowJobHistory = options?.allowJobHistory === true; let committedOldSecret: SecretRow | undefined; try { this.db.transaction(() => { const transactionCurrent = this.row(instanceId); if (!transactionCurrent) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found'); if (transactionCurrent.config_revision !== revision) throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match'); if ( !allowJobHistory && this.db.prepare('SELECT 1 FROM job_items WHERE instance_id=? LIMIT 1').get(instanceId) ) throw new InstanceServiceError('HAS_JOB_HISTORY', 'Instance has immutable job history'); const transactionOldSecret = this.secret(instanceId); if (transactionOldSecret) { this.queueCleanup( instanceId, transactionOldSecret.external_reference, this.clock().toISOString(), ); committedOldSecret = transactionOldSecret; } const deleted = this.db .prepare('DELETE FROM instances WHERE id=? AND config_revision=?') .run(instanceId, revision); if (deleted.changes !== 1) throw new InstanceServiceError('REVISION_CONFLICT', 'Instance revision does not match'); })(); } catch (error) { if (error instanceof InstanceServiceError) throw error; if ( !allowJobHistory && this.db.prepare('SELECT 1 FROM job_items WHERE instance_id=? LIMIT 1').get(instanceId) ) throw new InstanceServiceError('HAS_JOB_HISTORY', 'Instance has immutable job history'); throw new InstanceServiceError('DATABASE_FAILED', 'Could not delete instance'); } if (committedOldSecret) await this.tryCleanup(instanceId, committedOldSecret.external_reference); } async retryPendingSecretCleanup(): Promise<{ attempted: number; cleaned: number; remaining: number; }> { const entries = this.cleanupEntries(); let cleaned = 0; for (const entry of entries) { try { this.validateCleanupEntry(entry); if (this.isActiveReference(entry.reference)) continue; await this.store.delete(entry.reference); if (this.removeCleanup(entry.reference, entry.generation)) cleaned += 1; } catch { // Retain the opaque reference for a later explicit retry. } } return { attempted: entries.length, cleaned, remaining: this.cleanupEntries().length }; } private row(id: string): InstanceRow | undefined { return this.db .prepare('SELECT id,name,base_url,config_revision,updated_at FROM instances WHERE id=?') .get(id) as InstanceRow | undefined; } private secret(id: string): SecretRow | undefined { return this.db .prepare( 'SELECT id,external_reference FROM secret_references WHERE instance_id=? AND purpose=?', ) .get(id, PURPOSE) as SecretRow | undefined; } private replaceTags(id: string, tags: readonly string[], now: string): void { this.db.prepare('DELETE FROM instance_tags WHERE instance_id=?').run(id); const insert = this.db.prepare( 'INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)', ); for (const tag of tags) insert.run(id, tag, now); } private insertReference(id: string, instanceId: string, external: string, now: string): void { this.db .prepare( 'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', ) .run(id, instanceId, PURPOSE, this.store.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 = parseSecretReference(external); if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot) throw new Error('secret store returned a reference with an invalid binding'); } catch { try { await this.store.delete(external); } catch { throw new InstanceServiceError( 'COMPENSATION_PERSISTENCE_FAILED', 'Credential binding was invalid and cleanup could not be confirmed', ); } throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential'); } return { id: this.id(), external }; } private async compensate(instanceId: string, reference: string): Promise { try { this.validateCleanupReference(instanceId, reference); await this.store.delete(reference); } catch { try { this.queueCleanup(instanceId, reference, this.clock().toISOString()); } catch { throw new InstanceServiceError( 'COMPENSATION_PERSISTENCE_FAILED', 'Database operation failed and credential cleanup could not be persisted', ); } throw new InstanceServiceError( 'COMPENSATION_FAILED', 'Database operation failed and new credential cleanup failed', ); } } private async tryCleanup(instanceId: string, reference: string): Promise { const generation = this.cleanupGeneration(reference); try { this.validateCleanupReference(instanceId, reference); await this.store.delete(reference); if (generation !== undefined) this.removeCleanup(reference, generation); } catch { // The durable outbox is authoritative after the business transaction commits. } } private cleanupEntries(): CleanupEntry[] { return this.db .prepare( 'SELECT reference,instance_id,purpose,provider,generation,queued_at FROM secret_cleanup_tasks ORDER BY queued_at ASC,reference ASC', ) .all() .map((row) => { const task = row as { reference: string; instance_id: string; purpose: string; provider: string; generation: number; queued_at: string; }; return { reference: task.reference, instanceId: task.instance_id, purpose: task.purpose, provider: task.provider, generation: task.generation, queuedAt: task.queued_at, }; }); } private queueCleanup(instanceId: string, reference: string, now: string): void { this.validateCleanupReference(instanceId, reference); const queued = this.db .prepare( `INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at) VALUES (?,?,?,?,1,?,?) ON CONFLICT(reference) DO UPDATE SET generation=secret_cleanup_tasks.generation+1, updated_at=excluded.updated_at WHERE secret_cleanup_tasks.instance_id=excluded.instance_id AND secret_cleanup_tasks.purpose=excluded.purpose AND secret_cleanup_tasks.provider=excluded.provider`, ) .run(reference, instanceId, PURPOSE, this.store.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 !== this.store.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 = parseSecretReference(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,i.group_id, 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 normalizeGroupId(value: string | null | undefined): string | null { if (value === undefined || value === null) return null; if (typeof value !== 'string') validation('groupId must be a string or null'); const trimmed = value.trim(); if (!trimmed) return null; const exists = this.db.prepare('SELECT 1 FROM device_groups WHERE id=?').get(trimmed); if (!exists) throw new InstanceServiceError('NOT_FOUND', 'Device group was not found'); return trimmed; } 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) : [], groupId: row.group_id ?? null, 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) ); }