/** * Restores a backup pulled from the master onto THIS machine — the other half * of lib/backup-sync.ts, for replacing a dead VPS. * * Applying a backup overwrites this machine's settings, broker credentials, * instruments and assignments, and — the point of the exercise — adopts the * backed-up instance's uuid and name, so the master's roster, payouts and card * links carry straight on. The master already handles adoption: a hello * presenting a known uuid lands on that person's roster row. * * Run it before first boot via scripts/seed-settings.js --restore-from (which * duplicates this apply logic — keep them in sync), or from the settings UI * via POST /api/backup/restore. Either way the old machine must be stopped * first: two live instances presenting one uuid fight over the socket. */ import { setSetting, getSetting, setCredentials, setInstrumentEnabled, restoreAssignment, runInTransaction, } from './db'; import type { BackupPayload } from './backup-sync'; /** Mirror of the master's listing rows (lib/backups.ts BackupMeta). */ export interface BackupMeta { id: string; name: string; savedAt: string; sizeBytes: number; } const FETCH_TIMEOUT_MS = 15_000; // Runtime bookkeeping describing the machine the backup was taken on, not // this one. Kept in sync with the skip-set in scripts/seed-settings.js. const RESTORE_SKIP_KEYS = new Set([ 'runner_heartbeat', 'catalog_last_sync_at', 'catalog_last_error', 'backup_last_push_at', 'backup_last_error', ]); function authedFetch(masterUrl: string, token: string, path: string) { const url = new URL(path, masterUrl); return fetch(url, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); } export async function fetchBackupList(masterUrl: string, token: string): Promise { const res = await authedFetch(masterUrl, token, '/api/instances/backup'); if (!res.ok) { const detail = await res.json().then((b) => b?.error).catch(() => ''); throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`); } const body = await res.json(); if (!Array.isArray(body?.backups)) throw new Error('unrecognized backup list'); return body.backups as BackupMeta[]; } export async function fetchBackup(masterUrl: string, token: string, id: string): Promise { const res = await authedFetch(masterUrl, token, `/api/instances/backup/${encodeURIComponent(id)}`); if (!res.ok) { const detail = await res.json().then((b) => b?.error).catch(() => ''); throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`); } const payload = await res.json() as BackupPayload; if (payload?.v !== 1 || typeof payload.settings !== 'object' || payload.settings === null) { throw new Error('unrecognized backup payload'); } return payload; } export interface RestoreCounts { settings: number; credentials: number; instruments: number; assignments: number; } /** * Overwrites this machine's configuration with the backup, including * instance_id and instance_name — the identity adoption. Callers restart the * process afterwards: live platform clients, the master link's hello and any * cached settings all predate the restore. */ export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: boolean } = {}): RestoreCounts { // Defaults to keeping this machine's master url: the machine is talking to // that master right now, while an old backup may point at a dead host. const keepMasterUrl = opts.keepMasterUrl ?? true; const ownMasterUrl = getSetting('master_dashboard_url'); const ownToken = getSetting('master_backup_token'); const counts: RestoreCounts = { settings: 0, credentials: 0, instruments: 0, assignments: 0 }; runInTransaction(() => { for (const [key, value] of Object.entries(payload.settings)) { if (RESTORE_SKIP_KEYS.has(key)) continue; if (typeof value !== 'string') continue; setSetting(key, value); counts.settings++; } if (keepMasterUrl && ownMasterUrl) setSetting('master_dashboard_url', ownMasterUrl); if (ownToken) setSetting('master_backup_token', ownToken); for (const c of payload.credentials ?? []) { setCredentials(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? ''); counts.credentials++; } for (const i of payload.instruments ?? []) { setInstrumentEnabled(i.symbol, i.enabled); counts.instruments++; } // Rules dates travel with them: a restored machine must not date // its accounts afresh, or old ones would leave their kept rules. for (const a of payload.assignments ?? []) { restoreAssignment(a); counts.assignments++; } }); return counts; }