feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Build desktop / desktop (push) Successful in 4m17s
Build & push images / images (push) Failing after 39s
CI / check (push) Successful in 39s

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
This commit is contained in:
2026-06-29 12:52:18 +02:00
parent d5e41500a8
commit 84f00db48b
16 changed files with 234 additions and 44 deletions
+2 -3
View File
@@ -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
+20 -2
View File
@@ -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;
+4 -2
View File
@@ -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 {
@@ -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", () => {
+29 -6
View File
@@ -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();
},
+62 -18
View File
@@ -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 (
<div className="">
@@ -134,8 +148,9 @@ export function BackupSettings() {
</div>
)}
{/* Target directory — the admin-chosen destination. */}
{/* Config — admin-chosen destination + retention policy. */}
<div className="card mb-3 p-4">
{/* Target directory + its Test probe. */}
<div className="field">
<span className="label">{t("backup.targetLabel")}</span>
<div className="flex flex-wrap items-center gap-2">
@@ -156,17 +171,6 @@ export function BackupSettings() {
>
{t("backup.test")}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={save.isPending || !dirty}
onClick={() => {
setToast(null);
save.mutate();
}}
>
{t("backup.save")}
</button>
</div>
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.targetHint")}</span>
{check && (
@@ -175,6 +179,46 @@ export function BackupSettings() {
</span>
)}
</div>
{/* Retention — admin-tuned policy (how many backups to keep at the target). */}
<div className="mt-4 flex flex-wrap items-start gap-6">
<div className="field">
<span className="label">{t("backup.keepLastLabel")}</span>
<input
type="number"
min={0}
className="input w-28"
value={keepLast}
onChange={(e) => setKeepLast(e.target.value)}
/>
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepLastHint")}</span>
</div>
<div className="field">
<span className="label">{t("backup.keepDailyLabel")}</span>
<input
type="number"
min={0}
className="input w-28"
value={keepDaily}
onChange={(e) => setKeepDaily(e.target.value)}
/>
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepDailyHint")}</span>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={save.isPending || !dirty}
onClick={() => {
setToast(null);
save.mutate();
}}
>
{t("backup.save")}
</button>
</div>
</div>
<div className="card p-4">
+13 -3
View File
@@ -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<BackupStatus> {
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<BackupStatus> {
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<BackupStatus> {
return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify(patch) });
}
export interface TargetCheck {
+4
View File
@@ -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",
+4
View File
@@ -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",