4 Commits

Author SHA1 Message Date
julian d5e41500a8 feat(backup): admin UI with admin-chosen target directory
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s
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
2026-06-29 12:21:26 +02:00
julian 0c218179c4 feat(backup): encrypted on-site DB backup engine + local target
The SQLite DB is the signed append-only ledger, so a disk failure / stolen or
destroyed PC means total revenue-history loss (open-question #5). This is the first
slice of the backup-recovery design: the engine + a local/mounted target + a daily
timer + a manual route.

Engine (apps/server/src/backup.ts):
- Consistent online copy of the live WAL DB via better-sqlite3's native .backup()
  (not a raw file copy, which can capture a torn WAL) — the restored copy is a
  byte-identical, queryable DB.
- AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header
  (magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file.
  Zero new dependencies (Node crypto).
- The plaintext intermediate is kept in scratch (not the removable/network target)
  and wiped in a finally, success or fail.
- Retention: keep-last-N + one-per-day within N days.

Wiring:
- BackupService (env config, single in-flight guard, last-success/last-error).
- routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run
  (backup:create), 409 when unconfigured. No restore route — restore is an
  out-of-band runbook action on a fresh appliance, not a console call.
- New  permission resource in @parking/shared.
- server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY
  are set, deliberately not run at startup (a just-power-cut booth shouldn't write
  to a possibly-unmounted disk).
- openRawDb() added to @parking/db/testing (open a file without migrating, for
  restore-verification tests).

BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation;
backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP +
admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical,
GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC +
409. build/lint/test green (212 server tests). Wiki + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:59:45 +02:00
julian 9e442586af docs(wiki): settle on-site encrypted backup + disaster-recovery design
New concept page backup-recovery.md resolving the design half of open-question #5.
Driving scenario: a stolen/destroyed PC whose LUKS+TPM disk is unrecoverable by
design — recovery stands up a NEW PC, restores a backup, and keeps signing the
SAME chain.

Settled: admin-driven encrypted full-DB backup (SQLite online-backup/VACUUM INTO,
snapshots included) to local/USB, SMB/NFS, or SFTP targets; manual button + an
in-process daily timer; keep-last-N + dailies retention; restore is admin-only /
out-of-band (operator-adversary surface). A restored copy must still verifyChain.

Key custody (the load-bearing decision, bears on #6): three independent keys —
EVENT_SIGNING_KEY kept an extractable, escrowed software key DECOUPLED from the
TPM so the ledger survives total hardware loss (the conscious trade: a TPM-sealed
signing key would be unforgeable but permanently unverifiable after the machine
dies); a NEW dedicated park_buzi_backup_key in Komodo for backup encryption,
separate from the signing key; the LUKS/TPM disk key, appliance-only and
deliberately non-recoverable. Keys are never inside the backup they unlock.

Updated open-questions #5 (design SETTLED) + #10 note; disk-os-hardening deploy
runbook (why the signing key is not sealed + park_buzi_backup_key); index catalog
+ concept count. Design only — not yet built.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:43:23 +02:00
julian 11567a417f docs(wiki): catch concept pages up to the booth-UX/shift/font-scale work
Bring three queryable pages current with the booth-UX commit (cce99aa) whose
breadth hadn't propagated:
- booth-console: Active Sessions as a real table, dropped status column/filter,
  inline live-feed rows, removed TARGE via-badge + redundant Direction filter,
  plate now searchable + backfilled via plate-recognized WS push, per-user font scale.
- shift: Z-report display simplified (shitje dropped, opening cash added) while
  the signed payload is untouched.
- i18n: users.font_scale recorded alongside language/theme as the matching
  per-user server-stored pref (migration 0014).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:43:12 +02:00
25 changed files with 1506 additions and 13 deletions
+15
View File
@@ -15,6 +15,21 @@ JWT_SECRET=
# them (keyId), so verifyChain still validates a chain that spans a key change. # them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY= EVENT_SIGNING_KEY=
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
# not a console call. See wiki/concepts/backup-recovery.md.
#
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
# 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.
# 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 ---------------------------------------------------------------- # Optional ----------------------------------------------------------------
# PORT=3000 # PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
+150
View File
@@ -0,0 +1,150 @@
import { constants } from "node:fs";
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";
// 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
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
// wiki/concepts/backup-recovery.md.
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
export function backupKeyFromEnv(): string {
return process.env.BACKUP_KEY ?? "";
}
export interface TargetCheck {
readonly ok: boolean;
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
readonly reason?: string;
}
export interface BackupStatus {
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
readonly configured: boolean;
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
readonly targetDir: string | null;
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
readonly keyPresent: boolean;
readonly running: boolean;
readonly lastSuccessAt: string | null;
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
readonly lastErrorAt: string | null;
readonly lastError: string | null;
}
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
const trimmed = dir.trim();
if (!trimmed) return { ok: false, reason: "empty" };
const path = resolve(trimmed);
let st: Awaited<ReturnType<typeof stat>>;
try {
st = await stat(path);
} catch {
return { ok: false, reason: "missing" };
}
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
try {
await access(path, constants.W_OK);
} catch {
return { ok: false, reason: "not_writable" };
}
return { ok: true };
}
export class BackupService {
readonly #db: Db;
readonly #logger?: FastifyBaseLogger;
#running = false;
#lastSuccessAt: string | null = null;
#lastResult: BackupResult | null = null;
#lastErrorAt: string | null = null;
#lastError: string | null = null;
constructor(db: Db, logger?: FastifyBaseLogger) {
this.#db = db;
this.#logger = logger;
}
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
targetDir(): string | null {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const dir = row?.backupTargetDir?.trim();
return dir ? dir : null;
}
get keyPresent(): boolean {
return backupKeyFromEnv().length >= 16;
}
get configured(): boolean {
return this.targetDir() !== null && this.keyPresent;
}
status(): BackupStatus {
return {
configured: this.configured,
targetDir: this.targetDir(),
keyPresent: this.keyPresent,
running: this.#running,
lastSuccessAt: this.#lastSuccessAt,
lastResult: this.#lastResult
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
: null,
lastErrorAt: this.#lastErrorAt,
lastError: this.#lastError,
};
}
/**
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
* it; the scheduled timer wraps + swallows.
*/
#inflight: Promise<BackupResult> | null = null;
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
if (this.#inflight) return this.#inflight;
const targetDir = this.targetDir();
const key = backupKeyFromEnv();
if (!targetDir) throw new Error("backup: no target directory configured");
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
this.#running = true;
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);
this.#lastResult = res;
this.#lastSuccessAt = new Date().toISOString();
this.#lastError = null;
return res;
} catch (err) {
this.#lastError = (err as Error).message;
this.#lastErrorAt = new Date().toISOString();
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
throw err;
} finally {
this.#running = false;
this.#inflight = null;
}
})();
return this.#inflight;
}
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
async runScheduled(): Promise<void> {
if (!this.configured) return; // silent no-op when backups aren't set up
try {
await this.run("scheduled");
} catch {
/* recorded in last-error; already logged */
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestDb, openRawDb } from "@parking/db/testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_BACKUP_RETENTION,
parseBackupStamp,
pruneOldBackups,
runBackup,
} from "./backup.js";
// Mirror of the engine's header layout, so the test decrypts independently (a real restore
// tool would do exactly this) rather than trusting the engine to also decrypt.
const MAGIC = Buffer.from("PKBK", "ascii");
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
function decryptBackup(enc: Buffer, key: string): Buffer {
expect(enc.subarray(0, 4)).toEqual(MAGIC);
expect(enc[4]).toBe(1); // format version
let off = 5;
const salt = enc.subarray(off, (off += SALT_LEN));
const iv = enc.subarray(off, (off += IV_LEN));
const tag = enc.subarray(enc.length - TAG_LEN);
const ciphertext = enc.subarray(off, enc.length - TAG_LEN);
const derived = scryptSync(key, salt, 32);
const decipher = createDecipheriv("aes-256-gcm", derived, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
let workDir: string;
const KEY = "a-test-backup-key-that-is-long-enough";
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-"));
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});
describe("runBackup — round-trip", () => {
it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => {
// A real on-disk DB so the engine's better-sqlite3 .backup() runs for real.
const dbPath = join(workDir, "source.sqlite");
const t = createTestDb(dbPath);
// Put some recognizable data in.
t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)");
t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world");
const targetDir = join(workDir, "target");
const res = await runBackup(t.db, { targetDir, key: KEY });
t.close();
expect(res.bytes).toBeGreaterThan(0);
expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/);
// Decrypt independently and open the recovered DB raw (no migrations — verify as-written).
const plain = decryptBackup(readFileSync(res.path), KEY);
const restoredPath = join(workDir, "restored.sqlite");
writeFileSync(restoredPath, plain);
const restored = openRawDb(restoredPath);
const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string };
expect(row.v).toBe("world");
restored.close();
});
it("rejects a missing/short key before touching the filesystem", async () => {
const t = createTestDb();
await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow(
/BACKUP_KEY/,
);
t.close();
});
it("removes the plaintext scratch copy after a successful run", async () => {
const scratchDir = join(workDir, "scratch");
const t = createTestDb();
await runBackup(t.db, {
targetDir: join(workDir, "target"),
key: KEY,
scratchDir,
// Stub the copy so we don't need a file-backed handle here.
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"),
});
t.close();
// The only thing left in scratch must NOT be a .sqlite plaintext.
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
expect(left).toEqual([]);
});
it("wipes the plaintext scratch copy even when the copy step fails", async () => {
const scratchDir = join(workDir, "scratch");
mkdirSync(scratchDir, { recursive: true });
const t = createTestDb();
// Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The
// finally{} must still remove the plaintext it left behind.
await expect(
runBackup(t.db, {
targetDir: join(workDir, "target"),
key: KEY,
scratchDir,
makeConsistentCopy: async (_db, dest) => {
writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate…
throw new Error("simulated copy failure"); // …then fail
},
}),
).rejects.toThrow(/simulated copy failure/);
t.close();
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
expect(left).toEqual([]);
});
});
describe("backup encryption — tamper evidence (AES-256-GCM)", () => {
it("a flipped ciphertext byte fails authentication on decrypt", async () => {
const t = createTestDb();
const targetDir = join(workDir, "target");
const res = await runBackup(t.db, {
targetDir,
key: KEY,
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)),
});
t.close();
const enc = readFileSync(res.path);
// Flip a byte in the ciphertext region (after the header, before the tag).
enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff;
expect(() => decryptBackup(enc, KEY)).toThrow();
});
it("the wrong key fails authentication", async () => {
const t = createTestDb();
const res = await runBackup(t.db, {
targetDir: join(workDir, "target"),
key: KEY,
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)),
});
t.close();
expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow();
});
});
describe("parseBackupStamp", () => {
it("round-trips a stamped name and rejects non-backups", () => {
const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc");
expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z");
expect(parseBackupStamp("random.txt")).toBeNull();
expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull();
});
});
describe("pruneOldBackups — keep-last-N + dailies", () => {
const day = 24 * 60 * 60 * 1000;
const now = new Date("2026-06-29T12:00:00Z");
function seed(stamps: string[]) {
const dir = join(workDir, "retain");
mkdirSync(dir, { recursive: true });
for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x");
return dir;
}
const stamp = (ms: number) =>
new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
it("keeps the keepLast newest regardless of age", async () => {
// 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent.
const t = now.getTime();
const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000)));
const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now);
expect(pruned).toBe(2);
expect(readdirSync(dir).length).toBe(3);
});
it("keeps one-per-day within the daily window and drops older", async () => {
const t = now.getTime();
// Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30.
const dir = seed([
stamp(t), // today A (newest → kept by keepLast)
stamp(t - 60 * 1000), // today B (same day as the kept one → pruned)
stamp(t - 5 * day), // 5 days ago (kept: within window, unique day)
stamp(t - 40 * day), // 40 days ago (pruned: outside the window)
]);
const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now);
expect(pruned).toBe(2);
const left = readdirSync(dir);
expect(left.length).toBe(2);
});
it("is a no-op on a missing target dir", async () => {
const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now);
expect(pruned).toBe(0);
});
});
+215
View File
@@ -0,0 +1,215 @@
import { createCipheriv, randomBytes, scryptSync } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import type { Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
// On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite
// DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means
// total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh-
// appliance copy. See wiki/concepts/backup-recovery.md.
//
// Two load-bearing properties:
// 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a
// raw file copy, which can capture a torn WAL). The result must still verifyChain.
// 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from
// EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain,
// and a backup target never exposes the signing key. The key is NEVER written into the
// backup it unlocks.
//
// This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/
// mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on
// top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box.
/** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only
* the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */
const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup
const FORMAT_VERSION = 1;
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const SCRYPT_KEYLEN = 32; // AES-256
export interface BackupRetention {
/** Keep at least this many most-recent backups regardless of age. */
readonly keepLast: number;
/** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */
readonly keepDailyDays: number;
}
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
keepLast: Number(process.env.BACKUP_KEEP_LAST ?? 7),
keepDailyDays: Number(process.env.BACKUP_KEEP_DAILY_DAYS ?? 30),
};
export interface BackupOptions {
/** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */
readonly targetDir: string;
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */
readonly key: string;
readonly retention?: BackupRetention;
/** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */
readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise<void>;
/** Override "now" for deterministic filenames/retention in tests. */
readonly now?: () => Date;
/** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */
readonly scratchDir?: string;
}
export interface BackupResult {
/** Absolute path of the encrypted backup written. */
readonly path: string;
/** Size of the encrypted file in bytes. */
readonly bytes: number;
/** Backups pruned by the retention policy this run. */
readonly prunedFiles: number;
}
/** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */
const FILE_PREFIX = "parking-backup-";
const FILE_SUFFIX = ".sqlite.enc";
function stampFor(d: Date): string {
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
/** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */
export function parseBackupStamp(name: string): Date | null {
const base = basename(name);
if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null;
const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length);
// 20260629T141503Z → 2026-06-29T14:15:03Z
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
if (!m) return null;
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
const dt = new Date(iso);
return Number.isNaN(dt.getTime()) ? null : dt;
}
/** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */
async function defaultConsistentCopy(db: Db, destPath: string): Promise<void> {
// db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a
// transactionally-consistent snapshot even while the source is being written.
const client = db.$client as { backup: (dest: string) => Promise<unknown> };
await client.backup(destPath);
}
/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */
async function encryptFile(srcPath: string, destPath: string, key: string): Promise<void> {
const salt = randomBytes(SALT_LEN);
const iv = randomBytes(IV_LEN);
const derived = scryptSync(key, salt, SCRYPT_KEYLEN);
const cipher = createCipheriv("aes-256-gcm", derived, iv);
const out = createWriteStream(destPath);
const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]);
out.write(header);
await pipeline(createReadStream(srcPath), cipher, out, { end: false });
// GCM auth tag is available only after the cipher has flushed; append it, then close.
const tag = cipher.getAuthTag();
await new Promise<void>((res, rej) => {
out.end(tag, () => res());
out.on("error", rej);
});
}
/**
* Run one backup: consistent copy → encrypt → prune old backups by retention.
* Best-effort caller-facing: throws on real failure (so a manual run surfaces the error),
* but the scheduled timer wraps it and logs.
*/
export async function runBackup(
db: Db,
opts: BackupOptions,
logger?: FastifyBaseLogger,
): Promise<BackupResult> {
if (!opts.key || opts.key.length < 16) {
throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
}
const now = opts.now ?? (() => new Date());
const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION;
const targetDir = resolve(opts.targetDir);
await mkdir(targetDir, { recursive: true });
const stamp = stampFor(now());
const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`);
// Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network
// share / removable disk; keep the plaintext local and short-lived, then wipe it).
const scratch = opts.scratchDir ?? tmpdir();
await mkdir(scratch, { recursive: true });
const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`);
try {
const copy = opts.makeConsistentCopy ?? defaultConsistentCopy;
await copy(db, plainPath);
await encryptFile(plainPath, finalPath, opts.key);
} finally {
// Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger.
await rm(plainPath, { force: true }).catch((err) =>
logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`),
);
}
const { size } = await stat(finalPath);
const prunedFiles = await pruneOldBackups(targetDir, retention, now());
logger?.info(
`backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` +
(prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""),
);
return { path: finalPath, bytes: size, prunedFiles };
}
/**
* Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one
* backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day
* duplicates outside the keepLast window. Returns the count deleted.
*/
export async function pruneOldBackups(
targetDir: string,
retention: BackupRetention,
now: Date,
): Promise<number> {
let names: string[];
try {
names = await readdir(targetDir);
} catch {
return 0; // target gone/unmounted — nothing to prune (the write would have failed first)
}
const backups = names
.map((n) => ({ name: n, at: parseBackupStamp(n) }))
.filter((b): b is { name: string; at: Date } => b.at !== null)
.sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first
const keep = new Set<string>();
// 1. Always keep the keepLast newest.
for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name);
// 2. Beyond that, keep the newest per UTC day within the keepDailyDays window.
const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000;
const seenDays = new Set<string>();
for (const b of backups) {
if (keep.has(b.name)) {
seenDays.add(b.at.toISOString().slice(0, 10));
continue;
}
if (b.at.getTime() < cutoff) continue; // too old → not kept
const day = b.at.toISOString().slice(0, 10);
if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra
seenDays.add(day);
keep.add(b.name);
}
let pruned = 0;
for (const b of backups) {
if (keep.has(b.name)) continue;
await rm(join(targetDir, b.name), { force: true });
pruned += 1;
}
return pruned;
}
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// HTTP integration for the backup routes — the security seam + the unconfigured-state
// behaviour. The booted test app has no BACKUP_TARGET_DIR/BACKUP_KEY, so the service is
// "not configured": status reports it, and a manual run is a clean 409 (not a 500).
// See wiki/concepts/backup-recovery.md.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
describe("GET /api/backup/status", () => {
it("401 without a session", async () => {
const res = await app.inject({ method: "GET", url: "/api/backup/status" });
expect(res.statusCode).toBe(401);
});
it("403 for a user lacking backup:read", async () => {
const { username, password } = await seedUser(db, {
username: "viewer", roleId: "viewer", permissions: ["site:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
expect(res.statusCode).toBe(403);
});
it("an admin sees the (unconfigured) status shape", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toMatchObject({
configured: false,
targetDir: null,
running: false,
lastSuccessAt: null,
lastError: null,
});
});
});
describe("PUT /api/backup/config — admin-chosen target", () => {
it("403 for a user lacking backup:update", async () => {
const { username, password } = await seedUser(db, {
username: "viewer", roleId: "viewer", permissions: ["backup:read"],
});
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: { targetDir: "/tmp/x" },
});
expect(res.statusCode).toBe(403);
});
it("persists the target dir and reflects it in status", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const put = await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: " /mnt/backup " }, // trimmed server-side
});
expect(put.statusCode).toBe(200);
expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" });
const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
expect(status.json().targetDir).toBe("/mnt/backup");
});
it("clears the target dir when given empty/null", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" },
});
const clear = await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" },
});
expect(clear.json().targetDir).toBeNull();
});
});
describe("POST /api/backup/test — path probe", () => {
it("reports ok for a writable directory and a reason for a missing one", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const good = await app.inject({
method: "POST", url: "/api/backup/test",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: process.cwd() }, // an existing, writable dir
});
expect(good.json()).toMatchObject({ ok: true });
const bad = await app.inject({
method: "POST", url: "/api/backup/test",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: "/no/such/path/here-xyz" },
});
expect(bad.json()).toMatchObject({ ok: false, reason: "missing" });
});
});
describe("POST /api/backup/run", () => {
it("403 for a user lacking backup:create", async () => {
const { username, password } = await seedUser(db, {
username: "viewer", roleId: "viewer", permissions: ["backup:read"], // read but not create
});
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "POST", url: "/api/backup/run",
headers: { cookie, "x-csrf-token": csrf },
});
expect(res.statusCode).toBe(403);
});
it("requires CSRF on the mutation", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie } = await login(app, username, password);
const res = await app.inject({
method: "POST", url: "/api/backup/run",
headers: { cookie }, // no csrf header
});
expect(res.statusCode).toBe(403);
});
it("returns 409 backup_not_configured when no target/key is set (not a 500)", 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: "POST", url: "/api/backup/run",
headers: { cookie, "x-csrf-token": csrf },
});
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ error: "backup_not_configured" });
});
});
+69
View File
@@ -0,0 +1,69 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import { checkTargetDir, type BackupService } from "../backup-service.js";
// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md.
// - GET /api/backup/status : config + last-run success/error. (backup:read)
// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update)
// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update)
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
// The target dir lives in site_config (admin picks it from the UI); the encryption key stays an
// env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance.
interface ConfigBody {
targetDir?: string | null;
}
interface TestBody {
targetDir?: string;
}
export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise<void> {
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
backups.status(),
);
// Set (or clear) the target directory. Empty/null clears it (backups become a no-op).
app.put<{ Body: ConfigBody }>(
"/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 next = raw == null ? null : raw.trim() || 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();
} else {
db.insert(siteConfig).values({ id: 1, backupTargetDir: next, updatedAt }).run();
}
return backups.status();
},
);
// Probe a candidate path before relying on it (the UI "Test target" button).
app.post<{ Body: TestBody }>(
"/api/backup/test",
{ preHandler: requirePermission("backup:update") },
async (req) => {
const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : "";
return checkTargetDir(dir);
},
);
app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => {
if (!backups.configured) {
return reply.code(409).send({ error: "backup_not_configured" });
}
try {
const res = await backups.run("manual");
return reply.send({ ok: true, path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles });
} catch (err) {
return reply.code(500).send({ error: "backup_failed", message: (err as Error).message });
}
});
}
+21
View File
@@ -21,6 +21,8 @@ import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js"; import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js"; import { LogService, pinoDbStream } from "./log-service.js";
import { pruneSnapshots } from "./snapshot-retention.js"; import { pruneSnapshots } from "./snapshot-retention.js";
import { BackupService } from "./backup-service.js";
import { backupRoutes } from "./routes/backup.js";
import { logRoutes } from "./routes/logs.js"; import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js"; import { VisionClient } from "./vision-client.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
@@ -274,6 +276,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md. // read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService); await logRoutes(app, logService);
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
const backupService = new BackupService(db, app.log);
await backupRoutes(app, db, backupService);
// Periodic retention prune (age + row cap) so the log table stays bounded on the // Periodic retention prune (age + row cap) so the log table stays bounded on the
// offline appliance. Runs hourly; unref'd so it never holds the process open. // offline appliance. Runs hourly; unref'd so it never holds the process open.
const pruneTimer = setInterval(() => { const pruneTimer = setInterval(() => {
@@ -301,6 +310,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
void runSnapPrune(); // once at startup void runSnapPrune(); // once at startup
app.addHook("onClose", async () => clearInterval(snapPruneTimer)); app.addHook("onClose", async () => clearInterval(snapPruneTimer));
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
backupTimer.unref();
app.addHook("onClose", async () => clearInterval(backupTimer));
if (backupService.configured) {
app.log.info("backup: scheduled daily encrypted backup enabled");
}
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the // Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs // retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
// every 6h, unref'd, plus once at startup. See recycle-bin.ts. // every 6h, unref'd, plus once at startup. See recycle-bin.ts.
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
fetchBackupStatus,
runBackup,
setBackupTarget,
testBackupTarget,
type BackupStatus,
type TargetCheck,
} from "./api.js";
import { formatRelativeDateTime } from "./lib/format.js";
// Admin screen for the on-site encrypted DB backup. The admin picks the TARGET DIRECTORY here
// (stored in site_config; a mounted USB/SATA/SMB/NFS path) — the encryption key stays a server
// secret. Shows status + last-run outcome, a "Test target" probe, and a manual "Back up now".
// Gated by backup:read (config/test by backup:update, run by backup:create). RESTORE is absent
// by design — out-of-band on a fresh appliance. See wiki/concepts/backup-recovery.md.
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const mb = n / 1048576;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
}
/** Map a target-check result to a localized message. */
function checkMessage(c: TargetCheck, t: (k: string) => string): string {
if (c.ok) return t("backup.testOk");
switch (c.reason) {
case "empty":
return t("backup.testEmpty");
case "not_a_dir":
return t("backup.testNotDir");
case "not_writable":
return t("backup.testNotWritable");
default:
return t("backup.testMissing");
}
}
function StatusBadge({ status }: { status: BackupStatus }) {
const { t } = useTranslation();
if (!status.configured) {
return <span className="text-[0.75rem] font-semibold text-term-muted">{t("backup.notConfigured")}</span>;
}
if (status.running) {
return <span className="text-[0.75rem] font-semibold text-term-amber">{t("backup.running")}</span>;
}
return <span className="text-[0.75rem] font-semibold text-term-green">{t("backup.configured")}</span>;
}
export function BackupSettings() {
const { t } = useTranslation();
const qc = useQueryClient();
const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
const [target, setTarget] = useState("");
const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
const q = useQuery({
queryKey: ["backup-status"],
queryFn: fetchBackupStatus,
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
});
const status = q.data;
// Seed the editable field from the saved value once it loads (and when it changes server-side).
useEffect(() => {
if (status) setTarget(status.targetDir ?? "");
}, [status?.targetDir]);
const save = useMutation({
mutationFn: () => setBackupTarget(target.trim() || null),
onSuccess: (next) => {
setToast({ kind: "ok", msg: t("backup.saved") });
setCheck(null);
qc.setQueryData(["backup-status"], next);
},
onError: () => setToast({ kind: "err", msg: t("backup.runFailed") }),
});
const test = useMutation({
mutationFn: () => testBackupTarget(target.trim()),
onSuccess: (res) => setCheck({ kind: res.ok ? "ok" : "err", msg: checkMessage(res, t) }),
});
const run = useMutation({
mutationFn: runBackup,
onSuccess: () => {
setToast({ kind: "ok", msg: t("backup.runSuccess") });
void qc.invalidateQueries({ queryKey: ["backup-status"] });
},
onError: (err: unknown) => {
const code = err instanceof ApiError ? err.message : "";
setToast({
kind: "err",
msg: code === "backup_not_configured" ? t("backup.notConfiguredError") : t("backup.runFailed"),
});
void qc.invalidateQueries({ queryKey: ["backup-status"] });
},
});
const dirty = (status?.targetDir ?? "") !== target.trim();
return (
<div className="">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("backup.title")}</h1>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!status?.configured || status?.running || run.isPending || dirty}
onClick={() => {
setToast(null);
run.mutate();
}}
>
{status?.running || run.isPending ? t("backup.running") : t("backup.runNow")}
</button>
</div>
<p className="mb-3 max-w-2xl text-[0.75rem] text-term-muted">{t("backup.intro")}</p>
{toast && (
<div
className={`mb-3 rounded-term border px-3 py-2 text-[0.75rem] ${
toast.kind === "ok"
? "border-term-green/40 bg-term-green/5 text-term-green"
: "border-term-red/40 bg-term-red/5 text-term-red"
}`}
>
{toast.msg}
</div>
)}
{/* Target directory — the admin-chosen destination. */}
<div className="card mb-3 p-4">
<div className="field">
<span className="label">{t("backup.targetLabel")}</span>
<div className="flex flex-wrap items-center gap-2">
<input
className="input w-96 max-w-full"
value={target}
placeholder={t("backup.targetPlaceholder")}
onChange={(e) => {
setTarget(e.target.value);
setCheck(null);
}}
/>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={test.isPending || !target.trim()}
onClick={() => test.mutate()}
>
{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 && (
<span className={`mt-1 text-[0.75rem] ${check.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
{check.msg}
</span>
)}
</div>
</div>
<div className="card p-4">
{q.isLoading || !status ? (
<div className="text-[0.75rem] text-term-muted">{t("common.loading")}</div>
) : (
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-[0.8125rem]">
<dt className="text-term-muted">{t("backup.statusTitle")}</dt>
<dd>
<StatusBadge status={status} />
</dd>
{!status.keyPresent && (
<>
<dt className="text-term-muted" />
<dd className="text-[0.75rem] text-term-amber">{t("backup.keyMissing")}</dd>
</>
)}
<dt className="text-term-muted">{t("backup.lastSuccess")}</dt>
<dd className="text-term-text">
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
</dd>
{status.lastResult && (
<>
<dt className="text-term-muted">{t("backup.size")}</dt>
<dd className="text-term-text tabular-nums">
{formatBytes(status.lastResult.bytes)}
{status.lastResult.prunedFiles > 0 && (
<span className="ml-2 text-term-muted">
({t("backup.pruned")}: {status.lastResult.prunedFiles})
</span>
)}
</dd>
</>
)}
{status.lastError && (
<>
<dt className="text-term-muted">{t("backup.lastError")}</dt>
<dd className="text-term-red">
{status.lastError}
{status.lastErrorAt && (
<span className="ml-2 text-term-muted">
({formatRelativeDateTime(status.lastErrorAt, t)})
</span>
)}
</dd>
</>
)}
</dl>
)}
</div>
<p className="mt-3 max-w-2xl text-[0.6875rem] text-term-muted">{t("backup.restoreNote")}</p>
</div>
);
}
+48
View File
@@ -227,6 +227,54 @@ export function fetchLogs(params: {
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`); return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
} }
// --- Backup ---------------------------------------------------------------
// On-site encrypted DB backup. See wiki/concepts/backup-recovery.md.
export interface BackupStatus {
configured: boolean;
/** Admin-chosen target directory (null = not set). */
targetDir: string | null;
/** Whether the env encryption key is present (a missing key is flagged distinctly). */
keyPresent: boolean;
running: boolean;
lastSuccessAt: string | null;
lastResult: { path: string; bytes: number; prunedFiles: number } | null;
lastErrorAt: string | null;
lastError: string | null;
}
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 TargetCheck {
ok: boolean;
/** "empty" | "missing" | "not_a_dir" | "not_writable" when !ok. */
reason?: string;
}
/** Probe a candidate target path server-side (exists / is a dir / is writable). */
export async function testBackupTarget(targetDir: string): Promise<TargetCheck> {
return apiFetch("/api/backup/test", { method: "POST", body: JSON.stringify({ targetDir }) });
}
export interface BackupRunResult {
ok: true;
path: string;
bytes: number;
prunedFiles: number;
}
/** Trigger a manual "back up now". Throws on 409 (not configured) / 500 (run failed). */
export async function runBackup(): Promise<BackupRunResult> {
return apiFetch("/api/backup/run", { method: "POST" });
}
// --- Device setup --------------------------------------------------------- // --- Device setup ---------------------------------------------------------
export interface ConfigField { export interface ConfigField {
+37 -1
View File
@@ -61,6 +61,7 @@ export const en: Catalog = {
reports: "Reports", reports: "Reports",
recycleBin: "Recycle bin", recycleBin: "Recycle bin",
logs: "Logs", logs: "Logs",
backup: "Backup",
profile: "Profile", profile: "Profile",
}, },
profile: { profile: {
@@ -307,7 +308,7 @@ export const en: Catalog = {
setup: { setup: {
title: "Setup", title: "Setup",
intro: intro:
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.", "Add your barrier controllers first. Then add readers (QR/RF), cameras, printers",
catControllers: "Controllers (barriers + entry button)", catControllers: "Controllers (barriers + entry button)",
catReaders: "Readers (QR / RFID)", catReaders: "Readers (QR / RFID)",
catCameras: "Cameras (snapshot + plate)", catCameras: "Cameras (snapshot + plate)",
@@ -830,6 +831,41 @@ export const en: Catalog = {
path: "Path", path: "Path",
empty: "No logs.", empty: "No logs.",
}, },
backup: {
title: "Backup",
intro:
"An encrypted copy of the database (the signed ledger) to an external disk. Runs automatically every day and from the button below.",
statusTitle: "Status",
configured: "Enabled",
notConfigured: "Not configured",
notConfiguredHint: "Set BACKUP_TARGET_DIR and BACKUP_KEY on the server to enable backups.",
running: "Running…",
idle: "Idle",
lastSuccess: "Last successful backup",
lastError: "Last error",
never: "Never",
lastFile: "File",
size: "Size",
pruned: "Pruned",
runNow: "Back up now",
runSuccess: "Backup complete.",
runFailed: "Backup failed.",
notConfiguredError: "Backup is not configured.",
restoreNote:
"Restore is not done here — it's an out-of-band step when provisioning a fresh appliance (needs the backup file + the keys you escrowed offsite).",
targetLabel: "Backup location",
targetPlaceholder: "e.g. /mnt/backup or /media/usb",
targetHint: "An absolute path to a mounted disk (USB/SATA) or a network share (SMB/NFS).",
save: "Save",
saved: "Saved.",
test: "Test target",
testOk: "The location is writable.",
testEmpty: "Enter a path.",
testMissing: "The location does not exist.",
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.",
},
pay: { pay: {
ticket: "Ticket", ticket: "Ticket",
entry: "Entry", entry: "Entry",
+38 -1
View File
@@ -63,6 +63,7 @@ export const sq = {
reports: "Raportet", reports: "Raportet",
recycleBin: "Koshi", recycleBin: "Koshi",
logs: "Loget", logs: "Loget",
backup: "Kopje rezervë",
profile: "Profili", profile: "Profili",
}, },
profile: { profile: {
@@ -310,7 +311,7 @@ export const sq = {
setup: { setup: {
title: "Konfigurimi", title: "Konfigurimi",
intro: intro:
"Shto fillimisht kontrollerat e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.", "Shto fillimisht kontrollerat e barrierave — Pastaj shto lexues (QR/RF), kamera, printera.",
// Category titles + the singular noun used in buttons/modal titles. // Category titles + the singular noun used in buttons/modal titles.
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)", catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
catReaders: "Lexuesit (QR / RFID)", catReaders: "Lexuesit (QR / RFID)",
@@ -845,6 +846,42 @@ export const sq = {
path: "Rruga", path: "Rruga",
empty: "Asnjë regjistër.", empty: "Asnjë regjistër.",
}, },
backup: {
title: "Kopje rezervë",
intro:
"Kopje e enkriptuar e bazës së të dhënave (regjistri i nënshkruar) në një disk të jashtëm. Bëhet automatikisht çdo ditë dhe me butonin më poshtë.",
statusTitle: "Gjendja",
configured: "Aktive",
notConfigured: "E pakonfiguruar",
notConfiguredHint:
"Cakto BACKUP_TARGET_DIR dhe BACKUP_KEY në server që të aktivizohet kopja rezervë.",
running: "Duke u kryer…",
idle: "Në pritje",
lastSuccess: "Kopja e fundit e suksesshme",
lastError: "Gabimi i fundit",
never: "Asnjëherë",
lastFile: "Skedari",
size: "Madhësia",
pruned: "Të hequra",
runNow: "Bëj kopje tani",
runSuccess: "Kopja rezervë u krye.",
runFailed: "Kopja rezervë dështoi.",
notConfiguredError: "Kopja rezervë nuk është e konfiguruar.",
restoreNote:
"Rikthimi nuk bëhet nga këtu — është veprim i jashtëm gjatë instalimit të një aparati të ri (kërkon skedarin e kopjes + çelësat e ruajtur jashtë).",
targetLabel: "Vendndodhja e kopjes",
targetPlaceholder: "p.sh. /mnt/backup ose /media/usb",
targetHint: "Rrugë absolute drejt një disku të lidhur (USB/SATA) ose një ndarjeje rrjeti (SMB/NFS).",
save: "Ruaj",
saved: "U ruajt.",
test: "Testo vendndodhjen",
testOk: "Vendndodhja është e shkruajtshme.",
testEmpty: "Shkruaj një rrugë.",
testMissing: "Vendndodhja nuk ekziston.",
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.",
},
pay: { pay: {
ticket: "Bileta", ticket: "Bileta",
entry: "Hyrja", entry: "Hyrja",
+12
View File
@@ -42,6 +42,7 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js"; import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js"; import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js"; import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
import { RecycleBin } from "./RecycleBin.js"; import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js"; import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's // Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
@@ -106,6 +107,7 @@ function SetupLayout() {
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />} {show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />} {show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />} {show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
</nav> </nav>
<Outlet /> <Outlet />
</div> </div>
@@ -444,6 +446,7 @@ function RootLayout() {
show("user:read") || show("user:read") ||
show("role:read") || show("role:read") ||
show("recyclebin:read") || show("recyclebin:read") ||
show("backup:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />} show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
@@ -694,6 +697,14 @@ const logsRoute = createRoute({
component: LogsViewer, component: LogsViewer,
}); });
// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create).
const backupRoute = createRoute({
getParentRoute: () => setupRoute,
path: "backup",
beforeLoad: ({ context }) => requirePerm("backup:read")(context),
component: BackupSettings,
});
// My profile — self-service for ANY signed-in user (no permission gate). Edits only // My profile — self-service for ANY signed-in user (no permission gate). Edits only
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts. // the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
const profileRoute = createRoute({ const profileRoute = createRoute({
@@ -726,6 +737,7 @@ const routeTree = rootRoute.addChildren([
rolesRoute, rolesRoute,
recycleBinRoute, recycleBinRoute,
logsRoute, logsRoute,
backupRoute,
]), ]),
]); ]);
@@ -0,0 +1,5 @@
-- The on-site admin chooses where encrypted DB backups are written (a mounted USB/SATA/
-- SMB/NFS path), from the Backup screen — not a server env var. Additive, nullable ALTER:
-- null = not configured (backups stay a no-op). The BACKUP_KEY stays an env/Komodo secret
-- (a key must NEVER live in the DB it backs up). See wiki/concepts/backup-recovery.md.
ALTER TABLE `site_config` ADD `backup_target_dir` text;
+7
View File
@@ -113,6 +113,13 @@
"when": 1781885800000, "when": 1781885800000,
"tag": "0015_dingtian_qr_reader_driverid", "tag": "0015_dingtian_qr_reader_driverid",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1781885900000,
"tag": "0016_backup_target_dir",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -264,6 +264,11 @@ export const siteConfig = sqliteTable("site_config", {
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared * payload so V2 category pricing reprices identically at exit. null ⇒ the shared
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */ * DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
defaultVehicleCategory: text("default_vehicle_category"), defaultVehicleCategory: text("default_vehicle_category"),
/** Admin-chosen directory encrypted DB backups are written to — a mounted local/USB/
* SATA/SMB/NFS path. null = not configured (backups are a no-op). Set from the Backup
* 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"),
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
+10
View File
@@ -30,3 +30,13 @@ export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Datab
migrate(db, { migrationsFolder: MIGRATIONS_DIR }); migrate(db, { migrationsFolder: MIGRATIONS_DIR });
return { db, sqlite, close: () => sqlite.close() }; return { db, sqlite, close: () => sqlite.close() };
} }
/**
* Open an EXISTING SQLite file raw, WITHOUT running migrations — for tests that need to
* inspect a file produced elsewhere (e.g. a restored backup) exactly as written, without
* mutating it. Returns the raw better-sqlite3 handle so the caller depends only on
* `@parking/db/testing`, never on `better-sqlite3` directly.
*/
export function openRawDb(url: string): Database.Database {
return new Database(url);
}
+7
View File
@@ -28,6 +28,7 @@ export const RESOURCES = [
"report", // events feed, occupancy, future reports "report", // events feed, occupancy, future reports
"log", // application/diagnostic logs (app_logs) — view + retention "log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge "recyclebin", // soft-deleted master data: view / restore / purge
"backup", // encrypted DB backups: configure target + trigger a manual run
] as const; ] as const;
export type Resource = (typeof RESOURCES)[number]; export type Resource = (typeof RESOURCES)[number];
@@ -60,6 +61,12 @@ export const PERMISSIONS: readonly Permission[] = [
// Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These // Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent. // are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
"recyclebin:read", "recyclebin:update", "recyclebin:delete", "recyclebin:read", "recyclebin:update", "recyclebin:delete",
// Backup: read (view config + last-run status), update (set target/schedule), create
// (trigger a manual "back up now"). Admin-grade — a backup exposes the whole signed
// ledger off-box. RESTORE is deliberately NOT a permission: it's an out-of-band runbook
// action on a fresh appliance, never reachable from the running console. See
// wiki/concepts/backup-recovery.md.
"backup:read", "backup:update", "backup:create",
] as const; ] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL /** The protected built-in role: non-deletable, non-editable, always = ALL
+168
View File
@@ -0,0 +1,168 @@
---
type: concept
tags: [parking, durability, backup, recovery, security, crypto]
sources: []
updated: 2026-06-29
---
# Backup & Disaster Recovery
The appliance's [[sqlite]] DB **is** the signed [[append-only-event-chain]] — the whole
revenue/audit history. A disk failure or a stolen/destroyed PC currently means **total
loss** (this is [[open-questions]] #5). This page is the settled design for an on-site,
admin-driven backup that survives **total hardware loss** and restores to a fresh appliance
with the signed chain still verifying. (Designed 2026-06-29.)
## The recovery scenario it must satisfy
The driving scenario (the one that forces every decision below): **the PC is gone** — stolen
or destroyed. Its SSD is LUKS-encrypted and **TPM-sealed**, so the disk is unrecoverable *by
design* (a stolen disk won't unlock off its own TPM — see [[disk-os-hardening]], [[tpm]]). We do
**not** want the dead disk; we want to stand up a **new PC**, restore the backup, and continue
signing the **same** chain. For that to work, recovery must depend on **(a)** the backup file and
**(b) two keys held out-of-band** — never on the dead machine.
## Key custody — the load-bearing decision
This is the part the whole plan rests on, and it interacts with the secure-element question
([[open-questions]] #6). Three **independent** keys, three custodians:
| Key | Lives | Recoverable after PC loss? | Job |
| --- | --- | --- | --- |
| **`EVENT_SIGNING_KEY`** | [[fleet-deployment-komodo\|Komodo]] secret (`park_buzi_event_signing_key`), escrowed offsite | **Yes — by design** | Signs + verifies the ledger chain |
| **`park_buzi_backup_key`** *(new)* | Komodo secret, escrowed offsite, **separate** from the signing key | **Yes** | Encrypts/decrypts the backup file |
| **LUKS / TPM disk key** | The appliance's TPM only | **No — deliberately** | At-rest protection of the powered-off SSD |
- **The signing key is decoupled from the TPM** — kept an *extractable software HMAC secret*
([[append-only-event-chain]], `signer.ts`), held in Komodo and escrowed by the operator. This is a
**conscious trade**: a truly non-extractable TPM-sealed signing key (the #6 upgrade) would make the
ledger unforgeable even against a host-root attacker — but it would also make the **old ledger
permanently unverifiable after total hardware loss** (the sealed key dies with the machine;
`buildVerifier(keyId)` would return `undefined` forever). You cannot have *both* "key can never be
extracted" *and* "I can rescue the key after the machine dies" — they are the same property from two
sides. Against the [[threat-model|primary adversary]] (the **booth operator**, who has a UI login, not
host root) an escrowed software key is already tamper-evident, so the recoverable design is chosen
**today**; revisiting #6 means re-accepting the unverifiable-after-loss cost. See [[tpm]] "TPM vs.
ATECC608", [[fleet-deployment-komodo]] (the "EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius"
caveat is the same trade).
- **Backup key is separate from the signing key** even though Komodo holds both — so they can be
managed independently. Rationale: (1) the **signing key must almost never rotate** (every rotation
fractures the chain into a new `keyId` segment — old events stay pinned to the old key forever),
whereas the **backup key may want routine rotation** (a USB went home, a target was decommissioned);
coupling them drags the cheap op into the expensive one. (2) The backup key **travels to every backup
destination** (USB, NAS, SFTP); the signing key should travel *nowhere* but Komodo → process memory —
sharing one key means every backup target conceptually exposes the signing key. (3) Keeping them
separate keeps the **#6 TPM-migration door open** without re-wiring backups. Decided 2026-06-29
(the "one fewer secret to escrow" simplicity of a shared key is real, but weakest here because Komodo
already holds both).
> **The keys are never inside the backup they unlock.** A key can't decrypt the file it's locked in.
> Recovery = backup file **+** both escrowed keys, supplied out-of-band. The runbook must say this
> plainly so nobody "helpfully" stores the keys next to the backups.
## What a backup contains
**Full SQLite DB, snapshots included** — one self-contained, restore-to-identical-appliance file
(ledger + sessions + config + subscriptions + the [[entry-exit-points|snapshot]] BLOBs). Chosen for
completeness over size.
> **Size caveat (interacts with [[open-questions]] #10).** Snapshot BLOBs **dominate** DB size and
> bloat *every* backup. They are unsigned, advisory, and already disk-pressure-pruned
> ([[entry-exit-points]]). A future **"exclude snapshots" toggle** (ledger/sessions/config only — much
> smaller, signed chain still fully preserved) is the obvious knob if backup size becomes a problem; the
> default is the complete picture.
The backup is produced via SQLite **online-backup / `VACUUM INTO`** (a consistent snapshot of the
live WAL-mode DB — **never a raw file copy**, which can capture a torn WAL), then encrypted with
`park_buzi_backup_key`. **Acceptance test:** a restored copy must still pass `verifyChain` — the
signed chain is the thing being protected, so an unverifiable restore is a failed backup.
## Triggers
- **Manual** — an admin-only **"Back up now"** button runs immediately to the configured target.
- **Periodic** — an **in-process daily timer** (same pattern as the snapshot-retention prune,
[[entry-exit-points]] / `snapshot-retention.ts`): runs only if the configured target is
reachable/mounted; surfaces last-success / last-error in the UI. No OS cron — it lives inside the
Fastify process, works inside the [[container-deployment|Docker container]], and is configured in
one place. ([[offline-first]]: the periodic path must tolerate a missing/unmounted target without
failing the app.)
## Destinations (admin-configurable)
All three supported in the first cut; the manual button and the periodic timer share them:
- **Local / USB / SATA disk** — a mounted path on an attached disk. Simplest, fully offline, matches
the air-gapped appliance. The strong first target.
- **Network drive (SMB/NFS)** — a mounted share on the isolated LAN (a site NAS). Still
local-network, no internet ([[network-isolation]]).
- **SFTP** — push to an SFTP endpoint, useful for an offsite copy. **FTP is excluded** (plaintext
credentials + data); SFTP is the safe equivalent.
## Retention at the destination
**Keep last N + thinned dailies** (e.g. last 7 daily / last 4 weekly) — bounded disk use, and it
survives the "a bad/partial run clobbered the only good copy" failure. (A single rolling
overwrite-latest file was rejected for exactly that reason.)
## Threat-model fit — restore is the dangerous half
Writing a backup is benign; **restore is operator-adversary surface** ([[threat-model]]). A restored
DB *replaces* the live signed chain — so a malicious restore is a way to swap in a doctored history.
Therefore:
- **Restore is NOT a booth button.** It is an **admin-only, out-of-band runbook action** (new
appliance, deliberate provisioning step), not something reachable from the operator console.
- The backup **target configuration** and the **"Back up now"** action are admin-gated.
- Backups do **not** weaken the chain's tamper-evidence: a restored chain is re-verified with the
escrowed `EVENT_SIGNING_KEY`; a tampered restore fails `verifyChain` just as a tampered live DB
would. The backup is a **durability** control, not an integrity one — integrity stays with the
signed chain + [[reconciliation]].
## As-built (2026-06-29) — engine + local/mounted target
The first slice is **built and tested**: the backup **engine + a local/mounted target + the daily
timer + the manual route**. What landed:
- **`apps/server/src/backup.ts`** — the engine. Consistent online copy via better-sqlite3's native
`.backup()` (a transactionally-consistent snapshot of the live WAL DB — **not** a raw file copy),
then **AES-256-GCM** encryption with a **scrypt-derived** key from `BACKUP_KEY`. Self-describing
header (`magic | version | salt | iv | … | authTag`) so a restore tool needs only the key + the file
— **zero new dependencies** (Node `crypto`). The plaintext intermediate is written to **scratch**
(not the removable/network target) and **wiped in a `finally`**, success or fail. Retention =
**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".
- **`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 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 + 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]].
+18
View File
@@ -111,6 +111,24 @@ what happened." Now every event is **self-describing and clickable**:
[[entry-exit-points]] for the coverage list and the synthetic `REFUSED-…` key used when a refused [[entry-exit-points]] for the coverage list and the synthetic `REFUSED-…` key used when a refused
entry has no ticket id. entry has no ticket id.
### Layout & readability pass (2026-06-28)
- **Active Sessions is a real table** (columns: Ticket/subscriber · Plate · Entry · Elapsed), so
values align and long ones (subscriber names, ticket ids) no longer truncate. A subscriber shows
**★ + holder name**; an overstay keeps a red row tint; the audited "Open barrier" action sits in a
trailing cell. The **status column was dropped** (an unpaid transient is normal; a subscriber is
self-evident), and with it the **status filter** — only the Transient/Subscriber filter remains.
- **Live feed rows flow inline** — identity, plate, badges and reason sit on one line and wrap only
when the row runs out of width (no forced second line). The redundant `TARGË` *via*-badge was
dropped (the plate chip already conveys it), and the **Direction filter** (Hyrje/Dalje) was removed
— it duplicated the entry/exit options already in the Type filter.
- **Plate is searchable** in both the feed and active-sessions boxes (they now match the enriched
`plate` field, not the unsigned payload). A plate recognized AFTER its event shipped backfills the
feed row in place (a `plate-recognized` WS push), so it no longer needs a page refresh.
- **Per-user font scale.** An A−/A+ control in the header scales the whole UI; persisted on
`users.font_scale` and restored on login like the theme/language prefs (see [[i18n]]). Implemented
as a root `font-size` over rem-based type (NOT CSS `zoom`, which scaled viewport-locked modals out
of view) — so only text scales; `vh`/`h-screen` layout stays put.
## The shift control (header) + the booth gate ## The shift control (header) + the booth gate
The header carries a single **shift button** that expresses the [[shift|site-wide single-open The header carries a single **shift button** that expresses the [[shift|site-wide single-open
+8 -2
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, security, platform] tags: [parking, security, platform]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-06-21 updated: 2026-06-29
--- ---
# Disk / OS Hardening # Disk / OS Hardening
@@ -44,7 +44,13 @@ Env in `apps/server/.env` on the appliance (see `apps/server/.env.example`). The
- **`JWT_SECRET`** — ≥32 random chars; the server refuses to boot without a strong one (no - **`JWT_SECRET`** — ≥32 random chars; the server refuses to boot without a strong one (no
insecure default). `openssl rand -hex 32`. See [[local-jwt-auth]]. insecure default). `openssl rand -hex 32`. See [[local-jwt-auth]].
- **`EVENT_SIGNING_KEY`** — dedicated HMAC key for the signed ledger; ≥16 chars. Falls back to - **`EVENT_SIGNING_KEY`** — dedicated HMAC key for the signed ledger; ≥16 chars. Falls back to
`JWT_SECRET` with a warning if unset — set a dedicated one before production. `JWT_SECRET` with a warning if unset — set a dedicated one before production. Deliberately kept an
**extractable, escrowed software key (NOT TPM-sealed)** so the ledger survives total hardware loss —
see [[backup-recovery]] for the custody trade vs. [[open-questions]] #6.
- **`park_buzi_backup_key`** *(planned)* — dedicated key for encrypting [[backup-recovery|DB backups]],
**separate** from `EVENT_SIGNING_KEY` (independent rotation; backups travel, the signing key
shouldn't). Both escrowed offsite in [[fleet-deployment-komodo|Komodo]]; recovery needs both, held
out-of-band.
- **`COOKIE_SECURE=0`** — **REQUIRED on the plain-HTTP LAN appliance.** Auth/CSRF cookies are - **`COOKIE_SECURE=0`** — **REQUIRED on the plain-HTTP LAN appliance.** Auth/CSRF cookies are
`Secure` by **default** (fail-safe). The appliance serves the SPA same-origin over **plain `Secure` by **default** (fail-safe). The appliance serves the SPA same-origin over **plain
http** on the booth LAN, where a `Secure` cookie is **never sent** — so without this opt-out http** on the booth LAN, where a `Secure` cookie is **never sent** — so without this opt-out
+5
View File
@@ -22,6 +22,11 @@ any booth. (Decided + built 2026-06-18.)
(self-service, any signed-in role). It is **deliberately NOT in the JWT** (identity/role only) — so (self-service, any signed-in role). It is **deliberately NOT in the JWT** (identity/role only) — so
changing language is a DB write + immediate `/me`, with no token refresh / re-login. See changing language is a DB write + immediate `/me`, with no token refresh / re-login. See
[[local-jwt-auth]]. [[local-jwt-auth]].
- **Sibling per-user UI prefs (same pattern).** `users.theme` (`'dark'|'light'`) and **`users.font_scale`**
(percent, default 100; migration 0014, added 2026-06-28) follow language exactly — DB column,
surfaced on `/login` + `/me`, self-service `PUT /api/auth/theme` / `/api/auth/font-scale`, applied
after `/me` (`applyTheme` / `applyFontScale`), header toggles that persist. Font scale sets the
root `font-size` over the app's rem-based type (NOT CSS `zoom`); none of the three is a JWT claim.
- **Library: react-i18next** (i18next). Chosen over a hand-rolled `t()` for pluralization, - **Library: react-i18next** (i18next). Chosen over a hand-rolled `t()` for pluralization,
interpolation, and headroom beyond two languages. The active language is applied after `/me` interpolation, and headroom beyond two languages. The active language is applied after `/me`
resolves (App effect on `user.language`); the header **SQ/EN toggle** switches instantly *and* resolves (App effect on `user.language`); the header **SQ/EN toggle** switches instantly *and*
+8
View File
@@ -89,6 +89,14 @@ no variance gate, no manager override.
> (`ticketTotalMinor`/`subscriptionTotalMinor`/`subscriptionSalesMinor`/`subscriptionWindowMinor`), > (`ticketTotalMinor`/`subscriptionTotalMinor`/`subscriptionSalesMinor`/`subscriptionWindowMinor`),
> shown in the X-report, the close modal, the history detail, and the printed Z-report; old reports > shown in the X-report, the close modal, the history detail, and the printed Z-report; old reports
> that predate the fields default subscription to 0 (ticket absorbs the whole take). > that predate the fields default subscription to 0 (ticket absorbs the whole take).
>
> **Display simplified (2026-06-28).** All four figures stay in the SIGNED payload (audit data —
> untouched), but the operator-facing breakdown was trimmed: the `shitje` (subscription-sales)
> sub-line was **removed** from the close modal, the X/Z-report views, and the printed slip —
> `Abonime` is the subscription total, with only the out-of-window part broken out under it (it
> was confusing to show both halves under a total). The **opening cash** (`openingFloatMinor`,
> labelled "Arka fillestare") was **added** to the drawer block so `opening + cash-taken = expected
> drawer` reads explicitly.
> 2. The **header shift button no longer closes directly** — a stray click would sign an irreversible > 2. The **header shift button no longer closes directly** — a stray click would sign an irreversible
> Z-report. It opens a **confirm modal showing the live X-report** (the source split + expected > Z-report. It opens a **confirm modal showing the live X-report** (the source split + expected
> drawer) with Cancel / End-shift. Opening a shift stays immediate (no such risk). > drawer) with Cancel / End-shift. Opening a shift stays immediate (no such risk).
+20 -8
View File
@@ -2,7 +2,7 @@
type: decision type: decision
tags: [parking, decisions, open] tags: [parking, decisions, open]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-06-21 updated: 2026-06-29
status: open status: open
--- ---
@@ -23,13 +23,22 @@ procurement. (See [[parking-system-architecture]] §10.)
4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot, 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 manager visit) to reconcile the signed log against an external authority — the real anti-fraud
control. See [[reconciliation]]. control. See [[reconciliation]].
5. **Durability / backup.** Backup strategy for the [[sqlite]] database + recovery plan; "sync 5. **Durability / backup.** _(Design SETTLED + engine/target/UI BUILT 2026-06-29 — see
later" currently leaves a disk failure as **total revenue-history loss**. _(Confirmed in-scope [[backup-recovery]]; engine + admin-configured target (site_config, migration 0016) + daily timer +
to design, 2026-06-15.)_ Because the DB is the signed [[append-only-event-chain]], a backup must manual route + admin UI (target field, Test-target probe, status, Back-up-now) done; SFTP +
preserve the chain intact (a restored copy must still `verifyChain`); options include SQLite restore-tooling pending. The target dir is admin-chosen in the UI, not env; only BACKUP_KEY is a
WAL/online-backup snapshots to a second disk/USB + the periodic external export that doubles as server secret.)_ A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history
the [[reconciliation]] channel (#4). Encryption at rest already applies ([[disk-os-hardening]]). loss**.
Design TBD. 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
Komodo secret, *not* the signing key). Recovery = backup file **+** the two escrowed keys held
out-of-band; a restored copy must still `verifyChain`. **Key-custody stance:** `EVENT_SIGNING_KEY`
stays **decoupled from the TPM** (an extractable, escrowed software key) precisely so it survives
total hardware loss — the conscious trade against #6 (a TPM-sealed signing key would be unforgeable
but **unverifiable after the machine dies**). **Restore is admin-only/out-of-band** (operator-adversary
surface — [[threat-model]]). See [[backup-recovery]], [[fleet-deployment-komodo]], [[disk-os-hardening]],
[[reconciliation]] (#4).
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event 6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
being implemented for now** (access control is the [[dingtian-relay]] behind being implemented for now** (access control is the [[dingtian-relay]] behind
@@ -62,6 +71,9 @@ procurement. (See [[parking-system-architecture]] §10.)
reclaim deleted-blob pages without `VACUUM`. **Undecided:** pruning policy (age-based vs. reclaim deleted-blob pages without `VACUUM`. **Undecided:** pruning policy (age-based vs.
total-size cap), VACUUM cadence, and how this interacts with the #5 backup strategy (blobs total-size cap), VACUUM cadence, and how this interacts with the #5 backup strategy (blobs
bloat every backup). Until decided, snapshots accumulate unbounded. See [[entry-exit-points]]. bloat every backup). Until decided, snapshots accumulate unbounded. See [[entry-exit-points]].
_(Update 2026-06-29: pruning is now disk-pressure based — see [[entry-exit-points]]; and the
settled #5 backup includes snapshot BLOBs by default, with a noted future "exclude snapshots"
toggle since they dominate backup size — see [[backup-recovery]].)_
11. **Appliance OS image → WebKitGTK version (Tauri dependency).** _(Raised by 11. **Appliance OS image → WebKitGTK version (Tauri dependency).** _(Raised by
[[desktop-shell-tauri]], 2026-06-21; narrowed same day.)_ The chosen [[desktop-shell-tauri]], 2026-06-21; narrowed same day.)_ The chosen
[[desktop-shell-tauri|Tauri v2 desktop shell]] renders through the **host's WebKitGTK**, not a [[desktop-shell-tauri|Tauri v2 desktop shell]] renders through the **host's WebKitGTK**, not a
+2 -1
View File
@@ -7,7 +7,7 @@ updated: 2026-06-21
# Index # Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 4 sources · 19 entities · 45 concepts · 7 decision records. Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
## Overview & navigation ## Overview & navigation
- [[overview]] — the top-level synthesis and entry point. - [[overview]] — the top-level synthesis and entry point.
@@ -56,6 +56,7 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log. - [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log.
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is. - [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
- [[disk-os-hardening]] — LUKS/GRUB/Secure Boot; worthwhile but not the main event. - [[disk-os-hardening]] — LUKS/GRUB/Secure Boot; worthwhile but not the main event.
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only.
## Concepts — device architecture & safety ## Concepts — device architecture & safety
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter. - [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
+47
View File
@@ -1901,3 +1901,50 @@ verified-on-hardware protocol facts (cjihao serial, `.jsp` path, `Connection: cl
`gee-reader-serial-binding`→`dingtian-reader-serial-binding`. The only surviving "GEE" mentions are `gee-reader-serial-binding`→`dingtian-reader-serial-binding`. The only surviving "GEE" mentions are
deliberate naming-correction notes + the raw PDF filename. Behaviour unchanged — naming + the deliberate naming-correction notes + the raw PDF filename. Behaviour unchanged — naming + the
persisted id only. build/lint/test green. persisted id only. build/lint/test green.
## [2026-06-29] design | On-site encrypted backup + disaster recovery (resolves open-question #5 design)
New concept page [[backup-recovery]]. Driving scenario: the PC is stolen/destroyed and its LUKS+TPM
SSD is unrecoverable by design — recovery must stand up a NEW PC, restore a backup, and keep signing
the SAME chain. Settled: admin-driven **encrypted full-DB backup** (SQLite online-backup/`VACUUM INTO`,
snapshots INCLUDED) to **local/USB · SMB/NFS · SFTP** targets; **manual button + in-process daily timer**
(same pattern as snapshot prune); **keep-last-N + dailies** retention; restore is **admin-only/out-of-band**
(operator-adversary surface). Restored copy must still `verifyChain`.
KEY-CUSTODY decision (the load-bearing part, bears on #6): three independent keys — (1) `EVENT_SIGNING_KEY`
kept an **extractable, escrowed software key DECOUPLED from the TPM** so the ledger survives total hardware
loss [conscious trade: a TPM-sealed signing key would be unforgeable but PERMANENTLY UNVERIFIABLE after the
machine dies — same property from two sides]; (2) **new dedicated `park_buzi_backup_key`** in Komodo for
backup encryption, SEPARATE from the signing key (independent rotation; backups travel, signing key
shouldn't; keeps the #6 TPM door open); (3) LUKS/TPM disk key, appliance-only, deliberately non-recoverable.
Keys are NEVER inside the backup they unlock — recovery = backup file + both escrowed keys, out-of-band.
Updated: [[open-questions]] #5 (design SETTLED) + #10 note (backup includes snapshot BLOBs by default, future
exclude toggle); [[disk-os-hardening]] deploy env runbook (EVENT_SIGNING_KEY-not-sealed rationale +
`park_buzi_backup_key`); index catalog + concept count 45→46. Design only — NOT yet built.
## [2026-06-29] feat | Backup engine + local target (first slice of backup-recovery)
Built the durability engine designed in [[backup-recovery]]. `apps/server/src/backup.ts`: consistent
online copy via better-sqlite3 `.backup()` (NOT a raw file copy of a live WAL DB) → AES-256-GCM with a
scrypt-derived key from BACKUP_KEY, self-describing header (magic|ver|salt|iv|…|tag), zero new deps;
plaintext intermediate kept in scratch + wiped in finally; keep-last-N + dailies retention. Tested:
round-trip → byte-identical queryable DB, GCM tamper/wrong-key fails, short-key rejected, scratch always
cleaned. `backup-service.ts` (env config, single in-flight guard, last-success/error) + `routes/backup.ts`
(GET /api/backup/status backup:read, POST /api/backup/run backup:create, 409 when unconfigured; NO restore
route — out-of-band by design). New `backup` permission resource in @parking/shared. server.ts: unref'd
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.