From 266e9b00273bb294064a669e8f9db327fd92b946 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Tue, 30 Jun 2026 17:58:43 +0200 Subject: [PATCH] =?UTF-8?q?docs(wiki):=20record=20session=20findings=20?= =?UTF-8?q?=E2=80=94=20snapshot=20fix,=20booth=20rework,=20db=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entry-exit-points.md: the snapshot content-type bug + serve-side cleanType fix (Hikvision image/jpeg; charset="UTF-8" broke every legacy render). - booth-exit-flow.md: the Active-Sessions/modal rework — inline barrier button removed -> modal; closed-within-grace view; live grace countdown; actual paid amount; read-only snapshot review in the closed-session view. - local-dev-workflow.md: the gated `pnpm db:reset` training tool + flag table + the booth (docker exec, no pnpm) note. - appliance-provisioning.md: new §7d — reset on the booth via docker exec into the server container (script ships in the deploy bundle; DATABASE_URL= /data/parking.sqlite), ledger-truncation warning + the two safety gates. - index.md catalog line; log.md entries. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- wiki/concepts/booth-exit-flow.md | 18 +++++++++- wiki/concepts/entry-exit-points.md | 17 ++++++++- wiki/concepts/local-dev-workflow.md | 43 +++++++++++++++++++++- wiki/decisions/appliance-provisioning.md | 36 +++++++++++++++++++ wiki/index.md | 2 +- wiki/log.md | 46 ++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 4 deletions(-) diff --git a/wiki/concepts/booth-exit-flow.md b/wiki/concepts/booth-exit-flow.md index 69c7d35..4b47c8f 100644 --- a/wiki/concepts/booth-exit-flow.md +++ b/wiki/concepts/booth-exit-flow.md @@ -2,7 +2,7 @@ type: concept tags: [parking, domain, booth, exit, payment, threat-model] sources: [] -updated: 2026-06-18 +updated: 2026-06-30 status: open --- @@ -162,6 +162,22 @@ server-side in `reopenBarrier`: allow only when `subscription` OR (`paidAt != nu paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone) would be a separately-audited path — see Open. +> **Open-barrier moved INTO the modal — the inline row button is gone (2026-06-30).** The audited +> re-pulse was previously an inline button on the paid-in-grace Active Sessions *row*. It was removed: +> clicking any row now opens the modal, which carries the Open-barrier action. Why: a paid-and-exited +> session is `open=false`, so clicking its row used to dead-end on *"This session is already closed"* — +> useless for the very case (paid, barrier didn't confirm) where the operator needs to re-pulse. The +> modal now recognizes a **closed-within-grace** transient (`found && !open && withinGrace`) and renders +> the session view + **Open barrier** instead of the dead-end notice. The server guard is unchanged +> (`reopenBarrier` already handled the closed-but-in-grace case — the T-397815c0 fix above). The +> Active Sessions list distinguishes these rows with a **live grace-remaining countdown** badge +> (`exited · M:SS`, ticking each second off `graceExpiresAt`) instead of a static "exited" label. +> Settled amounts now show the **actual sum paid** (new `SessionLookup.paidMinor`, summed across +> payments) rather than a flat "PAID" badge. And a **fully-closed (grace-expired) session** is no longer +> a pure dead-end: its modal shows a read-only **review view** — figures + paid amount + the entry/exit +> [[entry-exit-points#camera-snapshots-evidence-not-a-gate|snapshot strip]] — so an operator can review +> evidence for a car that just left (disputes/audits), with no pay/exit/open controls. + ### Subscription occurrences in the booth (built 2026-06-18) A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by diff --git a/wiki/concepts/entry-exit-points.md b/wiki/concepts/entry-exit-points.md index 8944996..5a8ffcd 100644 --- a/wiki/concepts/entry-exit-points.md +++ b/wiki/concepts/entry-exit-points.md @@ -2,7 +2,7 @@ type: concept tags: [parking, architecture, devices, setup] sources: [] -updated: 2026-06-16 +updated: 2026-06-30 --- # Entry / Exit Points (pool-of-spaces model) @@ -114,6 +114,21 @@ re-encode is **storage-only** — ANPR recognition runs on the **original full-r (downscaling hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot (`snapshot.ts` `encodeForStorage`). +> **Content-type bug — every legacy snapshot rendered blank (fixed 2026-06-30).** Symptom: *no* +> snapshot showed in the booth modal. Root cause: some cameras (Hikvision) return +> `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is **malformed**, and +> browsers refuse to decode an `` declared that way. Old capture code persisted that raw header +> into `snapshots.content_type` (100 of 101 rows in the dev DB), and the serve route +> (`GET /api/snapshots/:id`) re-emitted it **verbatim** → broken render for every legacy row. The +> capture path was *already* hardened (`encodeForStorage` re-encodes to a clean `image/jpeg`; its +> fail-soft branch calls `cleanType` to strip `; charset=…`), so NEW rows were fine — but the serve +> route trusted the stored value. Fix: the route now also runs `cleanType(row.contentType)` on the way +> out (a bare `image/jpeg`), which un-breaks all legacy rows with **no data migration**. Verified: a +> previously-unrenderable 2560×1440 row now decodes in-browser. Lesson: **normalize a camera-supplied +> content-type both on capture AND on serve** — a stored value from an untrusted device is itself input. +> The stored `content_type` column could be backfilled to `image/jpeg` for cleanliness, but serving +> normalizes so it isn't required. + **Retention (2026-06-28, resolves the old open question) — DISK-PRESSURE safety valve.** Snapshots are unsigned/advisory, so they prune freely. The day-to-day shrink is the re-encode above; pruning is a backstop that only fires under real disk pressure. A **daily** check (`snapshot-retention.ts` diff --git a/wiki/concepts/local-dev-workflow.md b/wiki/concepts/local-dev-workflow.md index e10d1cc..55f3983 100644 --- a/wiki/concepts/local-dev-workflow.md +++ b/wiki/concepts/local-dev-workflow.md @@ -2,7 +2,7 @@ type: reference tags: [parking, dev-environment, workflow] sources: [] -updated: 2026-06-15 +updated: 2026-06-30 --- # Local Dev Workflow @@ -54,3 +54,44 @@ Production uses an **nginx** reverse proxy (`deploy/nginx.conf`) for the same sa `ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin`. Reset a password: add `FORCE=1`. - Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events), `uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]]. + +## Database reset — training / demo only (2026-06-30) + +A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data +must go without leaving an obvious self-serve button (an operator must not be able to wipe history). +So the reset is a **CLI script**, not UI: `packages/db/scripts/reset-db.mjs`, run via `pnpm db:reset`. + +```bash +RESET_ALLOWED=1 pnpm db:reset --financial # default DB = apps/server/parking.sqlite +RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all +``` + +**Category flags** (combinable; ≥1 required) — grounded in which tables hold what: + +| Flag | Wipes | Keeps | +| --- | --- | --- | +| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** | +| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions, subscription plans | everything else | +| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else | +| `--all` | every table (blank slate) | — | + +> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].** +> That is the anti-fraud record; a *partial* delete would break the hash 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). This is the opposite of how the ledger is meant to +> behave, hence the gates below. It is a **training/demo** tool; never point it at a live booth. + +**Two safety gates ([[threat-model|operator-as-adversary]]):** +1. **`RESET_ALLOWED=1`** env must be set — a real booth never sets it, so the command is inert in + production even if typed. +2. **Typed confirmation** of the DB filename (interactive). `--yes` skips it for CI/scripted training + setup only. + +Runs as a single transaction (all-or-nothing) + `VACUUM` to shrink the re-used demo DB. After +`--users`/`--all` (users cleared), re-seed an admin: `pnpm seed:admin`. The `EVENT_SIGNING_KEY` and +`BACKUP_KEY` are intentionally left alone (see [[backup-recovery]] on key custody). + +> **On the BOOTH there is no `pnpm`** — only Docker containers. `pnpm db:reset` is the *dev* form; +> on an appliance, run the same script via `docker exec` into the `server` container +> (`node node_modules/@parking/db/scripts/reset-db.mjs …`, `DATABASE_URL=/data/parking.sqlite`). +> Full booth procedure: [[appliance-provisioning]] §7d. diff --git a/wiki/decisions/appliance-provisioning.md b/wiki/decisions/appliance-provisioning.md index 91e7ee3..feda482 100644 --- a/wiki/decisions/appliance-provisioning.md +++ b/wiki/decisions/appliance-provisioning.md @@ -351,6 +351,42 @@ ENV=prod ./booth.sh up `booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g. `/opt/parking_systems/`). See [[container-deployment]]. +### 7d. Reset the DB for TRAINING/DEMO — `docker exec`, not `pnpm` (2026-06-30) + +A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data +must go without leaving an obvious self-serve button (the [[threat-model|operator must not be able to +wipe history]]). The reset is a **CLI script** (`packages/db/scripts/reset-db.mjs`), and on the booth +there is **no `pnpm`** — only the running containers. So run it the same way as the seed-admin step in +§7b: **`docker exec` into the `server` container**, where the script ships inside the deploy bundle at +`node_modules/@parking/db/scripts/reset-db.mjs` (the same place the boot migrator lives — see the +entrypoint). `DATABASE_URL` in-container is **`/data/parking.sqlite`** (the `parking-data` volume). + +```bash +# On the booth (or via Komodo's terminal on the server container). Category flags: +# --financial ledger (entry/exit/payment/void/shift/cash/anomaly) + device_events + snapshots + +# subscription INSTANCES/credentials/plates + blocklist. KEEPS users/devices/config/ +# tariffs/subscription PLANS. +# --config site_config, devices, setup_state (re-runs first-run setup), tariffs + versions, plans. +# --users users, roles, role_permissions, auth sessions. --all every table. +docker exec -it \ + -e RESET_ALLOWED=1 \ + -e DATABASE_URL=/data/parking.sqlite \ + park-buzi-server-1 \ + node node_modules/@parking/db/scripts/reset-db.mjs --financial +``` + +> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]]** — +> the anti-fraud record. A *partial* delete would break the hash chain, so a financial reset wipes the +> whole ledger back to empty (re-seeding starts a NEW chain under the **same** `EVENT_SIGNING_KEY`/ +> `BACKUP_KEY` — the keys are **not** touched). This is the opposite of how the ledger is meant to +> behave, hence the two gates: it refuses unless **`RESET_ALLOWED=1`** is set (a real booth never sets +> it) **and** you type the DB filename to confirm (`parking.sqlite`; `--yes` skips that for scripted +> setup only). It is a **training/demo** tool — never run on a production booth's data. + +After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b +(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. For dev (where `pnpm` exists) +the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]]. + ### Healthy startup + web-access Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights), diff --git a/wiki/index.md b/wiki/index.md index 1a06386..d00d23e 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -111,7 +111,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records. - [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian. ## Dev environment (reference) -- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin. +- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin, the gated `db:reset` training/demo tool. - [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after. ## Decisions diff --git a/wiki/log.md b/wiki/log.md index 6faa7ed..76cf259 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2028,3 +2028,49 @@ Boot Deployed, GRUB edit-lock, unprivileged-operator) + the firmware/dbx lockdow with its load-bearing nuance, plus the standing caveat that this is the SECONDARY control — reconciliation over the signed chain is the main anti-fraud event. Commands stay in appliance-provisioning (the how); this page points there. Updated the index.md line accordingly. + +## [2026-06-30] fix | Snapshot content-type bug — every legacy image rendered blank + +Symptom: no snapshot showed in the booth modal. Root cause: Hikvision-style cameras return +`Content-Type: image/jpeg; charset="UTF-8"` (a charset param on a binary body = malformed; browsers +refuse to decode an declared that way). Old capture code persisted that raw header into +snapshots.content_type (100 of 101 dev-DB rows); the serve route GET /api/snapshots/:id re-emitted it +verbatim → broken render for every legacy row. Capture was already hardened (encodeForStorage → +clean image/jpeg, fail-soft cleanType), but the serve route trusted the stored value. Fix: route now +runs cleanType(row.contentType) on the way OUT too → bare image/jpeg, un-breaks all legacy rows with +NO data migration. Verified via Playwright: a previously-unrenderable 2560×1440 row now decodes +in-browser; clean + malformed rows both load. Exported cleanType from snapshot.ts + unit tests. +Recorded in entry-exit-points.md. Lesson: normalize a device-supplied content-type on capture AND on +serve (a stored value from an untrusted camera is itself input). + +## [2026-06-30] feat | Booth Active-Sessions + pay/exit modal rework + +(1) The inline "Open barrier" button on paid-in-grace Active-Session ROWS was removed; the audited +re-pulse now lives only in the modal. Reason: a paid+exited session is open=false, so clicking its +row dead-ended on "already closed" — useless for the exact case (paid, barrier unconfirmed) that +needs a re-pulse. The modal now recognizes closed-within-grace (found && !open && withinGrace) and +shows the session view + Open barrier. Server reopenBarrier guard unchanged (already handled the +closed-in-grace case — the T-397815c0 fix). (2) Active-Sessions rows show a LIVE grace-remaining +countdown badge (exited · M:SS, 1s tick off graceExpiresAt) instead of a static label. (3) Settled +sessions show the ACTUAL sum paid (new SessionLookup.paidMinor, summed across payment events) not a +flat "PAID". (4) A fully-closed (grace-expired) session's modal is no longer a dead-end: it shows a +read-only review view (figures + paid amount + entry/exit snapshot strip) for dispute/audit review, +with no pay/exit/open controls. i18n sq+en parity kept; web build/tests green. Recorded in +booth-exit-flow.md. + +## [2026-06-30] feat | DB reset CLI for training/demo (packages/db/scripts/reset-db.mjs) + +A site is sometimes run live to train operators/admins; afterwards the demo data must go WITHOUT an +obvious self-serve button (operator must not wipe history). So: a CLI script `pnpm db:reset`, not UI. +Category flags grounded in the table map — --financial (ledger/telemetry/snapshots/subscription +instances/blocklist; keeps users/devices/config/tariffs/plans), --config, --users, --all. Because +shifts/cash/payments all live as event types INSIDE the hash-chained ledger_events, "financial" = +truncate the whole signed ledger back to empty (re-seed starts a new chain under the SAME +EVENT_SIGNING_KEY — key untouched). Two safety gates (decided with user): RESET_ALLOWED=1 env (real +booths never set it) + typed DB-filename confirmation (--yes skips for CI). Single txn + VACUUM; +re-seed admin after --users/--all. On the BOOTH there is no pnpm — only containers — so it runs via +`docker exec` into the server container (node node_modules/@parking/db/scripts/reset-db.mjs, +DATABASE_URL=/data/parking.sqlite); the script ships in the deploy bundle next to the boot migrator +(@parking/db has no `files` allowlist → whole pkg copied). Verified on throwaway dev-DB copies (both +gates refuse correctly; each flag wipes/keeps the right tables; real dev DB never touched). Recorded +in local-dev-workflow.md + appliance-provisioning.md §7d.