Retention (keep-last / keep-daily-days) is operational policy the on-site admin
should tune, not a server env var requiring a redeploy -- same reasoning that moved
the target directory to the UI.
- Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable;
null = code default 7 / 30 per field).
- BackupService reads retention fresh each run; status() exposes keepLast +
keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone).
- PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null
to reset to default; 400 on negative).
- UI: two retention fields on the Backup config card; one Save covers target +
retention. i18n sq + en.
BACKUP_KEY wired into Komodo:
- komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret,
alongside JWT / signing keys).
- komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it
offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are
admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY.
Also carries the small in-progress setup-intro i18n copy trim.
Tests: 218 server tests green, incl. retention persist / reset-to-default / reject-
negative and the updated status shape. Migration applies cleanly (needed a
statement-breakpoint between the two ALTERs). Wiki backup-recovery updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The backup destination is now chosen by the on-site admin in the UI (Setup ->
Backup), not a server env var. An env-pinned target defeats the purpose: the admin
can't point backups at a freshly-plugged USB or a NAS mount without editing .env
and restarting. The encryption key stays a server secret.
Target storage:
- New site_config.backup_target_dir (migration 0016, nullable; null = not
configured). BackupService reads it fresh each run, so a UI change takes effect
with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it
backs up.
Routes:
- PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1).
- POST /api/backup/test -- probe a candidate path server-side (exists / is a
directory / writable) so the admin gets feedback before relying on it.
- status() now exposes targetDir + keyPresent, so the UI distinguishes
'no target set' from 'BACKUP_KEY missing'.
UI (apps/web/src/BackupSettings.tsx):
- A Setup -> Backup tab (gated backup:read): an editable target-path field with a
Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status
panel (config state, last-run size/pruned/error, a distinct amber missing-key
warning), a Back up now button, and the restore-is-out-of-band note. Full i18n
(sq + en); nav.backup.
- API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup.
Also includes a small in-progress copy trim to the setup-intro i18n strings.
Verified live with Playwright: typed a path -> Test reported writable -> Save
persisted it -> status reflected it and showed the key-missing warning. Whole
monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
New concept page backup-recovery.md resolving the design half of open-question #5.
Driving scenario: a stolen/destroyed PC whose LUKS+TPM disk is unrecoverable by
design — recovery stands up a NEW PC, restores a backup, and keeps signing the
SAME chain.
Settled: admin-driven encrypted full-DB backup (SQLite online-backup/VACUUM INTO,
snapshots included) to local/USB, SMB/NFS, or SFTP targets; manual button + an
in-process daily timer; keep-last-N + dailies retention; restore is admin-only /
out-of-band (operator-adversary surface). A restored copy must still verifyChain.
Key custody (the load-bearing decision, bears on #6): three independent keys —
EVENT_SIGNING_KEY kept an extractable, escrowed software key DECOUPLED from the
TPM so the ledger survives total hardware loss (the conscious trade: a TPM-sealed
signing key would be unforgeable but permanently unverifiable after the machine
dies); a NEW dedicated park_buzi_backup_key in Komodo for backup encryption,
separate from the signing key; the LUKS/TPM disk key, appliance-only and
deliberately non-recoverable. Keys are never inside the backup they unlock.
Updated open-questions #5 (design SETTLED) + #10 note; disk-os-hardening deploy
runbook (why the signing key is not sealed + park_buzi_backup_key); index catalog
+ concept count. Design only — not yet built.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V