feat(migration): add safe legacy import preview
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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<string> {
|
||||
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<unknown>): Promise<Error> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<LegacyImportInstancePreview, 'status'> {
|
||||
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<T>(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<SourceRead> {
|
||||
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<string>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>)
|
||||
: 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<LegacyImportPreview> {
|
||||
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<LegacyImportResult> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user