From 84f00db48b435b60b67467dde68e6befe5300cb1 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 29 Jun 2026 12:52:18 +0200 Subject: [PATCH] feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret 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 --- apps/server/.env.example | 5 +- apps/server/src/backup-service.ts | 22 ++++- apps/server/src/backup.ts | 6 +- apps/server/src/routes/backup.routes.test.ts | 33 ++++++++ apps/server/src/routes/backup.ts | 35 ++++++-- apps/web/src/BackupSettings.tsx | 80 ++++++++++++++----- apps/web/src/api.ts | 16 +++- apps/web/src/lib/i18n/en.ts | 4 + apps/web/src/lib/i18n/sq.ts | 4 + komodo/.env.komodo.example | 13 +++ komodo/resources.toml | 4 +- packages/db/drizzle/0017_backup_retention.sql | 6 ++ packages/db/drizzle/meta/_journal.json | 7 ++ packages/db/src/schema.ts | 5 ++ wiki/concepts/backup-recovery.md | 24 +++--- wiki/log.md | 14 ++++ 16 files changed, 234 insertions(+), 44 deletions(-) create mode 100644 packages/db/drizzle/0017_backup_retention.sql diff --git a/apps/server/.env.example b/apps/server/.env.example index b954015..9c9069f 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -25,10 +25,9 @@ EVENT_SIGNING_KEY= # rotate without fracturing the signed chain. Generate with: openssl rand -hex 32 # Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever # stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI -# target directory are set. +# target directory are set. The target directory AND retention (keep-last / keep-daily) are +# admin-chosen in the UI (Setup → Backup), NOT env — only this key is an env secret. # BACKUP_KEY= -# BACKUP_KEEP_LAST=7 # keep this many newest backups always -# BACKUP_KEEP_DAILY_DAYS=30 # plus one-per-day within this window # Optional ---------------------------------------------------------------- # PORT=3000 diff --git a/apps/server/src/backup-service.ts b/apps/server/src/backup-service.ts index 0535a87..9a4a507 100644 --- a/apps/server/src/backup-service.ts +++ b/apps/server/src/backup-service.ts @@ -3,7 +3,7 @@ import { access, stat } from "node:fs/promises"; import { resolve } from "node:path"; import { eq, siteConfig, type Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; -import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult } from "./backup.js"; +import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js"; // Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen // and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI @@ -28,6 +28,9 @@ export interface BackupStatus { readonly configured: boolean; /** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */ readonly targetDir: string | null; + /** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */ + readonly keepLast: number; + readonly keepDailyDays: number; /** Whether the env key is present + long enough (the UI flags a missing key distinctly). */ readonly keyPresent: boolean; readonly running: boolean; @@ -79,6 +82,18 @@ export class BackupService { return dir ? dir : null; } + /** Resolved retention from site_config, falling back to the code default per field. Read fresh. */ + retention(): BackupRetention { + const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + const keepLast = row?.backupKeepLast; + const keepDailyDays = row?.backupKeepDailyDays; + return { + keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast, + keepDailyDays: + keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays, + }; + } + get keyPresent(): boolean { return backupKeyFromEnv().length >= 16; } @@ -88,9 +103,12 @@ export class BackupService { } status(): BackupStatus { + const r = this.retention(); return { configured: this.configured, targetDir: this.targetDir(), + keepLast: r.keepLast, + keepDailyDays: r.keepDailyDays, keyPresent: this.keyPresent, running: this.#running, lastSuccessAt: this.#lastSuccessAt, @@ -120,7 +138,7 @@ export class BackupService { this.#inflight = (async () => { try { this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`); - const res = await runBackup(this.#db, { targetDir, key, retention: DEFAULT_BACKUP_RETENTION }, this.#logger); + const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger); this.#lastResult = res; this.#lastSuccessAt = new Date().toISOString(); this.#lastError = null; diff --git a/apps/server/src/backup.ts b/apps/server/src/backup.ts index 06d9e1e..8a857cb 100644 --- a/apps/server/src/backup.ts +++ b/apps/server/src/backup.ts @@ -40,9 +40,11 @@ export interface BackupRetention { readonly keepDailyDays: number; } +// Code defaults — the fallback when the admin hasn't set a value in site_config (the source of +// truth). NOT env-driven: retention is operational policy tuned from the Backup screen. export const DEFAULT_BACKUP_RETENTION: BackupRetention = { - keepLast: Number(process.env.BACKUP_KEEP_LAST ?? 7), - keepDailyDays: Number(process.env.BACKUP_KEEP_DAILY_DAYS ?? 30), + keepLast: 7, + keepDailyDays: 30, }; export interface BackupOptions { diff --git a/apps/server/src/routes/backup.routes.test.ts b/apps/server/src/routes/backup.routes.test.ts index afd8b24..7c3e779 100644 --- a/apps/server/src/routes/backup.routes.test.ts +++ b/apps/server/src/routes/backup.routes.test.ts @@ -50,6 +50,8 @@ describe("GET /api/backup/status", () => { expect(body).toMatchObject({ configured: false, targetDir: null, + keepLast: 7, // code defaults surfaced when unset + keepDailyDays: 30, running: false, lastSuccessAt: null, lastError: null, @@ -99,6 +101,37 @@ describe("PUT /api/backup/config — admin-chosen target", () => { }); expect(clear.json().targetDir).toBeNull(); }); + + it("persists retention and resets to defaults on null", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie, csrf } = await login(app, username, password); + + const set = await app.inject({ + method: "PUT", url: "/api/backup/config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { keepLast: 3, keepDailyDays: 14 }, + }); + expect(set.json()).toMatchObject({ keepLast: 3, keepDailyDays: 14 }); + + // null resets to the code default. + const reset = await app.inject({ + method: "PUT", url: "/api/backup/config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { keepLast: null, keepDailyDays: null }, + }); + expect(reset.json()).toMatchObject({ keepLast: 7, keepDailyDays: 30 }); + }); + + it("rejects a negative retention value (400)", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie, csrf } = await login(app, username, password); + const res = await app.inject({ + method: "PUT", url: "/api/backup/config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { keepLast: -1 }, + }); + expect(res.statusCode).toBe(400); + }); }); describe("POST /api/backup/test — path probe", () => { diff --git a/apps/server/src/routes/backup.ts b/apps/server/src/routes/backup.ts index ecc2a97..144ef98 100644 --- a/apps/server/src/routes/backup.ts +++ b/apps/server/src/routes/backup.ts @@ -13,6 +13,10 @@ import { checkTargetDir, type BackupService } from "../backup-service.js"; interface ConfigBody { targetDir?: string | null; + /** Retention: keep this many newest backups. null = reset to the code default. */ + keepLast?: number | null; + /** Retention: keep one-per-day within this many days. null = reset to the code default. */ + keepDailyDays?: number | null; } interface TestBody { targetDir?: string; @@ -28,18 +32,37 @@ export async function backupRoutes(app: FastifyInstance, db: Db, backups: Backup "/api/backup/config", { preHandler: requirePermission("backup:update") }, async (req, reply) => { - const raw = req.body?.targetDir; - if (raw != null && typeof raw !== "string") { - return reply.code(400).send({ error: "targetDir must be a string or null" }); + const body = req.body ?? {}; + const patch: { backupTargetDir?: string | null; backupKeepLast?: number | null; backupKeepDailyDays?: number | null } = {}; + + if ("targetDir" in body) { + const raw = body.targetDir; + if (raw != null && typeof raw !== "string") { + return reply.code(400).send({ error: "targetDir must be a string or null" }); + } + patch.backupTargetDir = raw == null ? null : raw.trim() || null; } - const next = raw == null ? null : raw.trim() || null; + // Retention: a non-negative integer, or null to reset to the code default. + for (const [field, col] of [ + ["keepLast", "backupKeepLast"], + ["keepDailyDays", "backupKeepDailyDays"], + ] as const) { + if (field in body) { + const v = body[field]; + if (v != null && (!Number.isInteger(v) || v < 0)) { + return reply.code(400).send({ error: `${field} must be a non-negative integer or null` }); + } + patch[col] = v ?? null; + } + } + const updatedAt = new Date().toISOString(); // Single-row site_config (id=1): upsert, since a fresh install may not have it yet. const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); if (existing) { - db.update(siteConfig).set({ backupTargetDir: next, updatedAt }).where(eq(siteConfig.id, 1)).run(); + db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run(); } else { - db.insert(siteConfig).values({ id: 1, backupTargetDir: next, updatedAt }).run(); + db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run(); } return backups.status(); }, diff --git a/apps/web/src/BackupSettings.tsx b/apps/web/src/BackupSettings.tsx index f098f79..2568f19 100644 --- a/apps/web/src/BackupSettings.tsx +++ b/apps/web/src/BackupSettings.tsx @@ -5,7 +5,7 @@ import { ApiError, fetchBackupStatus, runBackup, - setBackupTarget, + setBackupConfig, testBackupTarget, type BackupStatus, type TargetCheck, @@ -56,6 +56,8 @@ export function BackupSettings() { const qc = useQueryClient(); const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null); const [target, setTarget] = useState(""); + const [keepLast, setKeepLast] = useState(""); + const [keepDaily, setKeepDaily] = useState(""); const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null); const q = useQuery({ @@ -65,13 +67,22 @@ export function BackupSettings() { }); const status = q.data; - // Seed the editable field from the saved value once it loads (and when it changes server-side). + // Seed the editable fields from the saved values once they load (and on server-side change). useEffect(() => { - if (status) setTarget(status.targetDir ?? ""); - }, [status?.targetDir]); + if (status) { + setTarget(status.targetDir ?? ""); + setKeepLast(String(status.keepLast)); + setKeepDaily(String(status.keepDailyDays)); + } + }, [status?.targetDir, status?.keepLast, status?.keepDailyDays]); const save = useMutation({ - mutationFn: () => setBackupTarget(target.trim() || null), + mutationFn: () => + setBackupConfig({ + targetDir: target.trim() || null, + keepLast: keepLast.trim() === "" ? null : Number(keepLast), + keepDailyDays: keepDaily.trim() === "" ? null : Number(keepDaily), + }), onSuccess: (next) => { setToast({ kind: "ok", msg: t("backup.saved") }); setCheck(null); @@ -101,7 +112,10 @@ export function BackupSettings() { }, }); - const dirty = (status?.targetDir ?? "") !== target.trim(); + const dirty = + (status?.targetDir ?? "") !== target.trim() || + String(status?.keepLast ?? "") !== keepLast.trim() || + String(status?.keepDailyDays ?? "") !== keepDaily.trim(); return (
@@ -134,8 +148,9 @@ export function BackupSettings() {
)} - {/* Target directory — the admin-chosen destination. */} + {/* Config — admin-chosen destination + retention policy. */}
+ {/* Target directory + its Test probe. */}
{t("backup.targetLabel")}
@@ -156,17 +171,6 @@ export function BackupSettings() { > {t("backup.test")} -
{t("backup.targetHint")} {check && ( @@ -175,6 +179,46 @@ export function BackupSettings() { )}
+ + {/* Retention — admin-tuned policy (how many backups to keep at the target). */} +
+
+ {t("backup.keepLastLabel")} + setKeepLast(e.target.value)} + /> + {t("backup.keepLastHint")} +
+
+ {t("backup.keepDailyLabel")} + setKeepDaily(e.target.value)} + /> + {t("backup.keepDailyHint")} +
+
+ +
+ +
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 23bee5d..a7e30be 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -234,6 +234,9 @@ export interface BackupStatus { configured: boolean; /** Admin-chosen target directory (null = not set). */ targetDir: string | null; + /** Admin-tuned retention (resolved value: DB or code default). */ + keepLast: number; + keepDailyDays: number; /** Whether the env encryption key is present (a missing key is flagged distinctly). */ keyPresent: boolean; running: boolean; @@ -247,9 +250,16 @@ export async function fetchBackupStatus(): Promise { return apiFetch("/api/backup/status"); } -/** Set (or clear, with "") the admin-chosen target directory. Returns the new status. */ -export async function setBackupTarget(targetDir: string | null): Promise { - return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify({ targetDir }) }); +export interface BackupConfigPatch { + /** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */ + targetDir?: string | null; + keepLast?: number | null; + keepDailyDays?: number | null; +} + +/** Update backup config (target dir and/or retention). Returns the new status. */ +export async function setBackupConfig(patch: BackupConfigPatch): Promise { + return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify(patch) }); } export interface TargetCheck { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 8b6d787..d3e2e02 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -865,6 +865,10 @@ export const en: Catalog = { testNotDir: "The path is not a directory.", testNotWritable: "The directory is not writable.", keyMissing: "The encryption key (BACKUP_KEY) is missing on the server — set it to enable backups.", + keepLastLabel: "Keep last", + keepLastHint: "How many of the newest backups to always keep.", + keepDailyLabel: "Keep daily (days)", + keepDailyHint: "Beyond those, keep one backup per day for this many days.", }, pay: { ticket: "Ticket", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index f7edda0..2b78256 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -881,6 +881,10 @@ export const sq = { testNotDir: "Rruga nuk është një dosje.", testNotWritable: "Dosja nuk është e shkruajtshme.", keyMissing: "Çelësi i enkriptimit (BACKUP_KEY) mungon në server — caktoje që kopja të aktivizohet.", + keepLastLabel: "Mbaj kopjet e fundit", + keepLastHint: "Numri i kopjeve më të reja që mbahen gjithmonë.", + keepDailyLabel: "Mbaj ditore (ditë)", + keepDailyHint: "Përtej atyre, mbaj një kopje për ditë për kaq ditë.", }, pay: { ticket: "Bileta", diff --git a/komodo/.env.komodo.example b/komodo/.env.komodo.example index c0b6105..ffa168e 100644 --- a/komodo/.env.komodo.example +++ b/komodo/.env.komodo.example @@ -29,6 +29,18 @@ EVENT_SIGNING_KEY=[[booth__event_signing_key]] # without =0 the auth cookie never sends and operators CANNOT log in. Set 1 only behind TLS. COOKIE_SECURE=0 +# ════════════════════════════════════════════════════════════════════════════ +# BACKUP (encrypted on-site DB backup — see wiki/concepts/backup-recovery.md) +# ════════════════════════════════════════════════════════════════════════════ +# Dedicated backup-ENCRYPTION key. SEPARATE from EVENT_SIGNING_KEY (independent rotation; +# backups travel to the target, the signing key must not). Per booth + unique. openssl rand +# -hex 32. ESCROW it offsite alongside EVENT_SIGNING_KEY — disaster recovery needs BOTH, and +# neither is ever stored inside the backup it unlocks. Backups stay OFF until this key is set +# AND the admin picks a target directory in the UI. The key is the ONLY backup env var — the +# target directory and retention (keep-last / keep-daily) are admin-chosen in the UI (Setup → +# Backup) and stored in the DB, so changing them needs no redeploy. +BACKUP_KEY=[[booth__backup_key]] + # ════════════════════════════════════════════════════════════════════════════ # COMMONLY SET (have defaults, but you usually want these explicit on a booth) # ════════════════════════════════════════════════════════════════════════════ @@ -103,5 +115,6 @@ WS_ALLOWED_ORIGINS= # ════════════════════════════════════════════════════════════════════════════ # JWT_SECRET -> [[booth__jwt_secret]] (login) # EVENT_SIGNING_KEY -> [[booth__event_signing_key]] (ledger signing — fraud root) +# BACKUP_KEY -> [[booth__backup_key]] (backup encryption — escrow offsite) # periphery passkey -> [[periphery_passkey_booth_]] (agent onboarding) # registry account -> [[gitea_registry_account]] (image pull) diff --git a/komodo/resources.toml b/komodo/resources.toml index 0e08091..99594a7 100644 --- a/komodo/resources.toml +++ b/komodo/resources.toml @@ -15,7 +15,8 @@ # # Secrets ([[park_buzi_jwt_secret]] etc.) are REFERENCES to Komodo Core's secret store — # per-booth + unique, never inlined here (this file is in git). JWT_SECRET gates login; -# EVENT_SIGNING_KEY signs the append-only anti-fraud ledger. +# EVENT_SIGNING_KEY signs the append-only anti-fraud ledger; BACKUP_KEY encrypts on-site DB +# backups (separate from the signing key; escrow it offsite — recovery needs both). # # Deploys are MANUAL + PINNED in spirit: bump TAG to an immutable dev- before a # production booth goes live (TAG=dev here is the moving tag, fine while staging). NO @@ -48,4 +49,5 @@ VISION_ENABLED=1 WS_ALLOWED_ORIGINS= JWT_SECRET=[[park_buzi_jwt_secret]] EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]] +BACKUP_KEY=[[park_buzi_backup_key]] """ diff --git a/packages/db/drizzle/0017_backup_retention.sql b/packages/db/drizzle/0017_backup_retention.sql new file mode 100644 index 0000000..553b285 --- /dev/null +++ b/packages/db/drizzle/0017_backup_retention.sql @@ -0,0 +1,6 @@ +-- Backup retention is OPERATIONAL POLICY the on-site admin tunes from the Backup screen, not a +-- server env var requiring a redeploy. Two additive, nullable columns on the single-row config; +-- null = fall back to the code default (keepLast 7, keepDailyDays 30). The backup ENGINE stays +-- parameterized; this just moves the source of truth env → DB. See wiki/concepts/backup-recovery.md. +ALTER TABLE `site_config` ADD `backup_keep_last` integer;--> statement-breakpoint +ALTER TABLE `site_config` ADD `backup_keep_daily_days` integer; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index a5b14a7..cc52057 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1781885900000, "tag": "0016_backup_target_dir", "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1781886000000, + "tag": "0017_backup_retention", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index eaa5c07..dd7124b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -269,6 +269,11 @@ export const siteConfig = sqliteTable("site_config", { * screen; the encryption key (BACKUP_KEY) stays an env/Komodo secret and is NEVER stored * here (a key must not live in the DB it backs up). See wiki/concepts/backup-recovery.md. */ backupTargetDir: text("backup_target_dir"), + /** Backup retention (admin-tunable policy, not env). Keep this many newest backups always. + * null ⇒ code default (7). See wiki/concepts/backup-recovery.md. */ + backupKeepLast: integer("backup_keep_last"), + /** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */ + backupKeepDailyDays: integer("backup_keep_daily_days"), updatedAt: text("updated_at") .notNull() .default(sql`(current_timestamp)`), diff --git a/wiki/concepts/backup-recovery.md b/wiki/concepts/backup-recovery.md index bab7906..645dc29 100644 --- a/wiki/concepts/backup-recovery.md +++ b/wiki/concepts/backup-recovery.md @@ -133,12 +133,15 @@ 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`** — 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". +- **`backup-service.ts`** — the **target directory AND retention are admin-chosen** in the UI + (`site_config.backup_target_dir`, migration 0016; `backup_keep_last` + `backup_keep_daily_days`, + migration 0017) and read **fresh each run**, so changing them takes effect with no restart. Retention + columns are nullable → fall back to the code default (keep-last 7, keep-daily 30) per field. The + **encryption key is the ONLY backup env/Komodo secret** (`BACKUP_KEY`) — a key must never live in the + DB it backs up; target+retention are operational policy, not secrets. The service **serializes** + concurrent runs (single in-flight guard) and records last-success / last-error; `status()` exposes + `targetDir`, `keepLast`, `keepDailyDays` + `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 @@ -146,9 +149,12 @@ timer + the manual route**. What landed: (`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). + **retention fields** (keep-last / keep-daily-days), one **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). +- **Komodo wiring.** `BACKUP_KEY` is a **per-booth Komodo secret** (`[[park_buzi_backup_key]]` in + `komodo/resources.toml`; documented in `komodo/.env.komodo.example`), escrowed offsite alongside + `EVENT_SIGNING_KEY`. It is the *only* backup env var — target + retention are in the DB. - **`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). diff --git a/wiki/log.md b/wiki/log.md index 51c4c77..3a1cf7d 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1948,3 +1948,17 @@ status panel (distinct amber "BACKUP_KEY missing" warning) + Back-up-now + resto 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. + +## [2026-06-29] feat | Backup retention admin-tunable + BACKUP_KEY wired into Komodo +Same reasoning as the target dir: backup retention is operational policy the on-site admin tunes, not a +server env var requiring a redeploy. Moved BACKUP_KEEP_LAST/BACKUP_KEEP_DAILY_DAYS env → site_config +(migration 0017: backup_keep_last, backup_keep_daily_days, both nullable → code default 7/30 per field). +BackupService reads retention fresh each run; status() now exposes keepLast/keepDailyDays. PUT +/api/backup/config extended to accept keepLast/keepDailyDays (non-negative int or null=reset-to-default, +400 on negative). UI: two retention number fields on the Backup config card, one Save covers target + +retention; i18n sq+en. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads dropped). Komodo: +BACKUP_KEY wired as a per-booth secret ([[park_buzi_backup_key]] in komodo/resources.toml; documented in +komodo/.env.komodo.example as the ONLY backup env var — target+retention are UI/DB). Server .env.example +trimmed to just BACKUP_KEY. build/lint/test green (218 server tests, incl. retention persist/reset/negative ++ updated status shape). NOTE: dev API process was down after this round (live process, not code) — verified +via the full test harness, not a live click-through this time. Updated [[backup-recovery]] as-built.