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
+7
View File
@@ -28,6 +28,7 @@ export const RESOURCES = [
"report", // events feed, occupancy, future reports
"log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge
"backup", // encrypted DB backups: configure target + trigger a manual run
] as const;
export type Resource = (typeof RESOURCES)[number];
@@ -60,6 +61,12 @@ export const PERMISSIONS: readonly Permission[] = [
// Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
"recyclebin:read", "recyclebin:update", "recyclebin:delete",
// Backup: read (view config + last-run status), update (set target/schedule), create
// (trigger a manual "back up now"). Admin-grade — a backup exposes the whole signed
// ledger off-box. RESTORE is deliberately NOT a permission: it's an out-of-band runbook
// action on a fresh appliance, never reachable from the running console. See
// wiki/concepts/backup-recovery.md.
"backup:read", "backup:update", "backup:create",
] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL