diff --git a/package.json b/package.json index 1a2b213..8f463a8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lint": "turbo run lint", "typecheck": "turbo run typecheck", "test": "turbo run test", - "seed:admin": "pnpm --filter @parking/server seed-admin" + "seed:admin": "pnpm --filter @parking/server seed-admin", + "db:reset": "pnpm --filter @parking/db db:reset" }, "devDependencies": { "turbo": "2.9.18", diff --git a/packages/db/package.json b/packages/db/package.json index 76a9d65..609a159 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -26,7 +26,8 @@ "lint": "tsc --noEmit", "db:generate": "drizzle-kit generate", "db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate", - "db:migrate:runtime": "node scripts/migrate-runtime.mjs" + "db:migrate:runtime": "node scripts/migrate-runtime.mjs", + "db:reset": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" node scripts/reset-db.mjs" }, "dependencies": { "@parking/shared": "workspace:*", diff --git a/packages/db/scripts/reset-db.mjs b/packages/db/scripts/reset-db.mjs new file mode 100644 index 0000000..3acc449 --- /dev/null +++ b/packages/db/scripts/reset-db.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// DESTRUCTIVE training/demo reset of the SQLite DB at DATABASE_URL. Deletes rows from +// whole CATEGORIES of tables so a site can be re-used to TRAIN operators/admins without +// leaving demo data behind. This is intentionally a CLI script (no UI button) so it +// cannot be triggered casually — and it is double-gated so it never runs on a real booth. +// +// ⚠ This TRUNCATES the append-only, hash-chained, SIGNED ledger (`ledger_events`). +// That is the anti-fraud record. A partial delete would break the chain, so a +// financial reset wipes the whole ledger back to empty (re-seeding starts a NEW +// chain under the same EVENT_SIGNING_KEY — the key is NOT touched here). Only ever +// do this on a TRAINING/DEMO box. See wiki/concepts/append-only-event-chain.md. +// +// Flags (combinable; at least one required): +// --all every category below (a blank-slate box) +// --financial transactional history: ledger (entry/exit/payment/void/shift/cash/ +// anomaly), device telemetry, snapshots, subscription INSTANCES + +// their credentials/plates, blocklist. KEEPS users, devices, config, +// tariffs, subscription PLANS. +// --config site_config, devices, setup_state (re-runs first-run setup), +// tariffs + tariff_versions, subscription_plans. +// --users users, roles, role_permissions, auth sessions. (After this or --all, +// re-seed an admin: apps/server/scripts/seed-admin.mjs.) +// +// Safety gates (BOTH required): +// 1. env RESET_ALLOWED=1 — a real booth never sets this. +// 2. type the DB filename — interactive confirmation (skip with --yes ONLY in CI). +// +// Usage: +// RESET_ALLOWED=1 DATABASE_URL=apps/server/parking.sqlite \ +// node packages/db/scripts/reset-db.mjs --financial +import { createInterface } from "node:readline"; +import { basename, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import Database from "better-sqlite3"; + +// --- Category → tables (child tables BEFORE parents; we also disable FKs for the txn). --- +const CATEGORIES = { + financial: [ + "ledger_events", + "device_events", + "snapshots", + "subscription_plates", + "subscription_credentials", + "subscriptions", + "blocklist", + ], + config: ["site_config", "devices", "setup_state", "tariff_versions", "tariffs", "subscription_plans"], + users: ["sessions", "role_permissions", "users", "roles"], +}; + +function parseArgs(argv) { + const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2))); + const wantAll = flags.has("all"); + const cats = wantAll ? Object.keys(CATEGORIES) : Object.keys(CATEGORIES).filter((c) => flags.has(c)); + return { cats, autoYes: flags.has("yes"), wantAll }; +} + +function die(msg) { + console.error(`[reset] ${msg}`); + process.exit(1); +} + +async function confirm(promptText, expected) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((res) => rl.question(promptText, res)); + rl.close(); + return answer.trim() === expected; +} + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) die("DATABASE_URL is required"); + const dbPath = resolve(url); + if (!existsSync(dbPath)) die(`no database at ${dbPath}`); + + const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2)); + if (cats.length === 0) { + die("nothing to do — pass --all, --financial, --config, and/or --users"); + } + + // GATE 1: env opt-in. A production booth never sets this. + if (process.env.RESET_ALLOWED !== "1") { + die( + `refusing to reset ${dbPath}\n` + + ` set RESET_ALLOWED=1 to enable (real booths never set this).`, + ); + } + + // Resolve the ordered, de-duplicated table list for the chosen categories. + const tables = []; + for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t); + + console.error(`\n⚠ DESTRUCTIVE RESET`); + console.error(` db : ${dbPath}`); + console.error(` categories: ${cats.join(", ")}${wantAll ? " (= everything)" : ""}`); + console.error(` tables : ${tables.join(", ")}`); + if (cats.includes("financial")) { + console.error(` NOTE: this TRUNCATES the signed append-only ledger. Training/demo only.`); + } + console.error(""); + + // GATE 2: typed confirmation of the DB filename (skippable only with --yes, for CI). + if (!autoYes) { + const fname = basename(dbPath); + const ok = await confirm(`Type the db filename to confirm (${fname}): `, fname); + if (!ok) die("confirmation did not match — aborted, nothing changed."); + } + + const sqlite = new Database(dbPath); + try { + // FKs OFF for the wipe so we can delete in any order without ordering hazards; + // a single transaction makes it all-or-nothing. + sqlite.pragma("foreign_keys = OFF"); + const wipe = sqlite.transaction(() => { + const counts = {}; + for (const t of tables) { + const before = sqlite.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n; + sqlite.prepare(`DELETE FROM "${t}"`).run(); + counts[t] = before; + } + return counts; + }); + const counts = wipe(); + sqlite.pragma("foreign_keys = ON"); + // Reclaim space + reset the WAL so the file shrinks (demo boxes get re-used a lot). + sqlite.exec("VACUUM"); + + console.error(`[reset] done. Rows deleted:`); + for (const t of tables) console.error(` ${String(counts[t]).padStart(7)} ${t}`); + if (cats.includes("users") || wantAll) { + console.error( + `\n[reset] users were cleared — re-seed an admin:\n` + + ` ADMIN_USER=admin ADMIN_PASS='…' node apps/server/scripts/seed-admin.mjs`, + ); + } + } finally { + sqlite.close(); + } +} + +main().catch((e) => die(e.message));