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
This commit is contained in:
2026-06-29 11:59:45 +02:00
parent 9e442586af
commit 0c218179c4
12 changed files with 750 additions and 7 deletions
+13
View File
@@ -15,6 +15,19 @@ JWT_SECRET=
# them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY=
# On-site encrypted DB backup (durability for the signed ledger). Disabled until both
# BACKUP_TARGET_DIR and BACKUP_KEY are set. A daily timer + an admin "back up now" button
# write a consistent, AES-256-GCM-encrypted copy to the target. RESTORE is an out-of-band
# runbook action, not a console call. See wiki/concepts/backup-recovery.md.
# BACKUP_TARGET_DIR=/mnt/backup # a mounted local/USB/SATA/SMB/NFS path
# 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.
# BACKUP_KEY=
# BACKUP_KEEP_LAST=7 # keep this many newest backups always
# BACKUP_KEEP_DAILY_DAYS=30 # plus one-per-day within this window
# Optional ----------------------------------------------------------------
# PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
+118
View File
@@ -0,0 +1,118 @@
import 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): resolves config once, runs a backup
// (manual or scheduled), and remembers the last outcome so the route + UI can show last-success
// / last-error without re-deriving it. One instance is shared by the daily timer and the
// "back up now" route, so a concurrent manual+timer run can't overlap (a single in-flight guard).
// See wiki/concepts/backup-recovery.md.
export interface BackupConfig {
/** Mounted directory backups are written to (local/USB/SATA/SMB/NFS). Empty = disabled. */
readonly targetDir: string;
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). */
readonly key: string;
}
export interface BackupStatus {
/** True once a target dir + key are configured (otherwise backups are a no-op). */
readonly configured: 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;
}
/** Resolve backup config from env. (First cut: env-driven, like EVENT_SIGNING_KEY + snapshot
* retention; a future admin-UI knob can override the target dir.) */
export function backupConfigFromEnv(): BackupConfig {
return {
targetDir: (process.env.BACKUP_TARGET_DIR ?? "").trim(),
key: process.env.BACKUP_KEY ?? "",
};
}
export class BackupService {
readonly #db: Db;
readonly #config: BackupConfig;
readonly #logger?: FastifyBaseLogger;
#running = false;
#lastSuccessAt: string | null = null;
#lastResult: BackupResult | null = null;
#lastErrorAt: string | null = null;
#lastError: string | null = null;
constructor(db: Db, config: BackupConfig, logger?: FastifyBaseLogger) {
this.#db = db;
this.#config = config;
this.#logger = logger;
}
get configured(): boolean {
return this.#config.targetDir.length > 0 && this.#config.key.length >= 16;
}
status(): BackupStatus {
return {
configured: this.configured,
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 ("manual" | "scheduled"). Serialized:
* if one is already in flight, this resolves to that same promise rather than starting a
* second. Records last-success/last-error on the instance. 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;
if (!this.configured) {
throw new Error("backup: not configured (set BACKUP_TARGET_DIR and BACKUP_KEY ≥16 chars)");
}
this.#running = true;
this.#inflight = (async () => {
try {
this.#logger?.info(`backup: starting (${trigger})`);
const res = await runBackup(
this.#db,
{ targetDir: this.#config.targetDir, key: this.#config.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,92 @@
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,
running: false,
lastSuccessAt: null,
lastError: null,
});
});
});
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" });
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { FastifyInstance } from "fastify";
import { requirePermission } from "../auth.js";
import type { BackupService } from "../backup-service.js";
// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md.
// - GET /api/backup/status : config-present flag + last-run success/error. (backup:read)
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
// RESTORE is intentionally absent — it's an out-of-band runbook action on a fresh appliance
// (a restore replaces the live signed chain → operator-adversary surface), never a console call.
export async function backupRoutes(app: FastifyInstance, backups: BackupService): Promise<void> {
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
backups.status(),
);
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 });
}
});
}
+20
View File
@@ -21,6 +21,8 @@ import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js";
import { pruneSnapshots } from "./snapshot-retention.js";
import { BackupService, backupConfigFromEnv } from "./backup-service.js";
import { backupRoutes } from "./routes/backup.js";
import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js";
import { authRoutes } from "./routes/auth.js";
@@ -274,6 +276,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService);
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: status +
// a manual "back up now"; the scheduled run is the daily timer below. A no-op until
// BACKUP_TARGET_DIR + BACKUP_KEY are set. See wiki/concepts/backup-recovery.md.
const backupService = new BackupService(db, backupConfigFromEnv(), app.log);
await backupRoutes(app, backupService);
// 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.
const pruneTimer = setInterval(() => {
@@ -301,6 +309,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
void runSnapPrune(); // once at startup
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
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
+10
View File
@@ -30,3 +30,13 @@ export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Datab
migrate(db, { migrationsFolder: MIGRATIONS_DIR });
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
"log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge
"backup", // encrypted DB backups: configure target + trigger a manual run
] as const;
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
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
"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;
/** The protected built-in role: non-deletable, non-editable, always = ALL
+35 -5
View File
@@ -119,10 +119,40 @@ Therefore:
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`** — resolves config from env (`BACKUP_TARGET_DIR`, `BACKUP_KEY`,
`BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`), **serializes** concurrent runs (single in-flight
guard), records last-success / last-error for the UI.
- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`) + `POST /api/backup/run`
(`backup:create`); a clean **409 `backup_not_configured`** when unset. New `backup` permission
resource (`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by
design.
- **`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 under `BACKUP_TARGET_DIR`. **Deferred to
follow-up slices:** an **SFTP** target, the **admin UI** (status panel + "Back up now" button + i18n),
and a **restore runbook / CLI**.
## Status
Design settled 2026-06-29; **not yet built**. Resolves the *design* half of [[open-questions]] #5
(implementation pending), and 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]].
Design settled 2026-06-29; **engine + local/mounted target BUILT 2026-06-29** (SFTP + UI + restore
tooling pending). Resolves the *design* half of [[open-questions]] #5 and the first build slice; 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]].
+3 -2
View File
@@ -23,8 +23,9 @@ procurement. (See [[parking-system-architecture]] §10.)
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
control. See [[reconciliation]].
5. **Durability / backup.** _(Design SETTLED 2026-06-29 — see [[backup-recovery]]; implementation
pending.)_ A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**.
5. **Durability / backup.** _(Design SETTLED + first slice BUILT 2026-06-29 — see [[backup-recovery]];
engine + local/mounted target + daily timer + manual route done, SFTP/UI/restore-tooling pending.)_
A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**.
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
+13
View File
@@ -1921,3 +1921,16 @@ Keys are NEVER inside the backup they unlock — recovery = backup file + both e
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.