docs(wiki): record session findings — snapshot fix, booth rework, db reset
Build desktop / desktop (push) Successful in 4m34s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s

- 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
This commit is contained in:
2026-06-30 17:58:43 +02:00
parent d92b8d1e6a
commit 266e9b0027
6 changed files with 158 additions and 4 deletions
+17 -1
View File
@@ -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
+16 -1
View File
@@ -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 `<img>` 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`
+42 -1
View File
@@ -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.
+36
View File
@@ -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),
+1 -1
View File
@@ -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
+46
View File
@@ -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 <img> 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.