'use strict'; /** * NIGHTLY DATABASE BACKUP — in-process scheduler (Session 64, Phase 1). * * Supabase free tier has ZERO backups. `scripts/backup-db.sh` was written and * mechanism-verified in Session 62, but it was never INSTALLED: it depended on * someone adding a host cron or a Coolify Scheduled Task by hand, and that never * happened. The ledger has been unbacked every night since. * * So the cron ships as CODE, in the container that already holds * SUPABASE_DB_URL, pg_dump and the Supabase network route. Deploy = installed. * No host shell, no dashboard click, nothing to forget. * * ARMING (deliberately opt-OUT, not opt-in — the whole failure mode was an * opt-in that nobody opted into): * - armed whenever SUPABASE_DB_URL is present * - BACKUP_CRON=0 is the explicit kill switch * - BACKUP_HOUR_UTC (default 3) / BACKUP_MINUTE_UTC (default 10) * * DURABILITY CAVEAT (reported, not hidden): the container filesystem is * ephemeral. Dumps survive a redeploy ONLY if BACKUP_DIR points at a persistent * volume, or BACKUP_REMOTE pushes them off-box. We detect and page when neither * is set, because an undurable backup that reads as "backed up" is worse than a * loud gap. */ const path = require('path'); const SCRIPT = path.join(__dirname, '..', 'scripts', 'backup-db.sh'); function hourUtc() { return Number(process.env.BACKUP_HOUR_UTC || 3); } function minuteUtc() { return Number(process.env.BACKUP_MINUTE_UTC || 10); } /** Is durable retention configured? Neither → the dump dies with the container. */ function durabilityWarning(env = process.env) { const hasRemote = !!(env.BACKUP_REMOTE && String(env.BACKUP_REMOTE).trim()); const hasVolume = !!(env.BACKUP_DIR && String(env.BACKUP_DIR).trim()); if (hasRemote || hasVolume) return null; return 'backups are written inside an ephemeral container — set BACKUP_REMOTE (off-box rsync) or point BACKUP_DIR at a persistent volume, or every dump is lost on redeploy'; } function shouldArm(env = process.env) { if (env.BACKUP_CRON === '0') return { armed: false, reason: 'BACKUP_CRON=0 (kill switch)' }; if (!env.SUPABASE_DB_URL) return { armed: false, reason: 'SUPABASE_DB_URL unset (nothing to dump)' }; return { armed: true, reason: null }; } /** One backup run. Resolves { ok, code, ms } — never throws. */ function runBackup(deps = {}) { const spawn = deps.spawn || require('child_process').spawn; const started = Date.now(); return new Promise((resolve) => { let child; try { child = spawn('sh', [SCRIPT], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return resolve({ ok: false, code: null, ms: Date.now() - started, error: e.message }); } let tail = ''; const grab = (b) => { tail = (tail + b.toString()).slice(-2000); }; if (child.stdout) child.stdout.on('data', grab); if (child.stderr) child.stderr.on('data', grab); child.on('error', (e) => resolve({ ok: false, code: null, ms: Date.now() - started, error: e.message, tail })); child.on('close', (code) => resolve({ ok: code === 0, code, ms: Date.now() - started, tail })); }); } function startBackupScheduler(opts = {}) { const notify = opts.notify || require('./utils/opsNotify').notify; const now = opts.now || (() => new Date()); const doBackup = opts.runBackup || runBackup; const { armed, reason } = shouldArm(opts.env || process.env); if (!armed) { console.log(`[backupScheduler] disarmed — ${reason}`); return null; } const warn = durabilityWarning(opts.env || process.env); console.log(`[backupScheduler] armed — nightly ${String(hourUtc()).padStart(2, '0')}:${String(minuteUtc()).padStart(2, '0')} UTC via ${SCRIPT}${warn ? ` — WARNING: ${warn}` : ''}`); if (warn) { void notify(`⚠️ VYNDR backup: ${warn}`, { title: 'VYNDR backup', priority: 'default', tags: ['warning'] }); } let lastRunDay = null; async function tick() { const d = now(); if (d.getUTCHours() !== hourUtc() || d.getUTCMinutes() !== minuteUtc()) return; const dayKey = d.toISOString().slice(0, 10); if (lastRunDay === dayKey) return; // once per day, even if the tick repeats lastRunDay = dayKey; console.log(`[backupScheduler] starting nightly backup (${dayKey})`); const res = await doBackup(opts); if (res.ok) { console.log(`[backupScheduler] backup OK in ${Math.round(res.ms / 1000)}s`); } else { console.error(`[backupScheduler] backup FAILED (code ${res.code}): ${res.error || ''}`); await notify( `❌ VYNDR nightly backup FAILED (exit ${res.code}). The database is unprotected tonight.`, { title: 'VYNDR backup', priority: 'high', tags: ['x'] }, ); } } const interval = setInterval(() => { void tick(); }, 60_000); if (interval.unref) interval.unref(); return { interval, tick }; } /** * Count rows for a table INSIDE a dump, without needing a Postgres server. * `pg_restore --data-only --table=X` emits a COPY block; the rows are the lines * between `FROM stdin;` and the terminating `\.`. This proves the dump actually * CONTAINS the data (not merely that it parses), which is the thing a backup has * to guarantee. A real server restore is still the gold standard — this is the * strongest check available from inside the API container. */ function countRowsInDump(dumpPath, table = 'ledger_entries', deps = {}) { const spawn = deps.spawn || require('child_process').spawn; return new Promise((resolve) => { let child; try { child = spawn('pg_restore', ['--data-only', `--table=${table}`, '-f', '-', dumpPath], { stdio: ['ignore', 'pipe', 'pipe'], }); } catch (e) { return resolve({ ok: false, rows: null, error: e.message }); } let out = ''; let err = ''; let inCopy = false; let rows = 0; let leftover = ''; child.stdout.on('data', (b) => { const text = leftover + b.toString(); const lines = text.split('\n'); leftover = lines.pop() ?? ''; for (const line of lines) { if (!inCopy) { if (/FROM stdin;\s*$/.test(line)) inCopy = true; } else if (line === '\\.') { inCopy = false; } else { rows += 1; } } if (out.length < 4000) out += text.slice(0, 4000); }); child.stderr.on('data', (b) => { err = (err + b.toString()).slice(-2000); }); child.on('error', (e) => resolve({ ok: false, rows: null, error: e.message })); child.on('close', (code) => resolve({ ok: code === 0, rows, code, error: code === 0 ? null : (err || `exit ${code}`), })); }); } /** Newest *.dump in BACKUP_DIR, with its size. */ function latestDump(dir = process.env.BACKUP_DIR || '/var/backups/vyndr', deps = {}) { const fs = deps.fs || require('fs'); try { const files = fs.readdirSync(dir) .filter((f) => f.startsWith('vyndr-') && f.endsWith('.dump')) .map((f) => { const full = path.join(dir, f); return { file: f, path: full, size: fs.statSync(full).size, mtime: fs.statSync(full).mtimeMs }; }) .sort((a, b) => b.mtime - a.mtime); return files[0] || null; } catch (e) { return null; } } /** * List the OFF-BOX copies actually present on the Storage Box. * * Exit 0 from the backup script is NOT proof the dump landed — the script * deliberately keeps its exit code tied to on-box durability. This reads the * remote directory back, so "the off-box copy exists" is a verified fact rather * than an inference. Uses the SAME pinned known_hosts as the push; host-key * checking is never disabled. */ function listOffbox(deps = {}) { const spawn = deps.spawn || require('child_process').spawn; const remote = process.env.BACKUP_REMOTE; const port = process.env.BACKUP_SSH_PORT || '23'; const knownHosts = process.env.BACKUP_KNOWN_HOSTS || path.join(__dirname, '..', 'scripts', 'storagebox_known_hosts'); return new Promise((resolve) => { if (!remote) return resolve({ ok: false, error: 'BACKUP_REMOTE unset' }); if (!process.env.BACKUP_SSH_KEY) return resolve({ ok: false, error: 'BACKUP_SSH_KEY unset' }); const fs = require('fs'); const os = require('os'); let keyFile; try { // Same decode contract as backup-db.sh: base64 preferred, raw PEM fallback. const raw = process.env.BACKUP_SSH_KEY; let decoded; try { const d = Buffer.from(raw, 'base64').toString('utf8'); decoded = d.includes('PRIVATE KEY') ? d : raw.replace(/\\n/g, '\n'); } catch { decoded = raw.replace(/\\n/g, '\n'); } keyFile = path.join(os.tmpdir(), `vyndr-offbox-${Date.now()}`); fs.writeFileSync(keyFile, decoded.endsWith('\n') ? decoded : `${decoded}\n`, { mode: 0o600 }); } catch (e) { return resolve({ ok: false, error: `key write failed: ${e.message}` }); } const sshCmd = `ssh -p ${port} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${knownHosts} -o BatchMode=yes -i ${keyFile}`; const child = spawn('rsync', ['--list-only', '-e', sshCmd, remote], { stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; let err = ''; child.stdout.on('data', (b) => { out += b.toString(); }); child.stderr.on('data', (b) => { err = (err + b.toString()).slice(-1500); }); const done = (result) => { try { require('fs').unlinkSync(keyFile); } catch { /* best effort */ } resolve(result); }; child.on('error', (e) => done({ ok: false, error: e.message })); child.on('close', (code) => { const files = out.split('\n') .map((l) => l.trim()) .filter((l) => /vyndr-.*\.dump$/.test(l)) .map((l) => { const parts = l.split(/\s+/); return { size: Number(parts[1].replace(/,/g, '')) || null, date: parts[2], time: parts[3], file: parts[parts.length - 1] }; }); done({ ok: code === 0, code, count: files.length, files, error: code === 0 ? null : (err || `exit ${code}`) }); }); }); } module.exports = { startBackupScheduler, runBackup, shouldArm, durabilityWarning, SCRIPT, countRowsInDump, latestDump, listOffbox, };