diff --git a/apps/hq/scripts/backup.ts b/apps/hq/scripts/backup.ts new file mode 100644 index 0000000..895ca25 --- /dev/null +++ b/apps/hq/scripts/backup.ts @@ -0,0 +1,24 @@ +// apps/hq/scripts/backup.ts — `npm run backup` (D33). Dumps the production Postgres to a +// timestamped, compressed, restorable file under ./backups (or $HQ_BACKUP_DIR) and rotates to +// the newest $HQ_BACKUP_KEEP (default 14). Run from the repo root: +// npm run backup +// DATABASE_URL=postgres://… npm run backup (explicit target, overrides resolution) +// The target is resolved through the SAME shared resolver the server and every other +// maintenance script uses (`requireDbUrlForEnv`, D19) — the old scripts/backup.mjs read +// DATABASE_URL directly and so could never find the production database, which is resolved +// rather than exported on that box. The resolved host/db is logged (never the password) +// before pg_dump runs. +// +// Restore a dump with: +// pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-.dump +// +// SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext +// logins. Keep backups/ access-controlled and off any shared drive (it is gitignored). +import { runBackup } from '../src/backup' + +try { + runBackup() +} catch (e) { + console.error(e instanceof Error ? e.message : String(e)) + process.exit(1) +} diff --git a/apps/hq/src/backup.ts b/apps/hq/src/backup.ts new file mode 100644 index 0000000..6a37c34 --- /dev/null +++ b/apps/hq/src/backup.ts @@ -0,0 +1,128 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' +import { sqliteFilePath } from './db' +import { pgTargetDescription, resolveDbUrl } from './db-pg' + +/** + * Database backup (D33): `pg_dump` of the production Postgres to a timestamped, compressed, + * restorable file under ./backups (or $HQ_BACKUP_DIR), rotated to the newest N. + * + * Lives in src/ (not scripts/) so it is typechecked and unit-testable; `scripts/backup.ts` is + * the thin CLI wrapper `npm run backup` invokes. + * + * The fix this file exists for: the previous `scripts/backup.mjs` read `process.env.DATABASE_URL` + * directly and bailed out when it was unset. On the production box DATABASE_URL is NOT set — the + * connection is resolved by `resolveDbUrl` (D19) — so the backup command could never reach the + * real database. It now resolves its target through the SAME shared resolver every other + * maintenance script uses, and logs the resolved target (never the password) before it runs. + * + * SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext logins. + * Keep the backups/ directory access-controlled and off any shared drive; it is gitignored so it + * can never be committed. + */ + +/** Env knobs, defaulted here so the CLI wrapper stays a one-liner. */ +export const DEFAULT_KEEP = 14 + +/** + * The Postgres URL this backup run must dump — via `resolveDbUrl`, the one shared resolver + * (D19: DATABASE_URL, else the production connection). This is the fix: the target is + * RESOLVED exactly as the server resolves it, instead of being read straight off + * `process.env.DATABASE_URL`, which is not set on the production box. + * + * A SQLite outcome is fatal here rather than an opt-in (unlike the migration scripts, which + * can legitimately run against SQLite): `pg_dump` cannot read a SQLite file at all, so the + * only useful answer is a loud error naming the SQLite snapshot script instead. + */ +export function resolveBackupTarget(env: NodeJS.ProcessEnv = process.env): string { + const url = resolveDbUrl(env) + if (url === '') { + throw new Error( + '[backup] refusing to run: no Postgres target resolved, so this would back up nothing. ' + + `The database resolved to SQLite at ${sqliteFilePath(env['HQ_DATA_DIR'])}, and this ` + + 'backup dumps Postgres (pg_dump). Set DATABASE_URL=postgres://… to back up the real ' + + 'database, or use apps/hq/scripts/backup-hq.sh for a SQLite snapshot.', + ) + } + return url +} + +/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */ +export function findPgDump(): string | null { + try { + execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' }) + return 'pg_dump' + } catch { /* not on PATH */ } + if (process.platform === 'win32') { + const base = 'C:/Program Files/PostgreSQL' + try { + for (const v of readdirSync(base).sort().reverse()) { + const cand = join(base, v, 'bin', 'pg_dump.exe') + if (existsSync(cand)) return cand + } + } catch { /* no install dir */ } + } + return null +} + +/** `hq-YYYYMMDD-HHMMSS.dump` for the given local time. Pure — the rotation regex must match it. */ +export function backupFileName(now: Date): string { + const p2 = (n: number): string => String(n).padStart(2, '0') + const ts = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}-` + + `${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}` + return `hq-${ts}.dump` +} + +const DUMP_RE = /^hq-\d{8}-\d{6}\.dump$/ + +/** Which of `files` fall outside the newest `keep` dumps. Pure, so rotation is testable. */ +export function dumpsToRotate(files: readonly string[], keep: number): string[] { + const dumps = files.filter((f) => DUMP_RE.test(f)).sort() // name sorts chronologically + return dumps.slice(0, Math.max(0, dumps.length - Math.max(1, keep))) +} + +export interface BackupResult { file: string; bytes: number; rotated: string[]; retained: number } + +/** + * Run the backup end to end. `log` is injected so the CLI prints and tests stay quiet. + * Env: HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14). + */ +export function runBackup( + env: NodeJS.ProcessEnv = process.env, + log: (msg: string) => void = console.log, +): BackupResult { + const url = resolveBackupTarget(env) + // Log the resolved target BEFORE doing anything: a backup that quietly dumped the wrong + // database is indistinguishable from a correct one until the day you need the restore. + log(`[db] engine: postgres ${pgTargetDescription(url)}`) + + const pgDump = findPgDump() + if (pgDump === null) { + throw new Error('[backup] pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.') + } + + const out = env['HQ_BACKUP_DIR'] || join(process.cwd(), 'backups') + const keep = Math.max(1, Number(env['HQ_BACKUP_KEEP'] || DEFAULT_KEEP)) + mkdirSync(out, { recursive: true }) + const file = join(out, backupFileName(new Date())) + + // -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable. + try { + execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] }) + } catch (e) { + throw new Error(`[backup] pg_dump failed: ${e instanceof Error ? e.message : String(e)}`) + } + + const bytes = statSync(file).size + log(`✓ backup written: ${file} (${(bytes / 1024).toFixed(1)} KB)`) + + const rotated = dumpsToRotate(readdirSync(out), keep) + for (const f of rotated) { + unlinkSync(join(out, f)) + log(` rotated out: ${f}`) + } + const retained = readdirSync(out).filter((f) => DUMP_RE.test(f)).length + log(`retained ${retained} backup(s) in ${out} (HQ_BACKUP_KEEP=${keep})`) + return { file, bytes, rotated, retained } +} diff --git a/apps/hq/test/backup.test.ts b/apps/hq/test/backup.test.ts new file mode 100644 index 0000000..46b34a4 --- /dev/null +++ b/apps/hq/test/backup.test.ts @@ -0,0 +1,51 @@ +// apps/hq/test/backup.test.ts — `npm run backup` (D33) must find the PRODUCTION database. +// It used to read process.env.DATABASE_URL directly and exit when it was unset — but on the +// prod box DATABASE_URL is not exported; the connection is RESOLVED (D19). So the backup +// command could never reach the real data. It now goes through the one shared resolver, and +// a SQLite outcome is a loud error rather than a pg_dump against a file it cannot read. +import { describe, it, expect } from 'vitest' +import { backupFileName, dumpsToRotate, resolveBackupTarget } from '../src/backup' + +describe('resolveBackupTarget (same resolver as the server + every maintenance script)', () => { + it('uses DATABASE_URL when it is set', () => { + expect(resolveBackupTarget({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y') + }) + + it('resolves the production target with no DATABASE_URL exported — the whole point of the fix', () => { + // NODE_ENV=production with no DATABASE_URL used to be an instant "DATABASE_URL is not set" + // exit; it must now resolve to the same Postgres the server picks. + const url = resolveBackupTarget({ NODE_ENV: 'production' }) + expect(url).toMatch(/^postgres:\/\//) + }) + + it('refuses (never silently no-ops) when nothing resolves a target', () => { + expect(() => resolveBackupTarget({})).toThrow(/refusing to run/i) + }) + + it('says WHY, naming pg_dump, the SQLite path it landed on, and the SQLite snapshot script', () => { + const err = (() => { try { resolveBackupTarget({ HQ_DATA_DIR: 'D:/hq-data' }); return '' } catch (e) { return String(e) } })() + expect(err).toMatch(/pg_dump/) + expect(err).toMatch(/backup-hq\.sh/) + expect(err).toMatch(/hq\.db/) + expect(err).toMatch(/DATABASE_URL=postgres/) + }) +}) + +describe('backup file naming + rotation', () => { + it('names dumps hq-YYYYMMDD-HHMMSS.dump', () => { + expect(backupFileName(new Date(2026, 6, 19, 2, 5, 9))).toBe('hq-20260719-020509.dump') + }) + + it('rotates out everything past the newest KEEP, ignoring unrelated files', () => { + const files = [ + 'hq-20260101-000000.dump', 'hq-20260102-000000.dump', 'hq-20260103-000000.dump', + 'notes.txt', 'hq-old.dump', + ] + expect(dumpsToRotate(files, 2)).toEqual(['hq-20260101-000000.dump']) + expect(dumpsToRotate(files, 14)).toEqual([]) + }) + + it('keeps at least one dump however low KEEP is set', () => { + expect(dumpsToRotate(['hq-20260101-000000.dump'], 0)).toEqual([]) + }) +}) diff --git a/docs/06-DECISIONS.md b/docs/06-DECISIONS.md index 12135c1..43d43ab 100644 --- a/docs/06-DECISIONS.md +++ b/docs/06-DECISIONS.md @@ -571,13 +571,18 @@ refresh no-ops with a clear message, it never stores anything unencrypted. (Superseded by D31/D32: the key was removed; the pull now runs keyless.) ## D33 — Database backups (ops, 2026-07-19) -`scripts/backup.mjs` (`npm run backup`) `pg_dump`s the database to a timestamped, compressed, +`apps/hq/scripts/backup.ts` → `apps/hq/src/backup.ts` (`npm run backup`) `pg_dump`s the +database to a timestamped, compressed, `pg_restore`-able file under `./backups` (gitignored), keeping the newest `HQ_BACKUP_KEEP` (default 14). It finds `pg_dump` on PATH or in the Windows `C:\Program Files\PostgreSQL\*\bin` install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" `. Priority rose with D32 — dumps now contain **plaintext credentials**, so the `backups/` dir must stay access-controlled and off shared drives. Still pending in the ops slice: schedule it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`. +It resolves its target through the SAME shared resolver as the server and the maintenance +scripts (`resolveDbUrl`, D19) and logs the resolved host/db before dumping — the original +`scripts/backup.mjs` read `DATABASE_URL` straight off the environment and so could never +reach production, where that variable is not exported. ## D34 — Employee master import + invited/first-login flag (2026-07-19) Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via diff --git a/package.json b/package.json index 99224ad..803b81e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -p tsconfig.json && npm run typecheck --workspaces --if-present", - "backup": "node scripts/backup.mjs" + "backup": "tsx apps/hq/scripts/backup.ts" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/scripts/backup.mjs b/scripts/backup.mjs deleted file mode 100644 index 0fb0081..0000000 --- a/scripts/backup.mjs +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -/** - * SiMS HQ database backup (ops slice). Dumps the Postgres database to a timestamped, - * compressed file under ./backups (or $HQ_BACKUP_DIR), then rotates to the newest N. - * - * DATABASE_URL=postgres://user:pass@host:5432/db node scripts/backup.mjs - * npm run backup (from apps/hq — passes DATABASE_URL through) - * - * Restore a dump with: - * pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-.dump - * - * SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext - * logins. Keep the backups/ directory access-controlled and off any shared drive; it is - * gitignored so it can never be committed. - * - * Env: DATABASE_URL (required), HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14). - */ -import { execFileSync } from 'node:child_process' -import { mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs' -import { join } from 'node:path' - -const url = process.env.DATABASE_URL -if (!url || url.trim() === '') { - console.error('DATABASE_URL is not set. Run: DATABASE_URL=postgres://… node scripts/backup.mjs') - process.exit(1) -} - -/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */ -function findPgDump() { - try { - execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' }) - return 'pg_dump' - } catch { /* not on PATH */ } - if (process.platform === 'win32') { - const base = 'C:/Program Files/PostgreSQL' - try { - for (const v of readdirSync(base).sort().reverse()) { - const cand = join(base, v, 'bin', 'pg_dump.exe') - if (existsSync(cand)) return cand - } - } catch { /* no install dir */ } - } - return null -} - -const pgDump = findPgDump() -if (pgDump === null) { - console.error('pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.') - process.exit(1) -} - -const OUT = process.env.HQ_BACKUP_DIR || join(process.cwd(), 'backups') -const KEEP = Math.max(1, Number(process.env.HQ_BACKUP_KEEP || 14)) -mkdirSync(OUT, { recursive: true }) - -const d = new Date() -const p2 = (n) => String(n).padStart(2, '0') -const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}` -const file = join(OUT, `hq-${ts}.dump`) - -// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable. -try { - execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] }) -} catch (e) { - console.error(`pg_dump failed: ${e instanceof Error ? e.message : String(e)}`) - process.exit(1) -} - -const kb = (statSync(file).size / 1024).toFixed(1) -console.log(`✓ backup written: ${file} (${kb} KB)`) - -// Rotation: keep the newest KEEP hq-YYYYMMDD-HHMMSS.dump files. -const dumps = readdirSync(OUT).filter((f) => /^hq-\d{8}-\d{6}\.dump$/.test(f)).sort() -for (const f of dumps.slice(0, Math.max(0, dumps.length - KEEP))) { - unlinkSync(join(OUT, f)) - console.log(` rotated out: ${f}`) -} -console.log(`retained ${Math.min(dumps.length, KEEP)} backup(s) in ${OUT} (HQ_BACKUP_KEEP=${KEEP})`)