From e4c4bce75bc6ed34158ae0f05d813e8ce5866311 Mon Sep 17 00:00:00 2001 From: chick Date: Mon, 7 Sep 2026 01:32:07 +0800 Subject: [PATCH] feat(secrets): add a 0600 file secret backend so production runs on Linux The production composition hardwired the macOS Keychain (/usr/bin/security), which made Linux deployment impossible. Now: - SecretStore gains a stable provider identity persisted in secret_references.provider; services stop hardcoding 'macos-keychain' - shared reference codec (secret-reference.ts) understands both keychain:// and secret-file:// references - FileSecretStore: single 0600 JSON map under the data root, atomic temp+rename writes, serialized in-process, same validation envelope - production composition picks the backend via MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store elsewhere) and readiness probes the matching backend --- .../scheduled-operation-dispatcher.test.ts | 6 +- .../automation/scheduled-task-service.test.ts | 1 + .../connections/connection-probe.test.ts | 1 + .../instance-credential-resolver.test.ts | 3 + .../instance-credential-resolver.ts | 9 +- .../instances/instance-service.test.ts | 1 + .../application/instances/instance-service.ts | 13 +- .../activate-pending-secret.test.ts | 1 + .../legacy-import/activate-pending-secret.ts | 3 +- .../central-notification-service.test.ts | 1 + .../delete-instance-operation.test.ts | 1 + apps/api/src/control-plane.operations.test.ts | 7 +- apps/api/src/control-plane.test.ts | 1 + .../infrastructure/database/backup.test.ts | 5 +- .../secrets/file-secret-store.ts | 174 ++++++++++++++++++ .../secrets/keychain-secret-store.ts | 67 ++----- .../secrets/secret-backend.test.ts | 162 ++++++++++++++++ .../infrastructure/secrets/secret-backend.ts | 78 ++++++++ .../secrets/secret-reference.ts | 104 +++++++++++ .../infrastructure/secrets/secret-store.ts | 2 + .../interface/http/automation-routes.test.ts | 1 + .../http/central-notification-routes.test.ts | 1 + .../interface/http/instance-routes.test.ts | 1 + apps/api/src/production-control-plane.test.ts | 1 + apps/api/src/production-control-plane.ts | 18 +- apps/api/src/production-readiness.ts | 19 +- 26 files changed, 605 insertions(+), 76 deletions(-) create mode 100644 apps/api/src/infrastructure/secrets/file-secret-store.ts create mode 100644 apps/api/src/infrastructure/secrets/secret-backend.test.ts create mode 100644 apps/api/src/infrastructure/secrets/secret-backend.ts create mode 100644 apps/api/src/infrastructure/secrets/secret-reference.ts diff --git a/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts b/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts index c75822b..9b61ae8 100644 --- a/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts +++ b/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts @@ -17,6 +17,7 @@ describe('ScheduledOperationDispatcher', () => { operations: { prepare: vi.fn(), execute: vi.fn() }, messages: { send }, store: { + provider: 'macos-keychain', set: vi.fn(), get: vi.fn(async () => JSON.stringify({ @@ -90,7 +91,7 @@ describe('ScheduledOperationDispatcher', () => { execute, }, messages: { send: vi.fn() }, - store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() }, + store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() }, repository: new ScheduledTaskRepository(db), }); const result = await dispatcher.dispatch( @@ -179,6 +180,7 @@ describe('ScheduledOperationDispatcher', () => { operations: { prepare: vi.fn(), execute: vi.fn() }, messages: { send }, store: { + provider: 'macos-keychain', set: vi.fn(), get: vi.fn(async () => JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }), @@ -216,7 +218,7 @@ describe('ScheduledOperationDispatcher', () => { execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }), }, messages: { send: vi.fn() }, - store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() }, + store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() }, repository: new ScheduledTaskRepository(db), }); const result = await dispatcher.dispatch( diff --git a/apps/api/src/application/automation/scheduled-task-service.test.ts b/apps/api/src/application/automation/scheduled-task-service.test.ts index b9d4b82..9196027 100644 --- a/apps/api/src/application/automation/scheduled-task-service.test.ts +++ b/apps/api/src/application/automation/scheduled-task-service.test.ts @@ -7,6 +7,7 @@ import { ScheduledTaskRepository } from './scheduled-task-repository.js'; import { ScheduledTaskService } from './scheduled-task-service.js'; class MemorySecrets implements SecretStore { + readonly provider = 'macos-keychain'; readonly values = new Map(); readonly setCalls: Array<{ key: SecretKey; value: string }> = []; blockRotations = false; diff --git a/apps/api/src/application/connections/connection-probe.test.ts b/apps/api/src/application/connections/connection-probe.test.ts index 5ac43f7..7d4b23d 100644 --- a/apps/api/src/application/connections/connection-probe.test.ts +++ b/apps/api/src/application/connections/connection-probe.test.ts @@ -8,6 +8,7 @@ import { ConnectionProbe } from './connection-probe.js'; import { ConnectionLogService } from '../system/connection-log-service.js'; class Store implements SecretStore { + readonly provider = 'macos-keychain'; 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')}`; } diff --git a/apps/api/src/application/connections/instance-credential-resolver.test.ts b/apps/api/src/application/connections/instance-credential-resolver.test.ts index a9a6b63..047e0aa 100644 --- a/apps/api/src/application/connections/instance-credential-resolver.test.ts +++ b/apps/api/src/application/connections/instance-credential-resolver.test.ts @@ -27,6 +27,7 @@ describe('InstanceCredentialResolver', () => { it('resolves only the canonical password reference for the requested existing instance', async () => { let asked: string | undefined; const { db, resolver } = fixture({ + provider: 'macos-keychain', set: async () => '', get: async (reference) => { asked = reference; @@ -44,6 +45,7 @@ describe('InstanceCredentialResolver', () => { it('does not call SecretStore when no saved credential exists', async () => { let calls = 0; const { resolver } = fixture({ + provider: 'macos-keychain', set: async () => '', get: async () => { calls += 1; @@ -57,6 +59,7 @@ describe('InstanceCredentialResolver', () => { it('rejects a reference bound to another instance before SecretStore access', async () => { let calls = 0; const { db, resolver } = fixture({ + provider: 'macos-keychain', set: async () => '', get: async () => { calls += 1; diff --git a/apps/api/src/application/connections/instance-credential-resolver.ts b/apps/api/src/application/connections/instance-credential-resolver.ts index c8d7cc6..1d7108e 100644 --- a/apps/api/src/application/connections/instance-credential-resolver.ts +++ b/apps/api/src/application/connections/instance-credential-resolver.ts @@ -1,9 +1,8 @@ import type Database from 'better-sqlite3'; -import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js'; +import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.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); @@ -24,10 +23,12 @@ export class InstanceCredentialResolver { .prepare( 'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?', ) - .get(instanceId, PURPOSE, PROVIDER) as { external_reference: string } | undefined; + .get(instanceId, PURPOSE, this.options.store.provider) as + | { external_reference: string } + | undefined; if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE'); try { - const parsed = parseKeychainReference(row.external_reference); + const parsed = parseSecretReference(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); diff --git a/apps/api/src/application/instances/instance-service.test.ts b/apps/api/src/application/instances/instance-service.test.ts index 9f7c48c..c852156 100644 --- a/apps/api/src/application/instances/instance-service.test.ts +++ b/apps/api/src/application/instances/instance-service.test.ts @@ -10,6 +10,7 @@ import type { Instance } from '@multi-simadmin/contracts'; import { InstanceService, InstanceServiceError } from './instance-service.js'; class MemorySecrets implements SecretStore { + readonly provider = 'macos-keychain'; readonly values = new Map(); readonly deletes: string[] = []; readonly sets: Array<{ diff --git a/apps/api/src/application/instances/instance-service.ts b/apps/api/src/application/instances/instance-service.ts index a02466c..0de7cb7 100644 --- a/apps/api/src/application/instances/instance-service.ts +++ b/apps/api/src/application/instances/instance-service.ts @@ -10,10 +10,9 @@ import type { SnapshotFreshness, } from '@multi-simadmin/contracts'; import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; -import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js'; +import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js'; const PURPOSE = 'instance-password'; -const PROVIDER = 'macos-keychain'; const MAX_TAGS = 50; const MAX_TAG_LENGTH = 100; @@ -408,7 +407,7 @@ export class InstanceService { .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); + .run(id, instanceId, PURPOSE, this.store.provider, external, now, now); } private async storeSecret( instanceId: string, @@ -422,7 +421,7 @@ export class InstanceService { throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential'); } try { - const parsed = parseKeychainReference(external); + 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 { @@ -504,7 +503,7 @@ export class InstanceService { AND secret_cleanup_tasks.purpose=excluded.purpose AND secret_cleanup_tasks.provider=excluded.provider`, ) - .run(reference, instanceId, PURPOSE, PROVIDER, now, now); + .run(reference, instanceId, PURPOSE, this.store.provider, now, now); if (queued.changes !== 1) throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted'); } @@ -529,13 +528,13 @@ export class InstanceService { ); } private validateCleanupEntry(entry: CleanupEntry): void { - if (entry.purpose !== PURPOSE || entry.provider !== PROVIDER) + 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 = parseKeychainReference(reference); + const parsed = parseSecretReference(reference); if ( parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || diff --git a/apps/api/src/application/legacy-import/activate-pending-secret.test.ts b/apps/api/src/application/legacy-import/activate-pending-secret.test.ts index edb4adc..611ce7f 100644 --- a/apps/api/src/application/legacy-import/activate-pending-secret.test.ts +++ b/apps/api/src/application/legacy-import/activate-pending-secret.test.ts @@ -39,6 +39,7 @@ function pendingDatabase(): Database.Database { } class FakeStore implements SecretStore { + readonly provider = 'macos-keychain'; readonly sets: { key: SecretKey; value: string }[] = []; readonly deletes: string[] = []; setError?: Error; diff --git a/apps/api/src/application/legacy-import/activate-pending-secret.ts b/apps/api/src/application/legacy-import/activate-pending-secret.ts index ac47107..9c65b67 100644 --- a/apps/api/src/application/legacy-import/activate-pending-secret.ts +++ b/apps/api/src/application/legacy-import/activate-pending-secret.ts @@ -4,7 +4,6 @@ import { getManagedDatabaseIdentity } from '../../infrastructure/database/databa import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; const PURPOSE = 'instance-password'; -const PROVIDER = 'macos-keychain'; const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`; const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`; @@ -163,7 +162,7 @@ async function activatePendingSecretExclusive({ throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation'); db.prepare( 'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', - ).run(id, instanceId, PURPOSE, PROVIDER, externalReference, now, now); + ).run(id, instanceId, PURPOSE, store.provider, externalReference, now, now); const updated = db .prepare( "UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?", diff --git a/apps/api/src/application/notifications/central-notification-service.test.ts b/apps/api/src/application/notifications/central-notification-service.test.ts index 41dce99..05f46d5 100644 --- a/apps/api/src/application/notifications/central-notification-service.test.ts +++ b/apps/api/src/application/notifications/central-notification-service.test.ts @@ -9,6 +9,7 @@ import { } from './central-notification-service.js'; class MemorySecrets implements SecretStore { + readonly provider = 'macos-keychain'; readonly values = new Map(); async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) { diff --git a/apps/api/src/application/operations/delete-instance-operation.test.ts b/apps/api/src/application/operations/delete-instance-operation.test.ts index 51d7fad..f656894 100644 --- a/apps/api/src/application/operations/delete-instance-operation.test.ts +++ b/apps/api/src/application/operations/delete-instance-operation.test.ts @@ -12,6 +12,7 @@ import { } from './delete-instance-operation.js'; class Store implements SecretStore { + readonly provider = 'macos-keychain'; async set() { return ''; } diff --git a/apps/api/src/control-plane.operations.test.ts b/apps/api/src/control-plane.operations.test.ts index a2e1e6d..487d187 100644 --- a/apps/api/src/control-plane.operations.test.ts +++ b/apps/api/src/control-plane.operations.test.ts @@ -9,7 +9,12 @@ it('registers the read-only operations catalog without touching upstream', async let upstreamCalls = 0; const app = buildControlPlaneApp({ db, - store: { set: async () => '', get: async () => undefined, delete: async () => false }, + store: { + provider: 'macos-keychain', + set: async () => '', + get: async () => undefined, + delete: async () => false, + }, upstream: { get: async () => { upstreamCalls += 1; diff --git a/apps/api/src/control-plane.test.ts b/apps/api/src/control-plane.test.ts index b469b7b..77ba9dd 100644 --- a/apps/api/src/control-plane.test.ts +++ b/apps/api/src/control-plane.test.ts @@ -13,6 +13,7 @@ afterEach(async () => { for (const db of dbs.splice(0)) db.close(); }); class Store implements SecretStore { + readonly provider = 'macos-keychain'; private readonly values = new Map(); async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) { const account = Buffer.from( diff --git a/apps/api/src/infrastructure/database/backup.test.ts b/apps/api/src/infrastructure/database/backup.test.ts index 6146fbb..ae3dd4d 100644 --- a/apps/api/src/infrastructure/database/backup.test.ts +++ b/apps/api/src/infrastructure/database/backup.test.ts @@ -54,7 +54,8 @@ describe('verified backup, restore, and rollback foundation', () => { // Activation renames a WAL file that a live connection still holds open; // POSIX permits that, Windows file locking does not. - it('verifies a separate candidate and digest-binds activation', { skip: process.platform === 'win32' && 'relies on POSIX rename-over-open-file semantics' }, async () => { + const itPosix = process.platform === 'win32' ? it.skip : it; + itPosix('verifies a separate candidate and digest-binds activation', async () => { const { livePath, backupPath, database } = await fixture(); const snapshot = await backupDatabase(database, backupPath); database.exec('UPDATE app_settings SET value_json = \'"after"\''); @@ -121,7 +122,7 @@ describe('verified backup, restore, and rollback foundation', () => { expect(await readFile(livePath)).toEqual(before); }); - it('rejects in-place staged-byte mutation even when metadata is preserved', { skip: process.platform === 'win32' && 'uses python3 utime and POSIX rename-over-open-file semantics' }, async () => { + itPosix('rejects in-place staged-byte mutation even when metadata is preserved', async () => { const { livePath, backupPath, database } = await fixture(); await backupDatabase(database, backupPath); database.close(); diff --git a/apps/api/src/infrastructure/secrets/file-secret-store.ts b/apps/api/src/infrastructure/secrets/file-secret-store.ts new file mode 100644 index 0000000..8fda483 --- /dev/null +++ b/apps/api/src/infrastructure/secrets/file-secret-store.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto'; +import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'; +import { dirname, isAbsolute } from 'node:path'; +import { + accountFor, + parseSecretAccount, + validateKey, + SecretReferenceError, +} from './secret-reference.js'; +import type { SecretKey, SecretStore } from './secret-store.js'; + +const PROVIDER = 'secret-file'; +const SERVICE = 'multi-simadmin'; +const MAX_SECRET_BYTES = 16_384; +const MAX_STORE_BYTES = 4 * 1024 * 1024; + +export type FileSecretStoreErrorCode = + | 'INVALID_KEY' + | 'INVALID_SECRET' + | 'INVALID_REFERENCE' + | 'INVALID_STORE_PATH' + | 'SIZE_LIMIT' + | 'PAYLOAD_INVALID'; + +export class FileSecretStoreError extends Error { + constructor( + readonly code: FileSecretStoreErrorCode, + message: string, + ) { + super(message); + this.name = 'FileSecretStoreError'; + } +} + +function invalid(code: FileSecretStoreErrorCode, message: string): never { + throw new FileSecretStoreError(code, message); +} + +/** + * POSIX filesystem-backed secret store for hosts without a system keyring + * (e.g. Linux servers). Secrets live in a single 0600 JSON map under the data + * root, replaced atomically via temp file + rename, with writes serialized + * in-process. + */ +export class FileSecretStore implements SecretStore { + readonly provider = PROVIDER; + + private queue: Promise = Promise.resolve(); + + constructor(readonly filePath: string) { + if (typeof filePath !== 'string' || filePath.length === 0 || !isAbsolute(filePath)) + invalid('INVALID_STORE_PATH', 'Secret file path must be absolute'); + } + + async set(key: SecretKey, value: string): Promise { + if ( + typeof value !== 'string' || + value.length === 0 || + /[\0\r\n]/.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES + ) { + invalid('INVALID_SECRET', 'Secret is not valid for file storage'); + } + validateKey(key); + const account = accountFor(key); + await this.enqueue(() => this.withStore((store) => ({ ...store, [account]: value }))); + return `secret-file://${SERVICE}/${account}`; + } + + async get(reference: string): Promise { + const { account } = this.parse(reference); + const store = await this.readStore(); + return store[account]; + } + + async delete(reference: string): Promise { + const { account } = this.parse(reference); + let removed = false; + await this.enqueue(() => + this.withStore((store) => { + if (!(account in store)) return store; + removed = true; + const next = { ...store }; + delete next[account]; + return next; + }), + ); + return removed; + } + + private parse(reference: string): { account: string } { + if (typeof reference !== 'string' || reference.length > 512) + invalid('INVALID_REFERENCE', 'Secret file reference is invalid'); + const match = /^secret-file:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference); + if (!match?.[1]) invalid('INVALID_REFERENCE', 'Secret file reference is invalid'); + try { + parseSecretAccount(match[1], PROVIDER); + } catch (error) { + if (error instanceof SecretReferenceError) + invalid('INVALID_REFERENCE', 'Secret file reference is invalid'); + throw error; + } + return { account: match[1] }; + } + + private enqueue(task: () => Promise): Promise { + const run = this.queue.then(task, task); + this.queue = run.catch(() => {}); + return run; + } + + private async readStore(): Promise> { + let raw: string; + try { + raw = await readFile(this.filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw error; + } + if (Buffer.byteLength(raw, 'utf8') > MAX_STORE_BYTES) + invalid('SIZE_LIMIT', 'Secret file exceeds the size limit'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + invalid('PAYLOAD_INVALID', 'Secret file payload is invalid'); + } + if (!isPlainStringMap(parsed)) invalid('PAYLOAD_INVALID', 'Secret file payload is invalid'); + return parsed; + } + + /** Reads the current map, applies `mutate`, and durably replaces the file. */ + private async withStore( + mutate: (store: Record) => Record, + ): Promise { + const current = await this.readStore(); + const next = mutate(current); + const directory = dirname(this.filePath); + await mkdir(directory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') { + const info = await stat(directory); + if (info.isDirectory() && (info.mode & 0o077) !== 0) await chmod(directory, 0o700); + } + const temporary = `${this.filePath}.${randomUUID()}.tmp`; + const handle = await open(temporary, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8'); + // Windows FlushFileBuffers is skipped along with the directory fsync below; + // durability anchoring is POSIX-only by design. + if (process.platform !== 'win32') await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, this.filePath); + if (process.platform !== 'win32') { + const directoryHandle = await open(directory, 'r'); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + } +} + +function isPlainStringMap(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + return Object.values(value).every((entry) => typeof entry === 'string'); +} diff --git a/apps/api/src/infrastructure/secrets/keychain-secret-store.ts b/apps/api/src/infrastructure/secrets/keychain-secret-store.ts index 7baa8cf..f957a0a 100644 --- a/apps/api/src/infrastructure/secrets/keychain-secret-store.ts +++ b/apps/api/src/infrastructure/secrets/keychain-secret-store.ts @@ -1,11 +1,15 @@ import { spawn } from 'node:child_process'; +import { + accountFor, + parseSecretAccount, + SecretReferenceError, +} from './secret-reference.js'; import type { SecretKey, SecretStore } from './secret-store.js'; const SECURITY_PATH = '/usr/bin/security'; const SERVICE = 'multi-simadmin'; const TIMEOUT_MS = 5_000; const MAX_STDOUT_BYTES = 65_536; -const safeComponent = /^[A-Za-z0-9_.-]+$/; export type SecretStoreErrorCode = | 'INVALID_KEY' @@ -105,43 +109,6 @@ function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): ne throw new SecretStoreError(code, message); } -function validateKey(key: SecretKey): void { - if ( - typeof key.instanceId !== 'string' || - key.instanceId.length === 0 || - key.instanceId.length > 128 || - !safeComponent.test(key.instanceId) - ) { - invalid('INVALID_KEY', 'Secret key instance id is invalid'); - } - if ( - typeof key.purpose !== 'string' || - key.purpose.length === 0 || - key.purpose.length > 64 || - !safeComponent.test(key.purpose) - ) { - invalid('INVALID_KEY', 'Secret key purpose is invalid'); - } - if ( - key.slot !== undefined && - (typeof key.slot !== 'string' || - key.slot.length === 0 || - key.slot.length > 64 || - !safeComponent.test(key.slot)) - ) { - invalid('INVALID_KEY', 'Secret key slot is invalid'); - } -} - -function accountFor(key: SecretKey): string { - validateKey(key); - const components = - key.slot === undefined - ? [key.instanceId, key.purpose] - : [key.instanceId, key.purpose, key.slot]; - return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url'); -} - function referenceFor(account: string): string { return `keychain://${SERVICE}/${account}`; } @@ -152,28 +119,26 @@ export function parseKeychainReference(reference: string): ParsedKeychainReferen const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference); if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid'); const account = match[1]; - let decoded: unknown; + let parsed; try { - decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8')); - } catch { - invalid('INVALID_REFERENCE', 'Keychain reference is invalid'); + parsed = parseSecretAccount(account, 'macos-keychain'); + } catch (error) { + if (error instanceof SecretReferenceError) + invalid('INVALID_REFERENCE', 'Keychain reference is invalid'); + throw error; } - if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3)) - invalid('INVALID_REFERENCE', 'Keychain reference is invalid'); - const [instanceId, purpose, slot] = decoded; - const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey; - validateKey(key); - if (accountFor(key) !== account) invalid('INVALID_REFERENCE', 'Keychain reference is invalid'); return { service: SERVICE, account, - instanceId: key.instanceId, - purpose: key.purpose, - ...(key.slot === undefined ? {} : { slot: key.slot }), + instanceId: parsed.instanceId, + purpose: parsed.purpose, + ...(parsed.slot === undefined ? {} : { slot: parsed.slot }), }; } export class MacOSKeychainSecretStore implements SecretStore { + readonly provider = 'macos-keychain'; + constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {} async set(key: SecretKey, value: string): Promise { diff --git a/apps/api/src/infrastructure/secrets/secret-backend.test.ts b/apps/api/src/infrastructure/secrets/secret-backend.test.ts new file mode 100644 index 0000000..f6ff24e --- /dev/null +++ b/apps/api/src/infrastructure/secrets/secret-backend.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtemp, readFile, readdir, rm, stat, writeFile, chmod } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FileSecretStore, FileSecretStoreError } from './file-secret-store.js'; +import { SecretReferenceError } from './secret-reference.js'; +import { + createDefaultSecretStore, + defaultSecretFileMetadataCheck, + resolveSecretBackend, + SecretBackendError, +} from './secret-backend.js'; +import { MacOSKeychainSecretStore } from './keychain-secret-store.js'; + +describe('FileSecretStore', () => { + it('round-trips set/get/delete with stable secret-file references', async () => { + const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-')); + try { + const store = new FileSecretStore(join(directory, 'secrets.json')); + const reference = await store.set( + { instanceId: 'device-1', purpose: 'instance-password', slot: 'slot-1' }, + 'pa$$word', + ); + expect(reference).toMatch(/^secret-file:\/\/multi-simadmin\/[A-Za-z0-9_-]+$/); + expect(await store.get(reference)).toBe('pa$$word'); + expect(await store.delete(reference)).toBe(true); + expect(await store.get(reference)).toBeUndefined(); + expect(await store.delete(reference)).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('rejects empty, framing, oversize, and malformed keys/values', async () => { + const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-')); + try { + const store = new FileSecretStore(join(directory, 'secrets.json')); + await expect(store.set({ instanceId: 'x', purpose: 'p' }, '')).rejects.toThrow( + FileSecretStoreError, + ); + await expect(store.set({ instanceId: 'x', purpose: 'p' }, 'a\nb')).rejects.toThrow( + FileSecretStoreError, + ); + await expect( + store.set({ instanceId: 'x', purpose: 'p' }, 'a'.repeat(16_385)), + ).rejects.toThrow(FileSecretStoreError); + await expect(store.set({ instanceId: '', purpose: 'p' }, 'v')).rejects.toThrow( + SecretReferenceError, + ); + await expect(store.get('https://evil.example/one')).rejects.toThrow(FileSecretStoreError); + await expect(store.get('secret-file://other-service/abc')).rejects.toThrow( + FileSecretStoreError, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('keeps the map private (0600) and leaves no temporary files behind', async () => { + const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-')); + try { + const path = join(directory, 'nested', 'secrets.json'); + const store = new FileSecretStore(path); + const reference = await store.set({ instanceId: 'x', purpose: 'p', slot: 's' }, 'secret'); + if (process.platform !== 'win32') + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(await readdir(join(directory, 'nested'))).toEqual(['secrets.json']); + const raw = await readFile(path, 'utf8'); + expect(JSON.parse(raw)).toEqual({ + [reference.slice('secret-file://multi-simadmin/'.length)]: 'secret', + }); + expect(await store.get(reference)).toBe('secret'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('serializes concurrent writes so every mutation lands', async () => { + const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-')); + try { + const store = new FileSecretStore(join(directory, 'secrets.json')); + const references = await Promise.all( + Array.from({ length: 25 }, (_, index) => + store.set({ instanceId: 'x', purpose: `p${index}`, slot: 's' }, `v${index}`), + ), + ); + for (const [index, reference] of references.entries()) + expect(await store.get(reference)).toBe(`v${index}`); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('refuses a corrupted or non-map payload instead of failing open', async () => { + const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-')); + try { + const path = join(directory, 'secrets.json'); + await writeFile(path, 'not json', 'utf8'); + const store = new FileSecretStore(path); + await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow( + FileSecretStoreError, + ); + await writeFile(path, '{"account": 42}', 'utf8'); + await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow( + FileSecretStoreError, + ); + expect(() => new FileSecretStore('relative/secrets.json')).toThrow(FileSecretStoreError); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe('secret backend selection', () => { + it('defaults to the keychain on darwin and the file store elsewhere', () => { + expect(resolveSecretBackend({}, 'darwin')).toBe('macos-keychain'); + expect(resolveSecretBackend({}, 'linux')).toBe('secret-file'); + expect(resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'secret-file' }, 'darwin')).toBe( + 'secret-file', + ); + expect(() => + resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'vault' }, 'darwin'), + ).toThrow(SecretBackendError); + }); + + it('builds the matching store and refuses keychain off macOS', () => { + const directory = join(tmpdir(), 'msa-backend-selection'); + expect( + createDefaultSecretStore({ env: {}, secretFilePath: join(directory, 's.json'), platform: 'linux' }), + ).toBeInstanceOf(FileSecretStore); + expect( + createDefaultSecretStore({ + env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' }, + secretFilePath: join(directory, 's.json'), + platform: 'darwin', + }), + ).toBeInstanceOf(MacOSKeychainSecretStore); + expect(() => + createDefaultSecretStore({ + env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' }, + secretFilePath: join(directory, 's.json'), + platform: 'linux', + }), + ).toThrow(SecretBackendError); + }); + + it('file metadata probe accepts a missing or private file and rejects a public one', async () => { + const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-meta-')); + try { + const path = join(directory, 'secrets.json'); + expect(await defaultSecretFileMetadataCheck(path)).toBe(true); + await writeFile(path, '{}\n', { mode: 0o600 }); + expect(await defaultSecretFileMetadataCheck(path)).toBe(true); + if (process.platform !== 'win32') { + await chmod(path, 0o644); + expect(await defaultSecretFileMetadataCheck(path)).toBe(false); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/api/src/infrastructure/secrets/secret-backend.ts b/apps/api/src/infrastructure/secrets/secret-backend.ts new file mode 100644 index 0000000..9f60a00 --- /dev/null +++ b/apps/api/src/infrastructure/secrets/secret-backend.ts @@ -0,0 +1,78 @@ +import { stat } from 'node:fs/promises'; +import { FileSecretStore } from './file-secret-store.js'; +import { MacOSKeychainSecretStore } from './keychain-secret-store.js'; +import type { SecretStore } from './secret-store.js'; + +export type SecretBackend = 'macos-keychain' | 'secret-file'; + +const BACKENDS: readonly SecretBackend[] = ['macos-keychain', 'secret-file']; + +export class SecretBackendError extends Error { + constructor(message: string) { + super(message); + this.name = 'SecretBackendError'; + } +} + +/** + * Resolves the secret backend: explicit MULTI_SIMADMIN_SECRET_BACKEND override, + * otherwise the macOS Keychain on darwin and the 0600 file store elsewhere. + */ +export function resolveSecretBackend( + env: Readonly> = process.env, + platform: NodeJS.Platform = process.platform, +): SecretBackend { + const raw = env.MULTI_SIMADMIN_SECRET_BACKEND; + if (raw !== undefined) { + if (!BACKENDS.includes(raw as SecretBackend)) + throw new SecretBackendError( + 'MULTI_SIMADMIN_SECRET_BACKEND must be one of: macos-keychain, secret-file', + ); + return raw as SecretBackend; + } + return platform === 'darwin' ? 'macos-keychain' : 'secret-file'; +} + +export interface CreateDefaultSecretStoreOptions { + readonly env?: Readonly>; + /** Absolute path of the file store map; required for the secret-file backend. */ + readonly secretFilePath: string; + readonly platform?: NodeJS.Platform; +} + +export function createDefaultSecretStore(options: CreateDefaultSecretStoreOptions): SecretStore { + const platform = options.platform ?? process.platform; + const backend = resolveSecretBackend(options.env, platform); + if (backend === 'secret-file') return new FileSecretStore(options.secretFilePath); + if (platform !== 'darwin') + throw new SecretBackendError( + 'The macos-keychain backend requires /usr/bin/security (macOS only)', + ); + return new MacOSKeychainSecretStore(); +} + +interface SecretFileMetadata { + isFile(): boolean; + readonly mode: number; +} + +/** + * Readiness metadata probe for the file backend. Availability only — this must + * never read secret material. + */ +export async function defaultSecretFileMetadataCheck( + filePath: string, + platform: NodeJS.Platform = process.platform, + statFn: (path: string) => Promise = stat, +): Promise { + try { + const info = await statFn(filePath); + if (!info.isFile()) return false; + // Windows keeps no POSIX mode bits; NTFS ACLs govern access there. + if (platform !== 'win32' && (info.mode & 0o777) !== 0o600) return false; + return true; + } catch (error) { + // A missing map is healthy: secrets are added lazily by instance setup. + return (error as NodeJS.ErrnoException).code === 'ENOENT'; + } +} diff --git a/apps/api/src/infrastructure/secrets/secret-reference.ts b/apps/api/src/infrastructure/secrets/secret-reference.ts new file mode 100644 index 0000000..f42eafd --- /dev/null +++ b/apps/api/src/infrastructure/secrets/secret-reference.ts @@ -0,0 +1,104 @@ +import type { SecretKey } from './secret-store.js'; + +export const SECRET_SERVICE = 'multi-simadmin'; + +export type SecretProvider = 'macos-keychain' | 'secret-file'; + +export const SECRET_PROVIDERS: readonly SecretProvider[] = ['macos-keychain', 'secret-file']; + +const safeComponent = /^[A-Za-z0-9_.-]+$/; + +export type SecretReferenceErrorCode = 'INVALID_KEY' | 'INVALID_REFERENCE'; + +export function parseSecretAccount( + account: string, + provider: SecretProvider, +): ParsedSecretReference { + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8')); + } catch { + throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid'); + } + if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3)) + throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid'); + const [instanceId, purpose, slot] = decoded; + const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey; + validateKey(key); + if (accountFor(key) !== account) + throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid'); + return { + provider, + instanceId: key.instanceId, + purpose: key.purpose, + ...(key.slot === undefined ? {} : { slot: key.slot }), + }; +} + +export function validateKey(key: SecretKey): void { + if ( + typeof key.instanceId !== 'string' || + key.instanceId.length === 0 || + key.instanceId.length > 128 || + !safeComponent.test(key.instanceId) + ) { + throw new SecretReferenceError('INVALID_KEY', 'Secret key instance id is invalid'); + } + if ( + typeof key.purpose !== 'string' || + key.purpose.length === 0 || + key.purpose.length > 64 || + !safeComponent.test(key.purpose) + ) { + throw new SecretReferenceError('INVALID_KEY', 'Secret key purpose is invalid'); + } + if ( + key.slot !== undefined && + (typeof key.slot !== 'string' || + key.slot.length === 0 || + key.slot.length > 64 || + !safeComponent.test(key.slot)) + ) { + throw new SecretReferenceError('INVALID_KEY', 'Secret key slot is invalid'); + } +} + +export function accountFor(key: SecretKey): string { + validateKey(key); + const components = + key.slot === undefined + ? [key.instanceId, key.purpose] + : [key.instanceId, key.purpose, key.slot]; + return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url'); +} + +export interface ParsedSecretReference { + readonly provider: SecretProvider; + readonly instanceId: string; + readonly purpose: string; + readonly slot?: string; +} + +export class SecretReferenceError extends Error { + constructor( + readonly code: SecretReferenceErrorCode, + message: string, + ) { + super(message); + this.name = 'SecretReferenceError'; + } +} + +/** Accepts `keychain://multi-simadmin/` and `secret-file://multi-simadmin/`. */ +export function parseSecretReference(reference: string): ParsedSecretReference { + if (typeof reference !== 'string' || reference.length > 512) + throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid'); + const match = /^([a-z-]+):\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference); + if (!match?.[1] || !match[2]) + throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid'); + const scheme = match[1]; + const provider = scheme === 'keychain' ? 'macos-keychain' : scheme === 'secret-file' ? 'secret-file' : undefined; + if (!provider) + throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid'); + return parseSecretAccount(match[2], provider); +} diff --git a/apps/api/src/infrastructure/secrets/secret-store.ts b/apps/api/src/infrastructure/secrets/secret-store.ts index 39aeb12..7d17e1d 100644 --- a/apps/api/src/infrastructure/secrets/secret-store.ts +++ b/apps/api/src/infrastructure/secrets/secret-store.ts @@ -5,6 +5,8 @@ export interface SecretKey { } export interface SecretStore { + /** Stable identifier persisted in secret_references.provider ('macos-keychain' | 'secret-file'). */ + readonly provider: string; set(key: SecretKey, value: string): Promise; get(reference: string): Promise; delete(reference: string): Promise; diff --git a/apps/api/src/interface/http/automation-routes.test.ts b/apps/api/src/interface/http/automation-routes.test.ts index 262558f..b87586b 100644 --- a/apps/api/src/interface/http/automation-routes.test.ts +++ b/apps/api/src/interface/http/automation-routes.test.ts @@ -9,6 +9,7 @@ import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret import { registerAutomationRoutes } from './automation-routes.js'; class MemorySecrets implements SecretStore { + readonly provider = 'macos-keychain'; readonly values = new Map(); async set(key: SecretKey, value: string): Promise { const reference = `memory://${key.instanceId}/${key.purpose}`; diff --git a/apps/api/src/interface/http/central-notification-routes.test.ts b/apps/api/src/interface/http/central-notification-routes.test.ts index 1c42463..db6a105 100644 --- a/apps/api/src/interface/http/central-notification-routes.test.ts +++ b/apps/api/src/interface/http/central-notification-routes.test.ts @@ -8,6 +8,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; import { registerCentralNotificationRoutes } from './central-notification-routes.js'; class MemorySecrets implements SecretStore { + readonly provider = 'macos-keychain'; readonly values = new Map(); async set(key: { instanceId: string; purpose: string }, value: string) { diff --git a/apps/api/src/interface/http/instance-routes.test.ts b/apps/api/src/interface/http/instance-routes.test.ts index cd8bb48..8875861 100644 --- a/apps/api/src/interface/http/instance-routes.test.ts +++ b/apps/api/src/interface/http/instance-routes.test.ts @@ -21,6 +21,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; import { registerInstanceRoutes } from './instance-routes.js'; class FakeStore implements SecretStore { + readonly provider = 'macos-keychain'; async set(key: { instanceId: string; purpose: string; slot?: string }) { return `keychain://multi-simadmin/${Buffer.from( JSON.stringify([key.instanceId, key.purpose, key.slot]), diff --git a/apps/api/src/production-control-plane.test.ts b/apps/api/src/production-control-plane.test.ts index 72159f3..8ca086f 100644 --- a/apps/api/src/production-control-plane.test.ts +++ b/apps/api/src/production-control-plane.test.ts @@ -23,6 +23,7 @@ async function fixtureOptions(): Promise { databasePath: join(directory, 'db.sqlite'), gatewayToken, store: { + provider: 'macos-keychain', set: async () => '', get: async () => undefined, delete: async () => false, diff --git a/apps/api/src/production-control-plane.ts b/apps/api/src/production-control-plane.ts index dc9e87d..1b3ec3a 100644 --- a/apps/api/src/production-control-plane.ts +++ b/apps/api/src/production-control-plane.ts @@ -10,7 +10,7 @@ import { } from './application/system/console-update-service.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 { createDefaultSecretStore, resolveSecretBackend, defaultSecretFileMetadataCheck } from './infrastructure/secrets/secret-backend.js'; import type { SecretStore } from './infrastructure/secrets/secret-store.js'; import { createProductionUpstream } from './infrastructure/transport/production-upstream.js'; import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js'; @@ -21,6 +21,7 @@ export interface ProductionControlPlaneOptions { readonly upstream?: SafeControlPlaneUpstream; readonly gatewayToken?: string; readonly keychainMetadataCheck?: () => boolean | Promise; + readonly secretFileMetadataCheck?: () => boolean | Promise; readonly assetsCheck?: () => boolean | Promise; readonly runtimeVersion?: string; readonly recoveryMarkersCheck?: () => number | Promise; @@ -44,8 +45,10 @@ export function buildProductionControlPlane( const db = openDatabase(options.databasePath); try { migrateDatabase(db); - const store = options.store ?? new MacOSKeychainSecretStore(); const env = options.env ?? process.env; + const secretFilePath = join(dirname(options.databasePath), 'secrets.json'); + const store = options.store ?? createDefaultSecretStore({ env, secretFilePath }); + const secretBackend = resolveSecretBackend(env); const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd(); const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 }); const app = buildControlPlaneApp({ @@ -71,7 +74,16 @@ export function buildProductionControlPlane( readiness: createProductionReadiness({ db, gatewayToken: options.gatewayToken ?? '', - keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck, + ...(secretBackend === 'macos-keychain' + ? { + keychainMetadataCheck: + options.keychainMetadataCheck ?? defaultKeychainMetadataCheck, + } + : { + secretFileMetadataCheck: + options.secretFileMetadataCheck ?? + (async () => defaultSecretFileMetadataCheck(secretFilePath)), + }), ...(options.assetsCheck ? { assetsCheck: options.assetsCheck } : {}), ...(options.recoveryMarkersCheck ? { recoveryMarkersCheck: options.recoveryMarkersCheck } diff --git a/apps/api/src/production-readiness.ts b/apps/api/src/production-readiness.ts index f4d4b7f..1a81f9e 100644 --- a/apps/api/src/production-readiness.ts +++ b/apps/api/src/production-readiness.ts @@ -7,7 +7,9 @@ export interface ProductionReadinessOptions { readonly db: Database.Database; readonly gatewayToken: string; /** Metadata/availability probe only. This callback must never retrieve a secret. */ - readonly keychainMetadataCheck: () => boolean | Promise; + readonly keychainMetadataCheck?: () => boolean | Promise; + /** Metadata/availability probe for the file secret backend (never reads secrets). */ + readonly secretFileMetadataCheck?: () => boolean | Promise; /** Release gates are deliberately required: omitting one fails readiness closed. */ readonly assetsCheck?: () => boolean | Promise; readonly recoveryMarkersCheck?: () => number | Promise; @@ -56,11 +58,20 @@ export function createProductionReadiness( checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured'; if (checks.gatewayAuth !== 'ready') ready = false; try { - checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable'; + if (options.keychainMetadataCheck !== undefined) { + checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable'; + if (checks.keychain !== 'ready') ready = false; + } else if (options.secretFileMetadataCheck !== undefined) { + checks.secretFile = (await options.secretFileMetadataCheck()) ? 'ready' : 'unavailable'; + if (checks.secretFile !== 'ready') ready = false; + } else { + checks.secrets = 'unconfigured'; + ready = false; + } } catch { - checks.keychain = 'unavailable'; + checks.secrets = 'unavailable'; + ready = false; } - if (checks.keychain !== 'ready') ready = false; try { const row = options.db