From 8a307781c6e61a86c5f74faab3db82f620409008 Mon Sep 17 00:00:00 2001 From: chick Date: Thu, 16 Jul 2026 13:51:11 +0800 Subject: [PATCH] feat(migration): add safe legacy import preview --- apps/api/package.json | 2 +- .../legacy-import/legacy-import.test.ts | 288 +++++++++++++ .../legacy-import/legacy-import.ts | 393 ++++++++++++++++++ apps/api/src/index.ts | 11 + 4 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/application/legacy-import/legacy-import.test.ts create mode 100644 apps/api/src/application/legacy-import/legacy-import.ts diff --git a/apps/api/package.json b/apps/api/package.json index 8e9bbb6..eab7306 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "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", + "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/application/legacy-import/legacy-import.test.ts", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { diff --git a/apps/api/src/application/legacy-import/legacy-import.test.ts b/apps/api/src/application/legacy-import/legacy-import.test.ts new file mode 100644 index 0000000..85bbeff --- /dev/null +++ b/apps/api/src/application/legacy-import/legacy-import.test.ts @@ -0,0 +1,288 @@ +import Database from 'better-sqlite3'; +import { lstat, mkdtemp, readFile, rename, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { + confirmLegacyImport, + previewLegacyImport, + type LegacyImportPreview, +} from './legacy-import.js'; + +const databases: Database.Database[] = []; +afterEach(() => { + for (const database of databases.splice(0)) database.close(); +}); + +function database(): Database.Database { + const result = new Database(':memory:'); + result.pragma('foreign_keys = ON'); + migrateDatabase(result); + databases.push(result); + return result; +} + +async function legacyFile(value: unknown): Promise { + const directory = await mkdtemp(join(tmpdir(), 'legacy-import-')); + const path = join(directory, 'legacy.json'); + await writeFile(path, JSON.stringify(value), { mode: 0o600 }); + return path; +} + +const password = 'phase-two-super-secret-value'; +const valid = { + server: { host: '0.0.0.0', port: 8788 }, + instances: [ + { + id: 'alpha', + name: 'Alpha', + url: 'https://203.0.113.8/path?discard=yes#fragment', + description: 'primary', + tags: ['prod', 'blue'], + auth: { mode: 'password', password }, + capabilities: ['status.read', 'sim.restart'], + }, + { id: 'beta', url: 'http://[2001:4860:4860::8888]:8080', auth: { mode: 'none' } }, + ], +}; + +function scan(value: unknown): string { + if (value instanceof Error) return JSON.stringify({ name: value.name, message: value.message }); + return JSON.stringify(value); +} + +function scanDatabaseText(database: Database.Database): string { + const tables = database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") + .all() as { name: string }[]; + const values: unknown[] = []; + for (const { name } of tables) { + const quotedTable = `"${name.replaceAll('"', '""')}"`; + const columns = database.prepare(`PRAGMA table_info(${quotedTable})`).all() as { + name: string; + type: string; + }[]; + const textColumns = columns.filter(({ type }) => type.toUpperCase().includes('TEXT')); + if (textColumns.length === 0) continue; + const selection = textColumns + .map(({ name: column }) => `"${column.replaceAll('"', '""')}"`) + .join(','); + values.push(...database.prepare(`SELECT ${selection} FROM ${quotedTable}`).all()); + } + return JSON.stringify(values); +} + +async function rejection(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + return error as Error; + } + throw new Error('expected rejection'); +} + +describe('legacy import', () => { + it('previews a deeply frozen, redacted, digest-bound plan without writing the database', async () => { + const path = await legacyFile(valid); + const db = database(); + const before = db.serialize(); + const preview = await previewLegacyImport(path, db); + + expect(preview.counts).toEqual({ + total: 2, + planned: 2, + conflict: 0, + unchanged: 0, + secretPending: 1, + }); + expect(preview.instances[0]).toEqual({ + id: 'alpha', + name: 'Alpha', + baseUrl: 'https://203.0.113.8/path', + description: 'primary', + tags: ['prod', 'blue'], + capabilities: ['status.read', 'sim.restart'], + credentialPresent: true, + status: 'planned', + }); + expect(preview.source.digest).toMatch(/^[a-f0-9]{64}$/); + expect(preview.planDigest).toMatch(/^[a-f0-9]{64}$/); + expect(preview.source.identity).toMatchObject({ + device: expect.any(String), + inode: expect.any(String), + }); + expect(Object.isFrozen(preview)).toBe(true); + expect(Object.isFrozen(preview.instances[0]?.tags)).toBe(true); + expect(scan(preview)).not.toContain(password); + expect(db.serialize()).toEqual(before); + }); + + it.each([ + ['bad JSON', '{'], + ['array root', '[]'], + ['missing instances', '{}'], + ['instances object', '{"instances":{}}'], + [ + 'dangerous key', + '{"instances":[{"id":"x","url":"https://203.0.113.1","__proto__":{"polluted":true}}]}', + ], + [ + 'duplicate id', + '{"instances":[{"id":"x","url":"https://203.0.113.1"},{"id":"x","url":"https://203.0.113.2"}]}', + ], + ['credentials in URL', '{"instances":[{"id":"x","url":"https://u:p@203.0.113.1"}]}'], + ['hostname', '{"instances":[{"id":"x","url":"https://example.com"}]}'], + ['loopback', '{"instances":[{"id":"x","url":"http://127.0.0.1"}]}'], + ['malformed member', '{"instances":[{"id":"ok","url":"https://203.0.113.1"},null]}'], + ])('rejects %s as an all-or-nothing malformed preview', async (_name, contents) => { + const path = await legacyFile({}); + await writeFile(path, contents); + const error = await rejection(() => previewLegacyImport(path)); + expect(scan(error)).not.toContain(password); + }); + + it('rejects symlinks and non-regular files', async () => { + const path = await legacyFile(valid); + const link = `${path}.link`; + await symlink(path, link); + await expect(previewLegacyImport(link)).rejects.toThrow(/regular|symlink/i); + const details = await lstat(link); + expect(details.isSymbolicLink()).toBe(true); + await expect(previewLegacyImport(join(path, 'child'))).rejects.toThrow(); + }); + + it('rejects oversized source files before parsing without echoing their content', async () => { + const path = await legacyFile({}); + const marker = 'oversized-secret-marker'; + await writeFile(path, JSON.stringify({ instances: [], padding: marker.repeat(100_000) })); + + const error = await rejection(() => previewLegacyImport(path)); + + expect(error.message).toMatch(/large|size|limit/i); + expect(scan(error)).not.toContain(marker); + }); + + it('imports atomically, ignores legacy server, records pending secrets without storing credentials, and is idempotent', async () => { + const path = await legacyFile(valid); + const db = database(); + const preview = await previewLegacyImport(path, db); + const result = await confirmLegacyImport(path, preview, db); + + expect(result).toMatchObject({ imported: 2, unchanged: 0, secretPending: ['alpha'] }); + expect(scan(result)).not.toContain(password); + expect( + db + .prepare( + 'SELECT id,name,base_url,auth_mode,enabled,config_revision FROM instances ORDER BY id', + ) + .all(), + ).toEqual([ + { + id: 'alpha', + name: 'Alpha', + base_url: 'https://203.0.113.8/path', + auth_mode: 'none', + enabled: 1, + config_revision: 1, + }, + { + id: 'beta', + name: 'beta', + base_url: 'http://[2001:4860:4860::8888]:8080', + auth_mode: 'none', + enabled: 1, + config_revision: 1, + }, + ]); + expect( + db.prepare('SELECT instance_id,tag FROM instance_tags ORDER BY instance_id,tag').all(), + ).toEqual([ + { instance_id: 'alpha', tag: 'blue' }, + { instance_id: 'alpha', tag: 'prod' }, + ]); + expect(db.prepare('SELECT COUNT(*) AS count FROM secret_references').get()).toEqual({ + count: 0, + }); + expect( + db + .prepare("SELECT COUNT(*) AS count FROM app_settings WHERE key LIKE 'legacy-import.%'") + .get(), + ).toEqual({ count: 2 }); + expect(scanDatabaseText(db)).not.toContain(password); + + const secondPreview = await previewLegacyImport(path, db); + expect(secondPreview.counts).toMatchObject({ planned: 0, unchanged: 2, conflict: 0 }); + const second = await confirmLegacyImport(path, secondPreview, db); + expect(second).toMatchObject({ imported: 0, unchanged: 2, secretPending: ['alpha'] }); + expect(db.prepare('SELECT id,config_revision FROM instances ORDER BY id').all()).toEqual([ + { id: 'alpha', config_revision: 1 }, + { id: 'beta', config_revision: 1 }, + ]); + }); + + it('binds confirmation to source identity, source digest, and plan digest', async () => { + const path = await legacyFile(valid); + const db = database(); + const preview = await previewLegacyImport(path, db); + await writeFile(path, JSON.stringify({ instances: [] })); + await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow( + /changed|digest|identity/i, + ); + expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 }); + + const fresh = await legacyFile(valid); + const good = await previewLegacyImport(fresh, db); + const replacement = `${fresh}.new`; + await writeFile(replacement, await readFile(fresh)); + await rename(replacement, fresh); + const replacementPreview = await previewLegacyImport(fresh, db); + await expect(confirmLegacyImport(fresh, good, db)).rejects.toThrow(/changed|identity/i); + + const tampered = { ...replacementPreview, planDigest: '0'.repeat(64) } as LegacyImportPreview; + await expect(confirmLegacyImport(fresh, tampered, db)).rejects.toThrow(/digest/i); + }); + + it('reports conflicts and confirmation rejects the entire transaction', async () => { + const path = await legacyFile(valid); + const db = database(); + const now = new Date().toISOString(); + db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run( + 'alpha', + 'Different', + 'https://203.0.113.99', + 'none', + 1, + 7, + now, + now, + ); + db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run( + 'owner', + 'Owner', + 'http://[2001:4860:4860::8888]:8080', + 'none', + 1, + 1, + now, + now, + ); + const preview = await previewLegacyImport(path, db); + expect(preview.instances.map((item) => item.status)).toEqual(['conflict', 'conflict']); + expect(preview.counts.conflict).toBe(2); + await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/conflict/i); + expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 2 }); + }); + + it('rolls back all writes on any database failure', async () => { + const path = await legacyFile(valid); + const db = database(); + db.exec( + "CREATE TRIGGER fail_beta BEFORE INSERT ON instances WHEN NEW.id = 'beta' BEGIN SELECT RAISE(ABORT, 'forced'); END", + ); + const preview = await previewLegacyImport(path, db); + await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/forced/); + expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 }); + expect(db.prepare('SELECT COUNT(*) AS count FROM app_settings').get()).toEqual({ count: 0 }); + }); +}); diff --git a/apps/api/src/application/legacy-import/legacy-import.ts b/apps/api/src/application/legacy-import/legacy-import.ts new file mode 100644 index 0000000..c24f27a --- /dev/null +++ b/apps/api/src/application/legacy-import/legacy-import.ts @@ -0,0 +1,393 @@ +import type Database from 'better-sqlite3'; +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; +import { isIP } from 'node:net'; + +export type LegacyImportStatus = 'planned' | 'conflict' | 'unchanged'; + +export interface LegacyImportInstancePreview { + readonly id: string; + readonly name: string; + readonly baseUrl: string; + readonly description: string; + readonly tags: readonly string[]; + readonly capabilities: readonly string[]; + readonly credentialPresent: boolean; + readonly status: LegacyImportStatus; +} + +export interface LegacyImportPreview { + readonly source: { + readonly digest: string; + readonly identity: { readonly device: string; readonly inode: string }; + }; + readonly planDigest: string; + readonly counts: { + readonly total: number; + readonly planned: number; + readonly conflict: number; + readonly unchanged: number; + readonly secretPending: number; + }; + readonly instances: readonly LegacyImportInstancePreview[]; +} + +export interface LegacyImportResult { + readonly imported: number; + readonly unchanged: number; + readonly secretPending: readonly string[]; + readonly sourceDigest: string; + readonly planDigest: string; +} + +interface NormalizedInstance extends Omit { + readonly authMode: 'none' | 'password'; +} + +interface SourceRead { + readonly bytes: Buffer; + readonly digest: string; + readonly identity: { readonly device: string; readonly inode: string }; +} + +interface ExistingInstance { + id: string; + name: string; + base_url: string; + auth_mode: string; +} + +const dangerousKeys = new Set(['__proto__', 'prototype', 'constructor']); +const MAX_LEGACY_CONFIG_BYTES = 1024 * 1024; +const metadataKey = (id: string) => `legacy-import.instance.${id}`; +const sha256 = (value: Buffer | string): string => createHash('sha256').update(value).digest('hex'); + +export class LegacyImportError extends Error { + constructor(message: string) { + super(message); + this.name = 'LegacyImportError'; + } +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} + +function sameIdentity( + left: { dev: number | bigint; ino: number | bigint }, + right: { dev: number | bigint; ino: number | bigint }, +): boolean { + return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino); +} + +async function securelyRead(path: string): Promise { + let beforePath; + try { + beforePath = await lstat(path, { bigint: true }); + } catch (error) { + throw new LegacyImportError(`Cannot inspect legacy config: ${(error as Error).message}`); + } + if (!beforePath.isFile() || beforePath.isSymbolicLink()) + throw new LegacyImportError('Legacy config must be a regular non-symlink file'); + if (beforePath.size > BigInt(MAX_LEGACY_CONFIG_BYTES)) + throw new LegacyImportError('Legacy config exceeds the size limit'); + + let handle; + try { + handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || !sameIdentity(beforePath, before)) + throw new LegacyImportError('Legacy config identity changed before read'); + if (before.size > BigInt(MAX_LEGACY_CONFIG_BYTES)) + throw new LegacyImportError('Legacy config exceeds the size limit'); + const bytes = await handle.readFile(); + const after = await handle.stat({ bigint: true }); + const afterPath = await lstat(path, { bigint: true }); + if ( + !after.isFile() || + !afterPath.isFile() || + afterPath.isSymbolicLink() || + !sameIdentity(before, after) || + !sameIdentity(after, afterPath) || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + throw new LegacyImportError('Legacy config changed while it was being read'); + } + return { + bytes, + digest: sha256(bytes), + identity: { device: String(after.dev), inode: String(after.ino) }, + }; + } catch (error) { + if (error instanceof LegacyImportError) throw error; + throw new LegacyImportError(`Cannot safely read legacy config: ${(error as Error).message}`); + } finally { + await handle?.close(); + } +} + +function parseJson(bytes: Buffer): unknown { + try { + return JSON.parse(bytes.toString('utf8'), (key, value: unknown) => { + if (dangerousKeys.has(key)) + throw new LegacyImportError(`Dangerous JSON key is prohibited: ${key}`); + return value; + }); + } catch (error) { + if (error instanceof LegacyImportError) throw error; + throw new LegacyImportError(`Legacy config is not valid JSON: ${(error as Error).message}`); + } +} + +function normalizeBaseUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new LegacyImportError('Instance URL is invalid'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') + throw new LegacyImportError('Instance URL must use http or https'); + if (url.username || url.password) + throw new LegacyImportError('Instance URL must not contain credentials'); + const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + const ipv4 = hostname.split('.').map(Number); + const isIpv4 = + ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255); + const prohibitedIpv4 = + isIpv4 && + (ipv4[0] === 127 || + ipv4[0] === 0 || + (ipv4[0] === 169 && ipv4[1] === 254) || + (ipv4[0] !== undefined && ipv4[0] >= 224)); + const prohibitedIpv6 = + hostname === '::' || + hostname === '::1' || + /^fe[89ab][0-9a-f]:/.test(hostname) || + hostname.startsWith('ff') || + hostname.startsWith('::ffff:') || + hostname.startsWith('64:ff9b:'); + const prohibitedSpecial = + hostname === '100.100.100.200' || hostname === '168.63.129.16' || hostname.startsWith('2002:'); + if (isIP(hostname) === 0 || prohibitedIpv4 || prohibitedIpv6 || prohibitedSpecial) + throw new LegacyImportError( + 'Instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited', + ); + url.hash = ''; + url.search = ''; + return url.toString().replace(/\/$/, ''); +} + +function uniqueStrings(value: unknown): readonly string[] { + return Array.isArray(value) ? [...new Set(value.map(String))] : []; +} + +function normalize(raw: unknown): readonly NormalizedInstance[] { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) + throw new LegacyImportError('Legacy config root must be an object'); + if ( + !Object.hasOwn(raw, 'instances') || + !Array.isArray((raw as { instances?: unknown }).instances) + ) + throw new LegacyImportError('Legacy config instances must be an array'); + + const ids = new Set(); + return (raw as { instances: unknown[] }).instances.map((candidate, index) => { + if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) + throw new LegacyImportError(`instances[${index}] must be an object`); + const item = candidate as Record; + const id = String(item.id || '').trim(); + if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) + throw new LegacyImportError( + `instances[${index}].id is required and may only contain letters, numbers, _, -, .`, + ); + if (ids.has(id)) throw new LegacyImportError(`Duplicate instance id: ${id}`); + ids.add(id); + const rawUrl = String(item.url || '').trim(); + if (!rawUrl) throw new LegacyImportError(`instances[${index}].url is required`); + const auth = + item.auth !== null && typeof item.auth === 'object' + ? (item.auth as Record) + : undefined; + const credential = auth?.password ?? item.password ?? ''; + const authMode = credential ? 'password' : String(auth?.mode || 'none'); + if (authMode !== 'none' && authMode !== 'password') + throw new LegacyImportError(`instances[${index}].auth.mode must be none or password`); + return { + id, + name: String(item.name || id || `SimAdmin ${index + 1}`).trim(), + baseUrl: normalizeBaseUrl(rawUrl), + description: String(item.description || '').trim(), + tags: uniqueStrings(item.tags), + capabilities: uniqueStrings(item.capabilities), + credentialPresent: Boolean(credential), + authMode, + }; + }); +} + +function metadata(instance: NormalizedInstance): string { + return JSON.stringify({ + source: 'legacy-config', + description: instance.description, + capabilities: instance.capabilities, + secretPending: instance.credentialPresent, + requestedAuthMode: instance.authMode, + secretPurpose: instance.credentialPresent ? 'instance-password' : null, + }); +} + +function statusFor( + database: Database.Database | undefined, + item: NormalizedInstance, +): LegacyImportStatus { + if (!database) return 'planned'; + const byId = database + .prepare('SELECT id,name,base_url,auth_mode FROM instances WHERE id = ?') + .get(item.id) as ExistingInstance | undefined; + const byUrl = database + .prepare('SELECT id FROM instances WHERE base_url = ?') + .get(item.baseUrl) as { id: string } | undefined; + if (!byId) return byUrl ? 'conflict' : 'planned'; + if (byUrl && byUrl.id !== item.id) return 'conflict'; + const tags = ( + database + .prepare('SELECT tag FROM instance_tags WHERE instance_id = ? ORDER BY tag') + .all(item.id) as { + tag: string; + }[] + ).map(({ tag }) => tag); + const expectedTags = [...item.tags].sort(); + const savedMetadata = database + .prepare('SELECT value_json FROM app_settings WHERE key = ?') + .get(metadataKey(item.id)) as { value_json: string } | undefined; + return byId.name === item.name && + byId.base_url === item.baseUrl && + byId.auth_mode === (item.credentialPresent ? 'none' : item.authMode) && + JSON.stringify(tags) === JSON.stringify(expectedTags) && + savedMetadata?.value_json === metadata(item) + ? 'unchanged' + : 'conflict'; +} + +function digestPlan(source: SourceRead, instances: readonly LegacyImportInstancePreview[]): string { + const normalizedPlan = instances.map((instance) => ({ + id: instance.id, + name: instance.name, + baseUrl: instance.baseUrl, + description: instance.description, + tags: instance.tags, + capabilities: instance.capabilities, + credentialPresent: instance.credentialPresent, + })); + return sha256( + JSON.stringify({ + sourceDigest: source.digest, + sourceIdentity: source.identity, + instances: normalizedPlan, + }), + ); +} + +export async function previewLegacyImport( + path: string, + database?: Database.Database, +): Promise { + const source = await securelyRead(path); + const normalized = normalize(parseJson(source.bytes)); + const instances = normalized.map(({ authMode: _authMode, ...item }) => ({ + ...item, + status: statusFor(database, { ...item, authMode: _authMode }), + })); + const counts = { + total: instances.length, + planned: instances.filter(({ status }) => status === 'planned').length, + conflict: instances.filter(({ status }) => status === 'conflict').length, + unchanged: instances.filter(({ status }) => status === 'unchanged').length, + secretPending: instances.filter(({ credentialPresent }) => credentialPresent).length, + }; + return deepFreeze({ + source: { digest: source.digest, identity: source.identity }, + planDigest: digestPlan(source, instances), + counts, + instances, + }); +} + +function validateConfirmation(preview: LegacyImportPreview, current: LegacyImportPreview): void { + if ( + !preview || + preview.source?.digest !== current.source.digest || + preview.source?.identity?.device !== current.source.identity.device || + preview.source?.identity?.inode !== current.source.identity.inode + ) + throw new LegacyImportError('Legacy config source changed since preview'); + if (preview.planDigest !== current.planDigest) + throw new LegacyImportError('Legacy import plan digest mismatch'); + if (current.counts.conflict > 0) + throw new LegacyImportError('Legacy import plan contains database conflicts'); +} + +export async function confirmLegacyImport( + path: string, + preview: LegacyImportPreview, + database: Database.Database, +): Promise { + const current = await previewLegacyImport(path, database); + validateConfirmation(preview, current); + // Re-read independently inside confirmation: data used for writes is never taken from the caller preview. + const source = await securelyRead(path); + if ( + source.digest !== current.source.digest || + source.identity.device !== current.source.identity.device || + source.identity.inode !== current.source.identity.inode + ) + throw new LegacyImportError('Legacy config changed during confirmation'); + const normalized = normalize(parseJson(source.bytes)); + const now = new Date().toISOString(); + const plannedIds = new Set( + current.instances.filter(({ status }) => status === 'planned').map(({ id }) => id), + ); + + database.transaction(() => { + const insertInstance = database.prepare( + 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,1,1,?,?)', + ); + const insertTag = database.prepare( + 'INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)', + ); + const insertMetadata = database.prepare( + 'INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)', + ); + for (const item of normalized) { + if (!plannedIds.has(item.id)) continue; + insertInstance.run( + item.id, + item.name, + item.baseUrl, + item.credentialPresent ? 'none' : item.authMode, + now, + now, + ); + for (const tag of item.tags) insertTag.run(item.id, tag, now); + insertMetadata.run(metadataKey(item.id), metadata(item), now, now); + } + })(); + + return deepFreeze({ + imported: current.counts.planned, + unchanged: current.counts.unchanged, + secretPending: normalized + .filter(({ credentialPresent }) => credentialPresent) + .map(({ id }) => id), + sourceDigest: current.source.digest, + planDigest: current.planDigest, + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9113160..bccef31 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,5 +10,16 @@ export { startApi } from './start.js'; export type { StartApiOptions } from './start.js'; export { backupDatabase, restoreDatabase } from './infrastructure/database/backup.js'; export { openDatabase } from './infrastructure/database/database.js'; +export { + LegacyImportError, + confirmLegacyImport, + previewLegacyImport, +} from './application/legacy-import/legacy-import.js'; +export type { + LegacyImportInstancePreview, + LegacyImportPreview, + LegacyImportResult, + LegacyImportStatus, +} from './application/legacy-import/legacy-import.js'; export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js'; export * as databaseSchema from './infrastructure/database/schema.js';