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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +29,18 @@ EVENT_SIGNING_KEY=[[booth_<name>_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_<name>_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_<name>_jwt_secret]] (login)
|
||||
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
|
||||
# BACKUP_KEY -> [[booth_<name>_backup_key]] (backup encryption — escrow offsite)
|
||||
# periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
|
||||
# registry account -> [[gitea_registry_account]] (image pull)
|
||||
|
||||
@@ -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-<sha> 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]]
|
||||
"""
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)`),
|
||||
|
||||
@@ -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).
|
||||
|
||||
+14
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user