docs(wiki): record session findings — snapshot fix, booth rework, db reset
- 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:
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, booth, exit, payment, threat-model]
|
tags: [parking, domain, booth, exit, payment, threat-model]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-18
|
updated: 2026-06-30
|
||||||
status: open
|
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)
|
paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone)
|
||||||
would be a separately-audited path — see Open.
|
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)
|
### 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
|
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, architecture, devices, setup]
|
tags: [parking, architecture, devices, setup]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-16
|
updated: 2026-06-30
|
||||||
---
|
---
|
||||||
|
|
||||||
# Entry / Exit Points (pool-of-spaces model)
|
# 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
|
(downscaling hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot
|
||||||
(`snapshot.ts` `encodeForStorage`).
|
(`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
|
**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
|
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`
|
a backstop that only fires under real disk pressure. A **daily** check (`snapshot-retention.ts`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: reference
|
type: reference
|
||||||
tags: [parking, dev-environment, workflow]
|
tags: [parking, dev-environment, workflow]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-15
|
updated: 2026-06-30
|
||||||
---
|
---
|
||||||
|
|
||||||
# Local Dev Workflow
|
# 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`.
|
`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),
|
- Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events),
|
||||||
`uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]].
|
`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.
|
||||||
|
|||||||
@@ -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.
|
`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]].
|
`/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 startup + web-access
|
||||||
|
|
||||||
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
|
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
|
||||||
|
|||||||
+1
-1
@@ -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.
|
- [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian.
|
||||||
|
|
||||||
## Dev environment (reference)
|
## 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.
|
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|||||||
+46
@@ -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 —
|
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
|
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.
|
(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.
|
||||||
|
|||||||
Reference in New Issue
Block a user