feat(backup): admin UI with admin-chosen target directory
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
This commit is contained in:
@@ -133,26 +133,36 @@ timer + the manual route**. What landed:
|
||||
**keep-last-N + one-per-day-within-N-days** (`pruneOldBackups`). Tested: round-trip decrypts to a
|
||||
**byte-identical, queryable DB**; a flipped byte or wrong key **fails GCM auth**; short key rejected;
|
||||
scratch plaintext always removed.
|
||||
- **`backup-service.ts`** — resolves config from env (`BACKUP_TARGET_DIR`, `BACKUP_KEY`,
|
||||
`BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`), **serializes** concurrent runs (single in-flight
|
||||
guard), records last-success / last-error for the UI.
|
||||
- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`) + `POST /api/backup/run`
|
||||
(`backup:create`); a clean **409 `backup_not_configured`** when unset. New `backup` permission
|
||||
resource (`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by
|
||||
design.
|
||||
- **`backup-service.ts`** — the **target directory is admin-chosen** (`site_config.backup_target_dir`,
|
||||
migration 0016) and read **fresh each run**, so changing it in the UI takes effect with no restart.
|
||||
Only the **encryption key stays an env/Komodo secret** (`BACKUP_KEY`) — a key must never live in the
|
||||
DB it backs up. Retention knobs (`BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`) stay env. The service
|
||||
**serializes** concurrent runs (single in-flight guard) and records last-success / last-error;
|
||||
`status()` exposes `targetDir` + `keyPresent` so the UI distinguishes "no target" from "no key".
|
||||
- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`); `PUT /api/backup/config` to set/
|
||||
clear the target (`backup:update`); `POST /api/backup/test` to probe a candidate path server-side —
|
||||
exists / is-a-dir / writable (`backup:update`); `POST /api/backup/run` (`backup:create`), a clean
|
||||
**409 `backup_not_configured`** when target+key aren't both set. New `backup` permission resource
|
||||
(`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by design.
|
||||
- **`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
|
||||
BACKUP_KEY** warning), a **Back up now** button, and the restore-is-out-of-band note. Full i18n
|
||||
(sq + en).
|
||||
- **`server.ts`** — an **unref'd daily timer** (`backupService.runScheduled`), a **no-op until
|
||||
configured**, and **deliberately NOT run at startup** (a just-power-cut booth shouldn't write to a
|
||||
possibly-unmounted disk; the daily cadence + the manual button cover it).
|
||||
- Env documented in `apps/server/.env.example` (with the escrow + separate-key notes).
|
||||
|
||||
**SMB/NFS already work** — they're just a mounted path under `BACKUP_TARGET_DIR`. **Deferred to
|
||||
follow-up slices:** an **SFTP** target, the **admin UI** (status panel + "Back up now" button + i18n),
|
||||
and a **restore runbook / CLI**.
|
||||
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
||||
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
||||
|
||||
## Status
|
||||
|
||||
Design settled 2026-06-29; **engine + local/mounted target BUILT 2026-06-29** (SFTP + UI + restore
|
||||
tooling pending). Resolves the *design* half of [[open-questions]] #5 and the first build slice; records
|
||||
the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat
|
||||
backups → future exclude toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]],
|
||||
[[fleet-deployment-komodo]], [[reconciliation]].
|
||||
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
||||
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
||||
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
||||
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
|
||||
build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and
|
||||
#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]],
|
||||
[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]].
|
||||
|
||||
@@ -23,9 +23,12 @@ procurement. (See [[parking-system-architecture]] §10.)
|
||||
4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot,
|
||||
manager visit) to reconcile the signed log against an external authority — the real anti-fraud
|
||||
control. See [[reconciliation]].
|
||||
5. **Durability / backup.** _(Design SETTLED + first slice BUILT 2026-06-29 — see [[backup-recovery]];
|
||||
engine + local/mounted target + daily timer + manual route done, SFTP/UI/restore-tooling pending.)_
|
||||
A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**.
|
||||
5. **Durability / backup.** _(Design SETTLED + engine/target/UI BUILT 2026-06-29 — see
|
||||
[[backup-recovery]]; engine + admin-configured target (site_config, migration 0016) + daily timer +
|
||||
manual route + admin UI (target field, Test-target probe, status, Back-up-now) done; SFTP +
|
||||
restore-tooling pending. The target dir is admin-chosen in the UI, not env; only BACKUP_KEY is a
|
||||
server secret.)_ A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history
|
||||
loss**.
|
||||
Settled design: an **admin-driven encrypted full-DB backup** (online-backup/`VACUUM INTO`, snapshots
|
||||
included) to a **local/USB · SMB/NFS · SFTP** target, **manual button + in-process daily timer**,
|
||||
**keep-last-N + dailies** retention, encrypted with a **dedicated `park_buzi_backup_key`** (separate
|
||||
|
||||
+14
@@ -1934,3 +1934,17 @@ route — out-of-band by design). New `backup` permission resource in @parking/s
|
||||
daily timer, no-op until configured, NOT run at startup. `openRawDb()` added to @parking/db/testing.
|
||||
SMB/NFS work as mount paths; SFTP + admin UI + restore runbook deferred. build/lint/test green (212 server
|
||||
tests, 25 files). Updated [[open-questions]] #5 (first slice BUILT). NOT yet committed beyond this branch.
|
||||
|
||||
## [2026-06-29] feat | Backup admin UI + admin-chosen target (site_config, not env)
|
||||
The backup TARGET DIRECTORY is now chosen by the on-site admin in the UI, not a server env var — env
|
||||
target defeats the purpose (admin can't change where backups land without editing .env + restart). Moved
|
||||
to `site_config.backup_target_dir` (migration 0016, nullable); BackupService reads it fresh each run (no
|
||||
restart to change). Only BACKUP_KEY stays an env secret — a key must NEVER live in the DB it backs up.
|
||||
New routes: PUT /api/backup/config (set/clear target, backup:update, upserts the id=1 row), POST
|
||||
/api/backup/test (server-side path probe: exists/is-dir/writable, backup:update). status() now exposes
|
||||
targetDir + keyPresent so the UI tells "no target" from "no key". UI: Setup → Backup tab
|
||||
(apps/web/src/BackupSettings.tsx) — editable target field + Test-target probe (localized reasons) + Save +
|
||||
status panel (distinct amber "BACKUP_KEY missing" warning) + Back-up-now + restore-out-of-band note; full
|
||||
i18n sq+en; nav.backup. Verified live with Playwright: typed path → Test "writable" → Save persisted →
|
||||
status reflects it + key-missing warning shown. build/lint/test green (whole monorepo). Updated
|
||||
[[backup-recovery]] as-built + [[open-questions]] #5.
|
||||
|
||||
Reference in New Issue
Block a user