/** * Pushes this machine's full configuration — settings, broker credentials, * instruments, manual assignments — to the master, so a dead VPS can be * replaced by restoring the backup (lib/backup-restore.ts) and adopting this * instance's uuid. * * This is the deliberate exception to "credentials never leave the machine": * the payload goes over HTTPS to the bearer-gated /api/instances/backup and is * AES-encrypted at rest on the master. It never rides the unauthenticated * control socket. * * Push policy: on bootstrap, then every POLL_INTERVAL, but only when the * content actually changed (hash comparison) — plus an immediate debounced * push whenever settings or credentials are edited (scheduleBackupPush, called * from the mutation routes). */ import { createHash } from 'node:crypto'; import { getSetting, setSetting, listSettings, listCredentials, getInstruments, listAllAssignments, type FirmCredentialRow, type AssignmentRow, } from './db'; export interface BackupPayload { v: 1; savedAt: string; settings: Record; credentials: FirmCredentialRow[]; instruments: { symbol: string; enabled: boolean }[]; assignments: AssignmentRow[]; } const BACKUP_PATH = '/api/instances/backup'; const POLL_INTERVAL_MS = 5 * 60_000; const DEBOUNCE_MS = 5_000; const FETCH_TIMEOUT_MS = 10_000; const g = globalThis as typeof globalThis & { __backupSyncTimer?: ReturnType; __backupDebounceTimer?: ReturnType; __backupLastHash?: string; }; // Bookkeeping that describes this process run, not the configuration worth // restoring. Excluded from the hash so a heartbeat doesn't look like a change // worth pushing. (The restore side has its own skip-set; these still travel in // the payload so a restore debugger can see them.) const VOLATILE_KEYS = new Set([ 'runner_heartbeat', 'catalog_last_sync_at', 'catalog_last_error', 'backup_last_push_at', 'backup_last_error', ]); export function collectBackup(): BackupPayload { return { v: 1, savedAt: new Date().toISOString(), settings: listSettings(), credentials: listCredentials(), instruments: getInstruments().map((i) => ({ symbol: i.symbol, enabled: Boolean(i.enabled) })), assignments: listAllAssignments(), }; } /** Stable digest of everything restorable, so unchanged config never pushes. */ function contentHash(payload: BackupPayload): string { const settings = Object.entries(payload.settings) .filter(([k]) => !VOLATILE_KEYS.has(k)) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return createHash('sha256') .update(JSON.stringify({ settings, credentials: payload.credentials, instruments: payload.instruments, assignments: payload.assignments, })) .digest('hex'); } function preconditions(): { masterUrl: string; id: string; token: string } | null { const masterUrl = getSetting('master_dashboard_url'); const token = getSetting('master_backup_token'); // No uuid yet = the master has never said welcome; there is nothing stable // to key the backup by, so wait for the first hello to complete. const id = getSetting('instance_id'); if (!masterUrl || !token || !id) return null; return { masterUrl, id, token }; } export async function pushBackup(force = false): Promise<{ ok: boolean; skipped?: boolean; error?: string }> { const pre = preconditions(); if (!pre) return { ok: false, skipped: true, error: 'master url, backup token or instance uuid not set' }; const payload = collectBackup(); const hash = contentHash(payload); if (!force && hash === g.__backupLastHash) return { ok: true, skipped: true }; let url: URL; try { url = new URL(BACKUP_PATH, pre.masterUrl); } catch { return { ok: false, error: `master_dashboard_url is not a URL: ${pre.masterUrl}` }; } try { const res = await fetch(url, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${pre.token}`, }, body: JSON.stringify({ id: pre.id, name: getSetting('instance_name') ?? '', payload, }), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) { const detail = await res.json().then((b) => b?.error).catch(() => ''); throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`); } g.__backupLastHash = hash; setSetting('backup_last_push_at', new Date().toISOString()); setSetting('backup_last_error', ''); console.log('[backup] pushed to master'); return { ok: true }; } catch (err) { const error = err instanceof Error ? err.message : String(err); console.error('[backup] push failed:', error); setSetting('backup_last_error', error); return { ok: false, error }; } } /** Debounced push for the mutation routes: a settings PATCH or credential PUT * lands on the master seconds later instead of at the next poll. */ export function scheduleBackupPush(): void { if (g.__backupDebounceTimer) clearTimeout(g.__backupDebounceTimer); g.__backupDebounceTimer = setTimeout(() => { void pushBackup(); }, DEBOUNCE_MS); } export function startBackupSync(): void { stopBackupSync(); const tick = async () => { await pushBackup(); // Jitter so a fleet restarting together doesn't push in lockstep. g.__backupSyncTimer = setTimeout(tick, POLL_INTERVAL_MS + Math.floor(Math.random() * 30_000)); }; void tick(); } export function stopBackupSync(): void { if (g.__backupSyncTimer) { clearTimeout(g.__backupSyncTimer); g.__backupSyncTimer = undefined; } }