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.