feat(backup): encrypted on-site DB backup engine + local target

The SQLite DB is the signed append-only ledger, so a disk failure / stolen or
destroyed PC means total revenue-history loss (open-question #5). This is the first
slice of the backup-recovery design: the engine + a local/mounted target + a daily
timer + a manual route.

Engine (apps/server/src/backup.ts):
- Consistent online copy of the live WAL DB via better-sqlite3's native .backup()
  (not a raw file copy, which can capture a torn WAL) — the restored copy is a
  byte-identical, queryable DB.
- AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header
  (magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file.
  Zero new dependencies (Node crypto).
- The plaintext intermediate is kept in scratch (not the removable/network target)
  and wiped in a finally, success or fail.
- Retention: keep-last-N + one-per-day within N days.

Wiring:
- BackupService (env config, single in-flight guard, last-success/last-error).
- routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run
  (backup:create), 409 when unconfigured. No restore route — restore is an
  out-of-band runbook action on a fresh appliance, not a console call.
- New  permission resource in @parking/shared.
- server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY
  are set, deliberately not run at startup (a just-power-cut booth shouldn't write
  to a possibly-unmounted disk).
- openRawDb() added to @parking/db/testing (open a file without migrating, for
  restore-verification tests).

BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation;
backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP +
admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical,
GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC +
409. build/lint/test green (212 server tests). Wiki + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-29 11:59:45 +02:00
parent 9e442586af
commit 0c218179c4
12 changed files with 750 additions and 7 deletions
+20
View File
@@ -21,6 +21,8 @@ import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js";
import { pruneSnapshots } from "./snapshot-retention.js";
import { BackupService, backupConfigFromEnv } from "./backup-service.js";
import { backupRoutes } from "./routes/backup.js";
import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js";
import { authRoutes } from "./routes/auth.js";
@@ -274,6 +276,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService);
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: status +
// a manual "back up now"; the scheduled run is the daily timer below. A no-op until
// BACKUP_TARGET_DIR + BACKUP_KEY are set. See wiki/concepts/backup-recovery.md.
const backupService = new BackupService(db, backupConfigFromEnv(), app.log);
await backupRoutes(app, backupService);
// Periodic retention prune (age + row cap) so the log table stays bounded on the
// offline appliance. Runs hourly; unref'd so it never holds the process open.
const pruneTimer = setInterval(() => {
@@ -301,6 +309,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
void runSnapPrune(); // once at startup
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
backupTimer.unref();
app.addHook("onClose", async () => clearInterval(backupTimer));
if (backupService.configured) {
app.log.info("backup: scheduled daily encrypted backup enabled");
}
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.