348 Commits

Author SHA1 Message Date
julian 0845e87ddd bump(resources): park-2 and wash-collector to stage-2d9bb15
Build & push images / images (push) Successful in 2m48s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:57:47 +02:00
julian 2d9bb15d4c fix(db): reset-db categorises the Car Wash tables and role_jobs — the drift guard refused every reset on a booth carrying them
Build & push images / images (push) Successful in 2m47s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:37:49 +02:00
julian 29594f8bad chore(desktop): bump version to 0.2.0 — first feature release of the bundle (Car Wash, tills, roles jobs, vision category, review outbox)
Build desktop / desktop (push) Successful in 5m19s
CI / check (push) Successful in 55s
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 5m21s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:33 +02:00
julian 4ff31557a8 feat(trainer): training from the collector UI — the trainer becomes a job service, the review page gains a Training section
Trainer: `parking-trainer serve` — a stdlib HTTP job API on the compose network (never
published): /health, /readiness, /versions, /versions/<v>/report, /jobs. One job at a
time; each job runs the CLI as a subprocess with its output captured, state + log
persisted under /out/jobs/ so a restart keeps history. `publish` takes its URL from
TRAINER_PUBLISH_URL. Dockerfile: CMD serve, EXPOSE 8091, healthcheck.

Collector: COLLECTOR_TRAINER_URL + /api/training/{status,jobs,jobs/:id,versions/:v/report}
— a reviewer-gated proxy that forwards a fixed set of paths and whitelisted knobs and
passes the trainer's status codes through (409 while a job runs; 503 unconfigured, 502
unreachable). /review gains the Training section: labels per class vs the minimum with
Train disabled until two classes clear it, mode / backbone / floor, the running job's
live log, the versions with Report / Evaluate / Publish (publish confirms), and the
reminder that pinning stays a git commit. Fixed on the way: an apostrophe in the page's
inline script broke the whole page — a test now parses the script.

Compose: `trainer` is a service (restart: unless-stopped, read-only data volume, its own
trainer-out volume), the `train` profile and TRAINER_OUT are gone; the Docker-socket
route was rejected (root on the host for a service booths upload to). Verified with both
images running together: a Train started through the proxy finished, version and report
came back, the page rendered.

Wiki: bodytype-classifier-training (loop, running it, operating notes superseded),
vision-review-outbox, fleet-deployment-komodo, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:13 +02:00
julian 3e77a4ad7c docs(wiki): trainer as a compose profile — one running service is expected, first-run pull/login, why the collector TAG bump mattered, park-2 needs no bump until a pin
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 11:30:28 +02:00
julian 3f16925fe0 bump(resources): update stage tag to stage-f7a262a in resources.toml
Build & push images / images (push) Successful in 2m59s
2026-09-07 11:26:28 +02:00
julian f7a262ac9a feat(trainer): phase-B body-type classifier — trainer job on the collector host + the classifier stage on the booth
Build & push images / images (push) Successful in 6m31s
apps/trainer (parking-trainer): inspect / train / evaluate / publish. Reads the wash
collector's SQLite + crops read-only off its volume; time split (validation = newest
slice); thin classes dropped; damped class weights; `features` mode (frozen ImageNet
backbone, on-disk feature cache, seconds to retrain) and `finetune` mode (light
augmentation). CPU-only torch from PyTorch's wheel index. ONNX export checked against
the torch model; NO model file below the validation floor (exit 3, report still written);
exit 2 = not enough labels. `evaluate` scores a shipped model on labels reviewed after
training + the unlabelled pile; `publish` PUTs a version folder to a Gitea generic package.
Light core deps; the `train` extra is heavy — CI syncs without it, torch tests skip.

apps/vision: BodyTypeClassifier (bodytype.onnx + sidecar = the preprocessing contract:
crop margin, input size, RGB 0-255, normalisation inside the graph) and
RefinedVehicleDetector over YOLOX — refines only `car` or a class the classifier trained
on, min-confidence, `detector_class` on the result; path set but no file = phase B off
without an error; a broken file is a health detail. models/bodytype.version (tracked,
empty) pins the published version the Dockerfile fetches at build (BuildKit secret;
a pin that cannot be fetched fails the build). Verified: a trainer model gives identical
probabilities inside the vision service; both images built and smoke-tested.

Delivery: parking-trainer image in build-images.yml, the `trainer` compose profile on the
collector stack (CPU, read-only data, TRAINER_OUT), commented TRAINER_OUT/PUBLISH_TOKEN in
the wash-collector stack, .dockerignore for both Python contexts, trainer deps synced in CI.

Wiki: bodytype-classifier-training rewritten as built (+ one fleet model not per site,
secrets/access, where the crops live), opencv-anpr-service §Phase B, vision-review-outbox,
vision-service-packaging, fleet-deployment-komodo, index, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 11:14:50 +02:00
julian f9cb973fe9 docs(wiki): collector live on park-2; secrets shape, DNS vs bind, token format, every-entry sampling, CI extra rule
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 10:01:39 +02:00
julian 1a0fe59488 docs(wiki): phase B training path and hardware decisions — recorded, not built
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 10:00:18 +02:00
julian 8bfc29db2a bump(resources): update stage tag to stage-dbbb051 in resources.toml for booth park-2 and wash-collector.
Build & push images / images (push) Successful in 3m37s
2026-09-07 09:40:29 +02:00
julian dbbb051ebd feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
Build & push images / images (push) Successful in 4m22s
The wash stream is small; the entry camera photographs every car in exactly the view the
classifier is trained on. The booth can now queue entry vehicle reads as pure training
material — crop + the camera's class, no order, no operator, no category.

- Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the
  Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N)
  and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage
  and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose.
- Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated
  without the operator fields, review screen shows an entry sample as such, export has a
  kind column, operator agreement computed from wash items only. Setup line shows
  "1 in N entries sampled"; status carries entrySample.
- komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2,
  the shared per-booth secret, every entry sampled) — the collector is up on the overlay.
- Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed
  assessment), log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 09:38:55 +02:00
julian 3e57af5abc ui(carwash): Setup → Car wash as a two-column grid — the master-data card takes the width
The wrapping flex row capped the left card at max-w-2xl; once each category row grew its
camera-class chips it was squeezed while the right side sat empty (user, park-2). Now the
master data takes the remaining width and the sponsorship card keeps a fixed readable
column; stacks on narrow screens.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 09:38:55 +02:00
julian ef55d1c6a9 fix(resources): bump stage tag to stage-0411b71 in resources.toml both for park-2 and wash-collector stacks.
Build & push images / images (push) Successful in 3m29s
2026-09-07 08:57:13 +02:00
julian 0411b71c2d fix(vision): CI has no numpy — the vehicle-stage tests need it without the alpr extra
Build & push images / images (push) Successful in 4m7s
CI syncs the service with `uv sync --frozen` (no extra), so test_vehicle.py's module-level
numpy import broke collection in ci.yml and both build-images runs. numpy joins the dev
group (the service imports it lazily); the one test that resizes with OpenCV skips when
cv2 is absent. Reproduced locally in a CI-identical env: 13 passed, 1 skipped; ruff + mypy clean.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:51:53 +02:00
julian b485e9870b feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
CI / check (push) Failing after 40s
Build & push images / images (push) Failing after 32s
Build desktop / desktop (push) Successful in 5m24s
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small
Fastify + SQLite service in the monorepo (shares the payload contract and the class
vocabulary via @parking/shared), delivered to art-docker-station by its own stack so
nothing booth-side lands there and nothing of it on a booth.

- POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart
  meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on
  the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row.
- /review + /api/*: the reviewer's screen served by the process (Basic auth, one login):
  one pending crop at a time, operator's pick and camera's pick beside it, one button/key
  per vocabulary class + unusable + skip; stats per booth and per hashed operator
  (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's
  category).
- GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are
  neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host.
- Booth payload now carries operatorCategory.classes so the comparison needs no site setup.
- Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml
  (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build
  step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one
  secret per booth referenced from both the collector's token list and the booth's own
  stack (park-2 lines templated, commented, DNS name for the URL).
- Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image
  built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:26:22 +02:00
julian ec44547122 docs(wiki): close the session's loose threads — bay printer question, vocabulary-in-code decision, training note, guard family, OQ #16 settled
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:37:57 +02:00
julian e67f0ccef0 feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash
order with a vehicle read queues a package for a trusted reviewer over the private overlay
(Netbird); the verdict becomes the phase-B training label and the per-operator error rate.
wiki/concepts/vision-review-outbox.md.

- Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and
  plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a
  downscaled copy); vehicleForIdentity() returns them.
- carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 %
  margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a
  pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no
  bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped);
  400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided
  orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured.
- Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every
  CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close.
- GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash.
- Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in
  .env.example and forwarded by compose.
- Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms,
  queue/drain/backoff/abandon, through the app). Wiki: new concept page, index,
  venue-modules As built, log. The collector is not built.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:33:43 +02:00
julian 78ca58d264 ui(carwash): mapping chips show the camera's canonical class ids, not translations
The vocabulary is a code constant (the model's contract), the site only maps it; a chip
reading VETURË beside a site category named Veture blurred exactly that (user, 2026-09-06).
Translation stays as the tooltip; a hint names where the list lives.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 21:25:08 +02:00
julian 20a3cb3e80 feat(vision): vehicle stage, phase A — YOLOX-S (Apache-2.0 ONNX) beside the plate recognizer
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO,
mapped to the shared vocabulary) for the Car Wash desk's category suggestion
(venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a
confident downgrade is flagged, nothing is gated on it.

- vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid
  decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre,
  else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads.
- recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included);
  a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never
  costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640".
- settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE
  (0.4, the detector's floor; the flag threshold is site config).
- Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and
  sets the path; compose forwards it (empty = off); .env.example documents it.
- Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in
  ~240–330 ms; empty lane with a person → none.
- tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition,
  and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 19:53:11 +02:00
julian 5e1395db18 feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).

- Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT,
  reason code carwash.categoryDowngrade; settings/order/lookup views carry the read.
- Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node
  client normalises to the vocabulary and drops the rest.
- Record: snapshot.ts stores the read in the plate's device_events row (or its own when
  the plate was unreadable); vehicleForIdentity() resolves it like the plate.
- Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"),
  carwash_config.vision_threshold (signed config_change when it moves), four vision
  columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId.
- Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup
  offers class chips per category and the threshold. Operator decides.
- Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen
  one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on
  the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no
  reason prompt (user, 2026-09-06).

Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 13:37:34 +02:00
julian 50c18405b6 feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix):

- `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from
  (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in
  @parking/shared surface a followed job whose bundle grew past the role in a later
  release; the roles list shows a "behind <job>" badge with a one-click "Update to job"
  (the union, nothing removed) and the editor lints it. Never a runtime union: the grid
  stays the explicit enforcement layer and an update never widens a role without a click.
- Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
  name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes
  now takes the ledger.
- booth-supervisor already carries subscription:*; the stale open note is closed.

Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:52:47 +02:00
julian e14e31a840 feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role
Closes the three known follow-ups of the Tills decision (venue-modules.md):

- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
  by payload till, other events by their owning module's till, everything else booth),
  applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
  panel and the booth feed (history + live pushes). The events route admits a role that
  holds a module feed permission without event:read and returns only that module's
  event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
  module; the ticket bucket excludes them (Bileta = parking only); printed
  "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
  back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
  one reading of the role field (the entry/booth loaders treated any non-booth role as
  an entry dispenser). Footer label "at wash desk".

Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:37:26 +02:00
julian ea304bbfd1 chore(resources): update TAG to stage-2aa1045 for deployment
Build desktop / desktop (push) Successful in 4m44s
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 45s
2026-09-06 10:51:05 +02:00
julian 2aa1045ddc fix(modules): Car Wash depends on parking only — the discount engine is core, not the validation module
Build & push images / images (push) Successful in 2m51s
A site entitled to parking,carwash had the wash silently dropped as dependency-broken.
The validation program routes (compose/read) leave the validation module gate; the
merchant scan routes (mine/lookup/apply/void) stay behind it.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 10:40:01 +02:00
julian acde3bba5b feat(resources): entitle park-2 to only have the Booth and the Car Wash module. Not the Merchant Validations.
Build & push images / images (push) Successful in 2m51s
2026-09-06 10:33:48 +02:00
julian 9a13528611 fix(deploy): forward MODULES_ENTITLED into the server container (default parking,validation)
Build & push images / images (push) Successful in 3m19s
The Komodo stack env alone is compose interpolation input; only variables in the
service's environment: block reach the container. Without it every booth on 55d6242
had Car Wash entitled (unset = every module).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 10:29:36 +02:00
julian 6f88026d3e testing(resources): remove carwash and merchan validations modules from park-2 stack.
Build & push images / images (push) Successful in 3m19s
2026-09-06 10:18:08 +02:00
julian c481c1e788 chore(resources): entitle park-2 to the Car Wash module
Build & push images / images (push) Successful in 2m52s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 14:54:40 +02:00
julian 3a7c3fae11 bump(resources park-2): bump TAG to stage-55d6242
Build & push images / images (push) Successful in 2m53s
2026-09-05 14:52:41 +02:00
julian 55d6242c7d feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
CI / check (push) Successful in 46s
Build & push images / images (push) Successful in 2m58s
Build desktop / desktop (push) Successful in 4m53s
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix",
open-questions #16) — the grid stays the enforcement layer:

- Move 1: each desk's money is guarded by that desk's own permissions. Manifest
  tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create
  (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes
  resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot
  touch the booth by construction. Replaces the session:read borrowing (tillPermission).
  /api/shift/tills lists the role's readable tills with canWork; history/movements
  without a till filter return the union of readable tills.
- Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor,
  merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and
  "partial job" lints (warnings, never blocks).
- Move 3: the live WebSocket admits any watch permission (event/session/device read or
  a module's feedPermission) and filters every push per role; report:read is the
  reports screen only.

Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves
the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's
role applies on the next request and a deleted user's session ends with 401.

Tests: till guards + look-only role, feed rules, every job's permissions exist, role
reassignment without re-login. 353/353.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 14:45:48 +02:00
julian a9ccf9e20c feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 13:23:09 +02:00
julian 23d6379be8 feat(modules): venue-module registry — entitled ∩ activated, requireModule, Setup panel
Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order
steps 1 + 3). No Car Wash code yet; validation is the first module behind the
seam, unchanged in behaviour.

- @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required;
  validation dependsOn parking), parseEntitledModules / resolveModuleActivation
  / effectiveModules as pure functions.
- DB: site_config.modules_json (migration 0026, hand-written + journal;
  additive, nullable = everything entitled).
- Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated
  from site_config, effective set, requireModule preHandler → 403
  module_disabled); modules/index.ts registers folder-based modules by
  iterating the registry (modules/validation); site-config GET exposes
  modules/modulesEntitled/modulesActivated, PUT takes the full desired set,
  enforces entitlement + dependency rules (400 with reason) and signs one
  config_change per module that actually flips; /api/auth/me carries the
  effective set; validation routes guarded requireModule → requirePermission.
- Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads
  WEB_MODULES into nav + route tree (validate route no longer named there);
  Setup → Site "Modules" panel (required shown disabled, dependencies as
  hints, server refusal shown verbatim); validation sections + programs fetch
  gated on the module; App invalidates the router whenever the session
  changes (route-context consumers only re-read on navigation — the nav was
  stale after a flip, and after every other setUser too).
- Lavazh validation station retired (STATIONS = ["bar"]; rows untouched).
- Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks;
  documented in .env.example.
- Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright
  round-trip on /setup/site verified live.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 11:04:39 +02:00
julian db9c3e0e31 docs(wiki): venue modules design — Car Wash pilot, Parking as a peer module (open decision)
Records the 2026-09-04/05 design sessions on wiki/decisions/venue-modules.md:
manifest-registry module system (folder per module, always-migrated schema,
one ledger union with prefixed event types, relations only via manifest
dependsOn + ledger events), enablement as entitled ∩ activated (vendor-set
Komodo env, site-admin site-config toggle recorded as config_change; server
enforces with requireModule, web only hides; disabling never deletes),
Parking recast as one module on a venue POS/audit core, Car Wash as the
pilot (inside the parking, entry snapshot as identity, bay camera for the
unrecorded-wash anti-fraud signals, v1 scope + build order), and vision
vehicle category as an advisory anomaly flag. Name stays parking-system;
validation stays for the Bar, only the Lavazh station retires with Car Wash.
Open-questions #15, index, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 10:24:20 +02:00
julian d86bffa500 Merge branch 'stage' into dev
Build desktop / desktop (push) Successful in 5m5s
Build & push images / images (push) Successful in 2m58s
CI / check (push) Successful in 48s
Brings dev level with stage: runtime-configurable backend (v0.1.5), WS ticket
auth + desktop log channel (v0.1.6), per-installer latest.json (v0.1.7), TAG
bumps, and the admin-only update decision.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 18:11:42 +02:00
julian 9c05f86c86 docs(desktop): updates are admin-only — keep the polkit prompt; AppImage rejected on field evidence
Decision (user, 2026-09-04) after the first successful self-update
(v0.1.6 → v0.1.7): a .deb update runs pkexec dpkg -i and asks for an admin
password the operator does not have — that prompt is the intended gate.
The AppImage was tried as the no-root path and aborts on the 26.04 booth
(bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa: EGL_BAD_PARAMETER), and it
discards the distro-maintained WebKitGTK the platform decision rests on.
Passwordless polkit for dpkg is root for the operator — rejected.

- update.prompt (en + sq) now says the install needs the administrator
  password.
- desktop-shell-tauri.md: decision, evidence, rejected alternatives, and the
  deferred fleet-grade option (root systemd timer in the .deb, minisign-
  verified, notify-only in-app).
- standing-decisions.md: ship the .deb; runtime backend; updates admin-only.
- appliance-provisioning.md: drop the stale "hardcoded to localhost" note.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 18:11:41 +02:00
julian 54e691a4c9 fix(release): latest.json entry per installer type — .deb booths could never self-update
Release desktop / bundle (push) Successful in 5m26s
tauri-plugin-updater resolves the download target as {os}-{arch}-{installer}
first (linux-x86_64-deb — the bundler stamps the installer type into the
binary, verified with `strings` on a local .deb) and only then bare
linux-x86_64. Our manifest carried only the bare key, pointing at the
AppImage. A .deb install therefore downloaded the AppImage, verified its
signature, then failed install_deb()'s is_deb check with
InvalidUpdaterFormat — after the download, before any relaunch. This, not
version drift or swallowed errors, is why v0.1.0→v0.1.6 never self-updated.

latest.json now carries linux-x86_64-deb, linux-x86_64-rpm (when built) and
linux-x86_64 (AppImage), each with its own .sig. A .deb update ends in a
polkit password prompt (pkexec dpkg -i) — the intended admin gate on a
root-installed package. README + wiki updated; wiki also records the v0.1.6
LIVE field verification.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 15:28:08 +02:00
julian 52862db8ad chore(resources): bump stage TAG to 8fa66c9
Build & push images / images (push) Successful in 2m48s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 12:16:16 +02:00
julian 8fa66c9911 fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 41m19s
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's
preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which
tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never
send. Every desktop handshake 401'd and use-live-feed reconnected every 10s
(confirmed in the park-2 server log).

- routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s,
  single-use, in-memory ticket; the WS preHandler accepts it via an
  x-ws-ticket header after the Origin check, then the same report:read
  role check. Browser cookie path unchanged; JWT stays out of JS.
- platform-ws.ts: fetch a ticket before connect, send it with the Origin
  header; connect failures now go through logClient (rate-limited).
- logger.ts: flush read the CSRF token from document.cookie, null on
  desktop, so every desktop POST /api/logs 403'd and was dropped silently —
  no desktop client log had ever reached app_logs. Stash moved to a
  dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts.
- backend-config.ts: ConnectScreen probe uses the unauthenticated /health
  (now also returns app: "parking-system") instead of accepting any 401.
- README: local-AppImage release gate — tauri dev runs at
  http://localhost:5173, not tauri://localhost, so none of these
  origin-dependent bugs reproduce there.
- wiki: new section + log entry; four citation corrections.

Requires the server image with this commit deployed before the new desktop
build connects (the ticket endpoint must exist).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 12:09:30 +02:00
julian 70e1e9939f chore(resources): bump stage TAG to 5c6a21e
Build & push images / images (push) Successful in 2m50s
Promotes park-buzi + park-2 to the runtime-configurable desktop backend
address (ConnectScreen) and the desktop CSRF fix. build-images.yml
confirmed green for this sha before bumping.
2026-09-04 11:27:17 +02:00
julian 5c6a21e2c3 feat(desktop): runtime-configurable backend server address
Build & push images / images (push) Successful in 3m19s
Release desktop / bundle (push) Successful in 4m57s
The desktop shell is one generic .deb/.AppImage distributed via
mca/public_releases, not built per-booth, but the backend origin was baked
in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the
same installer could never point at a different appliance without a
rebuild.

Adds ConnectScreen (shown before Login in Tauri when no backend is saved),
backed by tauri-plugin-store persisting the operator-entered URL across
restarts. CSP's connect-src tightens to 'self' only — all backend traffic
already routes through tauri-plugin-http/websocket, which run Rust-side
and are outside connect-src's reach anyway — and the real access boundary
moves to capabilities/default.json's http:default scope, wildcarded so an
operator-chosen host is actually reachable. Adds a "Change server" control
in Setup (desktop-only) to repoint an already-configured install.

While tracing the desktop auth path for this: tauri-plugin-http's fetch()
runs through Rust's reqwest, which keeps its own cookie jar separate from
the webview, so document.cookie on tauri://localhost never sees the
parking_csrf cookie the server sets (open upstream bug,
tauri-apps/tauri#13045/#11518). This means the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was
first built — pre-existing, independent of this change. Fixed by having
sessionView() (routes/auth.ts) also echo the CSRF value in the login/me
JSON body; the desktop client stashes it in memory and echoes that instead
of reading document.cookie. assertCsrf() itself is untouched.

Verified end-to-end against a real LAN-bound dev server: login returns a
csrfToken matching the cookie, a mutation using the body-sourced token in
X-CSRF-Token succeeds (200), and the same mutation without it still
correctly 403s.
2026-09-04 10:32:03 +02:00
julian 969bf2b191 chore(resources): bump stage TAG to 7d67934
Build & push images / images (push) Successful in 2m49s
Promotes park-buzi + park-2 to the WS_ALLOWED_ORIGINS fix and the desktop
version badge. build-images.yml confirmed green for this sha before bumping.
2026-09-03 18:22:07 +02:00
julian 7d67934a10 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-09-03 17:29:31 +02:00
julian 56904422af feat(desktop): show the installed app's own version in the UI
Build desktop / desktop (push) Successful in 4m47s
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 42s
Nothing displayed which desktop build was actually installed — debugging
a stuck update meant inferring the current version backwards from the
update prompt's target version. Added DesktopVersionBadge (next to the
existing server-side VersionBadge) using @tauri-apps/api's getVersion(),
the real running app version baked in from tauri.conf.json. No-ops in a
browser. Exported inTauri() from origin.ts instead of redefining it again.
2026-09-03 16:31:48 +02:00
julian 8bcdea9e4a Merge remote-tracking branch 'origin/dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-09-03 16:24:44 +02:00
julian 7804285dec fix(desktop): route update-failure logging through logClient, not console
Build desktop / desktop (push) Successful in 4m44s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
console.error/console.warn only forward to the server when the client log
level is debug/trace (default: info) — the earlier error-logging fix never
actually surfaced anything, and a real update failure produced zero logs
anywhere. desktop-updater.ts now calls logClient() directly, unconditionally,
plus download-progress events. Also documents the resource-sync-park-systems
branch misconfig (pointed at dev, Stacks are stage-tier) found while chasing
this — full writeup on fleet-deployment-komodo.md.
2026-09-03 16:23:02 +02:00
julian 4a7029cea6 chore(resources): bump stage TAG to 7317042
Build & push images / images (push) Successful in 2m49s
Promotes park-buzi + park-2 to the just-merged desktop-app fixes (login,
mixed-content routing, WS origin) and the WS_ALLOWED_ORIGINS fix — none of
this was on stage before. Wait for build-images.yml to confirm the image
actually exists before syncing/deploying in Komodo.
2026-09-03 16:04:57 +02:00
julian 7317042e8d fix(desktop): WS live feed offline — native plugin sends no Origin header
Build desktop / desktop (push) Successful in 4m42s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 4m43s
Build & push images / images (push) Successful in 2m46s
Login worked after the mixed-content fix, but the live feed 403'd silently:
tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the
webview page, so it never auto-attaches Origin the way a browser WebSocket
would — routes/ws.ts's anti-CSWSH check rejects a missing Origin before
auth. platform-ws.ts now sets Origin: tauri://localhost explicitly.

Also fixes a second, independent gap the above alone wouldn't have caught:
komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in
production despite .env.example documenting it as required for desktop.
Needs a Komodo sync + redeploy to reach a live booth.
2026-09-03 15:35:04 +02:00
julian 439b11d16d fix(desktop): route fetch + WebSocket through native Tauri plugins (mixed-content)
Build desktop / desktop (push) Successful in 4m33s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 5m13s
Fixing VITE_API_BASE got login to build a correct absolute URL, but it still
failed with WebKit's generic "Load failed" — WebKitGTK treats tauri://localhost
as a secure origin, so http://127.0.0.1:3000 (and ws://) from inside it is
blocked as mixed content, a WebKit limitation CSP's connect-src can't override.

Added tauri-plugin-http (genuine fetch() drop-in, wired via a new
platformFetch() in origin.ts, used by api.ts + logger.ts) and
tauri-plugin-websocket (not a drop-in — adapted behind a native-WebSocket-
shaped interface in the new platform-ws.ts so use-live-feed.ts needed no
changes). Both route through Tauri's Rust side instead of the webview's own
fetch/WebSocket. Capabilities scoped to 127.0.0.1:3000/localhost:3000, matching
the existing CSP allowlist.
2026-09-03 14:57:45 +02:00
julian 276b048fa9 fix(desktop): sync tauri.conf.json version to the release tag, stop swallowing install failures
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m48s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m37s
v0.1.1 was tagged but tauri.conf.json's own "version" field (what Tauri
bakes into the bundle filename/internal version) stayed at 0.1.0 — the
signed binary didn't match what latest.json claimed to describe, so every
update download failed signature verification. desktop-updater.ts's single
catch{} swallowed that identically to "offline", so it looked like nothing
happened at all. release.yml now syncs tauri.conf.json's version from the
git tag before building; the updater now logs a real post-accept failure
instead of silently reverting.
2026-09-03 12:24:04 +02:00
julian faa3265e49 fix(desktop): restore VITE_API_BASE for the desktop build
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m47s
apps/web/.env.production's VITE_API_BASE went empty in 96fd97e to fix the
booth/browser same-origin case, but the desktop build shares that file and
was never given its own override — login broke with WebKitGTK's "The
string did not match the expected pattern." (a relative fetch() URL with
no base, from tauri://localhost). beforeBuildCommand now sets
VITE_API_BASE=http://127.0.0.1:3000 inline for the desktop build only;
verified both builds independently produce the right output.
2026-09-03 12:01:56 +02:00
julian 21bfdce27a fix(release): surface the actual Gitea API error on mirror failure
Release desktop / bundle (push) Successful in 4m24s
The mirror step's release id came back empty on the last real run but
nothing failed loudly — every curl response was swallowed (|| true, or
piped straight to /dev/null), so we had no idea why. Capture HTTP status +
response body on every call and exit 1 with the actual error instead of
silently uploading to a malformed //assets URL with no release id.
2026-09-03 10:50:36 +02:00
julian d3288e29eb fix(release): don't let a grep-not-found kill the script under set -e
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m26s
Every REL_ID lookup piped grep -o '"id":...' straight into head/cut with no
guard. Under set -e + pipefail, a Gitea API response with no id (e.g.
"tag already exists" on a retry, or an empty existing-assets list on the
first desktop-latest publish) makes grep exit 1, which aborts the whole
step immediately — before the intended fallback lookup ever runs. Hit on
retrying v0.1.0 after the previous filename fix: the release already
existed from the earlier failed run, and the script died with no output at
all instead of finding it by tag. Guarded every such pipeline with || true.
2026-09-03 10:33:03 +02:00
julian baf7a4a99d fix(release): strip spaces from bundle filenames before upload
CI / check (push) Successful in 44s
Release desktop / bundle (push) Failing after 3m57s
productName "Parking System" produces installer filenames with a literal
space (e.g. "Parking System_0.1.0_amd64.deb"). curl rejected the resulting
asset-upload URL outright on the first real v0.1.0 release ("Malformed
input to a URL function"), before the job ever reached the new
public_releases mirror step. Sanitized on copy into dist/.
2026-09-03 10:26:17 +02:00
julian 885b410e48 chore(desktop): bump version to 0.1.0 for first tagged release
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 44s
Release desktop / bundle (push) Failing after 4m1s
Still at the scaffold default 0.0.0 with no v* tag ever cut. Bumping so a
v0.1.0 tag can exercise release.yml (and the new public_releases mirror
step) for the first time.
2026-09-03 10:11:20 +02:00
julian a1f3103a76 fix(desktop): mirror signed releases to public repo for the updater
Build desktop / desktop (push) Successful in 4m46s
CI / check (push) Successful in 43s
The updater endpoint pointed at mca/parking_solution's own Gitea "latest
release" redirect, but that repo is private and field appliances have no
Gitea credentials — every update check was silently failing. release.yml
now mirrors signed installers to mca/public_releases (public, installers
only) under a fixed desktop-latest tag; tauri.conf.json points there.
Rejected embedding a read token in the app instead, given the booth-operator
threat model.

Also: make the appliance-provisioning root_directory gotcha impossible to
skim past (boxed callout + explicit next-step pointers), after it caused a
second missed step on the park-2 install.
2026-09-03 09:56:49 +02:00
julian 0fd66b261a feat(resources): add park-2 booth Stack (stage tier)
Build & push images / images (push) Successful in 2m59s
CI / check (push) Successful in 46s
New real booth, same tier as park-buzi: tracks stage, pinned stage-<sha>,
own park_2_* secret refs. Periphery had the known /etc/komodo root_directory
permission bug on --user install (wiki gotcha #9); fixed and confirmed OK
in Core before adding this Stack.
2026-09-02 18:29:46 +02:00
julian dfc5a07c10 Retire the park-lab stack from art-docker-station
Build & push images / images (push) Successful in 2m56s
CI / check (push) Successful in 44s
That host is becoming a Traefik edge, and parking's prod override brings its own
Caddy on `network_mode: host` holding :80 — the two cannot share the port. The
lab tier moves to a dedicated bench PC rather than contorting either side.

This also names what has been holding :80 on that box: the edge stack deployed
there on 2026-09-01 failed with "address already in use" and the owner was
recorded as unidentified. It was almost certainly this Caddy.

REMOVING THIS BLOCK DOES NOT STOP ANYTHING. The containers keep running and keep
the port. Destroy park-lab from Komodo Core BEFORE syncing this removal:
DestroyStack names a stack and Core resolves where from its own synced copy of
the definitions, so a sync that drops the block first takes the teardown handle
with it. If that has already happened, remove the containers by hand on the host
— there is no compose project context on a Komodo-managed box.

Three Core secrets are now unreferenced: art_docker_station_jwt_secret,
art_docker_station_event_signing_key, art_docker_station_backup_key. Lab keys
with no real ledger behind them, so they are safe to delete once the stack is
gone.

Claude-Session: https://claude.ai/code/session_01SARfPK19vLBstMWBxubezN
2026-09-01 11:33:22 +02:00
julian 5aabd7a791 fix(.gitignore): add questions.txt to ignore list
CI / check (push) Successful in 43s
2026-08-31 12:23:32 +02:00
julian 0e9b9f5d82 fix(resources): drop stale park-lab-old Stack; docs(wiki): Periphery connect_as and upgrade gotchas
Build & push images / images (push) Successful in 3m17s
CI / check (push) Successful in 46s
park-lab-old referenced a server removed from Komodo, breaking the resource
sync. Also documents two Periphery incidents from this session: a Core-UI
rename doesn't touch the agent's own connect_as, and upgrading Periphery is
a config-preserving re-run of the installer.
2026-08-31 12:17:00 +02:00
julian 642c5f4f70 feat(setup): show running build version in the Setup tab bar
Build desktop / desktop (push) Successful in 4m21s
Build & push images / images (push) Successful in 3m6s
CI / check (push) Successful in 42s
CI already computes <branch>-<short-sha> for image tags but never
surfaced it anywhere reachable from the app, so there was no way to
tell what's actually deployed on a booth without cross-referencing
komodo/resources.toml's TAG by hand.

Thread it through: CI passes BUILD_VERSION as a Docker build-arg,
the Dockerfile captures it as a runtime env var, GET /api/version
(gated by the existing site:read permission) exposes it, and the
Setup page's tab bar shows it right-aligned, muted, absent entirely
on a local/dev build with no CI-supplied value.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 19:01:15 +02:00
julian cb9f4d4979 fix(resources): rename stacks for clarity and consistency
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
2026-08-30 18:46:55 +02:00
julian ea8fe22969 docs(wiki): USB printer cover-open field bug writeup; add art-docker-station lab box
Build desktop / desktop (push) Successful in 5m14s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 43s
Printer investigation (park-buzi): cover-open on the USB thermal
printer wedges its status offline/faulty, surviving a full reboot,
recoverable only via `docker restart server`. Traced sendRawUsb/
probeUsb end-to-end — no persistent handle in the app layer, so the
leading theory is the container's /dev/usb directory bind-mount
retaining a stale view across the printer's physical re-enumeration.
Not yet confirmed on hardware; documented with repro/confirmation
commands and ranked candidate fixes.

Also registers a new lab bench box, "art-docker-station", as a Komodo
Stack (dev tier, same shape as park-lab, its own isolated secret refs).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 18:11:34 +02:00
julian 2910672b5a fix(backup): persist last-success/error status; wall-clock-based schedule
BackupService tracked last-success/last-error as plain in-process fields
and scheduled the daily backup via setInterval measured from process
start — so any server restart (deploy/crash/OOM/reboot, routine under
`restart: always`) silently reset the admin UI to "last successful
backup: Never" and drifted the actual cadence, independent of whether
backups were writing correctly to disk (they were — a real field
incident at park-buzi showed 7 valid rotating backups on disk with the
status stuck on "Never").

Persist last-success/error to new site_config columns (migration 0025)
and add BackupService.isDue(), computed from the persisted timestamp
instead of process uptime; server.ts now polls every 15 min and lets
isDue() gate the actual run. No API/UI contract change.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 18:11:23 +02:00
julian 3a176c5cc8 docs(wiki): DS-2CD1047G3H-LIU main-stream ISAPI snapshot is a firmware bug — camera line to be replaced
CI / check (push) Successful in 1m1s
Build & push images / images (push) Successful in 3m20s
Full live investigation of the persistent 503 "deviceBusy" on main-stream ISAPI
snapshots (10.0.10.13): ruled out config (byte-identical to a working sibling
model), ruled out firmware age (reproduced on both the original V5.8.11 and
current V5.11.0 builds, ~15 months apart), and ruled out real resource
contention (a full channel-ID sweep shows every ID fails identically except
the one hardcoded working value, including nonexistent channels) — pointing
at a broken/incomplete ISAPI snapshot handler that mislabels itself as
"busy," not a real encoder ceiling.

RTSP main-stream frame-grab was confirmed as a working route around it, but
given the bug and the sub-stream's real-world plate-read accuracy problems,
the owner decided to replace the DS-2CD1047G3H-LIU units rather than carry
an ffmpeg/RTSP dependency to work around vendor firmware. Ingested the
vendor datasheet as a source page along the way.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-23 14:50:22 +02:00
julian 19dff97c74 fix(web): permission-degrade the app shell for merchant-only users
Build desktop / desktop (push) Successful in 4m51s
Build & push images / images (push) Successful in 3m8s
CI / check (push) Successful in 52s
A user whose role has only validation:create (the bar/lavazh validator) made
the shell misbehave: useLiveFeed() connected /api/ws unconditionally, the
server's report:read guard 403'd the upgrade, and the capped-backoff
reconnect hammered it forever — a 403 in the server log every few seconds.
Gate the socket on report:read (mirrors routes/ws.ts WATCH_PERMISSION) and
render StatusDot / ShiftButton / DeviceFooter only with their backing
permissions (report:read / shift:read / device:read), so a merchant's shell
is just the nav + their /validate screen, with zero doomed requests.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 20:12:43 +02:00
julian 0ed43239c3 bump(resources): update TAG to stage-28bd838 for deployment consistency
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 48s
2026-07-13 20:11:27 +02:00
julian 28bd838696 docs(wiki): merchant validations settled + as-built; scan input decided (camera paths postponed)
Build desktop / desktop (push) Successful in 5m5s
CI / check (push) Successful in 47s
Build & push images / images (push) Successful in 2m59s
validation-discounts: driving cases → the settled validation-only model (all
money/paper at the booth) → setup UX/storage/RBAC → full as-built record.
DECIDED: merchant stations scan with a USB/HID barcode scanner on the
web/desktop app (hand-keying + Luhn as fallback); POSTPONED with analysis:
web getUserMedia scanning (secure-context TLS prerequisite on the LAN +
Code128-via-camera weakness → QR-on-ticket first) and a Tauri v2 Android
merchant app (native ML Kit scanning; Android build/sideload overhead +
configurable-server-URL prerequisite). Also: wsl-dev-networking gains the
mirrored-mode gotcha where a Windows-side listener makes a port EADDRINUSE
inside WSL while invisible to ss — Vite auto-increments and tauri dev's fixed
devUrl waits on the wrong port.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:50:09 +02:00
julian 692dff5f89 feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:49:58 +02:00
julian ba7538aeb5 docs(wiki): capture cloud-service SaaS requirements (postponed)
Multi-tenant SaaS layered on the offline model: link-up monitoring of the
signed ledger, device status, financials; one admin → many sites; per-site
secret custody; recurring fee. Records the four tensions, the confirmed
secrets boundary (sync creds + device-password escrow + app identity, NOT
the signing key), and the two in-discussion corrections that stand (NetBird
already solves booth isolation; remote barrier-open is pulseOpen-and-signed,
driven by the unmanned future). status: open, postponed.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-13 14:51:01 +02:00
julian bb365b5d6e fix(booth-pay): entry/exit timestamps read alike (Sot 19:25:44)
The pay modal rendered entry via formatRelativeDateTime (relative day, no
seconds → "Sot 19:25") and exit/now via the legacy formatTime (raw
HH:MM:SS, no day → "19:25:44") — inconsistent on both day context and
seconds. Added a { seconds } option to formatRelativeDateTime and routed
all four call sites (entry, exit, live now, alreadyClosed toast) through
it, so every row reads "Sot 19:25:44". Removed formatTime — the last raw
toTimeString() helper and the source of the mismatch; BoothPayModal was
its only caller.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-11 10:38:12 +02:00
julian c52a42dad2 fix(resources): update TAG to stage-22544ec for deployment consistency
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 44s
2026-07-10 08:50:51 +02:00
julian 22544ecf63 docs(wiki): log-storm hardening + reset drift guard (2026-07-07 incident)
Build desktop / desktop (push) Successful in 4m37s
CI / check (push) Successful in 42s
Build & push images / images (push) Successful in 2m51s
button-light-indicator: failure backoff + rate-limited logging rationale;
app-logs: storm coalescing invariant + --diagnostics wipe; local-dev-workflow
and appliance-provisioning §7d: new reset flag table + drift guard; log entry
tying all three layers to the ENETUNREACH incident.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:59 +02:00
julian ba5b4b1f4e fix(reset-db): app_logs + tariff_drafts were uncategorized — add a drift guard
Both tables belonged to NO reset category and silently survived every
reset, --all included (the hand-maintained table list lagged the schema
twice). app_logs gets a new --diagnostics category; tariff_drafts joins
--config. A drift guard now compares the category union against
sqlite_master before doing anything and refuses on any uncategorized
table, so the next new table forces a deliberate one-line decision instead
of escaping by omission. Verified on a scratch DB: guard refuses a planted
table (exit 1), --all lists both new tables, --diagnostics wipes app_logs.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:52 +02:00
julian 51b160bfc9 feat(logs): coalesce repeated identical lines into one row (×N badge)
A line identical to the last persisted row (level+source+message+path)
within a 5-min refreshing window updates that row — context._repeat counts
the fold, _firstAt keeps the first occurrence, createdAt tracks the latest
so the storm stays at the top of the newest-first viewer. A continuous
storm stays ONE row however long it rages, so it can't evict unrelated
history via the 50k row cap or grind the appliance disk. LogsViewer badges
coalesced rows ×N (tooltip: count + first occurrence, sq/en). In-memory
last-row cache only; a pruned-under-us row falls through to a fresh insert.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:43 +02:00
julian e2d5105da2 fix(button-light): back off failed setAux sends — kill the ENETUNREACH hot loop
An unreachable controller rejects the UDP send instantly, and #pump's
failure re-pump retried inline: a tight loop logging hundreds of identical
errors per minute (park-buzi, 2026-07-07). Failed sends now arm a 1s→30s
exponential retry (reset on success); desiredOn keeps tracking the truth
table meanwhile and the armed retry converges to it. Logging is
rate-limited: first failure of a streak in full, then one summary/minute,
one info line on recovery. #finalOff waives the backoff so the last-gasp
OFF on drop/shutdown still gets an immediate try.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:33 +02:00
julian 5287be5278 docs(wiki): catch-up sweep — five pages lagging the log
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
rongta-printer still named the cashino driver id (→ escpos + migration
0023 note); tariff-time-tiers listed the composer price preview as
deferred (→ delivered by the lab fee breakdown); tariff.md lab section
gained the breakdown + composer increment-guard paragraph; i18n.md now
records the "25 Qer 14:30" date standard + never-toLocaleString-for-
dates rule; fleet-deployment-komodo gained the park-lab stack + tier
table (the park-lab addition had also slipped the log — both fixed).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00
julian 3a85483e6c deploy(park-buzi): pin TAG=stage-6ceaadf (supersedes cd3b534)
Adds on top of the un-deployed cd3b534 pin: camera clock sync via ISAPI
(heals the 1970 power-cut reset at the offline→ready edge + daily
backstop). Everything since the deployed f9887c2 rides along: USB
printer close-cancel fix (hardware-verified at the lab), USB device
dropdown (lp1 shows by model name), printers addable without a
controller. No migrations.

Post-deploy validation: pull a camera's power, let it come back, then
docker logs | grep "clock synced" — expect a warn with a huge drift.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00
julian 6ceaadfbf2 feat(devices): camera clock sync via ISAPI — heal the 1970 power-cut reset
Build desktop / desktop (push) Successful in 4m18s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m51s
park-buzi field observation: after a power cut the Hikvision cameras
reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there
until a human logs into the web UI (which silently pushes the browser
clock) — corrupting the snapshot OSD timestamps (the evidence trail) and
ANPR push times meanwhile.

The host is the site's time authority (offline-first, no NTP infra):

- Device monitor triggers a sync at each camera's offline→ready edge —
  exactly the power-restored moment — plus a 24h backstop; the attempt
  is stamped before the async call so a failing camera retries at
  backstop cadence, never every poll.
- HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave
  alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual
  with the site wall-clock now WITH explicit utc offset
  (localIsoWithOffset), echoing the camera's timeZone verbatim — correct
  the clock, never fight its tz/DST config.
- Jumps >1h (the power-cut signature) log warn (persisted to app_logs);
  small corrections info. Capability-guarded (isClockSyncable) —
  hikvision only; dahua's CGI has no such endpoint.
- http-digest generalised to digestRequest (GET/PUT/POST + body); the
  handshake was already method-aware. digestGet delegates unchanged.

8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant +
echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability,
DST-both-sides pins on the offset formatter.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 12:56:51 +02:00
julian 7f42805e8d bump(resources): update TAG to stage-cd3b534 for deployment consistency
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 42s
2026-07-07 11:57:09 +02:00
julian cd3b534e51 feat(setup): USB printer discovery — pick a real /dev/usb device
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 50s
Build & push images / images (push) Successful in 2m54s
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:

- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
  compose bind-mount) and enriches each with the printer's self-reported
  make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
  ("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
  first real device; a saved-but-unplugged path stays selectable,
  flagged "saved — not present now"; zero found falls back to free text
  + a check-the-cable hint.
- Transport option label no longer hardcodes lp0.

Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 11:35:25 +02:00
julian 011fe5a4c4 fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
Build desktop / desktop (push) Successful in 4m16s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m51s
The chunked-write fix (81bc2e3) still truncated on hardware: the lab
test slip stopped mid-sentence with no feed and no cut (text hidden
until the feed button). Verified against drivers/usb/class/usblp.c:

- write() returns at URB SUBMISSION, not completion;
- only ONE write URB is in flight (the next write EAGAINs until it
  completes);
- usblp_release() — our close() — KILLS in-flight URBs.

The printer drains bulk data at PRINT speed (tiny internal buffer on
these clones), so closing right after the last accepted write cancels
the still-transferring tail — exactly where the feed + GS V cut bytes
live. Kernel-accepted ≠ printer-received.

Fix: the one-URB rule makes acceptance of write N a completion
certificate for write N−1. writeAllUsb now writes the payload's FINAL
BYTE alone — its acceptance proves everything before it is physically
in the printer — then drains 300 ms for that single packet before the
caller closes. New test pins the final-byte-alone chunking; wiki
printer-usb-transport.md carries the kernel-level account.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:50:47 +02:00
julian 6f3f6ca596 fix(fleet): park-lab stack points at server "park-lab"
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
The lab box's Periphery onboarded as park-lab (the earlier park-test
name was from the first, discarded install attempt).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:06:47 +02:00
julian 5443b910c6 feat(fleet): add park-lab stack (lab bench, dev tier)
Build desktop / desktop (push) Successful in 4m15s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
Second [[stack]] block: server park-test (the lab box's Periphery
connect_as), compose files from the dev branch, MOVING TAG=dev (a lab
may float; real booths pin), its own park_lab_* secret refs (per-box
blast radius — never shared with a real booth). park-buzi is untouched
on stage + pinned.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:04:08 +02:00
julian a02957034d fix(web): setup allows adding a printer with no controller configured
Second half of the printer/relay decoupling: the category section's
add-button gate ("add a controller first — a printer points at one of
its relays") blocked every non-access category while zero controllers
existed — hit on the lab bench (USB printer test, no relays on hand).
Printers don't bind (role + failoverRank route jobs), so the gate now
exempts them like the form's requirement already does.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 09:54:59 +02:00
julian ee61c24bb9 docs(wiki): Periphery v2.2.0 --user installer defaults root_directory=/etc/komodo
Lab box (park-test) crash-looped: panic writing the agent key to
/etc/komodo/keys/periphery.key (Permission denied). Gotcha #9 was framed
as a hand-config hazard; v2.2.0's installer now writes the system-style
default even with --user. §7a: verify root_directory after every
install + sed fix + reset-failed/restart; user-unit vs sudo note; the
onboarding key survives a pre-connect crash.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 09:34:00 +02:00
julian 3a186d29df docs(wiki): runbook §5c uses gpasswd -d — deluser rejects hyphenated users
Build & push images / images (push) Successful in 3m14s
CI / check (push) Successful in 43s
Demoting the operator on park-buzi failed with "sanitize_string: invalid
characters in 'park-operator'" — Ubuntu's perl adduser/deluser tooling
rejects the hyphenated username. §5c now prescribes gpasswd -d for
sudo/lxd/lpadmin (shadow-suite, no perl sanitize) and documents that
group removal lands at NEXT login: the auto-login operator session keeps
its old memberships until reboot/relog, so verify `groups` from inside
the session afterwards.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 08:55:51 +02:00
julian 827445d514 deploy(park-buzi): pin TAG=stage-f9887c2 (supersedes d905dd1)
Carries since the deployed d905dd1: reports dashboard (occupancy curve,
hour×dow heatmap, stay histogram, fraud KPIs), USB printer chunked-write
fix (barcode + cut over usblp), driver rename cashino→escpos (migration
0023 rewrites device rows on boot), setup wizard printer-binding fix,
composer published-versions sidebar + increment-unit guards + currency-
scaled examples, lab fee breakdown, UI-wide "25 Qer" date standard,
camera health-check log bucketing, seed-admin role self-heal + signed
ledger event, Z-report label wording.

Post-deploy on-site: switch the ICS printer's driver to the generic
ESC/POS entry if still on rongta; USB print test (barcode + cut).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 08:55:51 +02:00
julian f9887c2a76 fix(server): seed-admin self-heals the admin role + signs a ledger event
Build desktop / desktop (push) Successful in 4m28s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m59s
Field failure on park-buzi: reset-db --users wipes the roles table and
points to seed-admin — which inserted the user with roleId "admin"
without recreating the role row (migration 0007 never re-runs), dying on
the role_id FOREIGN KEY. The script now upserts the built-in admin role
first (the row alone suffices — admin permissions resolve in code).

It also appends a SIGNED config_change (admin.passwordReset /
admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer: a console seed/reset by the Linux admin can't be
gated by the app, but it stays attributable in the chain. Best-effort —
no build/signing key warns loudly and proceeds (locking an admin out to
protect an audit line would invert the priority). Both paths verified
against a scratch DB reproducing the post-reset state.

Runbook: appliance-provisioning §7e — lost app-admin password reset via
FORCE=1 (interactive preferred; sessions not revoked → rotate JWT_SECRET
if theft suspected); §7d notes the FK failure + self-heal.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 7649b897c4 feat(tariff): lab explains the sum — fee breakdown from the engine walk
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs
the EXACT computeFee walk with an optional trace collector — one code
path, so Σ line items ≡ the amount by construction (golden V1 regression
byte-identical; instrumentation changes no fee). Items: contiguous
same-price increment runs (time window · N × unit · tier-card name),
window-package occurrences, stepped day totals (top-tier repeat
flagged), daily-cap clamps as NEGATIVE adjustments, entry grace.

/api/tariff/simulate returns `breakdown` (null when settled); the lab's
Outcome panel renders the lined table with a rounding note (raw min →
billed min at the increment — answers "why does 3h 2m bill as 4h") and
a total row. Works against active/historical versions and drafts alike,
so a night-package draft can be verified line by line before publish.
Largely delivers the wiki's open "composer price preview" item.

4 new engine tests pin the sum invariant + item shapes (97 shared green).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian ab968eb25e feat(web): composer states the billing unit — the 60→10 price trap closed
Ladder/flat prices are PER BILLING INCREMENT, but the form said only
"Çmimi / interval" — so changing the increment 60→10 silently multiplied
every price ×6 (operator walked into it). Now:

- Price headers name the real unit live: "Çmimi / orë" at 60,
  "Çmimi / N min" otherwise (flat-mode radio label likewise).
- Amber warning whenever the increment ≠ 60: every price below is
  charged per started N minutes, NOT per hour.
- Per-row "= X / orë" equivalence next to each ladder/flat price when
  the tick isn't an hour — the multiplication nobody should do mentally.
- Example defaults are currency-scaled: ALL gets 200/100 ladder, 200/500
  up-to, 2000 lost ticket (the old "2.00/1.00" euro-scale examples read
  as 2 lekë/hour); EUR/USD keep 2/1/5/20. Threaded through empty forms,
  new tier rows, and mode-switch templates alike.

Band DURATIONS stay in hours — real wall time, increment-independent.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 5e1a885dcb feat(web): one date standard across the UI — "25 Qer 14:30"
Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used
formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20
places that called raw toLocaleString/-Date-/-Time-String. Unified:

- common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj);
  formatDate ("25 Qer", year only when not current), formatDateTime
  ("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in
  lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its
  older-dates branch to the same short months.
- Every raw toLocale* DATE call swept: shifts X-report line, plan
  effective dates, sub version labels, drawer today feed, snapshot
  tooltips, device footer checkedAt, event-detail timestamp (keeps
  seconds — chain evidence), tariff composer active-since + version
  sidebar. Number toLocaleString (thousand separators) untouched.

The catalogs in this commit also carry the keys for the two follow-up
commits (fee breakdown, composer increment labels).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 6cf3492bff chore(shift): Z-report slip label wording (Albanian)
Operator-adjusted labels on the printed Z-report: "Gjëndje fillestare"
for the opening float, aligned "Abonime"/"Jashtë orarit" rows.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian ffe8c13a1c fix(web): setup wizard no longer forces printers to bind to a barrier
"Cilën barrierë shërben kjo pajisje?" is load-bearing for readers and
cameras (which barrier a scan opens + inherited direction) but nothing
consumes it on a printer — print routing is role + failoverRank
(printer-routing.ts). The wizard applied the requirement to every
non-controller device, so adding a printer demanded a meaningless relay
pick that got stored as dead config.

Printers are now exempt: no requirement, the binding panel is hidden,
the binding is not persisted (a stale pre-fix one drops off on next
edit), and the device list shows the printer's ROLE instead of a bogus
amber "unbound". Server never validated it — no API change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian fcea992e1e refactor(devices): rename driver "cashino" → "escpos" (generic ESC/POS)
The reachability-only clone driver carried its first unit's vendor name,
which read as misleading in the setup UI once other clones (ICS/Xprinter
XP-K200L, verified 2026-07-06: no /prn_stat.htm) used it. It was always
the generic ESC/POS driver — now named so:

- printer-cashino.ts → printer-generic.ts; GenericEscposPrinter;
  id "escpos", label "Generic ESC/POS 80mm printer (Cashino,
  ICS/Xprinter…)".
- Migration 0023 rewrites stored devices.driver_id rows.
- The registry keeps a PERMANENT cashino→escpos alias so restored
  pre-rename backups still resolve instead of "unknown driver".

Prose mentions of the Cashino as physical hardware stay — it's a real,
verified-fit printer; only the driver identity stopped being vendor-named.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 81bc2e357c fix(devices): USB printing dropped the job tail — chunked write loop
Field bug (ICS XP-K200L over USB): text printed, barcode + cut missing;
same bytes over TCP fine. sendRawUsb did ONE write() on an O_NONBLOCK
usblp fd and never checked bytesWritten — the kernel accepts only what
fits the printer's ~8 KB USB buffer and returns a short write, so the
tail of any job bigger than one buffer (the barcode mid-payload, the cut
at the end) was silently discarded. The regular-file test stand-in can't
short-write, which is why tests never caught it.

writeAllUsb now pushes 4 KB chunks until every byte is accepted,
continues after partial writes, retries EAGAIN/zero-byte with a short
pause, and fails at the deadline with an (N/M bytes) diagnostic. Driven
by fake-handle tests (short writes, EAGAIN interleave, wedged-printer
timeout, non-EAGAIN passthrough).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 7ef332999e feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
The dashboard had generic BI views but nothing parking-shaped. Added:

- Occupancy step-area over the range with the configured capacity as a
  red reference line. occupancyStart folds the ENTIRE prior ledger
  (voided entries excluded, clamped ≥0); each series point carries
  occupancyEnd. Answers "when are we near full".
- Entries heatmap hour × day-of-week (7×24, row 0 = Monday, site tz) as
  a pure CSS-grid intensity map — weekday-vs-weekend at a glance, the
  direct evidence for tariff windows. Replaces the flat hour histogram
  (strictly contains it).
- Stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h/
  tail): where ladder/up-to breakpoints should sit.
- Voids + anomalies KPIs (accented when >0) — the look-closer counters
  the signed chain exists for; peak-occupancy KPI (peak / capacity).
- Revenue bars stacked cash vs card (the drawer's money vs the bank's);
  CSV export gains cash, card, occupancy_end columns.

Internals: localParts caches its Intl formatter per tz (was one new
formatter per ledger row); @parking/db re-exports lt/gt. 5 new tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:35:54 +02:00
julian a2e102f3dd bump(resources): update TAG to stage-14638c2 for deployment consistency
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 42s
2026-07-05 16:53:32 +02:00
julian 14638c2e13 docs(wiki): industry survey of parking tariff systems + session log
Build desktop / desktop (push) Successful in 4m14s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m49s
New reference page tariff-industry-survey.md (2026-07 web research):
field taxonomy — per-started-increment hourly (per-minute tried and
rolled back in practice), degressive ladders, day caps, up-to matrices,
day tickets, evening/overnight packages, event rates, early bird
(entry-time-conditioned), day/night + weekend/holiday/seasonal windows,
category pricing, contracts, merchant validations (amount/percent/
time-credit/re-rate), SFpark-style dynamic pricing. Coverage map: our
engine expresses everything a staffed single lot advertises; real gaps =
early bird (the pick-table-by-entry-time future design, same mechanism
as weekend menus) and validation overlays; anti-features = per-minute
billing + dynamic pricing. Indexed + logged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian 4f902d869e feat(web): published-versions sidebar on the composer page
The lab redesign gave only the lab tab the published-history sidebar;
the composer page was expected to have it too. /setup/tariff now lists
every published version (name or effective date, active badge, currency)
on the right; clicking one loads it into the editor as the SEED for the
next publish — which always creates a new immutable version (the sidebar
hint states this), making "roll back to last month's prices" a two-click
republish while the history stays append-only.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian a5e54a8b93 fix(devices): bucket camera health-check detail — stop per-frame log/status churn
The device monitor logs + re-emits a status only when state OR detail
changes, but the camera probe's detail was the exact snapshot byte count,
which differs on every JPEG frame — so healthy cameras "changed" on
nearly every poll, writing a log line + websocket event each time
(inflating the freshly budgeted container logs). The detail is now a
stable power-of-two bucket ("snapshot ≈16 KB" / "≈256 KB") that moves
only on a real shift (stream/resolution change); an empty-ish 200 body
is flagged as "<1 KB" rather than bucketed away. Failure details
(auth/HTTP/timeout) unchanged. 3 tests pin the no-flap behavior.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian 0180394c45 bump(resources): update TAG to stage-d905dd1 for deployment consistency
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 43s
2026-07-05 16:09:35 +02:00
julian d905dd19b4 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 42s
2026-07-05 15:56:39 +02:00
julian c5ed3f1308 feat(drawer): drawer hub — balance now, this-shift figure, daily activity, shift history; busy spinners
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 41s
/drawer was record + review only: no current balance, no sight of the open
shift's incomings, no daily activity, no shift history. Rebuilt as a hub:

- Drawer now: the till's running balance (new GET /api/drawer/balance,
  shift:read — exposes the service's existing drawerBalance(); the drawer
  is one site-wide till, same exposure the X-report already had) with the
  open shift's X-report breakdown alongside (float + takings + vouchers =
  expected = balance) and a "This shift: ±X" figure (expected − opening
  float — the shift's own contribution vs what it inherited).
- Today's cash activity: every cash payment + voucher since local
  midnight from the signed chain, live, with day totals (card never
  enters the till).
- Record + movements/review: the 2026-07-01 flow, unchanged.
- Closed shifts: drawer-focused history via the scope-aware /api/shifts
  (float → takings ± vouchers → expected per shift).

Also: every shift open/close button (header, /shifts, pay modal, end-
shift confirm) now shows an animated spinner + dims while busy — the old
label-swap-only feedback read as a dead click when a shift open ran slow.
The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain))
is recorded as an open item in wiki/concepts/shift.md with the fix
sketch: fold from the last z-report's signed expectedDrawerMinor forward.

No new ledger surface — one read-only endpoint; RBAC test added.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:56:30 +02:00
julian 0b7eb28dfa Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m50s
2026-07-05 15:24:09 +02:00
julian d5ff2097bd feat(web): currency becomes a closed select (ALL / EUR / USD)
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 41s
Currency was free text in the tariff editor (composer page + lab draft
modal — shared form) and the subscription plan editor; a typo could
publish an unknown code onto immutable versions. Both now offer a closed
select from lib/currencies.ts. An out-of-set code already stored on an
old record is appended as an extra option so it displays + round-trips
unchanged. Blank tariff form defaults to ALL (was EUR) — the site's
actual currency.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:29 +02:00
julian 1de209be48 fix(shifts): operator filter — select over real operators, no more focus loss
The admin operator filter was a free-text input that broke three ways at
once: its visibility hangs off the query response (scope === "all") and
its value is part of the query key, so every keystroke started a new
query, data went undefined for the round-trip, and the input UNMOUNTED
mid-keystroke (lost focus, list blanking that read as a page reload).
Filtering also silently failed — the server matches the operator by
exact username, so partial text matched nothing.

- keepPreviousData on the shifts query: previous data (and scope) stays
  live during refetch, so filter controls never unmount and the list
  never blanks on preset/filter changes.
- The filter is now a <select> of operators that HAVE shifts: the server
  returns the distinct list (signed z-reports + the open shift's holder)
  on GET /api/shifts, admin scope only — operators still can't see other
  names. Exact match by construction.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:29 +02:00
julian dc2cdc0a91 feat(web): self-host Chakra Petch as the app's primary face
The booth is an offline appliance — no webfont CDN — so the font ships
from public/fonts/chakra-petch: latin subset (covers en + sq ë/ç), the
weights the UI actually uses (400/600/700 + 400 italic, ~40 KB total),
SIL OFL license alongside the files. Chakra Petch leads all four family
tokens (mono/display/ui/body) with the previous stacks kept as fallback;
index.html preloads the two everywhere-weights so first paint doesn't
flash the fallback. Not a true monospace — .num/.tabular still request
tabular figures and columns verified aligned in the built app.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:15 +02:00
julian fd9885e9ec feat(tariff-lab): DB-backed draft tariffs + named published versions
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:

- tariff_drafts table (migration 0021): MUTABLE by design — the one
  exception to "editing publishes a version"; a draft prices nothing and
  signs nothing. Drafts are validated + tz-stamped on save exactly like a
  publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
  tariff:update); publishing a draft goes through the normal immutable
  POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
  (click any to price against it); main pane cut to pure entry/exit
  (ticket loader, payment, category inputs dropped); the composer form is
  extracted to TariffEditorForm.tsx and reused in a modal (new drafts
  prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
  publish — carried from the lab draft, or typed in the composer's new
  optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
  landed in d9e6c13) and the "Flat price / hour" relabel.

5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:42 +02:00
julian 52a89bfa56 feat(web): move tariff lab under /setup/tariff as a sub-tab
The lab lived at /subscriptions/tariff-lab — the wrong neighborhood for a
tool that tests the rate card. /setup/tariff is now a small layout with
two sub-tabs (composer at the index, lab at /setup/tariff/lab) behind the
existing tariff:read gate. Old URLs (/subscriptions/tariff-lab and the
original /setup/tariff-lab) redirect, and the tariff-read-only redirect
branch on /subscriptions is gone with the tab.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:32 +02:00
julian d9e6c13831 feat(tariff): whole-window package pricing mode (packageMinor)
A windowed card can now charge ONE total for any presence in its window —
the real night rate ("20:00–07:00 = 400, leave earlier and it's still
400"), which the per-increment flatMinor could not express (park-buzi's
"night 400" card billed 400/HOUR). Engine charges once per contiguous run
of increments the card wins, tracked across rolling-day segments so a
night crossing the 24h boundary charges once; out-of-window increments
price by the base card as usual.

Operator decisions (2026-07-05): per-occurrence repeat (two nights = two
charges), any-touch-pays-full, windowed cards only (a base "price per
day" is a 1-row up-to table). Validator: mutually exclusive with
flat/blocks/steps, no per-card cap, forbidden on the defaultCard.
flatMinor docs clarified as PER INCREMENT. 6 new engine tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:25 +02:00
julian 493210bbb0 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-07-05 10:41:04 +02:00
julian a9f18be700 feat(logging): extend log retention to 60 days and update log level options
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 40s
2026-07-05 10:40:42 +02:00
julian 72ad504b8d deploy(park-buzi): pin TAG=stage-365b648 (supersedes 93f9ebe, adds anpr do-while fix)
Build & push images / images (push) Successful in 2m50s
Same payload as the 93f9ebe pin (camera press-gate + cooldown backstop +
duplicate-plate anomaly, reader channel tagging + phantom feed filter, log
rotation/format) plus the anpr poll-loop do-while fix (at least one analyze
attempt per detection). Code-only — no migration.

Manual Komodo step: refresh ResourceSync → Execute → Deploy. Then the
vendor-tool reader session (prefixes Q:/K:, Card Input format 8H, symbology
cut) — server first, readers second.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
(cherry picked from commit 5cdf8f227b)
2026-07-05 09:21:00 +02:00
julian 5cdf8f227b deploy(park-buzi): pin TAG=stage-365b648 (supersedes 93f9ebe, adds anpr do-while fix)
Build & push images / images (push) Successful in 3m7s
CI / check (push) Successful in 42s
Same payload as the 93f9ebe pin (camera press-gate + cooldown backstop +
duplicate-plate anomaly, reader channel tagging + phantom feed filter, log
rotation/format) plus the anpr poll-loop do-while fix (at least one analyze
attempt per detection). Code-only — no migration.

Manual Komodo step: refresh ResourceSync → Execute → Deploy. Then the
vendor-tool reader session (prefixes Q:/K:, Card Input format 8H, symbology
cut) — server first, readers second.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 09:20:58 +02:00
julian 365b648282 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m50s
2026-07-04 20:17:00 +02:00
julian c03ef2a34b fix(anpr): guarantee at least one analyze attempt per vehicle detection
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 40s
CI flake root cause (Gitea runner, anpr-entry.test.ts "records an advisory
anpr-skip"): the poll-until-confident loop was a plain
`while (Date.now() < deadline)` — zero iterations were possible when the
window elapsed between deadline-set and loop-entry (the tests run a 5ms
window; a slow runner loses that race). Zero attempts → no frame analyzed →
"gave up" → no anpr-skip row → assertion fails. Not a regression: nothing in
the recent merges touched this path; the race existed since the poll loop
was built.

The invariant is real beyond tests: on a sufficiently loaded booth the old
loop could silently drop a real car's detection the same way. The loop is
now do-while (exit via the existing breaks: confident read, or next tick
past the slid deadline/hard cap), so a detection ALWAYS analyzes at least
one frame.

New regression test forces ANPR_POLL_WINDOW_MS=0 (the CI scenario, made
deterministic) and asserts exactly one capture attempt + the recorded skip.
Suite 283 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 20:16:52 +02:00
julian d9829eb61f deploy(park-buzi): pin TAG=stage-93f9ebe (press-gate + reader hardening + logging)
Build & push images / images (push) Successful in 2m50s
Carries: camera press-gate + cooldown backstop + duplicate-plate anomaly
(b4f1418), reader channel tagging + structural phantom filter (43c1f45),
log rotation/format (c21babf). Code-only — no migration; boot log should
pass straight through [migrate] done. The compose logging-option change
forces container recreation, which the Komodo deploy does anyway.

Deploy is the manual Komodo step: refresh ResourceSync → Execute → Deploy.
Reminder: deploy server BEFORE the vendor-tool reader changes (prefixes
Q:/K:, Card Input format 8H, symbology cut).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
(cherry picked from commit bafa3282c7)
2026-07-04 20:03:12 +02:00
julian bafa3282c7 deploy(park-buzi): pin TAG=stage-93f9ebe (press-gate + reader hardening + logging)
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 41s
Carries: camera press-gate + cooldown backstop + duplicate-plate anomaly
(b4f1418), reader channel tagging + structural phantom filter (43c1f45),
log rotation/format (c21babf). Code-only — no migration; boot log should
pass straight through [migrate] done. The compose logging-option change
forces container recreation, which the Komodo deploy does anyway.

Deploy is the manual Komodo step: refresh ResourceSync → Execute → Deploy.
Reminder: deploy server BEFORE the vendor-tool reader changes (prefixes
Q:/K:, Card Input format 8H, symbology cut).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 20:03:01 +02:00
julian 93f9ebea05 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-07-04 20:02:33 +02:00
julian c21babf293 feat(logging): ~2-month container rotation, ISO timestamps, level names
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 41s
Operator asked for bounded container logs (~2 months of history), human-
readable timestamps, and clarity on levels. Levels already existed (LOG_LEVEL
env → pino, default info; warn+ teed into app_logs, queryable at /setup/logs)
— the "level":30 / epoch-ms "time" in docker logs were pino defaults.

- server.ts logger: stamp ISO-8601 UTC time (timestamp fn) and level NAMES
  (formatters.level) so `docker logs` reads human.
- log-service.ts pinoDbStream: accept BOTH level encodings (name + numeric) —
  the label switch would otherwise have silently stopped warn+ persistence
  into app_logs. New log-service-stream.test.ts pins both encodings, the
  info-stays-stdout-only rule, and the never-throws fallback.
- docker-compose.prod.yml: json-file caps resized from 10m×3 (≈30 MB — days,
  not months) to ≈2 months by volume: server 20m×30, vision 20m×10, proxy
  10m×5. json-file rotates by SIZE; time-based isn't a driver feature —
  comment says to revisit if `docker logs` holds under ~60 days.
- app_logs retention default aligned 30→60 days (LOG_RETENTION_DAYS still
  overrides).

Wiki: app-logs.md gains the container-log store section (rotation, format,
LOG_LEVEL knob) + retention update; log.md entry.

Suite 282 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 19:47:53 +02:00
julian 44f34d68c4 Merge branch 'dev' into stage
Build & push images / images (push) Failing after 41s
2026-07-04 19:35:42 +02:00
julian 43c1f45e29 feat(reader): channel tagging (clone defense) + structural filter for phantom scans
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 42s
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).

1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
   channel-blind (one opaque cardid from either engine) and SubscriptionFlow
   matched by value only, so printing an RF card's UID (often written on the
   card face, e.g. 86A158) as a barcode cloned the card. Now:
   - Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
     overrides READER_QR_PREFIX / READER_CARD_PREFIX).
   - routes/qr-reader.ts strips the prefix and tags the read's confirmed
     channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
     capture stores the BARE value. READ log lines carry ch=… (permanent
     phantom attribution).
   - SubscriptionFlow.match requires channel agreement: an optical decode may
     not claim an rf credential (and vice versa) — refused + signed
     sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
   - Unprefixed reads keep the legacy untagged shape and match as before, so
     enforcement only bites where prefixes are deployed. Deploy server FIRST,
     then set prefixes in the vendor tool.

2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
   requested, reverses the earlier "record every probe" position — red
   "who is exiting?" rows for NOBODY train the operator to ignore the feed).
   read-dispatch.ts drops a no-match reader value that cannot possibly be a
   credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
   confirmed-RF, not a plate) to UNSIGNED device_events telemetry
   (unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
   shapes, unknown physical cards, unknown SUB- codes all still sign the
   normal refusal anomaly; enrolled credentials match before the filter and
   can never be hidden. Works for legacy unprefixed reads too — the feed
   cleans up on deploy, before any vendor-tool change.

Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).

Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 19:34:48 +02:00
julian 35e593ab63 docs(wiki): DT-008 phantom-scan diagnosis + backfill bypass/relay-test concept pages
Two independent wiki updates bundled (all docs):

1. dingtian-dt008-reader.md: phantom optical decodes on the park-buzi EXIT
   reader (empty pre-opening site, low-sun afternoons). Chain of evidence:
   READ log lines carry the reader's own serial (H05MA5B0) → physical device,
   not a network source; snapshot shows nobody present; code shapes are the
   giveaway (6-digit numerics = checksum-less Interleaved 2-of-5, lone "C" =
   Code39/Codabar artifact) → 1D engine decoding sun-made stripe patterns
   (striped arm, fence shadows, glare). No fraud exposure (11-digit Luhn ids
   can't match); noise only. Fix on the entity page: vendor-tool symbology cut
   to QR+Code128 + min decode length, BOTH readers; config lives ON the device
   → re-apply after any factory reset/swap. Deliberately NOT filtering
   impossible codes server-side — probe recording is the anomaly path's job.

2. Backfilled two shipped-but-undocumented features (six code files already
   linked the first page as if it existed):
   - concepts/entry-presence-bypass.md — admin drops a FAULTY presence signal
     (granular radar/camera by decision, not a master switch); every flip is a
     signed config_change; persists till off; tickets stamped presenceBypassed;
     radar-bypass cooldown tradeoff; "the admin is not the adversary, but
     trusted never means invisible".
   - concepts/setup-relay-test.md — admin-only commissioning pulse, signed
     barrier_open_command BEFORE the fire so a test open never reads as the
     out-of-band-open fraud signal; saved controllers/declared relays only;
     radarAlert lamps excluded; pulseOpen only.
   Cross-linked from operator-issued-entry.md, cataloged in index.md, logged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 19:00:49 +02:00
julian b4f1418858 fix(entry): enforce the camera press-gate + duplicate-ticket defenses
Field report (park-buzi): a BLINKING entry button still printed — the lamp
encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only
checked the radar, so a radar false-positive (rain, pedestrian) minted a real
signed ticket. Three layered fixes:

1. CAMERA gate on the physical press: with an entry camera configured, a press
   is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into
   EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never
   opens, never traps). Camera-less sites keep the radar-only gate; a faulty
   camera is dropped via the existing bypassPresenceCamera admin toggle.

2. Cooldown as a REAL backstop behind presence: the presence branch returned
   early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds
   the stationary-car double-ticket (a motion radar drops a motionless car →
   spurious loop-clear re-arms one-car-one-ticket → same car reprints).

3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected):
   when entry ANPR recognizes a plate already OPEN under another session entered
   within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE
   entry.duplicatePlate anomaly naming both tickets for the operator to void.
   ANPR stays non-blocking (rides the post-open snapshot as before).

REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The
camera has no leave events — "free" is a ~30s silence timeout that never lapses
inside a queue, so every queued car after the first would be suppressed until
an operator intervened. Blocking legit entry at peak beats nothing; the proper
preventive fix is a pass-through sensor (passedInput) — recorded as open in
wiki/concepts/entry-double-press.md.

Also: setup.relayTest reason was missing from both web catalogs (parity is only
enforced sq<->en, so the build passed) — added.

Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less
unaffected / bypass honored / cooldown catches the dropout re-press / residual
risk documented / still-present re-press stays suppressed) +
entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other
plates). Suite 258 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 18:41:06 +02:00
julian 9d73561855 deploy(park-buzi): pin TAG=stage-6505a4a (presence-bypass promotion)
Promotion of the entry presence-gate bypass (+ signed relay test): merge is on
stage, CI built :stage-6505a4a (pull verified). Bump the park-buzi Stack pin to
the new immutable sha. Deploy is the manual Komodo step: refresh ResourceSync →
Execute → Deploy; watch for [migrate] done (carries 0020 — two site_config
bypass columns, additive, applied at boot against the /data volume).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
(cherry picked from commit 094e963e5e)
2026-07-04 17:11:10 +02:00
julian 094e963e5e deploy(park-buzi): pin TAG=stage-6505a4a (presence-bypass promotion)
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 40s
Promotion of the entry presence-gate bypass (+ signed relay test): merge is on
stage, CI built :stage-6505a4a (pull verified). Bump the park-buzi Stack pin to
the new immutable sha. Deploy is the manual Komodo step: refresh ResourceSync →
Execute → Deploy; watch for [migrate] done (carries 0020 — two site_config
bypass columns, additive, applied at boot against the /data volume).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 17:11:10 +02:00
julian 6505a4a73b feat(entry): admin bypass of the presence gate for faulty radar/camera
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 41s
The entry button (physical press AND the operator-issued mint) requires
radar/loop presence + camera detection to confirm a real vehicle. When one
of those devices is faulty, the gate blocks legitimate transient entry. Let
the ADMIN drop a specific signal as a requirement until support fixes the
hardware — the admin is not the adversary, but weakening an anti-fraud gate
stays attributed and auditable:

- Granular: bypass radar and camera independently (Setup → controller
  section). A dead camera drops only the camera check; a dead radar only
  radar. Both off = normal gate; both on = press-to-print.
- Signed: a DEDICATED endpoint (PUT /api/site-config/presence-bypass,
  site:update) appends a signed config_change {setting, value, prev,
  operator} per actually-changed signal — new ledger type. No-op toggles
  sign nothing; disabling signs too. Kept out of the generic site PUT.
- Flagged: every vehicle_entry issued (and every refusal anomaly) while
  bypassed carries presenceBypassed:[...] in its signed payload.
- Persists until turned off; amber warning in Setup while active. The
  booth entry light treats a bypassed signal as satisfied (server
  re-checks authoritatively). Physical-button path falls through to the
  cooldown backstop when radar is bypassed.
- Migration 0020: two boolean site_config columns (default off).

Fixes a latent bug surfaced by the tests: firstRelayByDirection returned no
presenceInput, so issueForOperator's radar gate always read "presence loop
unavailable" — operator-issue never actually gated on radar. The resolver
now attaches the presence input serving the relay (mirrors relayForButton).

10 new tests: 5 gate combinations (each bypass drops only its signal +
records it), 5 route tests (RBAC, signed transitions, no-op, validation).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 16:52:34 +02:00
julian 8b65e199a3 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m49s
2026-07-04 13:42:51 +02:00
julian f486dcbbfc docs(wiki): vision-service hardening backlog + boot-migration data-seed note
Build desktop / desktop (push) Successful in 4m32s
Build & push images / images (push) Successful in 2m58s
CI / check (push) Successful in 41s
Two unrelated leftover wiki edits from earlier sessions:
- NEW concepts/vision-service-hardening.md: the prioritised to-do list from the
  2026-07-02 code + security reviews of apps/vision/ (DoS gaps, unauthenticated/
  operator-writable model weights, 0.0.0.0 default bind). Cross-linked from
  opencv-anpr-service.md ("consult before touching this service").
- container-deployment.md: note that a boot-time migration can be a DATA SEED
  (e.g. an RBAC permission granted to the operator role via INSERT OR IGNORE),
  and that a built-in-role grant does not auto-apply to a custom role.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 13:41:32 +02:00
julian c142166972 docs(wiki): ATECC608 is upcoming — retag ledger signing to the on-host reality
No secure element is on-site: event signing runs on the software HMAC
(EVENT_SIGNING_KEY, an env var on the host disk), so the ledger is
tamper-EVIDENT but forgeable by anyone who owns the host. Several pages
overstated it as present-tense "ATECC608-signed / unforgeable"; correct them.

- NEW concepts/hardware-signer-options.md: four options for a non-extractable
  signing key (USB HSM / YubiKey / reuse the TPM / plain-dongle trap) + the
  recommendation (TPM interim → USB-HSM target; ATECC608 stays for the embedded
  ESP32, wrong part for a PC host).
- entities/atecc608.md: UPCOMING-not-present status banner + PC-vs-embedded.
- disk-os-hardening.md: fix the live-USB row (BIOS boot-order password is
  load-bearing, not Secure Boot — a signed live USB runs); add a physical-tamper
  chain (Dell 7070 CMOS-reset → live-USB → PCR-7 same-signer unseal) + accepted
  risks (that unseal, unsigned-initramfs evil-maid, operator-USB read TODO).
- open-questions #6 reframed; standing-decisions / overview / threat-model /
  index de-overstated; log query entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 13:41:11 +02:00
julian 306d136a08 feat(setup): operator-tested relay pulse, signed into the ledger
Add a per-relay "Test" control on each saved controller in /setup so an admin
can prove barrier wiring without a vehicle. POST /api/setup/test-relay pulses a
barrier relay — but because a physical open with no matching signed command is
the fraud signal, the route SIGNS a barrier_open_command (reason setup.relayTest,
source manual, attributed to the acting admin) BEFORE it fires. Reconciliation
then reads the open as explained, not an anomaly, and there's an audit trail.

- Admin-only (site:update), CSRF-guarded; fires only against a SAVED controller
  (real id → clean attribution; also stops a redirected/unsaved config from
  opening an arbitrary host's barrier). Sign-before-fire; a pulse failure is
  reported, not a 500. radarAlert relays (lamps) are excluded from the UI.
- New reason code setup.relayTest in @parking/shared (+ EN template); sq/en keys.
- EventLog constructed before setupRoutes so the route can sign.
- Integration test (stub controller, no hardware): RBAC 403, CSRF 403, signed
  barrier_open_command on success, 400 unknown relay w/ no ledger row, 404
  unknown controller, 400 bad relay value.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 13:40:58 +02:00
julian 61b9955160 deploy(park-buzi): pin TAG=stage-d2ab2e0 (keep dev in sync with stage)
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 39s
Mirrors the pin on the stage branch so resources.toml agrees regardless of which
branch the Komodo ResourceSync reads.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 13:06:51 +02:00
julian b7e4037fbe deploy(park-buzi): pin TAG=stage-d2ab2e0
Build & push images / images (push) Successful in 2m52s
Promotes the dev→stage merge (d2ab2e0) to the staging booth: snapshot
content-type fix, Active Sessions/modal rework, DB reset CLI, drawer redesign,
card tender disabled, operator-issued entry + plate-swap reconciliation.
Migrations 0018/0019 run at container boot.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 13:06:26 +02:00
julian d2ab2e022e Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m49s
Promote to staging (park-buzi): snapshot content-type fix, Active Sessions/modal
rework, DB reset CLI, drawer redesign (operator records / admin reviews), card
tender disabled (no POS), operator-issued entry + exit plate-swap reconciliation.

Migrations 0018 (drawer permissions) + 0019 (session:create) run at container
boot. TAG in komodo/resources.toml still points at the OLD image — re-pin to the
new stage-<sha> CI produces from this merge before deploying.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 13:01:54 +02:00
julian 33c4ea1e91 feat(entry): operator-issued entry + exit plate-swap reconciliation
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s
Two halves of one anti-fraud design.

(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
  - PRESENCE-GATED like the physical button: a real car must be present (radar/
    loop AND camera busy). Enforced BOTH sides — the server re-checks current
    presence so a direct POST can't bypass a disabled button; no presence loop
    => feature unavailable; a no-presence attempt signs an anomaly.
  - FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
    companion entry.operatorIssued anomaly (the adversary path always leaves a
    red-flag row).
  - capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
    a legit car).
  New session:create permission (migration 0019 -> operator role, admin-
  revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
  issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
  factored into one shared #issueTicket (button + operator). UI: the entry
  BarrierLight becomes a clickable issue-control when presence+permission+shift
  meet (confirm -> issue).

(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
  - BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
    pay/exit modal shows a red warning + "Override & release" (override signs an
    attributed exit.plateSwapOverride). Flag+override, never a silent hard block
    (exit fails-open; a plate is never the sole gate).
  - READER path (no operator): log-only anomaly + fail-open.
  Extended BoothExitResult + /api/exit (override); boothExit client returns a
  structured swap result.

Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 12:17:52 +02:00
julian 114a32e6f2 feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.

- New `drawer` resource: drawer:create (operator records; admin-revocable per
  role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
  default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
  A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
  touches the drawer balance (the correction is settled outside the app). This
  is what keeps a late review from leaking into the next operator's inherited
  drawer — a denial that lands after the reviewed shift closed moves no cash.
  Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
  op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
  (operator: record + own; admin: review queue + all). routes/drawer.ts lifted
  from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
  for its other job = admin-sees-all-shifts). New DrawerManager.tsx.

Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
  movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
  Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
  on-site), matching the card-tender gate.

shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 11:17:20 +02:00
julian 018328a877 feat(booth): disable card tender until a P2PE POS is on-site (cash-only)
No card processor / POS terminal on any site yet. Offering "Card" would let an
operator record a card payment that never cleared a terminal, corrupting the
till reconciliation — a fraud/error surface on an operator-adversary system.

Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both
tender pickers (BoothPayModal, SubscriptionManager). With card off there's
nothing to choose, so the tender row is suppressed and payment defaults to
cash. UI-only gate: the Tender type, payment events, shift accounting, and
reports still understand `card`, so historical card events and a future
re-enable stay coherent.

Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open
barrier" with no tender/cash/card row.

Wiki: new concepts/card-payments.md records the current cash-only state, the
PCI-scope-out-of-app constraint, the future-POS device requirements, and the
re-enable path (flip the flag once a bank-certified P2PE terminal is
provisioned). Linked from index, parking-session, open-questions #3.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 09:57:03 +02:00
julian 266e9b0027 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
2026-06-30 17:58:43 +02:00
julian d92b8d1e6a feat(db): gated training/demo database reset CLI
A site is sometimes run live to train operators/admins; afterwards the demo
data must go without an obvious self-serve button (the operator must not be
able to wipe history). Adds packages/db/scripts/reset-db.mjs, exposed as
`pnpm db:reset` (dev) and run via `docker exec ... node
node_modules/@parking/db/scripts/reset-db.mjs` on the booth (no pnpm there).

Category flags (combinable): --financial (ledger + telemetry + snapshots +
subscription instances + blocklist; keeps users/devices/config/tariffs/plans),
--config, --users, --all. Shifts/cash/payments live as event types inside the
hash-chained ledger_events, so --financial truncates the whole signed ledger
back to empty (re-seed starts a new chain under the SAME EVENT_SIGNING_KEY —
keys untouched).

Two safety gates: RESET_ALLOWED=1 env (a real booth never sets it) + typed
DB-filename confirmation (--yes skips for CI). Single transaction + VACUUM.

Verified on throwaway dev-DB copies: both gates refuse correctly; each flag
wipes/keeps the right tables; the real dev DB is never touched.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:34 +02:00
julian 61de1fe772 feat(booth): rework Active Sessions + pay/exit modal around barrier re-open
Move the audited barrier re-open out of the inline Active-Sessions row button
and into the modal, and turn the modal's dead-ends into useful views.

- Remove the inline per-row "Open barrier" button. Clicking a row opens the
  modal, which carries the action.
- Modal recognizes a closed-within-grace transient (found && !open &&
  withinGrace) and shows the session view + Open barrier instead of dead-ending
  on "already closed" — the exact case (paid, barrier unconfirmed) that needs a
  re-pulse. Server reopenBarrier guard unchanged.
- Active-Sessions rows show a live grace-remaining countdown badge
  (exited - M:SS, 1s tick off graceExpiresAt) via new formatCountdown helper.
- Settled sessions show the ACTUAL sum paid (new SessionLookup.paidMinor,
  summed across payment events) instead of a flat "PAID" badge.
- 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/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:24 +02:00
julian cfac14e09e fix(snapshots): normalize content-type on serve so stored images render
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/101 dev-DB rows); GET /api/snapshots/:id re-emitted
it verbatim, so every legacy snapshot rendered blank in the booth modal.

Capture was already hardened (encodeForStorage re-encodes to a clean
image/jpeg, fail-soft via cleanType), but the serve route trusted the stored
value. Export cleanType and apply it when setting the response header, so a
bare image/jpeg is sent regardless of what was stored — un-breaks all legacy
rows with no data migration. A stored value from an untrusted device is itself
input; normalize on capture AND on serve. Adds cleanType unit tests.

Verified: a previously-unrenderable 2560x1440 row now decodes in-browser.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:06 +02:00
julian 1b86750b0d docs(wiki): firmware/dbx-vs-TPM hardening + create disk-os-hardening page
Real-world park-buzi episode: a UEFI dbx update (delivered via fwupd/LVFS,
NOT apt) revoked a stale GRUB -> panic, and moved PCR 7 -> broke TPM-sealed
LUKS auto-unlock -> passphrase prompt. Recovered by re-sealing PCR 7.

- appliance-provisioning.md: extend the §4 re-seal runbook to name dbx; new
  §4a (fwupd-not-apt, GRUB-panic ordering, PCR-7 re-seal, operator lockdown:
  mask fwupd + remove firmware-updater snap + BIOS-password + passphrase
  escrow) incl. the --test-passphrase-silently-passes-via-TPM trap
  (--disable-external-tokens); gotchas #12/#13.
- disk-os-hardening.md: NEW — resolves a long-dangling wikilink referenced
  from ~18 pages. The *why* of host hardening (5 controls + firmware lockdown);
  commands stay in appliance-provisioning; reconciliation remains the primary
  anti-fraud control.
- index.md: expand the disk-os-hardening catalog line.
- log.md: two note entries.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 15:21:32 +02:00
julian 9c6741a485 docs(wiki): record backup deploy gotchas (compose allowlist + host mount)
CI / check (push) Successful in 40s
Two lessons from the first park-buzi staging deploy, both in backup-recovery.md:
- A new server env var (BACKUP_KEY) must be added to docker-compose.yml's
  server.environment: allowlist, not just the Komodo secret/Stack env — otherwise
  the container never receives it (inspect shows it absent, not empty).
- The backup target must be a host path bind-mounted into the container; a desktop-
  automounted USB (/run/media/...) is invisible inside the container, so Test target
  reports 'does not exist'. Destinations are admin-provisioned (fstab + compose bind-
  mount), not operator-pluggable — partly a threat-model feature. Acknowledged as a
  flexibility limitation; USB-automount-to-container flow deferred.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 17:38:24 +02:00
julian d0b609e375 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m46s
# Conflicts:
#	komodo/resources.toml
2026-06-29 15:57:10 +02:00
julian 8f32d90d28 fix(compose): pass BACKUP_KEY through to the server container
Build & push images / images (push) Successful in 2m47s
CI / check (push) Successful in 38s
The server's compose environment: block is an allowlist — it only forwards the vars
it names. BACKUP_KEY was never added when the backup feature landed, so even though
Komodo wrote BACKUP_KEY into the Stack .env, compose dropped it and the container
came up without it (docker inspect showed JWT/SIGN present, BACKUP_KEY absent — not
empty, absent). The Backup screen correctly reported 'BACKUP_KEY missing'.

Add BACKUP_KEY: ${BACKUP_KEY:-} next to EVENT_SIGNING_KEY (optional, empty default —
backups stay off until it's set). The prod overlay only merges VISION_URL, so the
base addition flows through to prod.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 15:56:39 +02:00
julian 07295f8063 deploy(park-buzi): pin TAG=stage-39c778f
Build & push images / images (push) Successful in 2m48s
CI / check (push) Successful in 38s
The first :stage image is built and in the registry (stage-39c778f). Pin it in the
IaC so git matches Core's Stack env and a ResourceSync won't revert TAG to the
placeholder. Bump this on each promotion.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 15:27:31 +02:00
julian 652d6599d3 ci(build-images): trigger on komodo/** too
Build & push images / images (push) Successful in 2m44s
CI / check (push) Successful in 41s
A push only builds if it touches a path in the filter. The first stage commit was
komodo-only, so no :stage image was ever built. Add komodo/** so IaC/Stack changes
(and a komodo-only push to stage) also build+check — a deploy-config change gets the
same sanity pass before it reaches a booth. This commit itself touches the workflow
file (already filtered), so it triggers the build that produces the first :stage image.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 15:19:35 +02:00
julian 39c778fbac ci(build-images): trigger on komodo/** too
Build & push images / images (push) Successful in 2m50s
A push only builds if it touches a path in the filter. The first stage commit was
komodo-only, so no :stage image was ever built. Add komodo/** so IaC/Stack changes
(and a komodo-only push to stage) also build+check — a deploy-config change gets the
same sanity pass before it reaches a booth. This commit itself touches the workflow
file (already filtered), so it triggers the build that produces the first :stage image.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 15:19:20 +02:00
julian e16bccc2f5 chore(deploy): park-buzi TAG is a placeholder, pinned at deploy time
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 40s
A committed stage-<sha> can never match the commit that introduces it (the pin
commit changes HEAD), so a hardcoded sha here is always stale by one. Make it an
explicit placeholder (stage-REPLACE_WITH_BUILT_SHA); the real immutable sha is set
when you deploy from Komodo Core after CI builds :stage-<sha>. No moving tag on a
booth still holds.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 13:12:27 +02:00
julian 2ab001054d chore(deploy): park-buzi TAG is a placeholder, pinned at deploy time
A committed stage-<sha> can never match the commit that introduces it (the pin
commit changes HEAD), so a hardcoded sha here is always stale by one. Make it an
explicit placeholder (stage-REPLACE_WITH_BUILT_SHA); the real immutable sha is set
when you deploy from Komodo Core after CI builds :stage-<sha>. No moving tag on a
booth still holds.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 13:12:20 +02:00
julian 381046190b feat(deploy): add stage tier — park-buzi as the staging booth
Model the staging-vs-production split that fleet-deployment-komodo flagged as open.
Three tiers: dev (working, no booth) -> stage (staging booth park-buzi, real-world
test) -> main (production, manual + pinned).

- build-images.yml: trigger on [dev, stage, main]. The tag computation is already
  branch-derived, so :stage / :stage-<sha> build with no other change.
- komodo/resources.toml: park-buzi now branch=stage + TAG=stage-<sha> (pinned;
  no webhook even on staging). BACKUP_KEY already wired as a per-booth secret.
- komodo/README.md: a Promotion (dev->stage->main) section; per-booth secret list
  now includes backup_key; hard-rule #1 generalised to pinned <branch>-<sha>.
- wiki: fleet-deployment-komodo open-item resolved + a Promotion-tiers table;
  deploy-trigger choice generalised; container-deployment tag list gains :stage.

Promotion is a merge: when dev is ready, merge dev->stage, CI builds the image,
bump TAG=stage-<sha> in resources.toml, deploy from Core. stage is branched from
dev HEAD so the first real-world test carries the full current app. Per-booth
secrets must pre-exist in Core; migrations run at boot so a promotion auto-migrates
the staging ledger (where a bad migration is caught before production).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 13:11:35 +02:00
julian 84f00db48b feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Build desktop / desktop (push) Successful in 4m17s
Build & push images / images (push) Failing after 39s
CI / check (push) Successful in 39s
Retention (keep-last / keep-daily-days) is operational policy the on-site admin
should tune, not a server env var requiring a redeploy -- same reasoning that moved
the target directory to the UI.

- Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable;
  null = code default 7 / 30 per field).
- BackupService reads retention fresh each run; status() exposes keepLast +
  keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone).
- PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null
  to reset to default; 400 on negative).
- UI: two retention fields on the Backup config card; one Save covers target +
  retention. i18n sq + en.

BACKUP_KEY wired into Komodo:
- komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret,
  alongside JWT / signing keys).
- komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it
  offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are
  admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY.

Also carries the small in-progress setup-intro i18n copy trim.

Tests: 218 server tests green, incl. retention persist / reset-to-default / reject-
negative and the updated status shape. Migration applies cleanly (needed a
statement-breakpoint between the two ALTERs). Wiki backup-recovery updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 12:52:18 +02:00
julian d5e41500a8 feat(backup): admin UI with admin-chosen target directory
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s
The backup destination is now chosen by the on-site admin in the UI (Setup ->
Backup), not a server env var. An env-pinned target defeats the purpose: the admin
can't point backups at a freshly-plugged USB or a NAS mount without editing .env
and restarting. The encryption key stays a server secret.

Target storage:
- New site_config.backup_target_dir (migration 0016, nullable; null = not
  configured). BackupService reads it fresh each run, so a UI change takes effect
  with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it
  backs up.

Routes:
- PUT /api/backup/config  -- set/clear the target (backup:update; upserts id=1).
- POST /api/backup/test   -- probe a candidate path server-side (exists / is a
  directory / writable) so the admin gets feedback before relying on it.
- status() now exposes targetDir + keyPresent, so the UI distinguishes
  'no target set' from 'BACKUP_KEY missing'.

UI (apps/web/src/BackupSettings.tsx):
- A Setup -> Backup tab (gated backup:read): an editable target-path field with a
  Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status
  panel (config state, last-run size/pruned/error, a distinct amber missing-key
  warning), a Back up now button, and the restore-is-out-of-band note. Full i18n
  (sq + en); nav.backup.
- API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup.

Also includes a small in-progress copy trim to the setup-intro i18n strings.

Verified live with Playwright: typed a path -> Test reported writable -> Save
persisted it -> status reflected it and showed the key-missing warning. Whole
monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 12:21:26 +02:00
julian 0c218179c4 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
2026-06-29 11:59:45 +02:00
julian 9e442586af docs(wiki): settle on-site encrypted backup + disaster-recovery design
New concept page backup-recovery.md resolving the design half of open-question #5.
Driving scenario: a stolen/destroyed PC whose LUKS+TPM disk is unrecoverable by
design — recovery stands up a NEW PC, restores a backup, and keeps signing the
SAME chain.

Settled: admin-driven encrypted full-DB backup (SQLite online-backup/VACUUM INTO,
snapshots included) to local/USB, SMB/NFS, or SFTP targets; manual button + an
in-process daily timer; keep-last-N + dailies retention; restore is admin-only /
out-of-band (operator-adversary surface). A restored copy must still verifyChain.

Key custody (the load-bearing decision, bears on #6): three independent keys —
EVENT_SIGNING_KEY kept an extractable, escrowed software key DECOUPLED from the
TPM so the ledger survives total hardware loss (the conscious trade: a TPM-sealed
signing key would be unforgeable but permanently unverifiable after the machine
dies); a NEW dedicated park_buzi_backup_key in Komodo for backup encryption,
separate from the signing key; the LUKS/TPM disk key, appliance-only and
deliberately non-recoverable. Keys are never inside the backup they unlock.

Updated open-questions #5 (design SETTLED) + #10 note; disk-os-hardening deploy
runbook (why the signing key is not sealed + park_buzi_backup_key); index catalog
+ concept count. Design only — not yet built.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:43:23 +02:00
julian 11567a417f docs(wiki): catch concept pages up to the booth-UX/shift/font-scale work
Bring three queryable pages current with the booth-UX commit (cce99aa) whose
breadth hadn't propagated:
- booth-console: Active Sessions as a real table, dropped status column/filter,
  inline live-feed rows, removed TARGE via-badge + redundant Direction filter,
  plate now searchable + backfilled via plate-recognized WS push, per-user font scale.
- shift: Z-report display simplified (shitje dropped, opening cash added) while
  the signed payload is untouched.
- i18n: users.font_scale recorded alongside language/theme as the matching
  per-user server-stored pref (migration 0014).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:43:12 +02:00
julian f6e35bbebf fix(reader): correct the QR reader's identity — Dingtian DT-008, not "GEE"
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m46s
CI / check (push) Successful in 38s
An early wrong assumption named the QR/RFID access reader "GEE" /
"GEE/Fondvision" / "GEE-QR-ER80" (and summarized a raw GEE PDF as its
datasheet). There is no GEE device — it's the Dingtian DT-008
(dingtian-tech.com/en_us/qr_code_reader.html), the same vendor as the relay
board, which is why it integrates the identical HTTP-GET-push way.

Code:
- Driver symbol geeQrReaderDriver → dingtianQrReaderDriver; label →
  "Dingtian DT-008 QR/RFID reader (HTTP push)"; comments/description rewritten
  to the real DT-008 facts (Wiegand 26/34, TCP/IP, USB, RS485 — not RS-232;
  QR/barcode + ID/IC/NFC — not DataMatrix/1D).
- Persisted driverId "gee-qr-reader" → "dingtian-qr-reader" (the registry
  lookup key + the row created on assign in qr-reader.ts).
- Migration 0015 rewrites existing devices.driver_id rows so configured readers
  keep resolving (applied to the dev DB — 2 rows; the booth applies it on boot).
  Behaviour is unchanged: naming + the persisted id only.

Wiki + memory:
- Renamed entities/gee-qr-er80.md → dingtian-dt008-reader.md and
  sources/gee-qr-er80.md → dingtian-dt008.md; rewrote both to the real DT-008
  product-page specs while KEEPING all the verified-on-hardware protocol facts
  (cjihao serial, .jsp path, Connection: close). Fixed every cross-reference +
  "GEE" mention in 6 other pages. Memory gee-reader-serial-binding →
  dingtian-reader-serial-binding. The only surviving "GEE" mentions are
  deliberate naming-correction notes, the raw PDF filename, and the
  append-only log history.

Full workspace build/lint/test green; dev DB readers verified resolving to the
registered dingtian-qr-reader driver.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 17:39:15 +02:00
julian 96acd6b662 feat(snapshot): re-encode captures + disk-pressure retention
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m56s
CI / check (push) Successful in 38s
Camera snapshots were stored RAW — the camera's full-res JPEG straight into
the BLOB, no resize/recompress. Measured on the dev DB: 300 snapshots = 81.7 MB
= ~72% of the 114 MB SQLite file (the big ones 2688×1520 / ~600 KB, Hikvision
main stream). They dominated the appliance's single backed-up DB file.

Re-encode on capture (snapshot.ts):
- Downscale each frame to SNAPSHOT_MAX_EDGE (1280px long edge) + recompress at
  SNAPSHOT_JPEG_QUALITY (80) via sharp (libvips, Apache-2.0) before storage —
  ~6-10× smaller (verified 2688×1520 → 1280×724, ~8×), plate still readable,
  clean image/jpeg (drops the camera's charset cruft). STORAGE-ONLY: recognition
  keeps the ORIGINAL full-res bytes (downscaling hurts OCR). Fail-soft — a
  re-encode error stores the original, never drops the snapshot or blocks the
  (already-open) path. sharp lives in apps/server (owns the capture path), where
  bcrypt already establishes the native-dep pattern.

Disk-pressure retention (snapshot-retention.ts) — a SAFETY VALVE, not the daily
mechanism (the re-encode does that). Daily check reads the DB filesystem used%
(statfs on db.$client.name); no-op unless ≥ SNAPSHOT_DISK_HIGH_PCT (70). Over the
mark: delete the OLDEST until an estimated SNAPSHOT_DISK_FREE_TARGET_PCT (10%) of
disk is freed — never below SNAPSHOT_MIN_KEEP (500) — then VACUUM once to return
space to the OS. A DELETE only frees SQLite pages (disk doesn't drop until VACUUM),
so the loop is driven by estimated freed bytes (SUM(length(bytes))), not a live
disk re-read; the prune owns the DB-locking VACUUM, run daily off-peak. diskUsage
is injectable for tests. None of this touches the signed ledger — snapshots are
unsigned/advisory, referenced only by id.

Tests: encodeForStorage (downscale / clean-type / no-enlarge / fail-soft) +
pruneSnapshots (no-op below mark / delete-oldest-to-target + VACUUM / MIN_KEEP
floor / skip-VACUUM-when-empty). All four snapshot env knobs documented in the
komodo env reference. Full workspace build/lint/test green; the prune smoke-verified
on a scratch DB copy (file shrank after VACUUM).

Existing ~81.7 MB of raw snapshots are unchanged (a one-off re-encode backfill is
a separate optional follow-up). Updated entry-exit-points + technology-stack wiki.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 17:15:15 +02:00
julian cce99aadfd fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)

Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
  (h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
  pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
  font utility to rem across the web app (~230 sites in 25 files + the
  .label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
  visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
  layout stays put, so chrome never clips; tall content scrolls its own container.
  Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.

Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
  the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
  options already in the Type filter.

Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
  align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
  name; overstay keeps a row tint). Removed the now-redundant status filter; only
  the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).

Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
  total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
  the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
  opening + cash-taken = expected reads clearly. Money values no longer line-wrap.

Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
  following row by one column — it now emits a full label+value pair.

Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 15:15:09 +02:00
julian f706726eeb feat(prefs): per-user UI font scale (A−/A+), saved to the profile
A header A−/value/A+ control scales the whole UI, persisted per user and
restored on login from any booth — cloning the theme-pref pattern end to end.

- DB: users.font_scale (migration 0014; percent, 100 = base, NOT NULL default).
- Server: PUT /api/auth/font-scale (auth-guarded; clamps to 80–160, snaps to a
  10-step); fontScale flows through sessionView → login + /me.
- Client: setFontScalePref + applyFontScale; applied in App alongside theme;
  FontScaleToggle in the header; i18n sq+en.

Scaling uses CSS `zoom` on the root, NOT root font-size: the app's type is pinned
in px (text-[12px] etc., ~230 spots), which a font-size change would not scale —
so the dense Active-sessions / Live-feed logs stayed tiny. `zoom` scales
everything uniformly (text, spacing, icons) like the browser's Ctrl+/−, which is
the readability win for operators who need larger text.

Tests: 4 font-scale auth-route cases (persist + /me, clamp/snap, 400, default-100).
Full workspace build/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 12:25:04 +02:00
julian 6734e9815e fix(booth): backfill the live-feed plate + make plate search work
Two booth feed fixes:

- Plate not showing until refresh. Plate recognition is async/advisory
  (snapshot.ts recognizePlate → a kind:"read" device_event keyed by the session
  identity), so it lands AFTER the entry/exit event already shipped over the WS
  without a plate; a refresh re-fetched via the bulk enrich path and showed it.
  Added a `plate-recognized` bus event (device-events.ts) emitted when the read
  is written; ws.ts forwards it; the client patchPlate(identity, plate)
  (live-store) backfills the already-rendered feed row in place and invalidates
  the Query-owned active-sessions list. No refresh.

- Plate search didn't filter. Both the live-feed (BoothScreen) and active-sessions
  (ActiveSessions) search haystacks matched the wrong field — the displayed plate
  is the ENRICHED top-level e.plate/s.plate (set by enrichEvent), not payload.plate
  (the plate is unsigned, never in the signed payload). Switched the haystacks to
  the displayed field.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 12:24:46 +02:00
julian 38481f105f feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy)
off the camera lane-status only — they couldn't show the radar-only "detected,
not yet confirmed" state that makes the physical button lamp (relay 3) blink.
Now they mirror the lamp's 3-state rule per lane:
  radar present + camera not busy → BLINK green↔red (~1 Hz)
  camera busy                     → SOLID red
  otherwise                       → SOLID green

End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
  each presence edge to its lane via the new direction-agnostic presenceLaneOf()
  (device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
  the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
  event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
  keyframe (index.css), which holds solid-red under prefers-reduced-motion.

Same input + same rule as the lamp, so the screen and the post never disagree.

A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.

Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 11:48:12 +02:00
julian 4418594af0 refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 38s
The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).

Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
  barrier; a new `radarAlert` event drives a non-barrier alert lamp (blink while
  its trigger input is active, SOLID once the camera confirms a car).
- Dropped the separate config.buttonLight block — the lamp is just a relays[] row
  with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
  replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
  machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
  so several alert lamps on one controller run independently. Every barrier
  resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).

Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
  "+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
  name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
  legacy relays[].button/presenceInput/... fields, so relayForButton /
  relayForPresence resolve identically from either shape — zero-downtime, no
  migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
  the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
  lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
  camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
  relays[].presenceActiveLow, and the inputActiveLow escape hatch.

UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.

Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).

Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 11:23:15 +02:00
julian 25a72ff20a feat(anpr): per-camera auto-open toggle (anprAutoTrigger) for shared lanes
Build desktop / desktop (push) Successful in 4m32s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 37s
A shared entry/exit lane has both an entry and an exit camera on ONE lane: a
subscriber driving IN is admitted by the entry cam, but the exit cam sees the same
car leaving its frame and phantom-EXITs the occurrence just opened (its back plate).

Separate RECOGNITION from AUTO-OPEN per camera:
- config.anpr (unchanged) = run snapshots through the recognizer, record the plate
  (evidence), BOTH directions — stays on.
- config.anprAutoTrigger (new, absent ⇒ on when anpr is on) = may THIS camera
  auto-open the barrier. Set false on the shared-lane exit cam: it still recognises
  plates but never auto-triggers. The bridge gates on it (anpr-entry.ts), before the
  poll loop.

UI: a "Auto open/close on subscriber plate" checkbox under ANPR in the camera setup
(shown when anpr is on); persisted true/false so a park can explicitly disable it.
i18n sq+en (also corrected the now-stale anprHint "never opens a barrier" wording —
it does, via the bridge). +1 server test (anprAutoTrigger=false → no snapshot, no
read); 172 green. Documented the two toggle levels (site-wide + per-camera) in
lane-presence-and-anpr-entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 23:52:16 +02:00
julian c2a861208f fix(anpr): sliding poll window so a car arriving mid-loop isn't lost
A loop started by a far/early car would (a) give up before the REAL car settled at
the barrier, and (b) swallow the real car's pushes (the #polling guard dropped them).
So a confident-but-wrong far-car plate could win, or the intended car get debounced
out after the loop ended — wrong car acted on, right car blocked.

Fix: a push that JOINS a running loop now EXTENDS the deadline (lastPush +
ANPR_POLL_WINDOW_MS) instead of being dropped, capped at start + ANPR_POLL_MAX_MS
(30s) so a continuously-busy lane can't slide forever. Each tick still pulls a FRESH
frame, so the loop tracks whoever is at the barrier NOW, not the car that started it.
Per-camera sliding deadline in #pollDeadline (cleared with #polling in finally).

+1 test (push mid-poll keeps the loop alive past the initial deadline); 171 server
tests green. New knob ANPR_POLL_MAX_MS documented in the komodo env reference + the
two concurrency guards written up in lane-presence-and-anpr-entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 23:30:41 +02:00
julian a888125eca docs(komodo): complete env reference — every server/vision var + defaults
The Stack example only listed the required set; expand it to the FULL reference so
an operator can see (and tweak) every env without digging through code. Grouped:
- IMAGE SELECTION (REGISTRY, TAG)
- REQUIRED (JWT_SECRET, EVENT_SIGNING_KEY, COOKIE_SECURE — no safe default)
- COMMONLY SET (VISION_ENABLED, WS_ALLOWED_ORIGINS)
- SET BY COMPOSE — don't put in the Stack (VISION_URL, DATABASE_URL, VISION_RECOGNIZER)
- OPTIONAL TUNABLES with code defaults: ports, logging/retention, device+printer poll
  intervals, lane/capture TTLs, and the ANPR knobs incl. this session's new
  ANPR_POLL_MS=1000 / ANPR_POLL_WINDOW_MS=8000 (raise the window for a slow barrier)
- VISION CONTAINER env (the Python service's own VISION_* vars)
All defaults pulled from the code (process.env.X ?? default). Documentation only.
2026-06-27 23:23:11 +02:00
julian 96fd97efa9 fix(web): VITE_API_BASE relative (empty) for the booth's same-origin SPA
apps/web/.env.production hardcoded VITE_API_BASE=http://127.0.0.1:3000 — a
desktop-only value that's WRONG for the booth, which serves the SPA same-origin
(Fastify dist/ via Caddy :80) and needs a RELATIVE /api base. An absolute origin
baked at build would point the browser at localhost. origin.ts treats empty as
relative (API_BASE=""), matching the deploy (the 77b2acb fix / container-deployment
"Web access").

The desktop (Tauri) build DOES need an absolute origin, but that app is a deferred
separate task (currently hardcoded localhost); it must set VITE_API_BASE for its own
build when resumed, not here. Comment updated to say so.
2026-06-27 23:16:19 +02:00
julian 2a13b95da6 fix(anpr): abort the poll loop if the subscriber transacts by card/QR mid-poll
The poll-until-confident loop (prev commit) opened a race: during its ~8s window a
subscriber could scan their card/QR at the reader and exit immediately — but the ANPR
loop kept polling and would ALSO emit a confident read a moment later, exiting the
NEXT open occurrence (a phantom double-exit, worst for a fleet sub with several open).

Guard it with the subscriber's open-occurrence count: the bridge identifies the
subscription as soon as a frame reads the bound plate (identity needs no confidence),
baselines openOccurrenceCount, then each tick AND before emit checks if it moved. If a
credential closed/opened an occurrence mid-poll, the subscriber already transacted →
abort, don't emit. New public SubscriptionFlow.openOccurrenceCount(). Bounded loop is
unchanged (ANPR_POLL_WINDOW_MS=8000 cap; never infinite).

+1 test (credential transacts mid-poll → no double-act); 170 server tests green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 23:05:31 +02:00
julian 513566c89e chore(debug): add test-post-camera-events.py — a dumb HTTP sink for camera pushes
A tiny stdlib HTTP server that logs every request (source IP, method, path, full
body, JPEG part stripped) to verify whether a Hikvision camera actually POSTs its
Alarm Server events — independent of our app's parsing/acceptance. It cracked the
2026-06-27 "auto-exit" investigation: proved the exit camera was sending NOTHING
(corrupt config DB), then later that it sent plain VMD without targetType=vehicle.

  python3 test-post-camera-events.py [port]   # default 8099

Point a camera's Alarm Server at this host:port; drive a car. A line from the
camera IP = it sends (debug downstream); silence = the camera isn't POSTing.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 22:55:02 +02:00
julian f77ed11782 feat(anpr): poll snapshots until a confident plate, so auto-exit works
The ANPR bridge took ONE snapshot at the camera's vehicle-alarm instant — but the
alarm fires as the car APPROACHES, so that frame's plate is small/blurry/half-in-
frame and ANPR returns a low-confidence misread ('111'@0.20). The manual test reads
the SAME car at ~100% because by then it's STOPPED at the barrier, well-framed. So
subscriber auto-exit silently never fired (read below the 0.85 floor → ignored).

Fix (the car-stops-at-the-barrier insight): the bridge now PULLS A FRESH FRAME every
ANPR_POLL_MS (1000) and re-runs ANPR until one clears VISION_ENTRY_MIN_CONFIDENCE, or
ANPR_POLL_WINDOW_MS (8000) elapses (drove off / non-subscriber → give up cleanly).
- One loop per camera (#polling set) — the camera's ~1Hz alarm re-fires JOIN the
  running loop instead of spawning N concurrent loops.
- Fresh camera.captureSnapshot each tick, NOT captureSnapshotShared (its 1.5s TTL
  would re-serve the same bad approach frame).
- Camera-level debounce stamp moved to AFTER a successful emit (suppresses re-fires
  for ANPR_DEBOUNCE_MS once we've acted), not before the loop.

VERIFIED on hardware (DS-2CD1047G3H-LIU exit lane): 7 garbage approach frames →
AA890XX@0.999 at the barrier → signed vehicle_exit. Still advisory + fail-soft; a
barrier never opens on a low-confidence read. anpr-entry.test.ts +1 (poll
escalation low→low→high); 169 server tests green. Documented in
lane-presence-and-anpr-entry + the lpr-camera camera-fault writeup.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 22:54:49 +02:00
julian e4a17efd97 feat(setup): reveal toggle for secret fields (the device web password)
Build desktop / desktop (push) Successful in 4m37s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 44s
The admin needs the device web password (to reach a controller/camera's own web
UI), and it's already stored + sent to this admin-only view (redactSecrets strips
only the machine secrets relay/push pw, NOT webPassword — by design, per the
SECRET_CONFIG_KEYS comment). But the form rendered every `secret` field as a masked
password input with no way to unmask it, so the value was present yet unreadable.

Add a per-field show/hide eye toggle on `secret` inputs. No new exposure: the field
is already admin-gated and the value already reaches the client; this just makes the
intended-visible credential readable/copyable. Machine secrets are redacted
server-side and never arrive, so there's nothing there to reveal. i18n sq+en.
2026-06-27 17:51:32 +02:00
julian 6d32e0fc0f fix(i18n): correct translation for 'addAnother' in Albanian
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m41s
CI / check (push) Successful in 42s
2026-06-27 14:37:02 +02:00
julian 3a60367232 feat(setup): print a real test slip from the printer "Test connection" modal
healthCheck only opens the transport (TCP connect / USB open) — it proves the
printer is REACHABLE, not that paper feeds and the head fires. Add a "Print test
slip" action so the admin can physically confirm a printer is live (the new
host-net USB /dev/usb/lpN path, or a network printer).

- server: POST /api/setup/test-print — printer-only, re-merges stored secrets like
  /test (so an edited network printer authenticates), creates the device, and pushes
  a short slip via the device-agnostic printReport(). Fail-soft: a print error
  (paper out, head fault, transport drop) is reported, never a 500. Mirrors the
  test-anpr pattern.
- web: testPrint() client + PrintTestResult; a button in the device modal shown for
  category=printer, with ok/fail rendering. i18n keys in sq + en (parity holds).

Server 168 tests pass; web + server typecheck clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 14:36:39 +02:00
julian 045892bc94 fix(deploy): group_add lp (gid 7) so the server can write the USB printer node
Build & push images / images (push) Successful in 2m41s
CI / check (push) Successful in 42s
USB passthrough (1ea1aa4) made /dev/usb/lp1 visible in the container, but the node
is `crw-rw---- root:lp` (660) and the server runs as the non-root `app` user, not in
`lp` — so open(O_WRONLY) → EACCES → printer still "offline". Add the host's `lp` GID
(7 on this Ubuntu booth, verified `getent group lp` → lp:x:7:) via group_add, so the
app process gains the supplementary group that owns the node. Least-privilege: no
world-writable device, no root, no image rebuild. (If a future booth's lp GID differs,
update the number.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 14:23:02 +02:00
julian 916c147b4d fix(camera): drop debug console.log(c) leaking the camera password to logs
Build desktop / desktop (push) Successful in 4m39s
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 41s
The Hikvision driver's create() had a leftover `console.log(c)` that dumped the
ENTIRE camera config — including the plaintext `password` — to stdout every time
the adapter was built, on every request that resolves a camera. That puts a device
credential in the logs (which get shipped/cached/read — the booth operator is the
adversary). Removed. Swept the rest of the shipped source: no other console.* leaks.
2026-06-27 14:13:45 +02:00
julian 1ea1aa4189 fix(deploy): pass the USB printer (usblp) into the host-net server container
Build & push images / images (push) Successful in 2m45s
CI / check (push) Successful in 39s
The USB ESC/POS printer is the host's /dev/usb/lpN (usblp char device, major 180),
but the container has its own /dev — `docker exec server ls /dev/usb` → "No such
file or directory", so probeUsb's open() ENOENTs and the printer is always offline
regardless of the path set in setup. Containerization isolates host hardware (same
root cause as the network fix); USB needs explicit passthrough:

- volumes: /dev/usb:/dev/usb  → the lpN NODES appear inside the container
- device_cgroup_rules: 'c 180:* rmw'  → permit the usblp char major (180), and the
  `:*` minor wildcard survives lp0/lp1/lp2 renumbering across replug/boot-order.

Binding the /dev/usb DIR (not a single `devices:` node) is what survives renumber.
Merge verified: parking-data ledger volume preserved (lists append), host net intact,
config valid. Booth prereq: `usblp` loaded at boot + printer attached before start,
else /dev/usb is absent.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 14:11:51 +02:00
julian 9c20faf8de fix(deploy): drop server sysctls under host net (runc rejects per-netns sysctl)
Build & push images / images (push) Successful in 2m37s
CI / check (push) Successful in 35s
network_mode: host + sysctls: net.ipv4.ping_group_range fails at container create:
"sysctl not allowed in host network namespace" — runc refuses a per-netns sysctl
when there's no separate netns. Remove it; under host net the server uses the HOST's
ping_group_range (set on the booth via /etc/sysctl.d). Fixes the park-buzi-server-1
start failure introduced by c87dcb2.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:59:10 +02:00
julian a68dc23393 fix(i18n): update placeholder text for park name in English and Albanian translations
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m38s
CI / check (push) Successful in 37s
2026-06-27 12:48:51 +02:00
julian c87dcb2253 fix(deploy): server on host network so it sees the booth LAN / device VLAN
In prod the containerized server sat on the Docker bridge (172.18.0.x) and could
only see eth0 — so the setup backend-IP picker (net.ts networkInterfaces) showed
just the Docker IP, the server couldn't reach the relay or fetch Hikvision ISAPI
snapshots, and push devices (readers/cameras) couldn't reach it. The server is the
ONLY container doing device I/O, so put it on the HOST network namespace.

- docker-compose.prod.yml: server + proxy → network_mode: host (server detaches the
  base `parking` network via `networks: !reset []`). server VISION_URL=127.0.0.1:8089.
  vision stays BRIDGED (it never touches a device — the server hands it JPEG bytes)
  but publishes 8089 on 127.0.0.1 only, so the host-net server reaches it over
  loopback while the ANPR service stays off the LAN.
- docker-compose.yml: VISION_URL is now ${VISION_URL:-http://vision:8089} so dev keeps
  compose-DNS service-name routing; prod overrides to loopback.
- Caddyfile: reverse_proxy 127.0.0.1:3000 (was server:3000 — service DNS doesn't
  resolve on host net). Dev doesn't use Caddy, so unaffected.

Merge validated for both envs (booth.sh config, exit 0). Host-net side effect: the
container ping_group_range sysctl is a no-op — the HOST must set it for reader ICMP
liveness (see appliance-provisioning).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:48:27 +02:00
julian 7eadf71a0b docs(wiki): appliance-provisioning — Komodo deploy is now the primary flow
§6 split: §6 = Docker engine only; new §7 = the Komodo Periphery deploy (PRIMARY,
verified end-to-end on park-buzi 2026-06-27):
- 7a install Periphery (onboarding key, user-mode/outbound, runs as admin, no
  inbound port; core_address = Core's proxy URL)
- 7b deploy the Stack in Core (registry+git accounts, per-booth [[..]] secrets,
  env incl. COOKIE_SECURE=0; seed admin via Komodo's container terminal — no SSH)
- 7b-bis fleet-as-code via komodo/resources.toml + ResourceSync (empty diff =
  in sync)
- 7c break-glass: manual booth.sh when mesh/Core is down

Added Komodo deploy gotchas 7-11 (core_address is the proxy URL not :9120;
git-auth ≠ registry-auth; user-mode vs /etc/komodo root_directory; core_address
singular; empty-diff/disabled-Execute = success). §5b SSH TODO reframed (Komodo
removes SSH from routine ops). Header + date updated; log entry added. Fixed a
stale [[atecc608-secure-element]] alias in the prior log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:30:08 +02:00
julian 9918f278b2 feat(deploy): Komodo fleet deployment — resources.toml + decision
CI / check (push) Successful in 36s
Adopt Komodo Periphery (over the NetBird mesh) as the booth fleet control plane,
superseding SSH-and-booth.sh. The booth runs the SAME compose files; Komodo Core
drives them remotely. booth.sh is demoted to a break-glass local fallback.

- komodo/resources.toml mirrors the working park-buzi Stack (built by hand in the
  Core UI, then exported to TOML — field names match the running v2.2). Stack-only:
  servers are created by the agent onboarding OUTBOUND (one-time onboarding key →
  Periphery self-registers, auto-rotating keys, booth opens no inbound port), so
  there is no [[server]] block. Per-booth secrets via [[...]] refs to Core's store.
- komodo/README.md + .env.komodo.example document the flow and the hard rules
  (no webhook; onboarding/outbound/mesh-only; per-booth unique secrets; never
  down -v the ledger volume).
- wiki/decisions/fleet-deployment-komodo.md records the decision + threat-model
  analysis (Periphery is a root agent → mesh-bound; EVENT_SIGNING_KEY-in-Core is a
  fraud-root blast radius until ATECC608 signs; Core is now Tier-0; GPL-3.0 is fine
  as external ops tooling). container-deployment reframed (booth.sh = fallback);
  index + log updated.

Verified end-to-end against a real booth (park-buzi): onboarded OK, Stack deployed,
all containers green, admin seeded.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 12:14:04 +02:00
julian 83298bc0c5 fix(deploy): booth.sh works in the flat /opt layout; .env TAG=dev default
Build desktop / desktop (push) Successful in 4m37s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 37s
The booth deploys the compose files FLAT (e.g. /opt/parking_systems/) with
booth.sh next to them, but the script assumed it lived in <repo>/scripts/ and
blindly did `cd ..` — so REPO_DIR resolved to the parent, where there are no
compose files, and every subcommand operated on the wrong dir. `usage()` then
sed-read a relative $0 that no longer existed after the cd ("can't read
booth.sh"). Discover the compose files instead: check the script's own dir,
then ../, then $PWD, and cd to whichever has docker-compose.yml. usage() reads
an absolute $SELF so it survives the cd.

Also: .env.example defaulted TAG=main, but the registry only has dev-* tags
(no main build yet), so `compose pull` 404s. Default to TAG=dev and document
the moving-vs-immutable (dev / dev-<sha>) tag scheme.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 08:42:11 +02:00
julian 898cf1953a docs(wiki): camera 503/stream, alarm URL helper, reader ICMP liveness
- lpr-camera.md: "503 Device Busy" can be PERSISTENT (main-stream saturation on
  the G3H) — the real fix is sub-stream selection, not just retry.
- device-status-monitoring.md: QR reader health was false-healthy (hardcoded
  "ready") until the ICMP-ping fix; document the push-device monitoring model.
- log entries for both 2026-06-26 sessions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:47:19 +02:00
julian dd0f6e483a fix(reader): real ICMP liveness — QR reader status was a hardcoded "ready"
Two genuinely-offline QR readers showed GREEN: the adapter's healthCheck was
hardcoded to { ready, "stub" } and never probed. These are PUSH devices (scan →
GET our backend, resolve by serial) with NO TCP port, so a connect probe has
nothing to hit — the stub "solved" that by lying. False-healthy is the worst
failure for a status bar.

- Optional reader IP field (monitor-ONLY; scans still resolve by serial,
  operation unchanged).
- Unprivileged ICMP ping (drivers/icmp.ts): shells /bin/ping -c1, exit-0 = reply.
  No native dep, no CAP_NET_RAW. docker-compose.prod.yml sets
  net.ipv4.ping_group_range so it works for the non-root container user.
- healthCheck: replies → ready, no reply → offline, NO IP → degraded
  ("set IP to monitor") — never a false green.

Verified on hardware: readers (10.0.10.7/.8) answer ICMP on the device VLAN;
UI Test connection → "● ready — ping 10.0.10.7". Tests: reader.test.ts (4).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:47:12 +02:00
julian 40de8a7467 feat(setup): generate the camera's Alarm Server settings to paste
When a camera has Alarm Server push enabled, the setup form now shows the
camera's Alarm Settings (Destination IP / URL / Protocol / Port) ready to copy,
so the operator never hunts the deviceId or memorises the endpoint.

CRUCIAL: host/port come from the BACKEND address on the camera's subnet
(backendIpForDevice + the server's listen port — the same probe the push-IP
picker uses), NOT window.location.origin (the SPA's dev/proxy origin, which
would wrongly say localhost:5173). Verified live: matches the on-camera config
field-for-field (10.0.10.203 / …/event / HTTP / 3000). Shows a "save first"
(needs a deviceId) then "test first" (needs the resolved backend IP) hint.
i18n keys added to sq + en (parity enforced).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:47:02 +02:00
julian f0fd15bb88 fix(camera): selectable snapshot stream + retry transient 503 Device Busy
A Hikvision DS-2CD1047G3H-LIU returned HTTP 503 (statusCode 2 / deviceBusy)
on EVERY main-stream snapshot — its main encoder is persistently saturated.
Probed on hardware: channels/101/picture → 503 on 5 consecutive tries, while
channels/102/picture (sub stream) → 200 clean JPEG every time. A retry loop
can't fix a persistent busy; the real fix is stream selection.

- Add a `stream` config field to the Hikvision driver (1=main, default for
  back-compat; 2=sub). ISAPI channel id is <channel><stream> (101 main, 102 sub).
  Verified live: setting the G3H to Sub flips its status degraded→ready (14.7KB
  JPEG in ~87ms).
- captureSnapshot also retries the TRANSIENT case (503/500, linear backoff
  250/500/750ms ×4) then fails naming it "(device busy)"; does NOT retry 401/404
  (config errors won't self-heal). Complements captureSnapshotShared (concurrent
  de-dup). healthCheck still reports a live 503 as degraded (surfaces a saturated
  main stream rather than hiding it).

Tests: camera.test.ts (10) — retry behaviour + main/sub path selection.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:46:46 +02:00
julian 40ffa90dac fix(vision): self-heal local real ANPR — dev scripts sync the alpr extra
The dev box runs vision as bare `uv run uvicorn`, and a plain uv run/uv sync
re-resolves the venv to the lockfile DEFAULTS, stripping fast-alpr/onnxruntime.
So after any `pnpm dev` real ANPR silently degraded to "snapshot, no plate"
(diagnosed 2026-06-25: real reads through 06-22, venv frozen lean since 06-19,
no other env with fast_alpr). The BOOTH was never affected — it runs the Docker
image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights
pre-warmed); a booth ModuleNotFoundError is a STALE image (fix: booth.sh update).

Vision package.json dev/start/recognize now run `uv sync --extra alpr &&` first
so pnpm dev is self-healing; added a dev:stub escape hatch for a lean run.
Documented in wiki/decisions/vision-service-packaging.md ("Two runtimes, one
fragile") + a log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 08:11:17 +02:00
julian b3cb67188e fix(anpr): share one camera snapshot across bridge + advisory paths
On a vehicle entry, two paths captured the SAME Hikvision camera within ~1s —
the ANPR bridge (barrier-driving) and the advisory snapshotAsync (evidence/
telemetry) — each from a separate adapter instance. Hikvision serves snapshots
single-threaded, so the second concurrent GET returned HTTP 503; the bridge
then fail-softed and burned its 12s debounce, producing a ~74s "slow" subscriber
entry (observed 2026-06-25, Qazim Mulleti / AB816NN — plate read was instant at
conf 1.000; the delay was the 503/debounce churn, not recognition).

Add captureSnapshotShared() in snapshot.ts: a module-level, deviceId-keyed cache
that both paths call. It coalesces in-flight captures (the 2nd caller awaits the
1st's pull → no concurrent 503), serves a brief freshness window (1500ms) so the
bridge→advisory sequence for one vehicle reuses one frame, never caches a failure
(next caller retries), and keys by deviceId (no cross-camera/stale-vehicle reuse).
Wired into anpr-entry.ts (bridge) and snapshot.ts (advisory).

Tests: snapshot.test.ts (concurrent coalescing, TTL reuse, TTL-lapse re-pull,
failure-not-cached, per-camera keying); anpr-entry.test.ts mock updated. 168
server tests green.

NOTE: this removes the latency (the 503 collision). The separate double-entry
(two signed vehicle_entry for one car) — debounce-too-short / stamp-before-
success — is still open; less likely now but not eliminated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 08:10:56 +02:00
julian b1c4109045 docs(wiki): document scripts/booth.sh in container-deployment
Add a "Booth operator wrapper" section (commands, the update flow, env
handling, the volume/ledger safety notes) + a log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:38:44 +02:00
julian 50dd554b43 feat(deploy): booth.sh wrapper over the compose files + update flow
The booth PC (Ubuntu) needs one command instead of the long
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …`
over the three compose files.

scripts/booth.sh — prod by default (ENV=dev for the dev override):
up/down/restart/status/logs/pull/config/exec, plus the requested `update` =
pull the moving branch tag → up -d --remove-orphans (recreates only
digest-changed services; named volumes / the SQLite ledger are preserved) →
docker image prune. Prod refuses to run without .env (no safe JWT_SECRET
default); dev with no .env injects the documented benign local secret (the
base file makes JWT_SECRET shell-required via ${JWT_SECRET:?}). down never
passes -v (would wipe the signed-ledger volume); help/unknown-command
short-circuit before any Docker/.env requirement.

.env.example — the vars the compose files consume (REGISTRY, TAG, JWT_SECRET,
EVENT_SIGNING_KEY, COOKIE_SECURE=0, WS_ALLOWED_ORIGINS). .env stays gitignored.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:38:44 +02:00
julian 6d7682ab4a docs(wiki): printer USB transport + open-question for the provisioning
New concepts/printer-usb-transport.md (the seam, usblp char device,
reachability-only status, threat model). open-questions #14: confirm the
on-site printer is USB and bake the usblp + udev write-access rule into the
appliance image (provisioning, not app code; unverified on hardware). Updated
rongta-printer.md (USB transport note), index.md, log.md.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:32:24 +02:00
julian 793b8d83ee fix(setup): hide transport-irrelevant printer fields (USB vs Network)
The wizard rendered every configField in a flat loop, so the USB device path
showed under a Network printer (and host/port would show under USB) — the
form could mislead. Add a transport-aware filter (mirroring the existing
pulseMs/inputRestingHigh skip): when Connection=USB hide host/port/httpPort,
otherwise hide devicePath. Verified live (Playwright): each transport shows
only its own fields and toggling swaps them.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:32:17 +02:00
julian 7366ad19cb feat(printer): USB transport behind the ESC/POS render layer
The ESC/POS printer drivers were TCP-only — every path went through
sendRaw/probe to a raw socket on port 9100. Add a USB transport behind
the existing render layer without touching a single render*() function.

- printer-escpos.ts: sendRawUsb/probeUsb write the same ESC/POS bytes to a
  kernel usblp char device (/dev/usb/lp0) via a plain fs write — no
  libusb/CUPS/native dep (keeps MIT-only + minimal-deps appliance). A
  discriminated Transport + transportFromConfig/sendTo/probeTo dispatch the
  wire; anything not transport:"usb" is TCP, so existing host-only configs
  need no migration. Shared transportField/devicePathField config fields.
- cashino + rongta resolve a Transport once; both are reachability-only over
  USB, and the Rongta's HTTP status page degrades to the open-the-node probe
  over USB (no guessed paper/cover — the standing honesty rule). host/port
  made not-required so a USB printer needs neither.
- Tests: printer-escpos.test.ts (USB writes the exact rendered bytes; probe
  present/absent; transportFromConfig TCP back-compat) + printer-cashino.test.ts
  (USB-configured driver prints to the node, ready/offline).

USB itself is unverified on hardware (the on-site printers are networked);
the appliance-side usblp + udev provisioning is tracked as open-questions #14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:32:10 +02:00
julian 5a5fedf4f4 docs(wiki): booth bring-up fixes — relay password, secret re-merge, lamp concurrency
- dingtian-relay: the "offline despite ping" gotcha (relay_pw in every binary frame,
  missing form field → Test connection sent 0 → timeout) + the identity-gated secret
  re-merge that stops a redirected probe exfiltrating the password.
- button-light-indicator: serialized desired-state worker (UDP is unordered → the lamp
  stuck on/off) and hot-reload of the lamp config (no restart).
- log entry for the three fixes (commits 420542c / fd15988 / 830993b).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:11:49 +02:00
julian 830993bcb8 fix(button-light): serialize relay sends + hot-reload the lamp config
Build desktop / desktop (push) Successful in 4m20s
Build & push images / images (push) Successful in 2m45s
CI / check (push) Successful in 37s
Two bugs in the button-light controller:

1. Stuck relay (random on/off). The blink fired fire-and-forget setAux every 500ms over
   UNORDERED UDP with no serialization — concurrent on/off packets reordered/overlapped,
   so the relay latched on whichever packet the device processed last. Replace with a
   desired-state + serialized worker (#pump): the blink timer only flips desiredOn; a
   single in-flight send per lamp is guaranteed, and on completion it re-converges to the
   latest desired state — so the final state is always authoritative and a lost/stale
   packet self-corrects.

2. Lamp ignored until restart. The lamp map was built once at start(); a button light
   added/changed via the UI never took effect without a server restart. #reconcile now
   re-reads the device config (at start and before each event, like DeviceMonitor),
   adding/updating/dropping lamps live — so a just-saved lamp blinks on the next radar
   edge.

Tests assert confirmedOf() (the device's latched state); +1 reconcile-after-start case.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:04:18 +02:00
julian fd15988a73 refactor(setup): split the controller form into Outputs and Inputs sections
The controller editor mixed outputs and inputs in one flat "Relays" block — relay
direction, the entry-button terminal, and the presence/radar terminal all on the same
row, with the lamp orphaned below. Reorganize into two labelled sections:

- Outputs — relays (barriers + lamp): relay # + direction, the button-light relay, and
  "Pulse open (ms)" (a relay hold-time, NOT an input setting — answers a recurring
  confusion).
- Inputs — terminals (button, sensor): per entry relay, the button + presence/radar
  terminals (kind, active-low) and cooldown, each labelled "For relay N", plus the
  board-wide "Inputs idle HIGH".

UI-only: storage stays config.relays[] (+ config.buttonLight), so saved booth configs
keep working with no migration. pulseMs/inputRestingHigh are pulled out of the generic
field loop and rendered in their section. i18n parity (sq + en).

Also passes the device id to testDevice() so an edited device's stored relay password
re-merges on Test connection (pairs with the secure-merge server change).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:04:05 +02:00
julian 420542ce10 fix(setup): add Dingtian relay-password field + secure secret re-merge on test
The relay control password (relay_pw) was read by the driver but had NO form field,
so Test connection sent it as 0 → the device ignored the probe → a controller showed
"offline" even though it pinged. Add a "Relay control password" config field (secret;
blank keeps the stored value).

Because relayPassword is redacted from the client, the edit form can't resend it — so
the test endpoint now re-merges the stored secret by device id (mirroring save). It is
re-merged ONLY when the submitted config addresses the SAME device: matching driverId
and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A
redirected host/port or mismatched driver yields NO secret, so a probe can't exfiltrate
the password to an attacker host (the booth operator is the threat-model adversary).
testDevice() now passes the device id; setup-secrets.test.ts covers the identity guard.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:03:53 +02:00
julian 2915d141aa feat(devices): radar presence input + button-light output on the controller
Model the entry button (I1) and a Hikvision radar (I2) as named children of the
access controller, and drive the button's 12V lamp on a spare relay.

- Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled
  presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input
  active-level override: relays[].presenceActiveLow -> driver inputActiveLow set,
  inverting just that terminal (pure helper inputActive()). The Dingtian has one
  board-wide resting level otherwise.
- AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian
  latch) so business logic drives a NON-barrier lamp through the interface. Barriers
  still only pulseOpen — barrier-not-a-door preserved.
- ButtonLightController: subscribes to the radar input edge + the camera lane status
  and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off.
  Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on
  its own (advisory; threat model).
- SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n.

Tests: button-light.test.ts (truth table + blink + fail-OFF + de-dupe),
access-dingtian.test.ts (active-level inversion). Workspace build+lint+test green
(158 server tests). Wiki: hikvision-radar, button-light-indicator + updates.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 11:45:22 +02:00
julian 215a3ac405 fix(ci): publish desktop installers via Gitea Release, not upload-artifact
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 39s
actions/upload-artifact@v4's backend fails on the Gitea runner (Upload installers
step errored). Mirror release.yml's proven path instead: curl + the built-in token
to the Releases API, into a ROLLING per-branch prerelease (tag desktop-<branch>,
deleted+recreated each push). Installers renamed space-free
(parking-desktop-<branch>-<sha>.{deb,AppImage}). Signed v* releases unchanged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:32:29 +02:00
julian e0cfeb5e71 fix(ci): unsigned desktop build must disable updater artifacts
CI / check (push) Successful in 38s
Build desktop / desktop (push) Failing after 3m56s
createUpdaterArtifacts:true (for release.yml's .sig signing) makes `tauri build`
demand TAURI_SIGNING_PRIVATE_KEY and fail without it — even though the .deb/.AppImage
built fine. Override it off for the unsigned per-commit build via
--config '{"bundle":{"createUpdaterArtifacts":false}}'. release.yml keeps signing.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:24:47 +02:00
julian 8129b63a8c feat(profile): self-service name/email/password + desktop installers in CI
Build desktop / desktop (push) Failing after 5m2s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
Self-service profile: any signed-in user edits their OWN fullName/email and
changes their OWN password (proving the current one), without any user:*
permission. New routes PUT /api/auth/profile + /api/auth/password act only on
req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile
reachable from the header username chip. email added to the session view +
SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green.

Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage
on every push to dev/main and uploads them as unsigned workflow artifacts
(per-commit test build). Signed/versioned release stays on release.yml (tag v*).

Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI
split), log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:15:34 +02:00
julian f9bd586265 docs(wiki): session context — first booth go-live (user split, Docker deploy, web access)
appliance-provisioning.md: new §5c (admin/operator OS user split — verified; strip
lxd/lpadmin/docker from the operator) + fleshed-out §6 runtime (resolute codename caveat,
the standalone deploy dir + .env, the deploy commands, seed-admin, healthy-startup signal,
and the web-access gotchas). log.md: the [2026-06-23] go-live entry (CI uv fix, compose env
passthrough, relative /api, Caddy proxy). Container-deployment "Web access" section already
landed last commit.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:29:36 +02:00
julian aa546235fb docs(wiki): container-deployment — relative /api + Caddy proxy web-access section
Build & push images / images (push) Successful in 2m40s
CI / check (push) Successful in 34s
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:15:44 +02:00
julian c637b2783c feat(deploy): Caddy reverse proxy — clean port-80 URL, server internal
Operators/admins reach the booth at http://<name-or-ip>/ (no :3000). Adds a caddy:2-alpine
proxy to the prod override that reverse-proxies :80 → server:3000 (the /api/ws WebSocket
upgrades pass through natively); the server is now `expose: 3000` (internal, no published
port), vision stays internal. The Caddyfile binds `:80` so it matches ANY hostname/IP —
works for the booth IP, localhost, AND parksystems.msai.al (pointed at the booth via
hosts/DNS on-site; no domain baked into any image). TLS later = swap `:80` for the real
hostname + uncomment :443 → Caddy auto-provisions HTTPS.

Pairs with the relative-/api SPA fix (77b2acb): together verified end-to-end locally —
through Caddy on :80 with Host: parksystems.msai.al, GET / serves the SPA, assets/health
200, and POST /api/auth/login reaches the server (real 401, no CORS/connection error).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:15:23 +02:00
julian 77b2acb1ca fix(docker): SPA must use same-origin API base in the server image (CORS)
apps/web/.env.production sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
desktop build (which loads from tauri://localhost and needs an absolute backend
origin). But Vite auto-loads .env.production for ANY `vite build`, so the server
image baked 127.0.0.1:3000 into the browser bundle — loading the UI from a real
host (e.g. http://parksystems.msai.al) then made the browser call 127.0.0.1:3000
cross-origin and fail the Same-Origin Policy on /api/auth/login.

Fix: the server Dockerfile writes apps/web/.env.production.local with an empty
VITE_API_BASE before the web build (.local has higher Vite precedence), so the SPA
served by Fastify stays relative/same-origin (/api/...). The desktop build is
unaffected (it doesn't use this Dockerfile). Verified: 127.0.0.1:3000 no longer in
the built bundle; /api/auth/login is relative.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:58:53 +02:00
julian 10923164ad fix(compose): pass COOKIE_SECURE, WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED
The base compose only forwarded DATABASE_URL/VISION_URL/JWT_SECRET, so a booth deploy
was missing the vars that actually make it usable on the plain-HTTP LAN:
- COOKIE_SECURE (default 0) — without it auth cookies are HTTPS-only and operators
  CANNOT log in over http. The #1 booth-deploy footgun.
- WS_ALLOWED_ORIGINS — the live-feed WS rejects the browser Origin without it.
- EVENT_SIGNING_KEY — dedicated ledger key (falls back to JWT_SECRET if empty).
- VISION_ENABLED=1 — the server's ANPR master switch.
All driven from .env; verified via `docker compose config` that the seven vars resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:37:52 +02:00
julian 0a22eab4a8 fix(ci): install uv via official script, not astral-sh/setup-uv action
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 35s
The Gitea runner can't reliably resolve the astral-sh/setup-uv@v5 action — the
"Set up uv" step failed (exit 1) in build-images.yml (and the same step exists in
ci.yml). Replace the action with uv's official standalone install script
(`curl -LsSf https://astral.sh/uv/install.sh | sh`) + add $HOME/.local/bin to
$GITHUB_PATH, matching how the rest of the pipeline provisions tools (apt, corepack).
No third-party action dependency. Verified the install method yields a working uv on
a clean HOME. Same fix in both workflows.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 15:59:10 +02:00
julian 9d65099d9b docs(wiki): appliance provisioning runbook — booth unit 1 hardened (LUKS+TPM+SecureBoot+GRUB)
CI / check (push) Successful in 45s
New wiki/decisions/appliance-provisioning.md: the hardware-verified step-by-step for
provisioning a booth PC (Dell OptiPlex 7070, i5-8500, discrete Nuvoton TPM 2.0) from
factory Windows to a hardened Ubuntu 26.04 LTS appliance. Every command was run on the
first real unit (2026-06-23). Captures the firmware-specific gotchas: Ventoy → 0x1A under
Secure Boot (flash ISO directly); the 7070 BIOS can't view db (verify via live USB); the
installer's hardware-backed encryption fails with PCR_UNUSABLE/dbt (use passphrase LUKS +
manual systemd-cryptenroll PCR-7 seal); GRUB password must be edit-only (--unrestricted)
to keep unattended boot.

OS hardening on unit 1 is COMPLETE + verified: LUKS FDE + TPM auto-unlock (PCR 7,
unattended) + Secure Boot (Deployed) + GRUB edit-lock (closes the init=/bin/bash root-shell
hole that PCR-7 sealing does not cover). Resolves the implementation half of
open-questions #12 for unit 1.

Cross-linked from disk-os-hardening; index + log updated. Still TODO on the box: Docker +
run the stack.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 15:53:56 +02:00
julian 8155ff456b feat(deploy): Docker images for server (API+SPA) and vision + branch-aware build pipeline
CI / check (push) Successful in 35s
Build & push images / images (push) Failing after 17s
Containerize the two non-desktop apps for the booth appliance. The desktop app stays
on its own tag-only release.yml.

- apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod`
  (NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune)
  yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds
  libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit-
  free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a
  devDep, pruned from prod.
- apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container
  serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never
  shadows the backend; a no-op in dev (no dist). Registered last in server.ts.
- apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image
  as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env-
  selected (VISION_RECOGNIZER stub|fast_alpr).
- Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) +
  .prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from
  env so a branch deploy pulls that branch's image.
- .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+
  test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with
  branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED).
- .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked.

Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, /
+ /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr
with 0 runtime downloads; compose stack healthy with server→vision over the private network.

Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 15:07:52 +02:00
julian 492a08a079 fix(ci): lint must depend on ^build (resolve workspace dep types)
CI / check (push) Successful in 36s
`@parking/server#lint` (tsc --noEmit) failed in CI with "Cannot find module
'@parking/db' / '@parking/shared'" + a cascade of implicit-any errors. Root cause:
the root turbo `lint` task had no dependsOn, but those packages expose their types via
"./dist/index.d.ts" — only present after their `build` runs. In a clean CI tree lint
ran before the deps were built, so tsc couldn't resolve them. Locally it passed only
because a prior `dist/` happened to exist. Make `lint` depend on `^build`, exactly
like `typecheck` and `test`. Verified from a fully clean tree (rm dist + .turbo +
*.tsbuildinfo): `turbo run build lint` → 14/14, 0 cached.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 20:17:17 +02:00
julian 8a437d0c4b feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
CI / check (push) Failing after 56s
Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.

Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.

CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.

- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 20:13:21 +02:00
julian 65328b8c11 feat(anpr): subscriber-entry bridge + admin disable toggle
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a
vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh
snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and —
matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read.
The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens
the barrier. A plate is never the sole authority: it routes through the same gate
(active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget,
subscriber-only by construction. Field-verified end to end (plate AA504LX opened the
entry barrier and appended a signed vehicle_entry).

Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site
Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and
lane busy/free are unaffected. Read live per event, so toggling takes effect with no
restart. Migration 0013 (additive ALTER ADD COLUMN, default 1).

- New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9)
- hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3)
- server.ts reorders the read flows above the hik-alarm registration
- snapshot.ts exports buildCamera for reuse
- env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000)
- site route + SiteSettings checkbox + i18n (sq/en parity)
- wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:49:18 +02:00
julian 411572511d docs(camera): lane presence + ANPR subscriber-entry bridge design
Captures this session's back-and-forth as a new concept page
[[lane-presence-and-anpr-entry]] and cross-links it:

- BUILT: advisory lane busy/free booth lights (LaneStatus + WS), with the
  measured camera limits behind the 30s timeout (no leave signal; movement-
  driven re-fire; notificationRecurrence locked to "beginning" — ISAPI flip
  silently reverts).
- PLANNED: the ANPR "bridge" — explicitly a small apps/server HANDLER (~40
  lines), NOT a new service/container. On a camera vehicle event: snapshot ->
  ANPR -> high-confidence match -> debounce -> emitRead{kind:"plate"}, then
  the existing subscription match/dispatch/gate admits the subscriber. Both
  directions, opt-in (config.anpr), plate never the sole authority.
- Records the decisions (high confidence floor, debounce-for-correctness)
  and the REJECTED ideas (continuous livestream / per-car queue tracking /
  make-model) with why, plus the open hardware question (booth-PC test).

Updates subscription.md (plate matching is built; the live source is this
bridge) and lpr-camera.md (the two consumers of the vehicle event).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:12:49 +02:00
julian a2bdf99db2 fix(lane-status): TTL 5s -> 30s after measuring the real re-fire pattern
Controlled in/out test on the camera: the `active` re-fire rate is
MOVEMENT-driven, not steady — ~1-3s apart while the car moves, but up to
~15-25s when it sits MOTIONLESS in the zone. A 5s TTL would flicker a
parked car free; the TTL must exceed the still-car gap. The camera has
~no dwell lag (goes silent within ~1s of the car leaving — measured: last
event 16:15:17 vs car-left ~16:15:30), so 30s keeps a motionless car busy
while clearing promptly after departure. This also confirms vision-based
tracking isn't warranted: the camera's leave signal is already tight.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 18:16:42 +02:00
julian 89542d4ab6 fix(lane-status): drop busy TTL 90s -> 5s (camera re-fires ~1s)
Measured the real re-fire rate on the camera: while a vehicle is in the
zone it POSTs `active` about every ~1 second (not the ~30-80s I'd guessed).
The camera sends no leave signal, so "free" is timeout-driven — but with a
~1s re-fire, 90s made the lane stay red for a minute and a half after the
car left. 5s of silence reliably means the car is gone; the light now
clears within seconds. Still override-able via LANE_BUSY_TTL_MS.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:58:25 +02:00
julian e0b9442acc feat(booth): live lane busy/free barrier lights from camera vehicle detection
A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a
camera bound to entry/exit now marks that lane "busy" and shows it as a
barrier light beside the scan input on the booth (green=free, red=busy).
Advisory only — it gates nothing (never blocks a ticket or opens a barrier).

- Parse eventState (active/inactive) from the Hik payload.
- LaneStatus tracker: a vehicle `active` event marks the camera's bound lane
  busy + arms an auto-clear timer. This camera class sends no leave/`inactive`
  signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the
  camera re-fires `active` while a car sits there, refreshing the timer). A
  "both"-direction camera marks both lanes.
- Push lane-status over the existing booth WS (+ in the hello snapshot);
  live-store holds { entry, exit }; two BarrierLight icons render it.
- i18n booth.laneEntry/laneExit (sq + en).

Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm,
no re-emit while busy, both/exit direction, unknown device). server 120/120;
web + server build/lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:42:54 +02:00
julian 6f4e390c05 feat(dev): bind Vite to 0.0.0.0 for LAN access (phone over wifi)
Vite had no host set (localhost only). Bind 0.0.0.0 so the dev booth UI is
reachable from other LAN devices at http://<host-lan-ip>:5173. The SPA
already uses relative paths + the page origin for API and the live WS, so
no app code changes — but loading from a non-localhost origin means the
/api/ws handshake's Origin is the LAN address, which the backend's
WS_ALLOWED_ORIGINS must include (documented in .env.example; the host's own
.env is gitignored).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:34:35 +02:00
julian df6a1ca63a docs(camera): correct the "dead camera" conclusion — root cause was undrawn detection area
The Hik DS-2CD1043G2-LIU was NOT defective. An earlier wiki entry wrongly
concluded it needed RMA (dead event engine) based on a silent alertStream
+ diskfull/EventScribe:except + dead RTC surviving a full factory reset.

Real cause: no detection AREA was drawn on the frame. With no region, the
camera detects nothing -> generates no event -> posts nothing. The instant
an area was drawn, the first vehicle produced a clean POST.

- Flag "draw the detection area" as the FIRST thing to check.
- Document the confirmed real payload: multipart/form-data (MoveDetection.xml),
  EventNotificationAlert with eventType=VMD, eventState=active,
  targetType=vehicle (vehicle/human classified on-device), targetRect bbox.
  Note the dateTime is garbage (dead RTC) -> use our own receive time.
- Reframe the SSH diagnostics: diskfull/EventScribe/RTC are RED HERRINGS,
  not proof of a dead camera; don't escalate to hardware fault while a basic
  config precondition is unmet.
- Append a log correction (append-only) superseding the earlier conclusion.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 16:53:22 +02:00
julian 547061edf9 docs(camera): Hik event-push gotchas + dead-camera diagnostic method
Captures the hard-won findings from the field session: the WSL source-IP
rewrite + skipSourceIpCheck fix, the boolean-as-string setup bug, the
unreliable "Test" button, the latching httpBroken flag, and Notify-
Surveillance-Center vs HTTP-Alarm-Server.

Adds a "diagnose a non-pushing camera from its OWN state" runbook
(alertStream heartbeat silence, SSH showStatus EventScribe:except, dmesg
RTC/UBIFS, netstat outbound watch) and documents the verified-dead
DS-2CD1043G2-LIU unit (defective event engine, survives factory reset ->
RMA), with the pull+vision fallback.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 12:50:44 +02:00
julian b7300ec080 fix(hik-alarm): listen on all methods + skippable source-IP guard (WSL)
Diagnosed why no camera push ever landed: (1) the route only registered
POST/GET, so a probe with another method got a generic 404 the camera
reads as "service available" while our handler never ran; (2) more
fundamentally, WSL mirrored mode REWRITES the inbound source IP to the
host's own address (10.0.10.203), so the camera's real IP (10.0.10.12)
never survives and the source-IP guard rejected every push as a mismatch.

- Register the event route on POST/GET/PUT/PATCH/DELETE/OPTIONS (HEAD comes
  with GET) so ANYTHING hitting the path reaches the handler and is recorded.
- Log + store the HTTP method of each hit; log every hit on arrival, before
  any guard, so even a rejected probe is visible immediately.
- Add per-device skipSourceIpCheck (a Setup checkbox) to bypass the
  source-IP guard where the network rewrites the source (WSL). Digest auth +
  the signed ledger remain the real guards.

Tests: hik-alarm 10 (skip-IP accept + method capture). server green;
web build green (new checkbox renderer + this field).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 11:02:17 +02:00
julian 461275521d fix(setup): render boolean config fields as a checkbox (not a text box)
The generic config-field loop had no boolean branch, so a type:"boolean"
field (e.g. the camera's alarmPushEnabled) fell through to a TEXT input and
saved the STRING "true" instead of a real boolean. Downstream checks use
=== true, so the feature read as disabled even when the admin ticked it.

- Web: render type:"boolean" config fields as a real checkbox; store/merge
  a true/false boolean (and persist false on edit so toggling off sticks);
  normalize a legacy string "true"/"false" on load.
- Server: isOn() coerces the flag when reading config (accepts true/"true"/
  1/"yes"/"on") so an existing row saved as the string "true" still works
  without a re-save, and no other boolean field hits the same trap.

Tests: hik-alarm accepts string "true" for alarmPushEnabled. server
112/112; web typecheck + build green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:39:10 +02:00
julian 3db8f517d3 feat(hik-alarm): record rejected pushes + a read endpoint to see arrivals
Debugging "is the camera event coming or not?" was painful: a rejected
push only logged a warning and recorded nothing, so "no event" was
ambiguous (never sent vs sent-and-refused), and the only durable record
was an unreadable device_events row.

- Record EVERY push, accepted or rejected: accepted -> kind:"alarm",
  rejected -> kind:"alarm-rejected" with the precise reason (unknown
  device / not-hikvision / push-disabled / source-IP mismatch / digest
  fail). The 404 body now also returns the reason.
- New GET /api/devices/hikvision/alarms (device:read): the recent pushes
  newest-first as JSON (accepted+rejected, with ip/reason/eventType/
  target/plate/rawHead) so you can SEE arrivals in the browser instead of
  grepping the dev log or querying SQLite.

Tests: hikvision-alarm.test.ts now 8 (rejection-recorded + read-endpoint
list + gating). server 111/111; build+lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:28:09 +02:00
julian 6133923094 feat(camera): Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware can PUSH events to us: Event -> Smart/VCA with
"Detection Target: Human/Vehicle" + Notify Surveillance Center + Alarm
Settings -> Alarm Server makes the camera HTTP-POST an
EventNotificationAlert on each detection.

- New POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts):
  same machine-push pattern as the Dingtian Input Link — source-IP guarded
  + optional HTTP Digest, not behind the SPA cookie/CSRF.
- Discovery-first / permissive: a wildcard content-type parser accepts ANY
  body as raw bytes (event XML, multipart+JPEG, or JSON — Hik varies by
  firmware), records it verbatim as a kind:"alarm" device_event, and
  best-effort extracts eventType/target/plate/dateTime/channelID for the
  summary + a loud log line. The point is to SEE exactly what a camera
  sends before wiring it further.
- hikvision driver gains alarmPushEnabled + pushUser/pushPassword config and
  pushesToBackend:true (setup offers the backend push IP).
- NOT yet a barrier trigger / DeviceReadEvent — records only. A plate read
  is advisory, never the sole reason a barrier opens; the read-bus/ANPR
  wiring is a deliberate next step once the real payload is known.

Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw
JSON, wrong-IP 404, disabled 404, unknown-device 404). server 109/109;
build+lint 14/14. Wiki: lpr-camera.md + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:02:35 +02:00
julian 7680d9a0ed feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.

Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.

Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).

Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).

Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).

Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.

Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 09:33:54 +02:00
julian 3527f48d76 refactor(reports): top-level /reports section in the header, not a Setup tab
CI / check (push) Failing after 30s
Moves Reports out of the Setup tab bar into a standalone top-level route
(/reports) with its own header nav link, alongside Booth/Shifts/
Subscriptions. Adds a /setup/reports → /reports legacy redirect. Same
report:read gate. Wiki note updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:20:30 +02:00
julian 5a5f5c554b feat(reports): admin Reports dashboard — ledger-first charts
Adds an admin Reports screen (/setup/reports, gated report:read) — an
on-demand dashboard over the signed event log.

Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates
in one call — entry/exit counts + all money summed straight from
ledger_events (same source the shift Z-report reconciles, so totals tie
out to the drawer); revenue split into ticket / subscription-sale /
out-of-window mirrors the Z-report. Duration stats come from the sessions
cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv
export of the per-bucket series. reports.ts + routes/reports.ts.

Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month
grain, KPI cards, entry/exit line, revenue bar + cash/card split,
revenue-mix pie, peak-hours histogram, numeric breakdown, subscription
stats. Charts via Recharts (MIT), lazy-loaded into its own chunk
(~111KB gz) so the booth bundle is untouched. New Setup tab + nav + i18n
(sq + en parity). asc() exported from @parking/db; formatMinutes helper.

Tests: reports.test.ts (10) pin the sums, tz bucketing, money split,
duration stats, subscription counts. server 90/90; build+lint 14/14.

Wiki: reporting-analytics.md "Built v1" section + log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:16:07 +02:00
julian 742653aefb feat(setup): "Test ANPR" probe on ANPR-enabled cameras
Adds a bottom-of-modal "Test ANPR" button (shown only when a camera's
Plate recognition opt-in is checked) that captures a live snapshot off
the camera and runs it through the vision service, reporting the plate
read + confidence + elapsed time, or which stage failed.

- New POST /api/setup/test-anpr: builds the camera from the unsaved
  config (no DB write/device change, like /test), captures a snapshot,
  runs vision.analyze. Fail-soft like the runtime path (snapshot.ts):
  camera/vision failures are reported results, never a 500.
- Thread the existing VisionClient into setupRoutes; add an isCamera()
  type guard to @parking/devices.
- Web: testAnpr() client + AnprTestResult; button, hint, result line.
- i18n keys in sq + en (Catalog parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:02:31 +02:00
julian 66c1291578 docs(deploy): COOKIE_SECURE=0 runbook for the plain-HTTP appliance
Documents the deploy-time requirement that the cookie fail-safe fix (7629d5d)
introduced: the LAN appliance serves the SPA same-origin over plain http, where a
Secure cookie is never sent — so it MUST set COOKIE_SECURE=0 or operators can't log
in. A TLS deploy leaves it unset.

- wiki/concepts/disk-os-hardening.md: new "Deploy-time server configuration (runbook)"
  section listing the security-load-bearing env (JWT_SECRET, EVENT_SIGNING_KEY,
  COOKIE_SECURE=0) with the why + the network-scoped justification.
- wiki/entities/local-jwt-auth.md: corrected the stale "Secure when NODE_ENV=production"
  cookie line to the Secure-by-default / opt-out model.
- wiki/log.md: entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 23:52:21 +02:00
julian 7629d5d7b1 fix(auth): make the Secure cookie flag fail-safe (default on)
secureCookies() keyed off NODE_ENV === "production", so an appliance deployed
without that var silently sent the auth + CSRF cookies WITHOUT the Secure flag —
the review's one Medium finding.

Now Secure is the DEFAULT and you only ever opt OUT: a misconfigured/forgotten env
can only make cookies more restrictive, never drop the flag. Dropped only on a
deliberate COOKIE_SECURE=0/false/no/off (or an explicit NODE_ENV=development as a
dev fallback). The LAN appliance that serves the SPA over plain http sets
COOKIE_SECURE=0 on purpose (a Secure cookie would never be sent over its http origin
and would lock operators out); a TLS deploy leaves it unset and gets Secure.

- auth.test.ts (5): pins the matrix — default Secure, production Secure, dev opt-out,
  COOKIE_SECURE falsey opts out, any other value opts in.
- .env.example documents COOKIE_SECURE (replaces the stale NODE_ENV cookie note).
- dev .env sets COOKIE_SECURE=0 (local http://localhost login keeps working).

server 80/80; build+lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 23:50:40 +02:00
julian 2fb947e908 test(vision): fix stub-mode tests; close the testing-gap wiki note
The two failing apps/vision smoke tests assumed stub mode but the local .env sets
VISION_RECOGNIZER=fast_alpr (real-model work, 2026-06-19), so the app built the real
recognizer: /health reported "fast_alpr" not "stub", and /analyze on garbage bytes
422'd (real decode reject) instead of returning the empty stub contract.

Fix is test isolation: a conftest autouse fixture pins VISION_RECOGNIZER=stub for the
session (an OS env var overrides the .env in pydantic-settings), restoring it after.
vision 7/7.

Updates wiki/concepts/booth-console.md (the "no automated tests" Open note now reflects
the coverage that landed) and appends wiki/log.md.

Full workspace: shared 87, server 75, devices 18, web 17, vision 7 = 204 tests across
8 turbo test tasks, 0 failures; build/lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:25:21 +02:00
julian cae900afd2 test(web): Phase 4 — booth formatters + focus-independent scanner hook
Closes the standing "no automated frontend tests" gap for the pure, testable logic:

- format.test.ts (12): the booth display formatters — formatMoney (minor units →
  currency, malformed-code fallback), formatDuration (m / h+m / 0m / em-dash on
  negative-invalid), formatTime, and formatRelativeDateTime (today/yesterday words +
  catalog month names, no Intl dependence).
- use-scanner.test.ts (5): the 2026-06-21 focus-independent hardware scan — a fast
  burst+Enter on <body> fires onScan; slow human typing (gap > 50ms) does not; paused
  (modal open) no-ops; keystrokes into an editable field are ignored; a lone Enter /
  too-short burst is ignored.

Wires Vitest (jsdom + @testing-library/react) into @parking/web. web 17/17.

Full workspace green: shared 87, devices 18, server 75, web 17 (= 197) + build/lint
14/14. (apps/vision still has its 2 pre-existing failures — next.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:23:15 +02:00
julian 7e912e193b test(server): Phase 3 — HTTP route integration (auth + RBAC guards)
Boots the REAL Fastify app over a fresh in-memory DB (buildServer({ db }), driven by
app.inject — no listen) to exercise the security seam end to end:

- routes.test.ts (7): /health open; login rejects bad creds and sets token+csrf
  cookies on good ones; an unauthenticated GET /api/occupancy is 401; a site:read-only
  role GETs occupancy but is 403 on PUT /api/site-config (the permission gate, with a
  valid CSRF so the 403 is the perm check); an admin passes the same PUT; and a mutation
  with the auth cookie but NO csrf header is 403 (double-submit enforced).

Adds seedUser()/login() helpers (real bcrypt + the real /api/auth/login route) and
LOG_LEVEL=silent in the vitest env so asserted 401/403 responses don't flood output.

server 75/75 green (8 suites).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:20:14 +02:00
julian 352c643009 test(devices): Phase 2 — ESC/POS byte stream + printer routing
Pins the device-layer bugs we kept hand-verifying, as pure byte-stream assertions
(no sockets, no hardware):

- printer-escpos.test.ts (12): CP852 codepage select; the ë→0x89 / Ë→0xD3 mapping
  and the em-dash/⚠ ASCII fallbacks (never a stray 0x3f "?"); and the Code128 MODULE
  WIDTH contract — a short ticket id at width 3, but the ~20-char out-of-window
  occurrence id at width 2 so it fits the 80mm head (width 3 overflows ~576 dots and
  the firmware silently aborts the barcode). Plus the QR-and-Code128 dual encoding and
  the Albanian stamp() format.
- printer-routing.test.ts (6): the failover order (booth printer is a fallback for
  entry tickets; a receipt never prints on the outside dispenser), rank-then-id
  tiebreak, and printWithFailover walking the order + NoPrinterAvailableError.

Wires Vitest into @parking/devices. devices 18/18 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:17:51 +02:00
julian 5e9be16f65 test(server): Phase 1 — server-core suites (occupancy, pay, exit, shift)
Completes the anti-fraud/safety core coverage on a fresh in-memory DB:

- occupancy.test.ts (12): the ledger-fold count, the capacity/full gate, and the
  reserved-subscriber-spots model — never double-count a parked subscriber, reserve
  tightens only the TRANSIENT gate.
- pay-station.test.ts (12): quote math against the frozen tariff, the signed-payment
  side effect (+ chain verify), no-session / no-tariff errors, the booth lookup view,
  active-session listing.
- exit-flow.test.ts (9): the GATE — refuse unknown / unpaid / grace-expired (no exit
  signed); a paid-within-grace session signs the exit; the booth transient path has NO
  subscription bypass; a prepaid subscriber leaves via the assist (reopenBarrier) path.
- shift-service.test.ts (14): site-wide single-open invariant, the takings SPLIT by
  source (subscription sales vs out-of-window vs transient tickets), drawer carry-
  forward + cash_in/out vouchers, Z-report sign + listShifts read-back.
- entry-flow.test.ts (5): the exported validateTicketCode Luhn typo-guard. (The
  capacity-gate/print-hold/sign-before-open paths need device fakes — covered in the
  device + route phases.)

Adds test-helpers.ts (real EventLog, silent logger, tariff seeder). server 68/68 green.

Note: apps/vision has 2 PRE-EXISTING failures (test_app.py) — environment drift now
that fast_alpr + the ONNX model are installed (the "stub mode" assertions are stale).
Untouched here; to be fixed in the vision phase.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:15:56 +02:00
julian 0985b86fa7 test(server): add fresh-SQLite test harness + anti-fraud core suites
Foundation for testing every service. Adds @parking/db/testing — createTestDb()
spins a fresh in-memory SQLite and applies the real Drizzle migrations, so server
tests run against the production schema with zero live-DB risk.

Wires Vitest into apps/server (test script + config; test signing keys via env)
and adds the first Phase-1 suites against the anti-fraud core:

- signer.test.ts (10): sign/verify round-trip, tamper + forgery rejection,
  malformed-signature guard, determinism, keyId rotation (buildVerifier).
- event-log.test.ts (12): monotonic index, prevHash linkage, payload-in-signature,
  append serialization, and verifyChain() catching every tamper class — edited
  payload, deleted row (index gap), broken prevHash, unknown keyId — plus
  canonicalize byte-stability.

Also stops *.test.ts leaking into shipped dist/ (tsconfig exclude in server +
shared; shared had been emitting compiled tests all along).

server 22/22, shared 87/87 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 15:20:38 +02:00
julian 3ed785c33e feat(booth): open the pay/exit modal on a hardware scan regardless of focus
A barcode/QR scanner is an HID "keyboard wedge" — it types the id + Enter into
whatever holds focus. Previously that only worked while the ticket <input> was
focused; a scan with focus elsewhere (or nowhere) went nowhere.

New useScanner hook (apps/web/src/lib/use-scanner.ts): a document-level keydown
listener that detects the scanner's FAST keystroke burst ended by Enter and opens
the pay/exit modal via setActiveTicket — regardless of focus. A gap > 50ms resets
the buffer, so human-paced typing with nothing focused never registers as a scan
(min length 3 guards stray Enters). Keystrokes into an input/textarea/select/
contenteditable are ignored, so the manual ticket field still works by hand. The
hook is paused while a modal is already open — a scan must not abandon an
in-progress payment; the operator finishes/closes, then scans the next car.

Verified at runtime (Playwright): a fast burst with focus on BODY opens the modal;
a second scan while the modal is open is ignored; slow (120ms) human typing does
NOT open it; the manual input submit still opens it. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 15:03:01 +02:00
julian 35c10a7310 feat(shifts): /shift→/shifts, clickable activity log (shared event-detail), booth-style full-height layout
Three changes to the shift hub, addressing the report:

1. Route rename /shift → /shifts (matches the plural "Turnet" label and the
   section). /shift and /setup/shifts both redirect to /shifts; the header link
   and the operator-landing fallback point at /shifts.

2. The activity-log rows are now CLICKABLE and open the same read-only
   event-detail modal the booth live feed uses (full signed payload + entry/exit
   snapshots + chain provenance) — previously they were static rows. Extracted
   EVENT_STYLE, the feed row, the detail modal, and their helpers out of
   BoothScreen into a shared apps/web/src/ui/event-detail.tsx imported by both the
   booth and the shift log, so the two render and behave identically and can't
   drift.

3. Reworked the /shifts layout to fill the viewport like /booth: a fixed
   title + filters, then a two-pane area (shift list | activity log) where each
   pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the
   whole page growing. ShiftActivityLog is now a flex column with a fixed header
   and a scrollable list.

Verified at runtime (Playwright): /shift redirects to /shifts, an activity row
opens the detail modal, the layout fills height, and the booth still works (0
console errors after the extraction). build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:58:50 +02:00
julian 2a9e6846a1 fix(nav): header "Turni"→"Turnet" (plural); remove duplicate Setup shifts tab
The header shift link used nav.shift (singular: Turni/Shift) but points at the
/shift HISTORY hub, so it now uses nav.shifts (plural: Turnet/Shifts).

The Setup "Turnet" tab was a duplicate — /setup/shifts and the standalone /shift
both rendered ShiftsHistory. Removed the Setup tab + its child route; /setup/shifts
redirects to /shift for old bookmarks, and the operator-landing fallback (a
shift:read user opening /setup) now points at /shift. The orphaned nav.shift key is
left in both catalogs (harmless).

Verified at runtime (Playwright): header reads Kabina·Turnet·Abonimet·Konfigurimi,
Setup no longer lists Turnet, /setup/shifts redirects to /shift. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:47:00 +02:00
julian 051b440627 feat(nav): promote Subscriptions to a top-level section with its own tabs
Subscriptions, Plans, and Tariff Lab were tabs under /setup. Moved them into a
standalone /subscriptions section with its own header nav entry (between Turni and
Konfigurimi) and a tab bar: Abonimet (/subscriptions), Planet
(/subscriptions/plans), Lab Tarife (/subscriptions/tariff-lab).

- New SubscriptionsLayout (tab bar + <Outlet>); the three screens are now its
  child routes at the top level, not under setupRoute.
- Removed Subscriptions/Plans/Tariff-Lab from SetupLayout and SETUP_TABS. Setup
  now holds Devices/Tariff/Site/Users/Roles/Shifts/Logs.
- Header gains the "Abonimet" link, gated on subscription:read OR subscription:plan
  OR tariff:read (shown if the user can reach any sub-tab).
- Tabs are permission-gated; the /subscriptions index redirects a user lacking
  subscription:read to the first sub-tab they can see (or the booth).
- Legacy redirects: /setup/subscriptions → /subscriptions, /setup/plans →
  /subscriptions/plans, /setup/tariff-lab → /subscriptions/tariff-lab. Dropped the
  old /subscriptions → /setup redirect (it's a real route now).
- The Tariff COMPOSER stays in Setup; only the Tariff LAB simulator moved.

Verified at runtime (Playwright): header order Kabina·Turni·Abonimet·Konfigurimi,
the three sub-tabs render, Setup no longer lists them, /setup/subscriptions
redirects cleanly. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:43:58 +02:00
julian eb47016ae3 feat(shift): confirm-before-close with X-report + split tickets vs subscriptions; fix dark <select>
CI / check (push) Failing after 31s
Three changes:

1. Confirm-before-close. The header shift button closed the shift directly — a
   stray click would sign the irreversible Z-report. It now opens a confirm modal
   showing the live X-report (takings split by source + expected drawer) with
   Cancel / End-shift. Opening a shift stays immediate (no such risk).

2. Split takings by SOURCE. The report separates Tickets (transient) from
   Subscriptions (monthly sales + a subscriber's out-of-window charge), so the
   operator sees subscriber money apart from ticket money. Buckets are derived
   from the signed payment payload flags (subscriptionSale /
   subscriptionWindowCharge) and always reconcile to cash + card (a payment with
   neither flag is a ticket). Computed in #summariseWindow, carried on the signed
   shift_z_report payload, and shown in the X-report, the close modal, the shift
   history detail, and the printed Z-report. Reports predating the fields default
   subscription to 0 (ticket absorbs the whole take), so old shifts still
   reconcile.

3. Fix dark-theme native <select> popups rendering WHITE on WebKitGTK (the Tauri
   Linux WebView): set color-scheme dark/light on <html> per theme + explicit
   <option> colours, so the OS-drawn dropdown list follows the theme.

Verified the split on a read-only DB copy: tickets 0, subscriptions 10,200
(10,000 sale + 200 out-of-window), reconciles to cash+card. build+lint 14/14,
i18n parity (sq+en).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:30:14 +02:00
julian 78d1f6808a feat(subs): admin can correct a subscription's plan VERSION
A subscription froze its planVersionId at sale (reproducible pricing). There was
no way to move a sold sub onto a different VERSION of the SAME plan — needed when
an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1
"every day" → v2 "weekdays only") and wants an existing subscriber on it, or back
on v1.

Backend (PUT /api/subscriptions/:id):
- accept planVersionId; honored only with the subscription:plan permission
  (stronger than subscription:update — a plan-management action). Non-privileged
  caller sending a change → 403, not silently dropped.
- validated to belong to the sub's EXISTING planId (a different plan = a
  different price basis = a re-sale → 400).
- price/currency/period/planId stay frozen; only planVersionId moves. The swap is
  server-logged for audit (the row is mutable master data, not on the ledger).
  Past signed entry/exit events keep their own windowTariffVersionId, so history
  reprices identically — only future access uses the new version's windows.

Frontend (SubscriptionManager):
- pass the session user through the route (like RolesManager).
- admin-only "Versioni" picker in the edit modal: lists every version of the
  sub's plan by effective date + a timeframe summary (days + window, or 24/7),
  current pre-selected. The plan itself stays read-only. Sends planVersionId only
  when it changed.
- i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay
  in both sq + en.

Verified on a writable DB copy: version changed, price + planId frozen,
cross-plan version rejected. Live DB untouched. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:08:58 +02:00
julian 31f116a068 fix(print): center the out-of-window slip barcode again
The previous width fix also forced ALIGN_LEFT inside code128(), which moved the
slip's barcode to the left. But the no-print bug was the barcode WIDTH (too wide
to fit the head at module width 3), not the centering — at width 2 it fits and
centers fine. So code128() no longer touches alignment; the caller controls it.
The out-of-window slip block is ALIGN_CENTER, so the Code128 + QR center as they
did before, just narrow enough (width 2, ~510 dots) to actually print. The
voucher receipt barcode likewise centers as it originally did.

Verified: alignment-in-effect at the barcode = CENTER, module width = 2, QR
present; build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:52:42 +02:00
julian 663bf0e925 fix(print): out-of-window slip Code128 was too wide to print — width 2 + left-align
The slip printed the QR but NOT the Code128 barcode on the Rongta. Root cause:
the ~20-char occurrence id (SUBSESS-…) at module width 3 is ~765 dots wide —
over the 80mm head's ~576 printable dots — so the firmware silently aborts the
barcode (prints nothing). It was also emitted while ALIGN_CENTER (set for the
title) was active, which shifts the start point right and makes it overflow
even sooner. The QR, being compact, rendered fine — hence QR-only output.

code128() now takes a moduleWidth (default 3, so the shorter entry-ticket id is
unchanged) and forces ALIGN_LEFT (a wide barcode must hug the margin). The slip
passes width 2 (~510 dots — fits with quiet zones) and re-centers the QR/text
after. renderReceipt's voucher barcode re-asserts ALIGN_CENTER for the lines
that follow it.

Verified: width n=2 in the byte stream, est 510 dots; Code128 + QR both present;
build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:44:01 +02:00
julian 8acef0464c fix(subs): price out-of-window charge from minutes actually parked, not a fixed entry stamp
An out-of-window subscriber entry stamped a FIXED windowOwedMinor = the whole
gap to window-open (e.g. 800 ALL for a 13:21 arrival to a 20:00 window) and
deferred it to exit. That over-charged anyone who left before the window
opened — a 1-hour visit was billed as 6.5 hours.

The amount isn't knowable at entry: a subscriber may enter early, leave after
an hour, come and go several times before the window opens, and linger past
window-close. They should pay only for the minutes actually parked outside the
window (capped at the window edges) — exactly what minutesOutsideWindow already
computes.

So the entry now stamps a MARKER only (outOfWindow: true + windowTariffVersionId
for reproducible pricing), no fixed amount. The exit gate and booth quote price
it live via windowOwedBetween(entry → settle-time), which already caps at the
window edges (early entry stops accruing at window-open; the in-window portion
of a crossing stay is free; the late-exit tail keeps accruing until payment).
Both already called that one function, so they agree.

- subscription-flow: entry stamps outOfWindow marker; the advisory slip is now a
  scannable out-of-window TICKET (Code128 + QR of the occurrence id).
- shared LedgerPayload: add outOfWindow; mark windowOwedMinor/windowGap*/
  windowCurrency deprecated read-only (historic signed events still type-check).
- BoothScreen: window-charge badge keys on outOfWindow (or the old stamp).
- ActiveSessions: drop the always-on "Open barrier" for subscribers — the
  assist-open / window-charge payment live in the pay modal, so the list can't
  one-click past an unpaid out-of-window charge.

Verified the live model on a DB copy: 13:21→14:30 = 200 ALL; 19:55(in grace)→
23:00 = 0; 19:00→21:30 (crosses into window) = 100 ALL. Existing signed
occurrences left untouched (immutable). build+lint 14/14, shared 87/87.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:34:35 +02:00
julian df5caf8d87 feat(subs): scannable out-of-window slip + two-step booth flow
The advisory out-of-window slip for a subscriber had two problems:

1. Faulty character codes. It rendered via the generic text printReport,
   which has no CP852 mapping for the em dash, ellipsis, or warning sign in
   the composed strings — so they printed as "?" ("PARKIM ? JASHTE ORARIT").
   Added ASCII transliterations for that typographic punctuation in the
   ESC/POS encoder (— → -, ⚠ → !, … → ..., curly quotes/bullet), so they
   degrade to a readable glyph instead of "?".

2. Not scannable. The slip printed only "Nr: SUBSESS-…" as plain text, so
   the operator had to hand-key it. Gave the notice its own render function
   (renderWindowChargeNotice) + a printWindowChargeNotice device method that
   prints the occurrence id as a Code128 AND a QR — the same scan path as a
   transient ticket, so the operator scans it straight into the booth pay
   modal, which then quotes the combined window charge. Implemented on both
   the rongta and cashino drivers.

Also fixed the booth pay modal: "Open barrier" no longer shows by default
for a subscriber. A prepaid subscriber with nothing owed sees only a small
"assist open" reveal (the audited manual open for a faulty reader / lost
card stays available, just not the default). A subscriber owing an
out-of-window charge is now two steps — take payment first, then "Open
barrier" appears — instead of an always-on open button.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:12:05 +02:00
julian 0cbae94842 feat(desktop): wire updater endpoint to self-hosted Gitea + document Tauri WS origin
Point the Tauri updater at the real self-hosted Gitea "latest release" path:
https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json
— redirects to the newest tag's latest.json published by release.yml. Verified
against tauri-plugin-updater: it GETs the endpoint (200 + manifest / 204 = up to
date) and reads platforms.linux-x86_64.{signature,url}.

Document the desktop WS origin: the Tauri window loads from tauri://localhost
(Linux may also send http://tauri.localhost), which is NOT same-origin with the
backend, so WS_ALLOWED_ORIGINS must include both or the live feed won't connect.
Added both to apps/server/.env.example.

Updated the as-built in wiki/decisions/desktop-shell-tauri.md. Also carries an
unrelated plans.namePlaceholder copy tweak already in the tree. turbo build lint
14/14 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:51:11 +02:00
julian ae5c122980 ci(gitea): CI checks + tag-triggered signed Tauri desktop release
Mirror the house Gitea Actions pattern (cf. trm/processor): corepack pnpm +
frozen install on ubuntu-latest.

ci.yml — push/PR to dev → pnpm turbo run build lint + test (covers tsc, vite
build, i18n catalog type-parity, and the shared vitest suite).

release.yml — on a v* tag → install Tauri Linux deps (webkit2gtk-4.1, libsoup-3,
gtk-3, appindicator, rsvg, patchelf) + rustup, cache cargo/target, then
`pnpm --filter @parking/desktop bundle` signed with the updater key from Gitea
secrets (TAURI_SIGNING_PRIVATE_KEY + _PASSWORD). Collects .deb/.rpm/.AppImage +
their .sig, assembles latest.json (platform key linux-x86_64 — verified against
the tauri-plugin-updater target format), and publishes a Gitea Release via the
API with the built-in token (no marketplace release action needed).

Both workflows validated (PyYAML parse). No secret values committed — only
${{ secrets.* }} references.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:33:20 +02:00
julian d0536da3d7 feat(desktop): Tauri v2 kiosk shell — maximized window, prod right-click block, auto-update + code-signing
Add apps/desktop, a thin Tauri v2 shell wrapping the SAME @parking/web SPA so
the desktop and browser UIs never drift: dev loads the Vite dev server (HMR),
prod bundles the web app's dist/. No business logic in the shell (device/auth/
ledger stay in @parking/server); deny-by-default capabilities.

apps/web (single UI source of truth):
- lib/origin.ts: centralize the backend origin (API_BASE/apiUrl/wsUrl from
  VITE_API_BASE); no-op in the browser, lets the desktop build target Fastify.
- lib/kiosk.ts: block the right-click context menu in PROD only (dev keeps it +
  devtools).
- lib/desktop-updater.ts: prompt-on-update auto-update (no-op in browser/offline)
  → downloadAndInstall + relaunch; i18n update.* keys (sq+en).
- .env.production: VITE_API_BASE wired to the Fastify origin for the bundle.

Desktop:
- window starts maximized (not fullscreen — operator keeps OS access).
- auto-update via tauri-plugin-updater + -process; self-hosted endpoint is a
  PLACEHOLDER to fill in. Updater keypair: pubkey embedded in tauri.conf.json;
  private key + password kept OUTSIDE the repo (~/.parking-updater-keys) and as
  TAURI_SIGNING_* build secrets.
- Turbo build is a no-op; the real signed bundle is `pnpm --filter
  @parking/desktop bundle` (verified → .deb/.rpm/.AppImage + .sig signatures).

Verified: cargo check clean; turbo run build lint 14/14 green; i18n parity holds;
no key/sig/bundle artifacts in the repo.

Wiki (security + desktop analysis recorded alongside):
- new concepts/tpm.md (TPM 2.0: how it works, sealed-LUKS auto-unlock + non-
  extractable signing key, limits — live-root, bus-sniff — TPM-vs-ATECC608 by
  platform).
- new decisions/desktop-shell-tauri.md (Tauri v2 over Electron; best-case Ubuntu
  26.04 LTS, worst-case Windows+WSL → kiosk browser; full as-built).
- pull-the-disk attack trace on append-only-event-chain; ATECC608 not-in-a-PC
  caveat; cross-links from disk-os-hardening / threat-model.
- open-questions #11 (appliance WebKitGTK), #12 (TPM hardening impl), #13
  (startup verifyChain self-check); index/overview/log/standing-decisions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:21:49 +02:00
julian ae736a9e3e feat(shift): current shift in the list + modal actions; full-width layout everywhere
Shift screen:
- The standalone ShiftControl block is gone from /shift. The open/CURRENT shift now
  appears at the TOP of the shift list (CURRENT badge, live figures synthesized from
  the X-report), unified with history. Selecting it shows its live activity log.
- Shift ACTIONS moved into the current shift's detail pane, each opening a MODAL:
  End shift (confirm → signed Z-report result), drawer voucher (Mandat in/out),
  takings-so-far (X-report). When no shift is open, a Start-shift button shows.
- The current shift's log auto-refreshes (5s); a closed shift is bounded by its
  window. /setup/shifts stays read-only history (no manage props). Deleted the now-
  orphaned ShiftControl.tsx.

Layout:
- Every screen is now full-width like /booth — stripped the per-screen
  `mx-auto max-w-*` caps (Logs, Subscriptions, Plans, Tariff, Users, Roles, Setup
  layout, Shifts). The shell <main> already provides padding.

Build+lint 12/12 (i18n parity). Verified a live open shift surfaces as the CURRENT
list entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 23:44:27 +02:00
julian 1b54775b4d feat(shift): two-pane shift history — list + per-shift activity log, timeframe presets
Rework the shift screen into a master/detail view on /shift: the shift CONTROL
(open/close, drawer vouchers, X-report) on top, then a two-pane history below —
shift list on the LEFT, the selected shift's signed activity log on the RIGHT.

- Timeframe presets replace the bare from/to inputs: Yesterday / Last week /
  Last month / All / Custom (custom reveals the date pickers). Filters the shift
  list by start time.
- Activity log = every ledger event in the selected shift's [start, end] window
  (entries, exits, payments, vouchers, anomalies, the Z-report), rendered like the
  booth live feed (same EVENT_STYLE), with the shift's drawer reconciliation in the
  pane header.
- Scope unchanged + enforced SERVER-SIDE: an operator sees only their own shifts
  (no operator filter); an admin (shift:cash) sees all + the operator filter. The
  list auto-selects the newest shift.

API: /api/events gains an optional `until` (ISO) upper bound so a shift's window
can be fetched ([start,end]); fetchEvents passes it. Verified on live data: a
closed shift window returns just its 20 events out of 260.

Build+lint 12/12 (i18n parity). The same component also backs /setup/shifts.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 21:56:34 +02:00
julian f2734641b2 feat(subs): print an advisory "out-of-window" slip at early entry
A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).

Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.

(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:47:45 +02:00
julian de858e91f4 i18n: translate sub.refused.unpaidWindow reason (sq + en)
The exit-gate refusal for an unpaid out-of-window subscriber charge rendered as
the raw code `reason.sub.refused.unpaidWindow` — the code + English fallback
existed in @parking/shared but the reason.* catalogs had no entry. Add it to
both catalogs with the {{amount}}/{{currency}} params the gate passes.

EN: "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth"
SQ: "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë"

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:39:53 +02:00
julian 294ca85ded fix(subs): out-of-window charge was a phantom 12h span (4,100 ALL bug)
The tariff-bridge owed amount summed TWO charges — the early-entry gap +
a "late-exit" gap — and the exit gap (outOfWindowGap edge:"exit") always
measured back to the PREVIOUS window close, even for a subscriber still BEFORE
their window. So a car that entered ~30 min early showed ~12h owed (4,100 ALL)
the moment it was looked up, instead of ~100 ALL.

Replace the two-gap sum with a single correct primitive,
minutesOutsideWindow(timeframes, tz, from, to): the minutes within the actual
stay [entry, now] that fall outside the allowed window (covering early entry AND
late exit, bounded by the stay, weekend/off-days free). windowOwedBetween prices
those minutes once as a transient stay (so increments + daily cap apply) against
the tariff in force at entry. Both the exit gate (subscription-flow) and the
booth quote (pay-station) now use this one source of truth — they can't disagree.

Verified on the live occurrence: was 4,100 ALL, now 100 ALL (9 min outside →
one increment). 87 shared tests (6 new regression cases incl. the phantom span).
Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:37:57 +02:00
julian eafbc3ddbb feat(booth): badge subscriber out-of-window entries in the live feed
A subscriber entering outside their plan's allowed window gets a deferred
transient charge (windowOwedMinor, collected/gated at exit) — but it was
SILENT at the booth: the entry showed as a plain subscriber pass with no hint
money is owed, so the operator only discovers it at exit.

Surface it: add a "out-of-window — owes fee" badge on any entry/exit event
carrying windowOwedMinor > 0, so the operator sees immediately that this
subscriber owes a fee. Also type the window-charge fields on LedgerPayload
(were riding the open-ended index signature).

Behaviour is otherwise unchanged and correct — verified the live "Mon Kukaleshi"
entry: entered 20:29 local (before the 21:00 Mon–Sat window, grace 5m), owes
100 ALL for 18:29–18:55Z, stamped on the signed entry, still owed, gated at
exit. Subscribers get no ticket by design. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:32:12 +02:00
julian 36f30d39ff feat(plans): reactivate + delete-when-unused; card layout fixes overlap
Addresses three issues with the plan catalog screen:

1. Retired plans had NO actions (the action cell was gated on "current
   version", which a retired plan lacks) — so there was no way to make one
   in-force again. Add POST /:planId/reactivate (inverse of retire) + a
   Reactivate button on retired plans.

2. No delete. Add DELETE /:planId, allowed ONLY when zero subscriptions
   reference the planId (any version) — a referenced plan version must survive
   for reproducible repricing/audit, so an in-use delete returns 409 and the UI
   says "retire it instead". The Delete button only shows when the plan has 0
   subscribers.

3. The 6-column table overflowed max-w-3xl: action buttons overlapped and the
   status badges wrapped to a second line. Replace it with a CARD list (one card
   per planId, grouped across versions): name + status on top, price · hours ·
   effective on a wrap row, "used by N" expandable to holder names, and actions
   on their own bordered row — nothing overlaps, badges stay inline.

Build+lint 12/12 (i18n parity). Verified on a DB copy: unused plans report
deletable; retire→reactivate flips active back.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:27:36 +02:00
julian 488dcb5e4e feat(plans): show hours, period/currency, and subscriber dependencies in the plan list
Deleting versioned plans is unsafe (a plan version referenced by a subscription's
planVersionId must survive for reproducible repricing/audit) — so instead of
delete, give the admin the VISIBILITY they actually needed:

- Hours column: a compact timeframes summary ("Hën–Pre 21:00–08:00" / "24/7"),
  so two same-priced plans are distinguishable at a glance.
- Period + currency are already in the price cell; the hours column removes the
  remaining ambiguity between night/day plans.
- "Used by" column: a count of subscriptions on each (current) plan (active /
  total), expandable to the holder names — so you can see what depends on a plan
  before retiring or replacing it. Computed client-side from the existing
  subscriptions list (both screens are admin-grade; no new endpoint).

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:14:30 +02:00
julian c64457020f fix(subs): resolve the plan version at the SALE instant, not validFrom
Selling/quoting a subscription resolved the plan version against `validFrom`,
but validFrom is a DATE (midnight UTC for "starts today"). A plan published
later the same day (effectiveFrom 15:22) then failed `effectiveFrom ≤ validFrom`
(00:00), so resolvePlanVersion returned null → "no active plan for that planId",
and the form's selectedPlan went null (hiding the new count field too).

The plan/price in force is determined by WHEN THE SALE HAPPENS, not by the
coverage start — like a tariff, the customer buys today's published rate. Resolve
at new Date() in all three sites (validate, priceSale, /quote); validFrom is kept
only for span pricing. Verified the two live plans now resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 19:55:29 +02:00
julian ff04ec10be feat(subs): add a "how many periods" count that drives the end date
When subscriptions moved to the plan model the span became start + end dates,
which lost the simple "renew for N months/weeks/days" input — the operator had
to hand-compute the end date. (quantity is CARS, a separate axis, not periods.)

Add a count field to the sell form: the operator types e.g. 3, and validTo is
auto-derived as validFrom + count × the plan's period (day/week/month), with the
same month-overflow clamp the server uses (Jan 31 +1mo → Feb 28) so the preview
matches what's stored + charged. The end-date field stays directly editable for
an irregular span (the hotel checkout case), and editing it isn't overwritten by
the count effect. The count row shows the plan's unit ("× month").

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 19:46:06 +02:00
julian e0e218fa61 refactor: plan timeframes use a per-day-of-week picker (like the V2 tariff)
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).

Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.

outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:43:21 +02:00
julian 21bd0f6227 fix(db): point db:migrate at the live appliance DB by default
`db:migrate` (and drizzle-kit's config default) resolved DATABASE_URL to
`./parking.sqlite` relative to packages/db — a stray, half-empty leftover DB,
not the real store at apps/server/parking.sqlite. Running `pnpm --filter
@parking/db db:migrate` with no env therefore migrated the wrong file and
failed on its broken state, while the real DB went untouched.

Default DATABASE_URL to ../../apps/server/parking.sqlite in both the db:migrate
script and drizzle.config.ts (an explicit DATABASE_URL still overrides). The
stray packages/db/parking.sqlite was untracked + already gitignored (*.sqlite);
deleted it from disk. Now `pnpm --filter @parking/db db:migrate` targets the
appliance DB out of the box.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:32:44 +02:00
julian 53e1e7b25c feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).

1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
   amount = span price × quantity; maxConcurrent defaults to the quantity so all
   N cars can be inside. Quantity rides in the payment payload.

2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
   park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
   NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
   tariff (the subscriber is a transient for that time):
     - early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
       the vehicle_entry payload), collected at exit;
     - late exit: window-close → departure, and exit is GATED
       (sub.refused.unpaidWindow) until paid at the booth.
   Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
   reuses computeFee + the active tariff version
   (apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
   business gate — the fail-open rule still governs the offline path.

3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
   max(0, quantity − itsCarsInside) per active subscription, so transients see
   "full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
   never gated by full.

UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.

Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:22:50 +02:00
julian fd4608a8f1 feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an
admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now
SELLS by picking a plan over a date span; the price is LOOKED UP, never typed —
removing the fat-finger risk on a money field — and day/week/month periods make
the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span.

- Schema/migration 0010: new `subscription_plans` (immutable, effective-dated,
  keyed by a stable planId; period day/week/month + per-period price + active
  flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a
  "Monthly" plan from the existing site default price (no data loss).
- Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period),
  amount = periods × per-period price. Ceil = any started period is full (hotel
  practice). `resolvePlanVersion` picks the latest active version ≤ sale instant.
- Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked
  sell path derives the amount from the plan; `POST /api/subscriptions/quote`
  returns a server-computed quote so the operator can't override it. The
  signed-payment sale fix is unchanged — only the amount SOURCE moved; payload
  now carries planId/planVersionId/periods. Updates never re-sell (price frozen).
- Frontend: SubscriptionManager sell form swaps the price field for a plan
  picker + start/end dates + a live quote line. New SubscriptionPlansManager
  (Setup tab) for the admin catalog. i18n (sq+en) for both.

Verified on a copy of the live DB: 0010 applies (existing subs intact), a
3-night hotel sale prices to 2,400 ALL, appends one signed payment with
planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 17:13:42 +02:00
julian 052da8c3a7 i18n: relabel X/Z report UI to plain language (keep X/Z in code)
The "X-REPORT / Z-REPORT" labels are till-accounting jargon operators don't
recognize. Relabel the user-facing strings to plain wording in both catalogs —
SQ: "ARKËTIMET DERI TANI" / "MBYLLJA E TURNIT"; EN: "TAKINGS SO FAR" /
"SHIFT CLOSE". The X/Z naming stays in code (xReport/zReport keys, the
shift_z_report event, currentReport) and the wiki.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:51:54 +02:00
julian cb68cbafdb feat: mid-shift X-report (read-only takings-so-far)
Let the operator see, on demand during an open shift, the opening float
inherited, cash/card collected so far, pay-ins/pay-outs, and the current
expected drawer balance — without closing.

GET /api/shift/report (shift:read; 204 when no shift is open) returns the same
drawer projection the Z-report computes. Factored that math into a shared
ShiftService.#summariseWindow(open, asOf) used by BOTH the X-report (asOf=now,
read-only) and close()'s Z-report (asOf=endedAt, signed), so the two can't
drift. The X-report appends NOTHING — it's a snapshot, not an accountability
mark; the Z-report at close remains the signed record.

UI: a "Takings so far" button on the shift control reveals a cyan X-report
panel; the header still shows the live drawer total for the at-a-glance figure.

Verified against a copy of the live DB: X figures match drawerBalance(), the
drawer identity holds, zero events appended, chain still verifies.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:22:55 +02:00
julian 2835f78635 feat: re-model drawer cash as directional vouchers (Mandat Arkëtimi / Pagese)
Replace the single signed-± cash_movement with two distinct financial
documents — the direction is the event TYPE, not the sign of an amount:

  cash_in  = Mandat Arkëtimi (receipt / pay-IN,  +)  voucher AR-NNNN
  cash_out = Mandat Pagese  (disbursement / pay-OUT, −)  voucher PA-NNNN

Each carries a positive magnitude, voucher number, reason, the operator who
raised it and the admin who authorized it, and prints an Albanian slip.

Authorization changes from admin-only to operator-RAISED / admin-AUTHORIZED:
any shift:create holder raises the voucher, but POST /api/cash-voucher only
commits when authorizedBy is a real admin (shift:cash) re-entering their
password (verified server-side). Keeps the float control while letting the
operator do the booth paperwork.

Legacy cash_movement events are kept — they still verify and still fold into
the drawer (signed-±); the append-only chain is never rewritten. The drawer
fold and the Z-report window now sum all three types.

Verified against a copy of the live DB with the real signing modules:
cash_in 3000 + cash_out 5000 → drawer −2000, hash-chain verifies OK.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:18:26 +02:00
julian a20400c2c5 fix: record subscription sale as a signed payment (close off-book hole)
Creating a priced subscription wrote only the mutable `subscriptions`
master row and appended NOTHING to the signed ledger — so the cash an
operator collected showed in the live feed, drawer, and shift Z-report
nowhere, leaving no signed trace. A booth operator could sell
subscriptions and pocket the money untraceably — the exact
operator-as-adversary path the append-only signed ledger exists to close.
Found live: 3 priced subscriptions (27,000 ALL) had zero payment events.

Selling a priced subscription now appends a signed `payment` event at
create time: amount = priceMinor x months (full multi-month prepay),
operator-chosen tender (cash->drawer / card->bank), payload
{ subscriptionSale: true, permitId, operator, months }. Folds into the
shift Z-report/drawer with no new summing logic; the feed badges it
"subscription sale" and resolves the holder name. The create response
returns the recorded { sale }; subscriptionRoutes now takes the EventLog
and ShiftService.

Not hard-gated on an open shift (a sale can happen outside the booth money
path) — it warns instead. The 3 historical off-book sales are not
back-fillable (append-only forbids forging dated events) — reconcile via
cash_movement or a Z-report note.

Verified against a copy of the live DB with the real signing modules:
signed payment appended, hash-chain still verifies, lands in shift cash
totals. Build + lint 12/12.

Wiki: subscription "Collecting the fee" deferred -> BUILT (+ the off-book
hole and why); shift sale-folds-in; threat-model worked example
("store the price != account for the sale").

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:46:17 +02:00
julian cdb55a8652 feat: show recognized plate in live feed + active sessions
Surface the advisory ANPR plate (device_events kind="read", keyed by
session identity — unsigned, prunable, never an access decision) next to
entry/exit events in the live feed and on active-session rows.

Resolved at serialize time (new plate-lookup.ts; prefers an entry read;
one device_events scan per page) like subscriber-name enrichment — the
signed ledger is untouched. Adds plate? to the shared LedgerEvent and to
ActiveSession/SessionLookup; a small amber badge in the UI.

Caveat: a vehicle_entry is signed + pushed over WS before the async ANPR
read lands, so a fresh feed row may show no plate until reload; always
present on active sessions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:45:57 +02:00
julian b0c9ba0f8c docs(wiki): per-increment vs per-hour tariff gotcha + composer UX idea
priceMinorPerIncrement is per BILLING INCREMENT, not per hour. Documented
the effective-hourly formula (price x 60/incrementMin) as a callout after
recurring "Lab is wrong" confusion (weekend 3h=600 not 300 was correct),
and filed a per-hour-preview composer UX candidate under Open.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:44:12 +02:00
julian 9a1feeeb20 fix(tariff): reject stepped base combined with time/seasonal tiers
A stepped ("up-to") default card prices the whole stay as one total, so the V2
engine short-circuits to steppedFee and NEVER consults windowed cards — any
time/seasonal tiers would silently never fire. Found live: an active tariff had a
stepped base plus weekday-night + weekend tiers, and every 3h stay priced 600 ALL
regardless of hour/day because the tiers were dead.

- validateTariffV2 now rejects a stepped defaultCard combined with windowedCards,
  with an actionable message (switch the base to ladder/flat, or remove the tiers).
- Composer shows an inline red warning the moment base mode is stepped and tiers
  exist; publishing is blocked server-side regardless.
- ApiError now carries the server's problems[], so the publish error surfaces the
  SPECIFIC reason instead of a generic "invalid tariff structure".
- 2 new validation tests (55 pass).

Wiki: tariff, log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 14:03:22 +02:00
julian cc507f490f feat(tariff): stepped ("up-to") pricing mode — total-by-duration
Owners often state rates as a total-by-duration matrix (0-1h=200, 0-3h=500,
0-6h=800, 0-9h=900, 0-12h=1000) that the marginal hourly ladder can't express
(the ladder sums per-increment rates; this is cumulative totals at thresholds).
Add STEPPED as a third pricing mode alongside the ladder and flat.

- @parking/shared: TariffStep {uptoMin, totalMinor} + a `steps[]` field on V1
  structures and V2 cards (mutually exclusive with blocks/flatMinor). steppedFee():
  smallest tier with uptoMin >= duration wins (INCLUSIVE boundary), the top tier
  repeats as a per-day cap; wired into computeFeeV1 + computeFeeV2 (V2 default card
  only — a whole-stay total can't be sliced per-increment by a windowed card).
  Validation: ascending uptoMin, non-negative totals, no daily-cap-with-steps,
  steps-only-on-default. priceSession/quote/booth/Lab price it via the shared core.
- Composer UI: a "By duration (up-to)" mode with an up-to/total table (base card
  only). i18n modeStepped/steppedHint/stepUpTo/stepTotal/addStep (sq+en).
- 8 new unit tests incl. the exact owner matrix, multi-day repeat, overstay, and
  validation (53 pass). Verified end-to-end via the UI: authored + published the
  matrix, Tariff Lab prices it exactly (3h->500, 6h->800, 12h->1000, 2d->2000).

Wiki: tariff (three pricing modes + stepped semantics), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 12:33:27 +02:00
julian 3d02134711 feat(tariff): Tariff Lab — pure session-pricing simulator
Test rates "in time" (overnight windows, daily caps, overstay) in seconds against
any tariff version, instead of waiting hours/days. No real ledger writes.

- Extract priceSession() into @parking/shared: the grace/overstay wrapper over
  computeFee (unpaid -> entry..now; within-grace -> settled 0; grace-expired ->
  overstay, a fresh period from grace-expiry). PayStation.quote() now calls it so
  the booth and the lab can never diverge.
- API (tariffs.ts, tariff:read, read-only): POST /api/tariff/simulate prices a
  hypothetical session (active/any version/inline structure) and returns the
  priceSession outcome + a 30m..3d duration curve (see where the daily cap flattens);
  GET /api/tariff/simulate/session/:identity prefills from a real ledger session.
- UI TariffLab.tsx at Setup -> "Tariff Lab": version picker, entry/asOf times,
  optional payment+grace, category, and load-a-real-ticket. Admin-gated, available
  on-site (useful to quote a dispute).
- 4 new priceSession unit tests incl. the ticket-1245791632490 overstay-not-zero
  regression (40 pass). i18n lab.* + nav.tariffLab (sq+en). Verified live via the UI.

Wiki: tariff (priceSession + Tariff Lab as-built), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 12:05:30 +02:00
julian a4712774ab feat(booth): overstay sessions, top-up pricing, and session/feed filters
Rework paid-but-grace-expired sessions and add booth filters.

Overstay (was "stuck"):
- Stop silently aging out a paid transient whose walk-back grace lapsed with no
  signed exit. Keep it listed with an OVERSTAY badge — a new parking period began
  (re-parked) or the car is faulty/abandoned; it is not a system fault.
- No free exit: reopenBarrier refuses server-side once a transient's payment grace
  has expired (allow only subscription OR paid-and-within-grace); the UI hides the
  Open-barrier button on overstay rows and routes to the pay/exit modal. Closes a
  hole where a stale payment authorized a free multi-day exit (operator-as-adversary).
- Price the overstay as a NEW period from grace-expiry -> now with its own daily-cap
  ladder, NOT "full stay minus paid" (which a daily cap collapsed to 0 — ticket
  1245791632490 owed ALL 0; now owes its real overstay). quote() gains periodStart +
  overstay; SessionLookup/ActiveSession gain `overstay`. handlePayAndExit charges
  whenever the session is payable (was: only if !alreadyPaid, skipping the overstay).

Filters (new ui/FilterBar): Active Sessions — search + status
(unpaid/paid/exiting/overstay) + transient-vs-subscriber. Live feed — search +
event (entry/exit/pay/void/anomaly) + direction + source (booth=manual vs reader).
All client-side over already-fetched data; matched/total count shown.

i18n parity (sq+en). Wiki: booth-exit-flow updated (overstay model, naming history,
no-free-exit security fix, new-period pricing; open question on grace-renewal noted).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 11:48:54 +02:00
julian 918f76fbef fix(booth): make the Active Sessions list scrollable
The session list filled to its content height instead of clipping, so a long list
(29 sessions) overflowed the column instead of scrolling — unlike the live feed.

The ActiveSessions Panel sat in a plain block wrapper, so it sized to content and its
inner `h-full overflow-y-auto` had no bounded height to scroll within. Make the wrapper
a flex column and give the Panel `flex-1` so it fills the column; the inner scroll area
is then bounded and scrolls — matching the live-feed treatment.

Verified live (Playwright): the scroll container is now 437px tall over 1118px of
content → scrollable, while the live feed is unchanged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 17:30:44 +02:00
julian 9ec644811a feat(vision): surface the recognized plate in the booth UI
The ANPR plate was saved (device_events kind:"read") but had no UI. Extend
GET /api/snapshots/by-identity/:identity to also return plates[] (plate, confidence,
region, direction, snapshotId, at) for that session, and render each as a cyan
"Plate: AA558EE 100%" chip in the SnapshotStrip — so it shows in both the booth
event-detail modal and the pay modal, beside the evidence photo, no separate screen.
Deduped by plate+direction; session:read gated; i18n sq+en.

Verified: by-identity returns plates[] for a seeded read (200, AA558EE 0.999 Albania
entry). Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 17:23:32 +02:00
julian ecaaefd899 refactor(vision): ANPR rides the entry/exit snapshot, drop polling reader
Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.

The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.

Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:53:43 +02:00
julian 4af8b56dda feat(vision): configurability — SetupWizard ANPR toggle, footer health chip, env docs
Make the vision service genuinely configurable (was env-only).

- SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted
  only when on; sq+en) — opt-in is no longer raw JSON.
- DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting
  a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the
  booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category
  union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when
  up, 0 chips when disabled.
- apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example
  (Node side) + a Configuration section in opencv-anpr-service.md covering all four
  layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE
  .env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr
  camera recognizes but every read is refused.

Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:41:29 +02:00
julian 540b333b06 feat(vision): persist every recognized plate + snapshot as telemetry (non-blocking)
Answers "is a recognized plate saved?" — now yes, for both transient and subscriber, as
an ANPR audit trail independent of whether it matched anything.

VisionReader now stores the snapshot bytes in `snapshots` keyed by identity=PLATE — the
same identity the flow signs its anomaly/event with — so GET /api/snapshots/by-identity/:plate
(the booth event-detail modal's snapshot strip) shows the car's photo against that
anomaly with no UI changes. It also records an unsigned device_events{kind:"read"}
breadcrumb (plate, confidence, region, model, snapshotId, and the dispatch outcome) as a
queryable recognition log. Switched from emitRead to calling ReadDispatcher.dispatch
directly (like qr-reader) to capture that outcome.

Non-blocking: a refused read (no session / unpaid / unknown plate) just returns
rejected — no barrier hold — and is logged with its snapshot for investigation. Plate
stays advisory (exit demands payment; subscription matches only a bound plate).

Verified e2e: a recognized AL plate with no open session signed exit.refused.noSession
(identity=plate), stored a 555KB snapshot under that plate, recorded the read breadcrumb
(accepted:false, reason "no open session"), and by-identity returned the image — the
refused read is fully investigable with its picture. Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:29:10 +02:00
julian 7e086ff0d7 feat(vision): wire ANPR into the read bus via VisionReader
A VisionReader polls each opt-in camera (config.anpr===true, off by default) every
VISION_POLL_MS, captures a snapshot, recognizes via VisionClient, and on a confident
plate emits deviceEvents.emitRead({kind:"plate", value}) — the same event a physical
plate reader sends, so the existing ReadDispatcher routes it to the subscription/exit
flow unchanged (no flow rewrite).

The plate stays advisory by construction: the exit flow still demands a covering
payment, the subscription flow only matches a bound plate. Guards: low-confidence reads
dropped; debounce (VISION_DEDUPE_MS) so a parked car doesn't re-fire; per-camera
in-flight guard; idle when vision is off or no camera opts in. #recognizeOn is public
for a future on-demand (loop-edge/API) trigger.

Verified end-to-end: an in-memory anpr camera (AL plate image) + live fast_alpr service
→ VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} onto the bus; debounce
held it to 1 emit over 7 polls. Build + lint green. Updates opencv-anpr-service
(trigger-wiring + per-camera opt-in marked done).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:21:34 +02:00
julian 236cbfecab feat(vision): add VisionClient Node adapter (advisory, fail-soft, opt-in)
Node-side adapter to the apps/vision ANPR microservice (localhost HTTP: POST /analyze
with snapshot bytes, GET /health), returning a normalised VisionResult or null. Enforces
"advisory, never sole authority" at the boundary: opt-in (VISION_ENABLED, default off),
fail-soft (any error/timeout/unreachable → null, never throws into the lane → ticket
fallback), and re-applies the confidence floor (VISION_MIN_CONFIDENCE) on top of the
service's own low_confidence flag. Per-request AbortController timeout so a slow call
can't hang the barrier. Constructed in server.ts.

Verified: fail-soft (disabled/unreachable → null, no throw) and live end-to-end (Node
client → running fast_alpr service → AA558EE 0.999, region=Albania). NOT yet wired into
the read bus — the opt-in snapshot→DeviceReadEvent{kind:"plate"} trigger is the next
step. Build + lint green. Updates opencv-anpr-service (adapter gap marked done).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:58:43 +02:00
julian 17fdf3d482 docs(wiki): vision fitness assessment for entry/exit flows
Record the verdict: the ANPR service is worthy to consume NOW as an advisory plate
IDENTITY source (Job 1) — the flows already treat a kind:"plate" read as first-class
(exit signs source:"lpr"; subscription matches read plate vs bound plates), so it feeds
an existing input with no flow rewrite. It is NOT worthy as the sole authority to open a
transient barrier (a plate is not a payment; spoofing needs Job 2 vehicle verification,
unbuilt) — gated by the confidence floor with ticket/manual fallback. Lists the four
gaps before consumption (VisionClient adapter, opt-in trigger, field accuracy,
weight-provenance). Next step is the adapter, not more model work.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:55:16 +02:00
julian 4833b4373d docs(wiki): Albanian-plate OCR benchmark — keep cct-xs-v2-global default
Benchmarked fast-alpr's four fast-plate-ocr models via the full pipeline on real AL
plates (AA558EE, AA687KE), CPU. All four read both correctly; the default
cct-xs-v2-global-model wins on confidence (0.999/1.000) AND speed (33-39ms) and returns
region=Albania. The "European 40+country" model is WORSE here (~0.77 confidence, one
synthetic misread) — overturning the "EU model better for AL" assumption from the prior
research. Decision: no config change. Resolves the AL-accuracy-benchmark open item
(results table + finding added to opencv-anpr-service); weight-provenance remains the
one open recognizer item. Re-benchmark on real on-site captures once cameras installed.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:51:08 +02:00
julian 5cedcaefe1 feat(vision): add recognize CLI + verify fast-alpr end-to-end
Add a dev CLI (uv run python -m vision_service.cli <image>) that runs a recognizer on
an image file and prints the parsed plate(s) + confidence + region — fast feedback with
no HTTP. Also a package.json `recognize` script and a vision-recognize entry point.

Verified fast-alpr for real: installed the `alpr` extra, downloaded the YOLOv9 + CCT
ONNX weights (~11MB, cached offline under ~/.cache), and ran recognition on the
project's test image → "5AU5341" at 1.000 confidence, region "Czech Republic", ~40ms
on CPU, via both the CLI and POST /analyze.

Fixes result parsing against the actual fast-alpr API: ocr.confidence is a LIST of
per-character confidences (not a scalar) — reduced to one plate confidence via the MIN
(a plate is only as trustworthy as its weakest character); also surface ocr.region.
Extracted the per-result mapping into a pure plate_from_alpr_result + _reduce_confidence
and unit-tested them (no model weights needed). 7 tests pass; ruff + mypy strict clean;
full turbo build/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:46:03 +02:00
julian 6933406ae3 feat(vision): scaffold apps/vision ANPR microservice (FastAPI, stub recognizer)
Skeleton of the host-side vision service per the packaging decision: a Python/FastAPI
app at apps/vision/, uv-managed, wired into the Turbo graph via a thin package.json
shim (dev/lint/test/build → uv/uvicorn/ruff/pytest). A per-package turbo.json sets
build outputs [] so the no-op build is warning-free.

Endpoints: GET /health (readiness + model version) and POST /analyze (raw
octet-stream body, so Node POSTs Snapshot.bytes directly; empty→400, oversize→413,
recognizer-not-ready→503). The recognizer is a Protocol with a StubRecognizer (no
models, boots/tests offline — the dev/CI default) and a FastAlprRecognizer (the real
MIT YOLOv9+CCT/ONNX stack, lazily imported; missing models ⇒ ready=False, not a crash)
— the device-adapter pattern applied to the model. fast-alpr + onnxruntime are an
optional `alpr` extra, so `uv sync` needs no model download.

Verified: turbo run lint|test|build includes @parking/vision and stays green; uv run
mypy strict-clean; uvicorn boots and serves /health + /analyze live; pnpm workspace
6→7. Not built yet: the Node VisionClient adapter, a Dockerfile + model fetch, and
Job 2 (vehicle verification). Updates the packaging decision (As-scaffolded) + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:37:38 +02:00
julian ee28b7302f docs(wiki): decide vision service packaging — apps/vision/ in the monorepo
Settle WHERE the host-side ANPR service lives and how it joins the build: in this
monorepo at apps/vision/ (not a separate repo), still a separate OS process called
over localhost HTTP, wired into the Turbo graph via a thin package.json shim whose
scripts shell to Python tooling (uv/uvicorn/ruff/pytest). Co-located source honors the
vision-service runtime+license isolation decision (AGPL reach is a linking boundary,
not a folder); the fast-alpr MIT baseline removes most of the split-repo pressure
anyway. New page vision-service-packaging; updates vision-service, opencv-anpr-service,
the CLAUDE.md layout, index, log. Not built yet — packaging decision only.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 14:25:31 +02:00
julian e4827c9651 docs(wiki): record fast-alpr as the evaluated ANPR recognizer baseline
Research note from the recognizer-options query. fast-alpr v0.4.0 (MIT) — a swappable
YOLOv9-detector + CCT-OCR pipeline on ONNX Runtime, CPU-only and offline — fits the
decided vision-service architecture and is MIT end-to-end (code + published weights),
so the ANPR path may not need the scoped AGPL exception. Flags the open caveats:
verify model-weight provenance, and benchmark AL-plate accuracy (default global vs.
the 40+ country EU model). fast-alpr is plate-only, so the vehicle-verification job
stays ours to build. Decision kept open. Updates opencv-anpr-service (new "Recognizer
evaluation" section + licensing nuance), vision-service (open/next), index, log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 14:19:16 +02:00
julian c0a775818b docs(wiki): note log:read perm + entry presence/cooldown guard in reference pages
Sync the two canonical reference pages with this session's features: local-jwt-auth
gains the new log resource / log:read permission in the RBAC grid (links app-logs);
first-run-setup notes the one-car-one-ticket presence-loop/cooldown guard the admin
configures on a relay (links entry-double-press).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 12:59:06 +02:00
julian 30e7fe85de feat(booth): refusal snapshots, subscriber access medium, one-car-one-ticket entry
Three booth-integrity improvements that share the entry/exit flows and activity log.

Refusal snapshots: previously only an accepted open captured a camera image; now
every refusal/hold anomaly fires the directional camera too (a turned-away car is
exactly the evidence wanted) — entry refused-full/held, exit refused
closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription.
A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo
together. Same fire-and-forget contract; failed captures still show as tiles.

Subscriber access medium: the subscription flow already signed `via`
(qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a
cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only.

One car = one ticket: the entry button could be mashed to mint many tickets per car
(corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard
only blocked overlapping presses. Add a per-relay guard configured on the relay spec:
PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input —
one ticket per car, re-armed when the loop clears) or COOLDOWN fallback
(entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned
device_events telemetry, not a signed anomaly. SetupWizard exposes both fields.
Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is
in-memory/rebuildable, starts armed after restart.

Wiki: new entry-double-press; updated entry-exit-points, booth-console, index.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 12:54:54 +02:00
julian bfb6ab0b36 feat(logs): app log store — backend pino DB sink + frontend error collection
Add a third data stream (app_logs), distinct from the signed ledger and device
telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to
ship to, so the host is the log store.

Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay
stdout-only) with no call-site change; the DB is built before Fastify so the logger
has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn),
window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console
warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on
pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere.

POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new
log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by
age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter
level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs.

Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record;
backend warn/error persisted, info dropped; non-admin GET 403 / POST 204.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 12:54:22 +02:00
julian 0074e82a2a docs(wiki): activity-log explainability, dates/i18n, KP-300H barcode fix
Record this session's work across the affected pages + three log entries.

- ticket-encoding: id 13→11 digits (guess-resistance rationale, legacy-safe
  validation) + a barcode-geometry rule (symbol dots must fit the narrowest
  deployed printer's line — the KP-300H 72mm overflow).
- rongta-printer: KP-300H raster-garbage root cause (line overflow, not
  corruption), sendRaw graceful-close fix, Albanian human dates (formatStampSq).
- i18n: localized ledger reason codes, relative/human dates + the
  "browser ICU lacks Albanian" gotcha, toggle stale-router-context fix.
- shift: Albanian Z-report, shift-history UI + permission scoping.
- booth-console: explainable activity log (inline reasons/badges, event-detail
  modal with snapshots + audit disclosure, subscriber names, failed-snapshot
  tiles).
- index/log updated; all added wikilinks resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:41:27 +02:00
julian bbf61c48df fix(ticket): 11-digit IDs — fix KP-300H barcode line-overflow
The Cashino KP-300H printed entry tickets as raster garbage (solid black
bars / banding) while the Rongta printed the same byte stream fine. Root
cause: the barcode overflowed the print line, not data corruption.

A 13-digit Code128 at module width 3 is ~534 dots. The KP-300H prints 72mm
(512 usable dots at 203 dpi), so the symbol overran the line and the firmware
rendered the overflow as raster noise. The Rongta runs 80mm (576 dots) and had
just enough room — which is why only the Cashino failed. Confirmed on hardware:
plain text printed clean, the barcode was the trigger, and an 11-digit code at
width 3 (~468 dots) both fits and scans the full value at the exit reader.

- Ticket IDs reduced 13 → 11 digits (10 random + Luhn). Length is driven by
  guess-resistance (10^10 space, ~1-in-10^7 to hit a live OPEN ticket even with
  thousands parked — the booth-operator threat model), not volume.
- validateTicketCode is now length-agnostic (\d{10,14} + Luhn) so legacy
  13-digit tickets still in circulation keep validating; the id stays opaque.

Also: sendRaw now closes the print socket GRACEFULLY (end()+FIN, wait for
close) instead of write-then-destroy, which could RST mid-stream and truncate a
job. A separate latent bug found while diagnosing, fixed here.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:35:13 +02:00
julian 00f3d141b6 feat: human + relative dates; fix language/theme toggle stale-context
Dates were raw ISO on printed slips and time-only in the UI (a session from
two days ago showed just "10:48"). Make them human and day-relative. Also fix
a latent toggle bug surfaced while testing.

Dates:
- Printed tickets/receipts/subscription cards now show "19 Qershor 2026
  10:48:25" (Albanian month, 24h with seconds) instead of YYYY-MM-DD HH:MM.
  stamp() exported as formatStampSq so the shift Z-report shares it.
- Shift Z-report is now Albanian (Operatori/Nga/Deri/Para në dorë/Arka…),
  was English-only with ISO dates.
- Web sessions/logs/history show relative days: "Sot 10:48" / "Dje 17:33" /
  "17 Qershor 10:48" via formatRelativeDateTime(). Month names come from the
  i18n catalog (common.months), NOT Intl — the appliance browser's ICU lacks
  Albanian locale data and Intl silently falls back to English month names.

Toggle fix:
- The language + theme toggles read the active value from the TanStack Router
  context `user`, which is captured at route-resolution time and does not
  re-render on setUser. After one switch the highlight froze and the equality
  guard blocked switching back until a page refresh. Drive them off live state
  instead: language from i18n.language (useTranslation subscribes to
  languageChanged), theme from local useState. (Bug dated to 040c0ff.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:14:06 +02:00
julian f31e57b4ae feat: explainable activity log — reasons, subscriber names, snapshot gaps
The live activity feed flagged anomalies with no explanation and showed
opaque session keys. Make events self-describing and clickable.

- Clickable feed rows → read-only event-detail modal: humanized fields,
  entry/exit snapshots, and signed-chain provenance collapsed behind an
  audit disclosure (operator sees the story, auditor expands for crypto).
- Localized reason codes (backend i18n): the signed ledger now carries a
  stable REASON_CODE + params (+ English fallback) instead of free-text
  English. The UI translates via reason.<code> catalogs in sq/en, so an
  Albanian operator reads Albanian — from the same immutable event. Adding
  a language is a catalog change, no re-signing. (@parking/shared
  REASON_CODES, reasonPayload; entry/exit/subscription flows emit codes.)
- Subscriber-name resolution: a SUBSESS-… occurrence now shows the
  subscription holder's name (fallback "Abonent"/"Subscriber"). Resolved
  read-time server-side (events API + WS push) as a non-signed
  subscriberLabel; cached with invalidation on subscription edit/delete.
- Failed-snapshot visibility: a camera that was attempted but unreachable
  now shows a "⚠ camera unreachable" tile instead of a silent gap. The
  snapshots API returns failures[] from telemetry, filtered so a recovered
  capture shows no stale warning.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:57:17 +02:00
julian 040c0ff4ca feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission-
gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing
the top nav to Booth·Shift·Setup; old top-level paths redirect.

Users: add optional profile metadata (full name, phone, email, address) on
create/edit. Theme: a light palette saved to the user's profile (users.theme),
toggled in the header beside the language switch and applied on load like the
language preference. Both ride on a single additive migration (0008).

Shift history: a new GET /api/shifts folds the signed shift_z_report chain into
completed shifts, SCOPED server-side — operators see only their own; holders of
shift:cash see all with an operator + date-range filter. Surfaced as the Shifts
tab; an operator cannot read another operator's takings (param spoofing is
ignored).

These three features share the router, api client and i18n catalogs, so they
land together. Verified live: theme persists across reload, metadata round-
trips to the DB, and shift scoping holds (operator self-only, admin all+filter).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:09:18 +02:00
julian 8444bf34c3 feat(web): pop-out modal forms for setup/subscriptions/roles
Add a reusable ui/Modal (Radix Dialog + terminal chrome) and move the
add/edit forms in the Devices setup, Subscriptions and Roles screens into it,
leaving each list in the page behind the modal. The Devices wizard's per-
category device form is also fully translated (setup.* i18n keys).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:08:33 +02:00
julian 808fb26ab6 feat(web): UI component layer + dark-theme reskin
The TRM tokens were good but every screen hand-rolled inputs and buttons as
bare outlines on near-black panels, so fields, cards and buttons were
visually indistinguishable. Add a component layer (.input/.select/.textarea
as recessed slots, .btn family with a FILLED primary, .card scaffolding) and
adopt it across the booth/shift/login/tariff/site screens — several of which
were still light-theme inline styles dropped on a dark background.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:07:39 +02:00
julian ef0ecadff9 fix(auth): block privilege escalation via role/user management
The dynamic-RBAC management routes are themselves grantable (role:* and
user:*), so a non-admin holding them could self-escalate: edit their own
role to add a permission they lack, mint a privileged role, assign someone
the admin role, or reset/delete a more-privileged account. Found by the
commit security review (2× HIGH).

Fix — enforce the RBAC invariant "you cannot grant beyond yourself":
- roles.ts: role:create/update reject any permission not held by the caller
  (escalates()). An admin holds the full set, so it stays unrestricted.
- users.ts: user:create/update reject assigning a role whose permissions
  exceed the caller's; update/password-reset/delete reject acting on a user
  whose current role exceeds the caller's (exceedsCaller()).

The existing no-lockout + builtin-admin protections are unchanged.

Verified: 10-assertion inject test — manager (role:* + user:* but no
tariff:update, not admin) gets 403 on self-grant, minting a privileged role,
assigning/resetting/deleting an admin; admin stays unrestricted; the manager
can still create peers + in-scope roles (not over-blocked). Full build green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 01:27:02 +02:00
julian d0841c8601 feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.

@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).

DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).

auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.

Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).

Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).

Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 01:19:28 +02:00
julian d71ba82999 feat(booth): payment receipt / exit voucher — transparency slip + CP852 fixes
After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".

Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().

CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.

Full build green; both receipt modes render-verified; routes live.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:46:38 +02:00
julian 9c9f777784 docs(wiki): record the two configured printers (Cashino at lane, Rongta at booth)
The rongta-printer "Deployment" section still described a single
2026-06-14 unit. The live site now runs two: entry-dispenser 10.0.10.9
(Cashino, `cashino` ping-only driver) and booth-receipt 10.0.10.10
(Rongta, full status-page monitoring). Follow-up to 3e6773a.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:05:40 +02:00
julian 486f8deae6 fix(shift): update Z-report title to Albanian translation 2026-06-18 20:04:13 +02:00
julian 3e6773a6d5 fix(devices): Cashino printer — ping-only driver (no false status) + Albanian role wording
The Cashino 80mm printer reported wrong status: it ran on the `rongta`
driver, whose readStatus() scrapes the Rongta board's /prn_stat.htm status
page — which the Cashino does not serve — yielding a bogus degraded/page-
error verdict while the printer was online and printing fine. Root cause:
the Cashino is an ESC/POS PRINT clone with no trustworthy STATUS mechanism.

Fix: extract the shared ESC/POS rendering + transport (renderTicket/
renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/
qrCode) from printer-rongta into drivers/printer-escpos.ts, and add a
dedicated `cashino` driver that reuses that print path but is deliberately
NOT MonitorableDevice (no readStatus). isMonitorable() is then false, so the
device monitor falls back to healthCheck() — a plain TCP reachability ping:
reachable -> ready, unreachable -> offline, never a guessed paper/cover
state it cannot sense. Rongta driver unchanged (still scrapes its page,
still monitorable). Register + re-export cashinoDriver.

Verified at runtime (cashino registered, isMonitorable=false, no readStatus,
healthCheck->offline on unreachable) and live: /api/devices/status shows both
printers ready (lane via ping, booth via page). The live entry-dispenser at
10.0.10.9 was switched rongta->cashino in the operator DB (backed up).

Also fix the Albanian device-role chip wording, which read wrong as a
"{category} {role}" label: access mixed "i përzier" -> "hyrje/dalje"
(it means a barrier spanning both directions); printer lane "korsia" ->
"në korsi"; booth "kabina" -> "në kabinë". English tidied to match
(mixed->entry/exit, lane->at lane, booth->at booth).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:00:42 +02:00
julian cf1ff5676d feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.

computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.

Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:00:13 +02:00
julian 91cc79b14e feat(web): adopt TRM design-system tokens (tokens only)
Linked Claude Design project "TRM — Tracking & Race Management" is a
race-timing kit, not a parking design. Adopted its TOKENS only — no TRM
components. Aligned the existing term-* accents onto TRM's exact night/
semantic values (surfaces → night scale; amber→#f2a516, green→#2e8c4a,
red→#e8412b flag, cyan→#2563c8 blue) so the whole booth UI shifts palette
with zero component edits. Exposed TRM's full vocabulary (night/ink/paper
scales, flag/amber/green/blue, viz-1..8, 4px spacing, type scale, square
radii, sharp offset shadows) as Tailwind v4 utilities for new work.

Offline appliance: dropped TRM's Google-Fonts @import (no runtime network);
Goldplay display face not self-hosted yet — falls back to a sans stack.

Web build green; login renders on the new palette.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 19:46:56 +02:00
julian dfa76346d6 feat(tariff): complete the progressive ladder — require open-ended last block, hours-based composer
The stepped-block engine already does "first N hrs x X, next N hrs x Y, ...,
24h cap" (ordered blocks, per-block rate, rolling-24h cap). No new axis; this
completes the model and removes its footgun.

- validateTariffStructure (shared) now REQUIRES the last block to be open-ended
  (uptoMin: null). A bounded final block silently inherited its own rate past
  its bound (a hidden, never-stated price — e.g. the live ALL tariff billed
  hour 4+ at the 3rd-hour rate). rateAt() still prices legacy bounded-tail
  versions; validation is publish-only, so published immutable versions are
  unaffected (no migration).
- TariffComposer edits bands as a DURATION in hours ("first 2 hours, then next
  3 hours"), accumulated into the engine's cumulative uptoMin (minutes) on
  submit. The last row is a pinned, non-removable "thereafter (open-ended)"
  band, so a published card always satisfies the open-ended-last rule.
  blocksToForm round-trips stored minutes back to band hours (legacy loads).
- i18n: replaced upToMin/egExample with bandDuration/hoursUnit/egHours (sq+en,
  catalog parity green).

Verified: validator rejects bounded-last / accepts open-ended; computeFee
correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full
build green. Wiki (tariff.md, log.md) updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:58:47 +02:00
julian c9a2ef81a9 fix(tariff): forbid backdated effectiveFrom — versioning was retroactive
Version selection is "latest tariff_version with effectiveFrom <= entry time",
but the publish handler accepted ANY effectiveFrom (defaulting to now). So an
admin could publish a version with a backdated effectiveFrom and silently
reprice sessions that had already entered — the retroactive rewrite the
versioning exists to prevent. Pricing itself was sound (quote resolves by entry
time; payment records tariffVersionId, freezing completed sessions); the leak
was the publish side only.

Reject effectiveFrom earlier than now (60s skew tolerance); future-dated
(scheduling a price change) stays allowed; bad ISO -> 400. Combined with
entry-time selection this is structural: once a car has entered, no later
publish can reprice it. Did not pin tariffVersionId onto vehicle_entry (not
needed). Verified 5/5 via inject against a copy of the live DB.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:58:36 +02:00
julian b8ddda86e7 feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.

- RFID credentials enabled with a "Read card" enrollment flow: the operator
  arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
  reader's next read is captured into the form and NOT dispatched to the access
  flow — the OTHER reader keeps serving live entry/exit. Routes:
  /api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
  per-occurrence id (SUBSESS-<short>), not the credential value, with
  permitId in the payload. Direction is decided by the barrier the reader sits
  at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
  admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
  pay/exit modal shows a subscription mode (snapshots + a single audited
  Open-barrier action) to assist a faulty exit reader / missing card;
  reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
  "abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
  verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.

Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:26:48 +02:00
julian bba988c4e8 feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card
Builds out subscription credentials on top of the rename.

- Operator chooses the credential type; only QR is live (RFID shown disabled
  "soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
  globally-unique-checked) — the customer/operator never picks the value.
  RF stays operator-entered (the physical card id). Reader output decided =
  TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
  validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
  (GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
  create (best-effort — never fails the create; returns {printed,printError})
  + reprint via POST /api/subscriptions/:id/print and a "Print code" button.

Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 14:48:38 +02:00
julian 5697137c52 feat(subscription): rename permit→subscription + monthly pricing
The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.

- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
  permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
  site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
  (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
  hash-chained history; renaming it would break verification of past events.

Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).

Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.

Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:15:04 +02:00
julian ca8c7f2fa2 fix(exit): stuck active session — paid ticket with no vehicle_exit
A paid car that left via a manual barrier re-open kept no vehicle_exit, so
activeSessions() saw it as permanently open and the grace-expiry eviction
(which only ran for exited sessions) never fired — it lingered forever
(ticket T-397815c0).

- reopenBarrier() now signs a vehicle_exit (source:manual) when the session
  is still open, closing it; still no second exit when already exited
  (phantom re-close — no double-count).
- activeSessions() ages out a PAID open session past grace even with no exit
  (unpaid open sessions never age out — a car owing money stays). Pure
  display filter; the signed log is untouched.

Verified both fixes + chain integrity on a fresh DB. A one-off corrective
vehicle_exit was appended to the live ledger to clear T-397815c0.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:45 +02:00
julian f87e4c0d6b feat(devices): live device-status footer across all categories
Generalise printer-only monitoring to every configured device. New
DeviceMonitor polls all enabled devices each tick (default 8s): printers
via rich readStatus(), relays/readers/cameras via the generic healthCheck()
reachability probe, flattened to one traffic-light (ready/degraded/offline)
+ detail, deduped (emit on change only), fail-toward-offline.

- device-status bus event + GET /api/devices/status snapshot.
- Pushed over the existing /api/ws (hello carries the initial set;
  device-status frame per change).
- Web: live-store devices map, WS handler, DeviceFooter chip-per-device
  (role label not vendor; click a degraded/offline chip for an issues panel).

Verified roleKind resolution + change-only emit on a fresh DB.

Note: the footer's UI surface (api type, router mount, i18n devices) rides
in the subsequent subscription commit due to shared-file overlap.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:36 +02:00
julian 4e2e4feedb feat(shift): site-wide single-open shift + booth money-path gate
A shift becomes a SITE-WIDE accountability period — at most one open at a
time — so every taking is unambiguously attributed to one operator. Login
stays decoupled from shifts (an operator can log in off-shift to review).

Backend:
- ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY
  shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other).
- requireShift preHandler gates /api/pay, /api/exit, /api/voucher,
  /api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open.
- GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}.
- GET /api/events?since=<iso> for per-shift log scoping (db: re-export gte).

Frontend:
- Header shift button: open / close-mine / disabled-when-another-holds-it.
- Pay/exit modal gate banner (one-click open; "held by X" when another's);
  pay/exit/voucher disabled until this operator's shift is open.
- Active-Sessions barrier re-open gated the same way.
- Live feed scoped to the open shift's window; shared useShift() Query
  invalidated over the WS on shift_open/shift_z_report/cash_movement.
- sq/en strings for the control + gate.

Wiki: shift.md (site-wide single-open + gate; superseded per-operator note),
booth-console.md (header control + gate), log entry.

Verified: site-wide invariant + heldBy + handover + chain integrity on a
fresh migrated DB (11/11); db/server/web build clean.
2026-06-18 12:13:17 +02:00
julian 48660d3ec8 docs(wiki): reconcile with session — booth console, i18n, live WS
File concept pages for the operator-UI architecture ([[booth-console]]: stack,
/api/ws live feed, anti-CSWSH) and [[i18n]] (per-user server-stored language;
resolves a dangling code-comment link). Qualify the stale 'plain React' note on
react-vite-spa. Backfill log entries for the live WebSocket, frontend foundation,
and i18n builds (which had none), plus a reconciliation lint entry. Catalog
booth-exit-flow + the two new pages in index; fix the concept count (27→41).
2026-06-18 11:50:58 +02:00
julian 14c83e182a feat(web): i18n with react-i18next — Albanian default, English second
Add react-i18next with two key-parity-checked catalogs (sq default/fallback, en).
Active language driven by the logged-in user's stored preference (applied after
/me resolves); SQ/EN toggle in the header persists via PUT /api/auth/language.
Translate the booth (screen, pay/exit modal, active sessions, snapshots, status),
Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.

SetupWizard deferred (its content is server-provided; needs backend catalog i18n).
2026-06-18 11:47:39 +02:00
julian 445bca0bf6 feat(auth): per-user UI language preference (sq default, en)
Add users.language ('sq'|'en', default 'sq'; migration 0003). Returned from
/api/auth/login and /api/auth/me (read from the DB, not the JWT — so changing it
needs no re-login). New PUT /api/auth/language for self-service. Loaded on login
and restored from any booth. Printed tickets stay Albanian (customer-facing).
2026-06-18 11:47:30 +02:00
julian 062feeae2f docs(wiki): update index + log for booth console, drawer, and tariff research
Catalog the new concept/source pages and append chronological log entries for the
tariff research, live WebSocket, booth pay/exit, active sessions, and shift drawer
work.
2026-06-18 11:05:43 +02:00
julian 50a3095ef3 feat(shift): cash drawer balance carried across shifts + admin cash movements
New signed cash_movement event (admin-only): load/remove drawer float, signed +
attributed. ShiftService folds cash payments + movements by time into a drawer
balance; shift open auto-inherits the prior shift's expected closing drawer as its
opening float; the Z-report reports opening/taken/added/removed/expected (= next
shift's opening float). Card payments excluded (settle to bank). Routes: POST
/api/cash-movement, drawer in GET /api/shift/current. ShiftControl shows the live
drawer + admin load/remove form + Z-report drawer block. Wiki: shift.md.
2026-06-18 11:05:36 +02:00
julian eb3dc18e67 feat(booth): active sessions panel + audited barrier re-open
Active Sessions panel lists sessions that are open OR exited-but-within-grace
(barrier state is unconfirmed, so a paid car is presumed possibly-present until
grace expires). Row click → pay/exit modal; 'Open barrier' (paid sessions only —
no payment, no button) fires a human-intervention re-pulse signed as an attributed
anomaly, never a second vehicle_exit. Wiki: booth-exit-flow.md.

Note: the backend (PayStation.activeSessions, ExitFlow.reopenBarrier, routes,
api.ts) landed with the prior commit's shared files.
2026-06-18 11:05:26 +02:00
julian 06dab1e790 feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth
reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and
signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket
id barcode; site_config.exit_voucher_default (migration 0002) drives the default.
Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher.

Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'),
SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
2026-06-18 11:05:10 +02:00
julian 9956488fd5 chore: removed graphify 2026-06-18 11:03:45 +02:00
julian 49df2015c8 feat(web): frontend foundation — Tailwind terminal theme, Query, Router, Zustand + live booth screen
Add tailwindcss (Bloomberg-terminal theme in index.css), @tanstack/react-query +
react-router, zustand, and Radix primitives. Router with role-guarded routes;
QueryClient wrapping the existing apiFetch; a small Zustand live store fed by a
/api/ws client that invalidates Query caches. Booth screen: live occupancy gauge
+ streaming entry/exit/payment feed. Vite proxies the WS upgrade.

Note: BoothScreen references the pay/exit modal + active-sessions panel added in
following commits; final HEAD builds.
2026-06-18 11:00:42 +02:00
julian c2f06a5d2a feat(server): live booth WebSocket feed (/api/ws)
Add @fastify/websocket. EventLog fires an onAppended callback after each durable
append; device-events gains a ledger channel (emitLedger). /api/ws fans out
ledger + occupancy + printer-status to authenticated booth clients. Origin
allowlist (WS_ALLOWED_ORIGINS) replaces CSRF for the handshake (anti-CSWSH).

Note: server.ts also reflects later booth route wiring; the final HEAD builds.
2026-06-18 11:00:22 +02:00
julian 58d8f06ba0 docs(wiki): tariff research — legacy ParkSQL2017 schema, time-tiers & validation/sponsorship design
Ingest the predecessor SQL Server schema (raw + source summary) and file design
pages for time-of-day/seasonal tariff tiers and merchant validation/postpaid
sponsorship. Cross-link tariff.md and validation-discounts.md. No code.
2026-06-18 10:59:21 +02:00
julian 71aaad03b9 exit: open free within entry-grace, no pay-station visit
A quick in-and-out the tariff prices at 0 (stay <= gracePeriodEntryMin) now
exits at the gate instead of being refused as "not paid". exit-flow resolves
the active site tariff (same logic as the pay station) and, if computeFee for
entry->now is 0, mints a signed $0 payment event (reason: free entry-grace)
then signs the vehicle_exit and opens. The $0 payment keeps the append-only
ledger invariant that an exit is covered by a payment, so a grace exit stays
attributable in the audit trail. A real payment still takes precedence (the
walk-back grace path is untouched). Sign+open extracted to #signExitAndOpen,
shared by both paths.
2026-06-17 12:17:28 +02:00
julian 727c62da90 ticket: site metadata header + scannable Albanian ticket; widen barcode
- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
2026-06-17 12:17:21 +02:00
julian 1efa77bf56 devices: pool-of-spaces model — drop lane, per-relay direction
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.

Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
  config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)

Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
  (v1 events won't verify under v2 — intentional, gated per-event by keyId)

Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
  relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
  relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]

Web:
- wizard: no lane selector; add controllers (relay map + entry-button
  terminal) first, then bind readers/cameras/printers to a controller relay

Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
2026-06-16 20:29:38 +02:00
julian 15d3e1ba08 update .gitignore and CLAUDE.md for graphify integration; add settings.json for pre-tool hooks 2026-06-16 14:34:20 +02:00
julian ff3b011fe0 qr-reader: reply Connection: close (fixes ~10s beep delay)
The reader sends Connection: keep-alive but only acts on the verdict (beep,
output) once the TCP socket closes. Fastify's default kept the connection alive,
so the reader waited out a ~10s keep-alive timeout before beeping — even though
the server replied in ~15ms. Every vendor demo replies Connection: close and
shuts the socket. Set reply.header('connection','close') on the QR endpoint.

Verified the header is now sent; symptom was correct accept/reject with a ~10s
lag before the beep.
2026-06-16 12:56:10 +02:00
julian 5705098054 devices: stub-access driver (bench-test flows without a relay)
A live QR scan reached the app but rejected: 'reader not on an access-equipped
lane' — the dispatcher requires an access device on the reader's lane. Add a
no-op stub-access driver (access category, no config) whose pulseOpen only logs
and does no device I/O, so the QR->permit->accept flow (incl. the beep) can be
tested without the Dingtian relay connected. Not for production; registered in
the catalog.
2026-06-16 12:50:05 +02:00
julian 68d61f2d99 qr-reader: gee-qr-reader driver — assign in wizard, resolve lane by serial
The QR reader is a push device and the setup wizard assigns random-UUID ids, so
'id = serial' can't be set via the UI. Add a dedicated gee-qr-reader driver
(reader category) with a single 'serial' config field; the admin assigns it
normally and enters the device's serial (its cjihao).

The QR endpoint now resolves the lane by matching lane_devices.config.serial to
the scan's cjihao (instead of row id == cjihao), so no DB hand-editing. An
unassigned serial resolves to no lane -> status:0, gracefully.

Verified via inject through the real /api/setup/assign: assign {serial:
H05M2AFA} -> .jsp scan with a matching permit QR -> status:1 (accept) + open;
re-scan -> permit exit; unknown card -> status:0; unassigned serial -> status:0.
2026-06-16 12:36:58 +02:00
julian 04135b27cf qr-reader: register all server-language extensions (reader posts .jsp)
Hardware capture: the GEE/Fondvision reader (serial H05M2AFA) scans + sends +
beeps fine — the earlier 'no beep' was just nothing answering :3000. Real
request: GET /qa/mcardsea.jsp?cardid=...&cjihao=H05M2AFA&... — the 'server
language' setting (JSP here) selects the URL EXTENSION, so it posts .jsp, not
.php. Our route was .php-only and would have 404'd it.

Register the endpoint at php/jsp/asp/aspx/cgi so it works whatever the device is
configured to. cjihao (serial) is the lane key: assign the reader as
lane_devices.id = its serial.
2026-06-16 12:30:00 +02:00
julian 392d44d842 server: GEE/Dingtian QR reader endpoint + synchronous ReadOutcome
The reader HTTP-GETs on each scan and beeps/acts on our JSON reply (host-in-the-
loop, synchronous). New route GET/POST /qa/mcardsea.php parses the SDK query,
runs the scan through the read dispatcher (permit match -> permit flow; else
transient exit), and replies the SDK verdict: status 1=valid (beep 2x) /
0=invalid (beep 1x), output, time-sync.

Refactored the read flows to return a ReadOutcome {accepted, direction, reason}
so the reply reflects the real accept/reject decision (ReadDispatcher.dispatch,
ExitFlow.handleAt, PermitFlow.run). Fire-and-forget readers ignore it.

Reader's lane is keyed off its serial (cjihao) as lane_devices.id for now;
endpoint is public (reader has no auth, on the device subnet).

Verified via inject: valid permit QR -> status:1 + open; re-scan -> permit exit;
unknown QR -> status:0; barrier-less lane -> status:0.
2026-06-16 12:12:09 +02:00
julian f67c1ead87 wiki: ER80 protocol = HTTP GET poll + JSON verdict (from QRCode SDK)
The QRCode SDK v1.6.5 settles the reader protocol (supersedes the earlier
serial guess). On each scan the reader HTTP-GETs the host
(/qa/mcardsea.php?cardid&mjihao&cjihao&status&time); the host replies JSON
{data:[{...,status,output}],code:0}. Reply status 1=valid(beep 2x)/0=invalid
(beep 1x); output 0=Access/1=WG26/2=WG34; time syncs the clock. The GET's status
low digit is the direction (1=in/0=out).

Key: the beep/accept is decided by the SERVER REPLY, not locally -- the 'no
beep' during bring-up was a plain-text reply, not a scan failure. Host-in-the-
loop and synchronous. 'Server language' only selects the URL path; transport is
plain HTTP.

New source page qrcode-sdk; updated gee-qr-er80 (protocol resolved), index.
2026-06-16 12:05:05 +02:00
julian bf37106c5c wiki: ingest GEE-QR-ER80 QR access reader datasheet
The reader on hand is a GEE-QR-ER80 QR/DataMatrix/1D barcode access reader
(not an EM4100 prox-card reader as first guessed). Interfaces: Wiegand 26/34,
RS-232, RS-485, USB, TCP/IP; 4-15 VDC; Linux-supported. Variant on hand: -Q-W
(QR scanner, Wiegand/RS-232/485).

This is the QR-ticket scanner the design already needed: a host-side reader
whose scans become read-bus events consumed by the (already-built) exit flow
and QR-permit path. Prefer RS-232/485 over Wiegand (Wiegand can't carry a
variable-length QR string; autonomy is moot with the no-ACL Dingtian).

New source + entity pages; updated ticket-encoding, entry-exit-readers, index.
Open (blocks the adapter): the RS-232/485 frame + baud (ASCII CR/LF expected).
2026-06-16 08:22:23 +02:00
julian e579fe5b6e server+web: capacity / FULL gate (occupancy fold + transient refuse)
Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).

FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).

Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).

Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
2026-06-16 08:13:06 +02:00
julian 644bfa1462 server+web: shifts — open/close + signed Z-report (manned mode)
A shift is two signed ledger events, no mutable table: new shift_open event
type + existing shift_z_report. The operator is the logged-in user (carried in
event identity); a shift is open iff their latest shift event is a shift_open.

ShiftService: close sums payment events in [start,end] by tender (cash/card, by
payment time), appends the signed shift_z_report (totals/counts/window), and
prints via a new generic PrinterDevice.printReport(title, lines) (Rongta ESC/POS
text) to a booth-receipt printer. Print is best-effort — a failed print does not
undo the signed close.

Routes (cashier/operator/admin): GET /api/shift/current, POST /api/shift/open
(409 if open), POST /api/shift/close (409 if none). Web ShiftControl in the
shell (non-readonly): Start/End + Z-report totals.

Verified: open -> double-open 409 -> payments (cash+card; one outside the window
excluded) -> close totals correct + signed + printed -> close-again 409 ->
re-open ok; readonly 403; verifyChain ok.
2026-06-16 08:01:59 +02:00
julian 3429642edb permits: admin CRUD (route + UI)
A permit is an aggregate (row + credentials + bound plates); create/update
treat it as one unit (child sets replaced on update). GET /api/permits (any
signed-in role, for lookup); POST/PUT/DELETE + POST /:id/revoke (admin only).
Validation: maxConcurrent positive-int-or-null (unbound); a permit must have at
least one credential OR one bound plate. Revoke is the soft common case (keeps
history, barred at the barrier); DELETE hard-removes — past ledger events that
reference it are untouched (append-only audit trail, independent of this row).

Web PermitManager in the admin shell: list + add/edit (holder, car-bound toggle,
validity, credentials, plates), revoke, delete. Makes permits usable without
hand-seeding (companion to the tariff composer).

Verified via inject: validation (empty / maxConcurrent=0 -> 400), create -> 201,
operator can LIST but not write (403), update replaces child rows, revoke ->
revoked, delete -> 204 then 404 with children cleaned.
2026-06-15 19:53:03 +02:00
julian c24d99b0f4 server: permit entry/exit branch + read dispatcher
A credential read now routes by what the credential IS: matches a permit
(card/QR credential or a bound plate) -> permit flow; else -> transient exit
flow. Lane resolved once (readerLaneWithAccess); ExitFlow.onRead -> handleAt so
the dispatcher owns lane resolution.

Permit direction is inferred from session state for that car (the read value is
the per-car session key): no open session -> ENTRY (enforce maxConcurrent, sign
vehicle_entry, open); open -> EXIT (sign vehicle_exit, open, close). Fleet
permit = one session per car; anti-passback falls out naturally.

maxConcurrent enforced as a fold over the signed ledger (null = unbound).
Validity window + status + plate-OR-card identity as designed. No ticket/fee;
every use is a signed event carrying permitId. Refusals (revoked / out-of-window
/ at-capacity) are signed anomalies, barrier stays closed.

Verified against stubs: card entry -> inferred exit; fleet cap 2 (F3 rejected
at 2/2, then admitted after F1 exits); plate-bound opens; revoked rejects;
unknown credential falls through to exit reject; verifyChain ok.
2026-06-15 19:47:01 +02:00
julian b4d0dfadd6 tariff composer: admin publishes rate-card versions (pay station now operable)
validateTariffStructure (shared): non-negative ints, ascending block bounds,
only the last block open-ended — a malformed card can't be published.

Routes: GET /api/tariff (active + history, any signed-in role), POST
/api/tariff/versions (publish an immutable, effective-dated version; admin
only). The single site tariff row is created lazily. Editing = publish a new
version; past sessions keep their pricing.

Web: TariffComposer in the admin shell — edit currency, grace windows,
increment, daily cap, lost-ticket fee, and add/remove rate blocks (major-unit
input -> minor on submit); shows active version + history.

Verified via inject: empty -> active null; invalid blocks -> 400 with problem;
valid -> 201; readonly publish -> 403; after publishing, the pay station quote
returns 404 (no session) instead of 409 (no tariff) -- it now prices against the
active card.
2026-06-15 19:35:33 +02:00
julian f18e28eeca server: pay station + fee calc — full transient loop now passes
computeFee() in @parking/shared: pure integer fee over a TariffStructure
(stepped blocks, rolling-24h cap). Two edges fixed under test: grace uses RAW
duration (not rounded-up minutes), and the block ladder resets each 24h day.

PayStation + routes (GET /api/pay/quote, POST /api/pay): look up the open
session, resolve the active tariff version (latest effectiveFrom <= entry),
computeFee, append a signed payment event (amount/currency/tender/
tariffVersionId/graceExitMin). overrideMinor handles lost-ticket/dispute. PCI
stays out of the app: tender only records cash/card.

Verified end to end: entry -> quote (300 for 90min) -> pay -> exit opens and
closes the session, verifyChain ok.
2026-06-15 19:15:53 +02:00
julian a8c6d6e714 auth: JWT valid until logout (drop 8h expiry)
Booth reality breaks a fixed clock (relief late/absent, forced double shifts),
and a shift is a separate explicit boundary. Drop expiresIn from the global jwt
config and from login; the token carries no exp. Cookie maxAge = 30 days so a
browser restart doesn't log out an active operator; logout still clears it.
2026-06-15 19:15:53 +02:00
julian 2a36830880 server: exit flow (pay-on-foot validation)
A credential read at an exit lane validates the session, then opens. Adds a
'read' channel to the device bus (DeviceReadEvent: ticket/plate/qr/card);
entry stays button-driven so reads are exit/identity events.

Flow: read -> fold the SIGNED ledger for that identity -> validate open + PAID
+ within gracePeriodExitMin -> signed vehicle_exit -> pulseOpen -> close the
session cache. Unpaid / grace-expired / unknown -> signed anomaly, barrier
stays closed (a deliberate business reject, not a fail-state; 'exit fails open'
is about host/power loss). Validation reads the ledger (authoritative), not the
cache.

No payment events exist until the pay station is built, so every transient exit
currently rejects -- the correct end-state, not yet passable. Verified against
stubs: unpaid->anomaly+no-open; paid+grace->exit+open+closed; expired->anomaly;
unknown->anomaly; verifyChain ok across entry->pay->exit.

Flagged: lane_devices has no entry/exit direction model (exit door hardcoded to
1); needs a lane-direction/role model before multi-reader lanes.
2026-06-15 18:57:14 +02:00
julian 2696d281ce server: transient entry flow (button -> ticket -> signed entry -> open)
Closes the long-dangling thread from device-input-flow. On an access device's
rising input edge: print the ticket (failover), then sign vehicle_entry, then
pulseOpen, then cache the session projection.

Two invariants enforced:
- signed BEFORE open (an open with no signed event is the fraud signal);
- HOLD on print failure — no ticket means a transient can't pay on exit, so
  sign an anomaly and do NOT open, and do NOT write a vehicle_entry for a car
  that never got in.

Subscribes the same input bus as the device-telemetry writer (independent:
telemetry always records; entry acts only on an access device's on-edge,
debounced). Verified end to end against stubs: success path signs+opens+caches
and verifyChain ok; printer-down path emits only an anomaly with no open and
no entry; release edge ignored.
2026-06-15 18:32:40 +02:00
julian 648d3254d6 wiki: valet / over-capacity mode; 'full' is a soft operator policy
Capture that refusing at capacity is the default, not absolute: an operator
may opt into valet over-capacity (customer hands over keys, operator stacks
the car into custody). Manned-only, new custody/session shape. Deferred;
not built into the entry flow. Made capacity-occupancy's FULL gate a soft
policy knob.
2026-06-15 18:32:40 +02:00
julian 8c2cf93067 db: business-layer schema — ledger/device event split, tariffs, permits, sessions
Implements the wiki design in packages/db + packages/shared.

Event split: rename events -> ledger_events (signed business ledger) and add
device_events (unsigned telemetry). ledger_events gains a signed JSON payload
(amount/tariffVersionId/sessionRef/tender…) + keyId; canonicalize() includes
the payload via sorted-key serialization so business data is tamper-evident.
Raw Dingtian input now writes device_events, not a signed input_received.

New tables: tariffs + immutable tariff_versions (composable/versioned, currency
+ FX-ready), permits (+ permit_credentials, permit_plates; maxConcurrent default
1), blocklist, sessions (rebuildable projection cache — not a source of truth).

shared: split ParkingEvent/Type into LedgerEvent/LedgerEventType + DeviceEventKind;
add LedgerPayload, Tender, TariffStructure/TariffBlock.

Regenerated a single baseline migration (no production chain data existed).
Verified: chain appends + verifyChain ok; tampering a payment payload breaks
the signature. Full repo builds (5/5).
2026-06-15 18:13:35 +02:00
julian 9a4c7ee27b wiki: split signed business ledger from device telemetry
Correction before schema work: the events table conflated the anti-fraud
business ledger with device telemetry. Decision: ledger_events (signed,
chained, reconciled) holds only business facts; device_events (unsigned,
prunable) holds relay/printer/camera/reader/input telemetry. A raw button
press is telemetry; the entry flow mints a signed vehicle_entry. Drops
input_received-as-signed-event.

New: decisions/event-streams-split, concepts/device-events; updated
append-only-event-chain, index, log.
2026-06-15 18:08:56 +02:00
julian 8a8e74561d wiki: design the business layer (session, tariff, permit, vision, shift, ops)
Pivot from the hardware/integrity layer to the parking operation. All
wiki-only; no code yet. Core principle throughout: business entities are
projections over the signed append-only event log, never mutable tables.

New concepts: parking-session, tariff (composable/versioned, FX-ready),
shift (manned-only Z-report), capacity-occupancy, validation-discounts,
reporting-analytics, clock-integrity, ticket-encoding, anti-passback.
New entities: permit, opencv-anpr-service, blocklist.
Decisions: session-model, vision-service (host-side ANPR + vehicle
verification; scoped AGPL exception for the isolated service).

Updates: append-only-event-chain (new event types + vision witness),
local-jwt-auth (drop 8h expiry -> until logout; code change pending),
lpr-camera (host-side recognition supersedes edge-AI), standing-decisions
(AGPL exception), open-questions (+FX, +pay-station money corners, backup).

Deferred + flagged: intercom/help-call, receipts/refunds/change, FX engine,
lane topology (#1).
2026-06-15 17:41:38 +02:00
julian 2ab5a39a57 Permanent WSL2 dev fix for multi-subnet source-address trap
Mirrored mode re-clones the Windows NIC's addresses each boot, so the kernel
keeps picking the wrong source for stacked device subnets (10.0.10.x sourced
from 192.168.1.123) — ARP resolves but ping/TCP dies, and every runtime
ip-route fix is wiped by wsl --shutdown.

deploy/wsl-fix-route-source.sh pins each scope-link route's src to this
host's own address in that subnet (no hardcoded IPs, idempotent, preserves
metric, non-fatal per route, waits for the route at boot). deploy/parking-net
.service reapplies it on every boot.

Dev-box only; the appliance is bare-metal Linux with static networkd config.
Verified: camera pings with no -I flag; driver pulls a snapshot with no
localAddress set.
2026-06-15 16:17:59 +02:00
julian fa65b2df86 Real Hikvision/Dahua camera driver; gate Backend-push-IP on capability
Replace the camera stub with HttpCamera: Hikvision ISAPI and Dahua CGI
snapshots over client-side HTTP Digest (new drivers/http-digest.ts).
healthCheck() now pulls a real frame instead of returning ready/stub.
Snapshot carries bytes (driver fetches); storage/imageRef is the caller's
job, keeping the adapter free of storage deps.

Fix the cosmetic Backend-push-IP field: add pushesToBackend to DeviceDriver
(only Dingtian sets it), expose as pushCapable in the catalog, and gate the
wizard's backend-IP fetch + field on it so pull-only devices hide it.

Verified on hardware (Hikvision 10.0.10.121): healthCheck ready,
captureSnapshot returns a valid JPEG.
2026-06-15 16:17:49 +02:00
468 changed files with 83948 additions and 1003 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"hooks": {
"PreToolUse": []
}
}
+44
View File
@@ -0,0 +1,44 @@
# Build context hygiene for the server + vision images (context = repo root).
# Keep the context small and NEVER bake build artifacts, secrets, or the live DB.
# Node / build outputs (rebuilt inside the image)
**/node_modules/
**/dist/
**/.turbo/
**/*.tsbuildinfo
.turbo/
# Python (vision) — rebuilt by uv inside the image
**/.venv/
**/__pycache__/
**/.mypy_cache/
**/.pytest_cache/
**/.ruff_cache/
# Secrets + local env (the image gets config via runtime env, never baked)
**/.env
**/.env.local
# NEVER bake the live signed-ledger DB (or any of its WAL/SHM/backup variants) into an
# image — it lives on a mounted volume. Match the base file AND every -wal/-shm/.bak-*
# sibling (deploy copies the package dir's files, ignoring .gitignore).
**/*.sqlite
**/*.sqlite-*
**/parking.sqlite*
# Desktop app is built by its own tag-only release.yml, not these images
apps/desktop/
# VCS, logs, caches, editor cruft
.git/
.github/
*.log
**/.DS_Store
.vscode/
.idea/
# Wiki raw sources / large docs (not needed to build)
wiki/raw/
# Plans / scratch
.planning/
+37
View File
@@ -0,0 +1,37 @@
# Booth deploy env — copy to `.env` and fill in, then run ./scripts/booth.sh up
# (prod). Consumed by docker-compose.yml + the prod override via --env-file.
# See wiki/decisions/container-deployment.md. Do NOT commit the filled-in .env.
# --- image source (prod pulls from the house Gitea registry) ------------------
# The registry namespace; combined with the image name + TAG below.
REGISTRY=git.infra.msai.al/mca/parking_solution
# Image tag to deploy. CI publishes TWO tags per build: a MOVING branch tag
# (`dev`, and `main` once that branch is built) republished on every push, and an
# IMMUTABLE per-commit `dev-<sha>` (e.g. dev-830993b). Use the moving tag for a
# self-updating booth (`booth.sh update` pulls the latest); pin the `<branch>-<sha>`
# form for a reproducible, deterministic deploy. NOTE: `main` images only exist once
# something is built on main — until then deploy from `dev`.
TAG=dev
# --- secrets (NO safe defaults — the server refuses to boot without a real one) -
# JWT signing secret. Generate yourself, never share it: openssl rand -hex 32
# Must be 32+ chars and must NOT contain change-me / insecure / dev-only.
JWT_SECRET=
# Ledger-signing key for the append-only signed event chain. Set a DISTINCT value
# in prod (don't reuse JWT_SECRET). openssl rand -hex 32
EVENT_SIGNING_KEY=
# --- booth LAN specifics ------------------------------------------------------
# Auth cookie is HTTPS-only by default; the booth is plain HTTP behind Caddy on
# :80, so this MUST stay 0 or operators cannot log in. Set to 1 only behind TLS.
COOKIE_SECURE=0
# Remote origins the live WS feed must accept (same-origin always passes). Add any
# address admins hit the UI from beyond the booth itself, comma-separated, e.g.
# http://parksystems.msai.al (leave blank if only the local booth URL is used).
WS_ALLOWED_ORIGINS=
# Vision/ANPR. Prod override already forces the fast_alpr engine; leave VISION_ENABLED=1
# unless you are running without the camera. (Set 0 to disable the vision call entirely.)
VISION_ENABLED=1
+134
View File
@@ -0,0 +1,134 @@
name: Build desktop
# Build the Tauri desktop installers (.deb + .AppImage) on every push to dev/main and
# upload them as workflow ARTIFACTS — a downloadable, per-commit build for testing the
# native shell. This is NOT a release: it's unsigned (no updater key) and creates no Gitea
# Release. Signed, versioned releases stay on release.yml (tag v* → .deb/.rpm/.AppImage +
# latest.json for the auto-updater). See wiki/decisions/desktop-shell-tauri.md.
on:
push:
branches: [dev, main]
paths:
- 'apps/desktop/**'
- 'apps/web/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/build-desktop.yml'
workflow_dispatch:
jobs:
desktop:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install Tauri system deps
# Same set release.yml uses (verified): WebKitGTK 4.1 + libsoup-3 + the GTK/
# appindicator/rsvg stack + AppImage tooling (patchelf, file).
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
file \
build-essential \
curl \
wget
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/desktop/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build desktop bundle (.deb + .AppImage)
# Unsigned — no TAURI_SIGNING_* here (this is a test artifact, not an updater
# release). The config sets createUpdaterArtifacts:true (release.yml signs them),
# which makes tauri DEMAND the signing key and fail without it — so override it to
# false for this build via --config (a JSON patch merged over tauri.conf.json).
# --bundles restricts to the two installers we ship; tauri builds the web SPA
# first (beforeBuildCommand), so the desktop UI matches.
run: >
pnpm --filter @parking/desktop bundle
--bundles deb,appimage
--config '{"bundle":{"createUpdaterArtifacts":false}}'
- name: Collect installers
id: collect
# Copy out the two installers under SPACE-FREE names (tauri names them
# "Parking System_0.0.0_amd64.deb" — spaces break asset URLs). Short SHA in the
# name so a downloaded file is traceable to its commit.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
mkdir -p dist
deb=$(find "$BUNDLE/deb" -name '*.deb' | head -1)
app=$(find "$BUNDLE/appimage" -name '*.AppImage' | head -1)
cp "$deb" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.deb"
cp "$app" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.AppImage"
echo "Artifacts:"; ls -la dist/
- name: Publish to a rolling per-branch pre-release
# actions/upload-artifact's backend isn't reliable on this Gitea runner, so we
# publish to a Gitea RELEASE via the API instead (the proven pattern from
# release.yml — built-in token, plain curl). One ROLLING pre-release per branch
# (tag desktop-<branch>): delete + recreate each push so it always holds the
# latest dev/main installer. This is NOT the signed updater release (release.yml,
# tag v*) — it's a prerelease, unsigned, with no latest.json.
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}
REPO: ${{ github.repository }}
TAG: desktop-${{ github.ref_name }}
run: |
set -e
auth="Authorization: token ${TOKEN}"
# Drop any existing rolling release for this branch (ignore if absent) so its
# tag + stale assets don't pile up; recreate it fresh below.
OLD=$(curl -sS -H "$auth" "${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$OLD" ]; then
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/releases/${OLD}" || true
# Also delete the tag itself so the recreate points at this commit.
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/git/refs/tags/${TAG}" || true
fi
REL=$(curl -sS -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"Desktop build (${GITHUB_REF_NAME})\",\"body\":\"Unsigned per-commit desktop installers from ${GITHUB_REF_NAME} @ ${GITHUB_SHA}. Rolling — overwritten each push. Not an updater release.\",\"draft\":false,\"prerelease\":true}" \
"${API}/repos/${REPO}/releases")
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
echo "release id: ${REL_ID}"
for f in dist/*; do
name=$(basename "$f")
echo "uploading ${name}"
curl -sS -X POST -H "$auth" -H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
+165
View File
@@ -0,0 +1,165 @@
name: Build & push images
# Build the SERVER (API + SPA), COLLECTOR (wash review), VISION (ANPR) and TRAINER (phase-B job) container images and push them to the
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
# Mirrors the house pattern (cf. trm/processor build.yml). See
# wiki/decisions/container-deployment.md and fleet-deployment-komodo.md (dev→stage→main tiers).
on:
push:
branches: [dev, stage, main]
paths:
- 'apps/server/**'
- 'apps/web/**'
- 'apps/vision/**'
- 'apps/collector/**'
- 'apps/trainer/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'docker-compose*.yml'
- '.dockerignore'
- '.gitea/workflows/build-images.yml'
# Deploy/IaC changes (compose above, plus the Komodo Stack defs) also rebuild — so a
# promotion or a Stack tweak gets the same build+checks sanity pass before it reaches a
# booth, and a komodo-only push to `stage` still produces a :stage image.
- 'komodo/**'
workflow_dispatch:
env:
REGISTRY: git.infra.msai.al/mca/parking_solution
jobs:
images:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up uv (for @parking/vision checks)
# Install uv via its official standalone script rather than a third-party action —
# the Gitea runner can't reliably resolve astral-sh/setup-uv. uv provisions the
# pinned Python (apps/vision/.python-version) itself. Add it to PATH for later steps.
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync vision deps
working-directory: apps/vision
run: uv sync --frozen
- name: Sync trainer deps
# Light core only — NOT the `train` extra (CPU torch, ~200 MB); the torch tests skip.
working-directory: apps/trainer
run: uv sync --frozen
# Don't publish a broken image — run the same checks as ci.yml first.
- name: Build + lint + test (Turbo)
run: pnpm turbo run build lint test
- name: Compute tags
id: meta
# BRANCH = the pushed branch (dev|main); SHA = short commit. Two tags per image:
# the moving branch tag + an immutable branch-SHA tag.
run: |
BRANCH="${GITHUB_REF_NAME}"
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.infra.msai.al
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build & push SERVER (API + SPA)
uses: docker/build-push-action@v5
with:
context: .
file: apps/server/Dockerfile
push: true
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
tags: |
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
- name: Build & push COLLECTOR (wash review)
uses: docker/build-push-action@v5
with:
context: .
file: apps/collector/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache,mode=max
- name: Build & push VISION (ANPR)
uses: docker/build-push-action@v5
with:
context: apps/vision
file: apps/vision/Dockerfile
push: true
# The phase-B body-type classifier is fetched from the Gitea generic package registry
# at build when apps/vision/models/bodytype.version pins a version (empty = none). The
# registry user's credentials double as the fetch auth (BuildKit secret, never a layer).
secrets: |
bodytype_auth=${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_PASSWORD }}
tags: |
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max
- name: Build & push TRAINER (phase-B job)
uses: docker/build-push-action@v5
with:
context: apps/trainer
file: apps/trainer/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/parking-trainer:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-trainer:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-trainer:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-trainer:buildcache,mode=max
# Optional: trigger a Komodo stack redeploy (cf. trm/processor). Enable by setting the
# KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired.
- name: Trigger Komodo redeploy
if: success() && vars.KOMODO_ENABLED == 'true'
env:
URL: ${{ secrets.KOMODO_STACK_WEBHOOK_URL }}
SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}
run: |
body="{\"ref\":\"refs/heads/${GITHUB_REF_NAME}\"}"
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$sig" \
-d "$body" \
"$URL"
+62
View File
@@ -0,0 +1,62 @@
name: CI
# Lint/typecheck/test the whole Turborepo on every push/PR to dev. Mirrors the
# house pattern (cf. trm/processor): setup-node + corepack pnpm + frozen install.
# No Docker, no signing — pure checks. The desktop bundle is a separate, tag-only
# pipeline (see release.yml).
on:
push:
branches: [dev]
pull_request:
branches: [dev, main]
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
# Pin to the repo's packageManager version (pnpm 10), not latest.
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up uv (Python toolchain for @parking/vision)
# The vision service is a Python package wired into the Turbo graph via a
# package.json shim; its lint/typecheck/test scripts shell to `uv run …`. CI
# has no Python by default, so `uv run` would fail with "uv: not found" and
# break the whole Turbo run. Install uv via its official standalone script
# (the Gitea runner can't reliably resolve astral-sh/setup-uv); uv provisions the
# pinned Python (.python-version) itself. See wiki/decisions/vision-service-packaging.md.
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync vision deps
# Light deps + the dev group (ruff/mypy/pytest) only — NOT the optional `alpr`
# extra (heavy onnx/model stack), which isn't needed to lint/typecheck/test.
working-directory: apps/vision
run: uv sync --frozen
- name: Sync trainer deps
# Same rule: light core only, not the `train` extra (CPU torch); torch tests skip.
working-directory: apps/trainer
run: uv sync --frozen
- name: Build + lint (Turbo)
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
# key fails the build), AND the vision service's ruff lint via uv.
run: pnpm turbo run build lint
- name: Test
run: pnpm turbo run test
+306
View File
@@ -0,0 +1,306 @@
name: Release desktop
# Build the signed Tauri desktop installers on a version tag and publish them as
# a Gitea Release — TWICE: once on this (private, source) repo for our own
# records/history, and once mirrored to mca/public_releases, which is what the
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
#
# WHY a separate public repo: the updater runs on offline-first field appliances
# with no Gitea credentials, so its endpoint + installer downloads must be
# reachable unauthenticated. Mirroring compiled installers to a public
# releases-only repo avoids embedding any read token in the shipped app (which
# would leak the moment a booth PC is compromised — this box's threat model
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
# stays private; only signed installers become public, same as most desktop
# software. mca/public_releases is shared across apps in the org, not
# parking-specific — namespace release tags/asset names accordingly if another
# app starts publishing there too.
#
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
# with the updater key (Gitea secrets), assembles latest.json pointing at the
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install Tauri system deps
# ubuntu-latest runner has no GUI/webkit libs by default. These are the
# exact deps a Tauri v2 Linux build needs (verified locally): WebKitGTK
# 4.1 + libsoup-3 + the GTK/appindicator/rsvg stack + AppImage tooling.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
file \
build-essential \
curl \
wget
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/desktop/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Sync tauri.conf.json version to the git tag
# tauri.conf.json's own "version" field is what Tauri bakes into the
# bundle filename, the app's internal version, AND the updater's
# "current vs. new" comparison — it is NOT derived from the git tag
# automatically. Hit in v0.1.1: the tag was bumped but this file
# wasn't, so the signed binary + its .sig were still built (and
# named) as 0.1.0 while latest.json (built from TAG below) claimed
# 0.1.1 — the updater found the "update", downloaded a file whose
# signature didn't match what the manifest claimed to sign, and
# silently failed (a separate bug in desktop-updater.ts's error
# handling made this invisible — also fixed). Patch it here so the
# checked-in value is only ever a placeholder for local dev builds;
# a real release's version is always driven by the tag.
run: |
set -e
VERSION="${TAG#v}"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
env:
TAG: ${{ github.ref_name }}
- name: Build + sign desktop bundle
env:
# Updater signing key (Gitea repo/org secrets). Without these the
# bundle is unsigned and the updater would reject it.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm --filter @parking/desktop bundle
- name: Collect artifacts
id: collect
# Gather the installers + their .sig into a flat dist/ for upload, spaces
# stripped from filenames. productName is "Parking System" (a space), so
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
# unescaped space in a filename breaks the later curl asset-upload URL
# ("URL rejected: Malformed input to a URL function", hit on the very
# first v0.1.0 release) AND would land in latest.json's asset url, which
# the updater's plain HTTP GET can't handle either. Rename on copy.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
mkdir -p dist
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
-print0 | while IFS= read -r -d '' f; do
name=$(basename "$f" | tr ' ' '-')
cp "$f" "dist/${name}"
done
echo "Artifacts:"; ls -la dist/
- name: Assemble latest.json
# The Tauri updater fetches a manifest describing the newest version, its
# notes, and per-target {signature, url}. The URL points at the MIRROR
# repo (mca/public_releases) — that's the unauthenticated endpoint field
# appliances actually reach; see the workflow header for why.
#
# ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update
# v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up
# `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage,
# from the running app's detected bundle type) and only then the bare
# `linux-x86_64`. The booths run the .deb, and the manifest used to
# carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the
# "update", downloaded the AppImage, verified its signature fine, then
# handed the bytes to install_deb(), which checks they're a .deb
# (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after
# the download, before any relaunch, with the error swallowed client-
# side until v0.1.6. Now each installer gets its own signed asset; the
# bare key stays for an AppImage install. .deb/.rpm updates run
# `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password
# prompt — intended: updating a root-installed package IS an admin
# action on this box (see wiki/decisions/desktop-shell-tauri.md).
env:
SERVER_URL: ${{ github.server_url }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
VERSION="${TAG#v}"
ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
cat > /tmp/latest.js <<'JS'
const fs = require("fs");
const [version, tag, base] = process.argv.slice(2);
const files = fs.readdirSync("dist");
const pick = (ext) => files.find((f) => f.endsWith(ext));
const entry = (f) => ({
signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(),
url: `${base}/${f}`,
});
const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage");
if (!deb || !appimage) {
console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`);
process.exit(1);
}
const platforms = {
"linux-x86_64-deb": entry(deb),
...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}),
"linux-x86_64": entry(appimage),
};
fs.writeFileSync(
"dist/latest.json",
JSON.stringify(
{
version,
notes: `Parking System ${tag}`,
pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
platforms,
},
null,
2,
) + "\n",
);
JS
node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
echo "latest.json:"; cat dist/latest.json
- name: Create release + upload assets (Gitea API)
# Uses the built-in token; no marketplace release action required. Creates
# the release for this tag (idempotent-ish: ignores "already exists") and
# uploads every file in dist/ as an asset.
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -e
# Create the release (capture id; tolerate an existing one).
REL=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${API}/repos/${REPO}/releases" || true)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
# Release may already exist for this tag — look it up by tag.
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
echo "release id: ${REL_ID}"
for f in dist/*; do
name=$(basename "$f")
echo "uploading ${name}"
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
- name: Mirror release to mca/public_releases (Gitea API)
# This is the release the updater and any human downloader actually use —
# public_releases has no source, only installers, so it can be public
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
# token scoped for pushing releases into that repo (Gitea's org secrets,
# not exposed to any deployed client).
#
# Publishes to TWO tags there, since public_releases is shared across
# apps in the org and Gitea's "latest release" redirect resolves by
# newest tag on the WHOLE repo (would break the moment another app
# publishes something newer):
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
# - desktop-latest moving — assets deleted + re-uploaded each release.
# This is the fixed URL tauri.conf.json's updater endpoint points at
# (a stable name every appliance can always resolve, regardless of
# what else gets released in this repo meanwhile).
env:
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
API: ${{ github.api_url }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
create_or_get_release() {
local mirror_tag="$1" prerelease="$2"
REL=$(curl -sS -w '\n%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
"${API}/repos/${MIRROR_REPO}/releases" || true)
echo "create response (${mirror_tag}): ${REL}"
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
exit 1
fi
}
upload_assets() {
local rel_id="$1"
for f in dist/*; do
name=$(basename "$f")
echo "mirroring ${name} -> release ${rel_id}"
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
if [ "$HTTP_CODE" -ge 300 ]; then
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
exit 1
fi
done
}
# 1. Versioned, permanent.
create_or_get_release "desktop-${TAG}" false
echo "versioned mirror release id: ${REL_ID}"
upload_assets "${REL_ID}"
# 2. Moving desktop-latest — delete existing assets first (re-upload
# with the same name 409s otherwise), then re-upload.
create_or_get_release "desktop-latest" false
LATEST_REL_ID="${REL_ID}"
echo "latest mirror release id: ${LATEST_REL_ID}"
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
done || true
upload_assets "${LATEST_REL_ID}"
echo "done"
+13
View File
@@ -11,6 +11,8 @@ dist/
.env .env
.env.* .env.*
!.env.example !.env.example
# Committed (non-secret): the desktop/prod build's backend origin — see apps/web/.env.production
!.env.production
# Editor/OS # Editor/OS
.DS_Store .DS_Store
@@ -20,3 +22,14 @@ dist/
/*.png /*.png
# Vendor device SDKs (reference only — protocol captured in wiki, not committed) # Vendor device SDKs (reference only — protocol captured in wiki, not committed)
/dingtian/ /dingtian/
/QRCode_sdk*/
# Graphify knowledge-graph output (dev tool; generated, not committed)
graphify-out/
parking.sqlite*.bak-*
questions.txt
# session planning files (planning-with-files skill)
task_plan.md
findings.md
progress.md
+2 -1
View File
@@ -17,7 +17,8 @@ parking-system/
├── turbo.json ├── turbo.json
├── apps/ ├── apps/
│ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA │ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA
│ └── web/ # React + Vite SPA (operator UI) │ ├── web/ # React + Vite SPA (operator UI)
│ └── vision/ # Python/FastAPI ANPR service (planned; separate process, Turbo shim — see wiki/decisions/vision-service-packaging.md)
├── packages/ ├── packages/
│ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target) │ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target)
│ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay) │ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay)
+15
View File
@@ -0,0 +1,15 @@
# Booth reverse proxy. `:80` matches ANY hostname/IP, so the booth is reachable as
# http://<booth-ip>/, http://localhost/, or http://parksystems.msai.al/ (the name pointed
# at the booth's IP via hosts/DNS on-site) — with no domain baked into any image. The SPA
# uses a relative /api base, so everything (HTTP + the /api/ws WebSocket, which Caddy
# upgrades automatically) just flows through to the server container.
#
# TLS later: replace `:80` with the real hostname (e.g. `parksystems.msai.al`), uncomment
# Caddy's :443 in docker-compose.prod.yml, and Caddy auto-provisions HTTPS. For a private
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
:80 {
encode gzip
# Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
# device VLAN), so reach it over loopback, not the compose service name `server`.
reverse_proxy 127.0.0.1:3000
}
+16
View File
@@ -0,0 +1,16 @@
# Car Wash review collector (wiki/concepts/vision-review-outbox.md). Runs on the
# reviewer's host (art-docker-station), reachable by the booths ONLY over the Netbird
# overlay. Deployed by its own Komodo stack (komodo/resources.toml, "wash-collector").
# COLLECTOR_HOST=0.0.0.0 # in Docker the compose file binds the published port to the overlay IP
# COLLECTOR_PORT=8090
# COLLECTOR_DATA_DIR=/data # collector.sqlite + crops/<booth>/<item>.jpg
# One bearer token per booth: "<boothId>:<token>" pairs, comma- or newline-separated. The
# booth id is the pseudonymous CARWASH_REVIEW_BOOTH_ID that booth was deployed with — never
# a site name. Generate tokens with: openssl rand -hex 32
COLLECTOR_BOOTH_TOKENS=booth-7:REPLACE,booth-9:REPLACE
# The reviewer's login for the review screen and the export (HTTP Basic over the overlay).
COLLECTOR_REVIEWER_USER=reviewer
COLLECTOR_REVIEWER_PASS=REPLACE
+48
View File
@@ -0,0 +1,48 @@
# parking-collector — the Car Wash review collector (wiki/concepts/vision-review-outbox.md).
# Built from the monorepo root (context: .) like the server image, so it shares the
# lockfile and @parking/shared. Runs on the REVIEWER's host (not a booth), delivered by
# its own Komodo stack (docker-compose.collector.yml). Data on /data: collector.sqlite +
# crops/<booth>/<item>.jpg — the trainer on the same host reads the crops off that volume.
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/
COPY apps/vision/package.json apps/vision/
COPY apps/collector/package.json apps/collector/
COPY packages/db/package.json packages/db/
COPY packages/devices/package.json packages/devices/
COPY packages/shared/package.json packages/shared/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm fetch
FROM deps AS build
ENV CI=true
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --offline
RUN pnpm turbo run build --filter=@parking/collector
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter=@parking/collector --legacy deploy --prod /deploy
FROM node:22-alpine AS runtime
WORKDIR /app
ARG BUILD_VERSION=""
ENV BUILD_VERSION=$BUILD_VERSION
ENV NODE_ENV=production
RUN apk add --no-cache libstdc++ wget # better-sqlite3 native runtime; wget for the healthcheck
RUN addgroup -S app && adduser -S -G app app
COPY --from=build --chown=app:app /deploy ./
ENV COLLECTOR_DATA_DIR=/data
ENV COLLECTOR_HOST=0.0.0.0
ENV COLLECTOR_PORT=8090
RUN mkdir -p /data && chown app:app /data
VOLUME ["/data"]
USER app
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- "http://localhost:${COLLECTOR_PORT:-8090}/health" >/dev/null 2>&1 || exit 1
CMD ["node", "dist/index.js"]
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@parking/collector",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Car Wash review collector: receives plate-blurred vehicle crops + the operator's category choice from booths over the private overlay, serves the reviewer's screen, exports labels for training. See wiki/concepts/vision-review-outbox.md.",
"scripts": {
"build": "tsc -b",
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"start": "node --env-file-if-exists=.env dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fastify/multipart": "^9.2.1",
"@parking/shared": "workspace:*",
"better-sqlite3": "12.10.1",
"fastify": "5.8.5"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/node": "25.9.3",
"tsx": "4.22.4",
"typescript": "6.0.3",
"vitest": "^4.1.9"
}
}
+153
View File
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildCollector, type CollectorApp } from "./app.js";
import { parseBoothTokens } from "./config.js";
// The collector: one ingest surface (bearer per booth, idempotent), one review surface
// (Basic), one export. Exercised over app.inject with a hand-built multipart body.
let app: CollectorApp;
let dir: string;
const TOKENS = new Map([["booth-7", "0123456789abcdef0123456789abcdef"], ["booth-9", "fedcba9876543210fedcba9876543210"]]);
const REVIEWER = { user: "julian", pass: "review-pass-123" };
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER, trainerUrl: null }, { dbFile: ":memory:" });
await app.ready();
});
afterEach(async () => {
await app.close();
await rm(dir, { recursive: true, force: true });
});
/** A minimal JPEG-looking blob (SOI marker + padding) — the collector checks the magic only. */
const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(200, 1)]);
function meta(over: Record<string, unknown> = {}) {
return {
v: 1, booth: "booth-7", item: "item-1", order: "o-1", at: "2026-09-06T10:00:00.000Z", operator: "ab12cd34ef56ab12",
operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan", "hatchback"] }, service: "Standard",
vision: { class: "suv", confidence: 0.91, categoryId: "suv" }, downgraded: true,
image: { width: 320, height: 200, plateBlurred: true },
...over,
};
}
function multipart(fields: Record<string, string>, file: Buffer | null): { body: Buffer; type: string } {
const b = "----collector-test";
const parts: Buffer[] = [];
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="image"; filename="x.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`), file, Buffer.from("\r\n"));
parts.push(Buffer.from(`--${b}--\r\n`));
return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
}
async function ingest(m: Record<string, unknown>, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record<string, string> = {}) {
const { body, type } = multipart({ meta: JSON.stringify(m) }, file);
return app.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${token}`, "content-type": type, ...extra }, payload: body });
}
describe("ingest", () => {
it("stores the crop and the decision under the token's booth; retries are idempotent", async () => {
const r = await ingest(meta());
expect(r.statusCode).toBe(201);
const row = app.collectorDb.get("item-1")!;
expect(row).toMatchObject({ booth: "booth-7", operatorCategoryName: "Vetura", visionClass: "suv", downgraded: 1, plateBlurred: 1, imagePath: "crops/booth-7/item-1.jpg" });
expect(JSON.parse(row.operatorClasses)).toEqual(["car", "sedan", "hatchback"]);
const again = await ingest(meta());
expect(again.statusCode).toBe(200);
expect(again.json()).toEqual({ ok: true, duplicate: true });
expect((await app.inject({ method: "GET", url: "/health" })).json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
});
it("refuses a bad token, a booth mismatch, a non-JPEG, and malformed meta", async () => {
expect((await ingest(meta(), "nope-nope-nope-nope-nope")).statusCode).toBe(401);
expect((await ingest(meta({ booth: "booth-9" }))).statusCode).toBe(422); // token is booth-7's
expect((await ingest(meta(), TOKENS.get("booth-7")!, JPEG, { "x-booth-id": "booth-9" })).statusCode).toBe(403);
expect((await ingest(meta(), TOKENS.get("booth-7")!, Buffer.alloc(300, 7))).statusCode).toBe(415);
expect((await ingest(meta(), TOKENS.get("booth-7")!, null)).statusCode).toBe(400);
expect((await ingest(meta({ vision: { class: "spaceship", confidence: 0.5, categoryId: null } }))).statusCode).toBe(422);
expect((await ingest(meta({ item: "../../etc/passwd" }))).statusCode).toBe(422);
expect((await ingest(meta({ v: 2 }))).statusCode).toBe(422);
expect(app.collectorDb.stats().booths).toEqual([]);
});
});
describe("review + export", () => {
it("the reviewer lists pending items, sees the crop, labels it; stats compare the label with the operator's category; the export lists usable labels only", async () => {
await ingest(meta());
await ingest(meta({ item: "item-2", operator: "ab12cd34ef56ab12", vision: { class: "car", confidence: 0.8, categoryId: "car" }, downgraded: false }));
await ingest(meta({ item: "item-3", booth: "booth-9", operator: "9999999999999999" }), TOKENS.get("booth-9")!);
// No login → 401 with a challenge; nothing without a configured reviewer is tested in config.
const anon = await app.inject({ method: "GET", url: "/api/items" });
expect(anon.statusCode).toBe(401);
expect(anon.headers["www-authenticate"]).toContain("Basic");
expect((await app.inject({ method: "GET", url: "/review", headers: { authorization: basic } })).headers["content-type"]).toContain("text/html");
const list = (await app.inject({ method: "GET", url: "/api/items?status=pending", headers: { authorization: basic } })).json();
expect(list.items.map((i: { id: string }) => i.id)).toEqual(["item-1", "item-2", "item-3"]);
expect(list.items[0].imagePath).toBeUndefined();
const img = await app.inject({ method: "GET", url: "/api/items/item-1/image", headers: { authorization: basic } });
expect(img.statusCode).toBe(200);
expect(img.headers["content-type"]).toBe("image/jpeg");
expect(img.rawPayload.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
// item-1: operator said Vetura (car/sedan/hatchback), reviewer says suv → disagree.
// item-2: reviewer says sedan → inside Vetura → agree. item-3: unusable.
const post = (id: string, label: string) =>
app.inject({ method: "POST", url: `/api/items/${id}/review`, headers: { authorization: basic, "content-type": "application/json" }, payload: { label } });
expect((await post("item-1", "suv")).json()).toMatchObject({ reviewLabel: "suv", reviewer: "julian" });
expect((await post("item-2", "sedan")).statusCode).toBe(200);
expect((await post("item-3", "unusable")).statusCode).toBe(200);
expect((await post("item-3", "spaceship")).statusCode).toBe(400);
expect((await post("nope", "suv")).statusCode).toBe(404);
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats.booths).toEqual([
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2, entries: 0 },
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1, entries: 0 },
]);
expect(stats.operators).toEqual([
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
{ booth: "booth-9", operatorRef: "9999999999999999", reviewed: 1, agree: 0, disagree: 0, unusable: 1 },
]);
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
expect(csv.statusCode).toBe(200);
const lines = csv.body.trim().split("\n");
expect(lines[0]).toBe("item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out
expect(lines[1]).toContain('"item-1","booth-7","wash","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
// An ENTRY sample: no order, no operator — accepted, reviewable, in the export, and
// never counted in any operator's agreement.
const entry = await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-1", at: "2026-09-06T11:00:00.000Z", vision: { class: "car", confidence: 0.7 }, image: { width: 300, height: 180, plateBlurred: true } });
expect(entry.statusCode).toBe(201);
expect((await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-2", at: "x", vision: { class: "car", confidence: 0.7 }, image: { width: 1, height: 1, plateBlurred: true } })).statusCode).toBe(422);
expect((await post("entry-1", "suv")).statusCode).toBe(200);
const stats2 = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats2.booths[0]).toEqual({ booth: "booth-7", received: 3, pending: 0, reviewed: 3, entries: 1 });
expect(stats2.operators.find((o: { booth: string }) => o.booth === "booth-7")).toMatchObject({ reviewed: 2, agree: 1, disagree: 1 });
const csv3 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
expect(csv3).toContain('"entry-1","booth-7","entry","crops/booth-7/entry-1.jpg","suv","","","car"');
// A booth-supplied name that looks like a spreadsheet formula is neutralised in the export.
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
await post("item-4", "car");
const csv2 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
expect(csv2).toContain(`"'=HYPERLINK(""http://evil"")"`);
});
});
describe("config", () => {
it("parses booth:token pairs and refuses short tokens", () => {
expect([...parseBoothTokens("a:0123456789abcdef, b:fedcba9876543210\nc:0000000000000000").keys()]).toEqual(["a", "b", "c"]);
expect(() => parseBoothTokens("a:short")).toThrow(/too short/);
expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/);
});
});
+291
View File
@@ -0,0 +1,291 @@
import { timingSafeEqual } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import multipart from "@fastify/multipart";
import { isVehicleClass } from "@parking/shared";
import type { CollectorConfig } from "./config.js";
import { CollectorDb, type ItemRow, type ReviewVerdict } from "./db.js";
import { reviewPage } from "./review-page.js";
// The collector — the far end of the booth's review outbox
// (wiki/concepts/vision-review-outbox.md). Three surfaces and nothing else:
// POST /ingest one package from one booth (bearer token per booth; idempotent)
// /review + /api/* the reviewer's screen (HTTP Basic, one login)
// GET /export/labels.csv the training set: reviewed, usable rows (crops sit beside it on
// the volume, so the trainer on this host reads them directly)
// /api/training/* the Training section: a thin proxy to the trainer's job API on
// the compose network (never published), behind the reviewer login
// It deliberately has no fleet features and no path back into a booth.
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
interface IngestMeta {
v: number;
/** "wash" (default when absent) = a desk decision; "entry" = a sampled entry read with
* no order and no operator — crop + the camera's class only. */
kind?: "wash" | "entry";
booth: string;
item: string;
order?: string;
at: string;
operator?: string;
operatorCategory?: { id: string; name: string; classes?: string[] };
service?: string;
vision: { class: string; confidence: number; categoryId?: string | null };
downgraded?: boolean;
image: { width: number; height: number; plateBlurred: boolean };
}
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
function str(v: unknown, max = 200): string | null {
return typeof v === "string" && v.length > 0 && v.length <= max ? v : null;
}
/** Validate the meta part; returns a message on the first problem. */
function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } | { ok: false; why: string } {
if (!m || typeof m !== "object") return { ok: false, why: "meta must be an object" };
const x = m as Record<string, unknown>;
if (x.v !== 1) return { ok: false, why: "unsupported meta version" };
if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" };
if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" };
const kind = x.kind === undefined ? "wash" : x.kind;
if (kind !== "wash" && kind !== "entry") return { ok: false, why: "bad kind" };
const v = x.vision as Record<string, unknown> | undefined;
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
if (kind === "wash") {
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
const oc = x.operatorCategory as Record<string, unknown> | undefined;
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
if (!str(x.service, 120)) return { ok: false, why: "bad service" };
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
}
const im = x.image as Record<string, unknown> | undefined;
if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" };
return { ok: true, meta: x as unknown as IngestMeta };
}
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
return ba.length === bb.length && timingSafeEqual(ba, bb);
}
export interface CollectorApp extends FastifyInstance {
collectorDb: CollectorDb;
}
export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: string } = {}): Promise<CollectorApp> {
await mkdir(path.join(cfg.dataDir, "crops"), { recursive: true });
const db = new CollectorDb(opts.dbFile ?? path.join(cfg.dataDir, "collector.sqlite"));
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, bodyLimit: 64 * 1024 }) as unknown as CollectorApp;
app.collectorDb = db;
await app.register(multipart, { limits: { fileSize: MAX_IMAGE_BYTES, files: 1, fields: 4, parts: 6 } });
app.addHook("onClose", async () => db.close());
/** Which booth this bearer token belongs to, or null. Constant-time per candidate. */
function boothForToken(req: FastifyRequest): string | null {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Bearer ")) return null;
const token = h.slice(7).trim();
let found: string | null = null;
for (const [booth, t] of cfg.boothTokens) if (safeEqual(token, t)) found = booth;
return found;
}
/** HTTP Basic for the reviewer. */
async function requireReviewer(req: FastifyRequest, reply: FastifyReply): Promise<void> {
if (!cfg.reviewer) return reply.code(503).send({ error: "reviewer login not configured" });
const h = req.headers.authorization ?? "";
if (h.startsWith("Basic ")) {
const [user, ...rest] = Buffer.from(h.slice(6), "base64").toString("utf8").split(":");
const pass = rest.join(":");
if (user && safeEqual(user, cfg.reviewer.user) && safeEqual(pass, cfg.reviewer.pass)) return;
}
return reply.code(401).header("www-authenticate", 'Basic realm="wash review", charset="UTF-8"').send({ error: "unauthorized" });
}
app.get("/health", async () => {
const s = db.stats();
return { ok: true, booths: s.booths.length, pending: s.booths.reduce((n, b) => n + b.pending, 0) };
});
// --- Ingest (booths) -----------------------------------------------------------------
app.post("/ingest", async (req, reply) => {
const booth = boothForToken(req);
if (!booth) return reply.code(401).send({ error: "unauthorized" });
const claimed = req.headers["x-booth-id"];
if (typeof claimed === "string" && claimed !== booth) return reply.code(403).send({ error: "booth id does not match the token" });
if (!req.isMultipart()) return reply.code(415).send({ error: "multipart/form-data expected" });
let metaRaw: string | null = null;
let image: Buffer | null = null;
try {
for await (const part of req.parts()) {
if (part.type === "file" && part.fieldname === "image") {
image = await part.toBuffer();
} else if (part.type === "field" && part.fieldname === "meta") {
metaRaw = String(part.value);
}
}
} catch (err) {
const code = (err as { code?: string }).code;
return reply.code(code === "FST_REQ_FILE_TOO_LARGE" ? 413 : 400).send({ error: (err as Error).message });
}
if (!metaRaw) return reply.code(400).send({ error: "meta part missing" });
if (!image || image.length < 100) return reply.code(400).send({ error: "image part missing" });
if (!(image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff)) return reply.code(415).send({ error: "image must be a JPEG" });
let parsed: unknown;
try {
parsed = JSON.parse(metaRaw);
} catch {
return reply.code(400).send({ error: "meta is not JSON" });
}
const checked = checkMeta(parsed, booth);
if (!checked.ok) return reply.code(422).send({ error: checked.why });
const meta = checked.meta;
// Idempotent on the item id: a booth retrying after a lost 2xx must not duplicate.
if (db.get(meta.item)) return reply.code(200).send({ ok: true, duplicate: true });
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
await writeFile(path.join(cfg.dataDir, rel), image);
const kind = meta.kind ?? "wash";
db.insert({
id: meta.item,
booth,
kind,
orderRef: meta.order ?? "",
at: meta.at,
operatorRef: meta.operator ?? "",
operatorCategoryId: meta.operatorCategory?.id ?? "",
operatorCategoryName: meta.operatorCategory?.name ?? "",
operatorClasses: JSON.stringify(meta.operatorCategory?.classes ?? []),
service: meta.service ?? "",
visionClass: meta.vision.class,
visionConfidence: meta.vision.confidence,
visionCategoryId: meta.vision.categoryId ?? null,
downgraded: meta.downgraded ? 1 : 0,
imageWidth: meta.image.width,
imageHeight: meta.image.height,
plateBlurred: meta.image.plateBlurred ? 1 : 0,
imagePath: rel,
receivedAt: new Date().toISOString(),
});
req.log.info(`ingest: ${booth} ${kind} ${meta.item} (${meta.vision.class}${kind === "wash" ? ` → ${meta.operatorCategory!.name}` : ""})`);
return reply.code(201).send({ ok: true });
});
// --- Review (the trusted person) -----------------------------------------------------
const page = reviewPage();
app.get("/", { preHandler: requireReviewer }, async (_req, reply) => reply.redirect("/review"));
app.get("/review", { preHandler: requireReviewer }, async (_req, reply) => reply.type("text/html; charset=utf-8").send(page));
app.get<{ Querystring: { status?: string; limit?: string; booth?: string } }>(
"/api/items",
{ preHandler: requireReviewer },
async (req) => {
const status = req.query.status === "reviewed" ? "reviewed" : "pending";
const limit = Math.min(Math.max(Number(req.query.limit) || 25, 1), 200);
return { items: db.list(status, limit, req.query.booth || undefined).map(publicItem) };
},
);
app.get<{ Params: { id: string } }>("/api/items/:id/image", { preHandler: requireReviewer }, async (req, reply) => {
const row = db.get(req.params.id);
if (!row) return reply.code(404).send({ error: "not found" });
return reply.type("image/jpeg").header("cache-control", "private, max-age=3600").send(createReadStream(path.join(cfg.dataDir, row.imagePath)));
});
app.post<{ Params: { id: string }; Body: { label?: unknown } }>("/api/items/:id/review", { preHandler: requireReviewer }, async (req, reply) => {
const label = req.body?.label;
if (label !== "unusable" && !isVehicleClass(label)) return reply.code(400).send({ error: "label must be a vehicle class or 'unusable'" });
if (!db.get(req.params.id)) return reply.code(404).send({ error: "not found" });
const row = db.review(req.params.id, label as ReviewVerdict, cfg.reviewer!.user);
return publicItem(row!);
});
app.get("/api/stats", { preHandler: requireReviewer }, async () => db.stats());
// --- Export (the training set) --------------------------------------------------------
app.get("/export/labels.csv", { preHandler: requireReviewer }, async (_req, reply) => {
const rows = db.labelled();
// Quote every cell; a cell starting like a spreadsheet formula (=, +, -, @, tab, CR)
// gets a leading apostrophe — the category/service names are booth-supplied text and
// the reviewer will open this in a spreadsheet (CSV formula injection).
const q = (s: string | number | null) => {
let v = String(s ?? "");
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
return `"${v.replace(/"/g, '""')}"`;
};
const head = "item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
const lines = rows.map((r) =>
[r.id, r.booth, r.kind, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
);
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
});
// --- Training (proxy to the trainer's job API) ----------------------------------------
// The trainer is a sibling container reading the same volume; it is reachable only on the
// compose network, so the reviewer's login here is the only gate. The proxy forwards a
// fixed set of paths and passes the trainer's status codes through (409 = a job runs).
const trainer = cfg.trainerUrl;
async function viaTrainer(reply: FastifyReply, tpath: string, init?: RequestInit): Promise<unknown> {
if (!trainer) return reply.code(503).send({ error: "trainer not configured" });
let r: Response;
try {
r = await fetch(trainer + tpath, { ...init, signal: AbortSignal.timeout(15_000) });
} catch (err) {
return reply.code(502).send({ error: `trainer unreachable: ${(err as Error).message}` });
}
const ctype = r.headers.get("content-type") ?? "application/json";
return reply.code(r.status).type(ctype).send(Buffer.from(await r.arrayBuffer()));
}
app.get("/api/training/status", { preHandler: requireReviewer }, async (_req, reply) => {
if (!trainer) return { configured: false };
try {
const get = async (p: string) => {
const r = await fetch(trainer + p, { signal: AbortSignal.timeout(15_000) });
if (!r.ok) throw new Error(`${p} → HTTP ${r.status}`);
return r.json() as Promise<Record<string, unknown>>;
};
const [health, readiness, versions, jobs] = await Promise.all([get("/health"), get("/readiness"), get("/versions"), get("/jobs")]);
return { configured: true, reachable: true, health, readiness, versions: versions.versions, jobs: jobs.jobs, current: jobs.current };
} catch (err) {
return reply.code(200).send({ configured: true, reachable: false, error: (err as Error).message });
}
});
app.post<{ Body: Record<string, unknown> }>("/api/training/jobs", { preHandler: requireReviewer }, async (req, reply) => {
const b = req.body && typeof req.body === "object" ? req.body : {};
const kind = b.kind;
if (kind !== "train" && kind !== "evaluate" && kind !== "publish") return reply.code(400).send({ error: "kind must be train, evaluate or publish" });
// Only the knobs the UI offers cross over; the trainer validates their values.
const allowed = ["kind", "mode", "backbone", "minAccuracy", "minPerClass", "epochs", "version"];
const body: Record<string, unknown> = {};
for (const k of allowed) if (b[k] !== undefined) body[k] = b[k];
return viaTrainer(reply, "/jobs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
});
app.get<{ Params: { id: string } }>("/api/training/jobs/:id", { preHandler: requireReviewer }, async (req, reply) => {
if (!ID_RE.test(req.params.id)) return reply.code(400).send({ error: "bad job id" });
return viaTrainer(reply, `/jobs/${encodeURIComponent(req.params.id)}`);
});
app.get<{ Params: { v: string } }>("/api/training/versions/:v/report", { preHandler: requireReviewer }, async (req, reply) => {
if (!ID_RE.test(req.params.v)) return reply.code(400).send({ error: "bad version" });
return viaTrainer(reply, `/versions/${encodeURIComponent(req.params.v)}/report`);
});
return app;
}
/** The row as the review screen sees it (no server paths). */
function publicItem(r: ItemRow): Omit<ItemRow, "imagePath"> {
const { imagePath: _p, ...rest } = r;
return rest;
}
+40
View File
@@ -0,0 +1,40 @@
export interface CollectorConfig {
readonly host: string;
readonly port: number;
readonly dataDir: string;
/** boothId → bearer token. */
readonly boothTokens: ReadonlyMap<string, string>;
/** The single reviewer login; null = review screen and export refuse (503). */
readonly reviewer: { readonly user: string; readonly pass: string } | null;
/** The trainer's job API on the compose network (http://trainer:8091); null = the
* Training section is hidden and /api/training/* answers 503. */
readonly trainerUrl: string | null;
}
/** "booth-7:abc,booth-9:def" (commas, whitespace or newlines between pairs). */
export function parseBoothTokens(raw: string): Map<string, string> {
const out = new Map<string, string>();
for (const pair of raw.split(/[,\s]+/)) {
if (!pair) continue;
const i = pair.indexOf(":");
if (i <= 0) throw new Error(`COLLECTOR_BOOTH_TOKENS: bad pair "${pair}" (want boothId:token)`);
const booth = pair.slice(0, i).trim();
const token = pair.slice(i + 1).trim();
if (!booth || token.length < 16) throw new Error(`COLLECTOR_BOOTH_TOKENS: token for "${booth}" too short (>=16 chars)`);
out.set(booth, token);
}
return out;
}
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): CollectorConfig {
const user = (env.COLLECTOR_REVIEWER_USER ?? "").trim();
const pass = env.COLLECTOR_REVIEWER_PASS ?? "";
return {
host: env.COLLECTOR_HOST ?? "0.0.0.0",
port: Number(env.COLLECTOR_PORT ?? 8090),
dataDir: env.COLLECTOR_DATA_DIR ?? "/data",
boothTokens: parseBoothTokens(env.COLLECTOR_BOOTH_TOKENS ?? ""),
reviewer: user && pass.length >= 8 ? { user, pass } : null,
trainerUrl: (env.COLLECTOR_TRAINER_URL ?? "").trim().replace(/\/+$/, "") || null,
};
}
+186
View File
@@ -0,0 +1,186 @@
import Database from "better-sqlite3";
import type { VehicleClass } from "@parking/shared";
// One table. Each row is one booth decision: what the camera saw, what the operator
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
// reads it straight off the volume.
export interface ItemRow {
id: string;
booth: string;
/** "wash" = a desk decision (operator fields set); "entry" = a sampled entry read (pure
* training material: crop + the camera's class, operator fields empty). */
kind: "wash" | "entry";
orderRef: string;
at: string;
operatorRef: string;
operatorCategoryId: string;
operatorCategoryName: string;
/** The vision classes the operator's category covers at that site (its mapping) — what
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
operatorClasses: string;
service: string;
visionClass: string;
visionConfidence: number;
visionCategoryId: string | null;
downgraded: number;
imageWidth: number;
imageHeight: number;
plateBlurred: number;
imagePath: string;
receivedAt: string;
reviewLabel: string | null; // a VehicleClass, or "unusable"
reviewedAt: string | null;
reviewer: string | null;
}
export type ReviewVerdict = VehicleClass | "unusable";
export class CollectorDb {
readonly #db: Database.Database;
constructor(file: string) {
this.#db = new Database(file);
this.#db.pragma("journal_mode = WAL");
this.#db.exec(`
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
booth TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'wash',
order_ref TEXT NOT NULL,
at TEXT NOT NULL,
operator_ref TEXT NOT NULL DEFAULT '',
operator_category_id TEXT NOT NULL DEFAULT '',
operator_category_name TEXT NOT NULL DEFAULT '',
operator_classes TEXT NOT NULL DEFAULT '[]',
service TEXT NOT NULL,
vision_class TEXT NOT NULL,
vision_confidence REAL NOT NULL,
vision_category_id TEXT,
downgraded INTEGER NOT NULL DEFAULT 0,
image_width INTEGER NOT NULL,
image_height INTEGER NOT NULL,
plate_blurred INTEGER NOT NULL,
image_path TEXT NOT NULL,
received_at TEXT NOT NULL,
review_label TEXT,
reviewed_at TEXT,
reviewer TEXT
);
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
`);
}
close(): void {
this.#db.close();
}
static #map(r: Record<string, unknown>): ItemRow {
return {
id: r.id as string,
booth: r.booth as string,
kind: r.kind === "entry" ? "entry" : "wash",
orderRef: r.order_ref as string,
at: r.at as string,
operatorRef: r.operator_ref as string,
operatorCategoryId: r.operator_category_id as string,
operatorCategoryName: r.operator_category_name as string,
operatorClasses: r.operator_classes as string,
service: r.service as string,
visionClass: r.vision_class as string,
visionConfidence: r.vision_confidence as number,
visionCategoryId: (r.vision_category_id as string | null) ?? null,
downgraded: r.downgraded as number,
imageWidth: r.image_width as number,
imageHeight: r.image_height as number,
plateBlurred: r.plate_blurred as number,
imagePath: r.image_path as string,
receivedAt: r.received_at as string,
reviewLabel: (r.review_label as string | null) ?? null,
reviewedAt: (r.reviewed_at as string | null) ?? null,
reviewer: (r.reviewer as string | null) ?? null,
};
}
get(id: string): ItemRow | null {
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
return r ? CollectorDb.#map(r) : null;
}
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
this.#db
.prepare(
`INSERT INTO items (id, booth, kind, order_ref, at, operator_ref, operator_category_id, operator_category_name,
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
image_width, image_height, plate_blurred, image_path, received_at)
VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
)
.run(row);
}
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
const params: unknown[] = [];
if (booth) {
where.push("booth = ?");
params.push(booth);
}
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
const rows = this.#db
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
.all(...params, limit) as Record<string, unknown>[];
return rows.map((r) => CollectorDb.#map(r));
}
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
this.#db
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
.run(label, new Date().toISOString(), reviewer, id);
return this.get(id);
}
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
* reviewer's class fell inside the operator's chosen category (agree) or outside
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
stats(): {
booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
} {
const booths = this.#db
.prepare(
`SELECT booth, COUNT(*) AS received,
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
SUM(CASE WHEN kind = 'entry' THEN 1 ELSE 0 END) AS entries
FROM items GROUP BY booth ORDER BY booth`,
)
.all() as { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
// Operator agreement is a WASH thing — an entry sample has no operator decision.
const reviewed = this.#db
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL AND kind = 'wash'")
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
for (const r of reviewed) {
const key = `${r.booth} ${r.operator_ref}`;
let o = ops.get(key);
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
o.reviewed += 1;
if (r.review_label === "unusable") o.unusable += 1;
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
else o.disagree += 1;
}
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
}
/** Reviewed, usable rows — the training set. */
labelled(): ItemRow[] {
const rows = this.#db
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
.all() as Record<string, unknown>[];
return rows.map((r) => CollectorDb.#map(r));
}
}
+16
View File
@@ -0,0 +1,16 @@
import { buildCollector } from "./app.js";
import { configFromEnv } from "./config.js";
const cfg = configFromEnv();
const app = await buildCollector(cfg);
if (cfg.boothTokens.size === 0) app.log.warn("COLLECTOR_BOOTH_TOKENS is empty — no booth can ingest");
if (!cfg.reviewer) app.log.warn("COLLECTOR_REVIEWER_USER/PASS not set — the review screen and export refuse");
app.log.info(`collector: ${cfg.boothTokens.size} booth token(s), data in ${cfg.dataDir}, trainer ${cfg.trainerUrl ?? "not configured"}`);
await app.listen({ host: cfg.host, port: cfg.port });
const stop = async () => {
await app.close();
process.exit(0);
};
process.on("SIGTERM", () => void stop());
process.on("SIGINT", () => void stop());
+234
View File
@@ -0,0 +1,234 @@
import { VEHICLE_CLASSES } from "@parking/shared";
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
// itself (no build step, no framework) — this is deliberately the whole UI.
export function reviewPage(): string {
const classes = JSON.stringify(VEHICLE_CLASSES);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wash review</title>
<style>
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
dt { color:var(--muted); }
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
button:hover { border-color:var(--amber); }
button.mono { font-family:ui-monospace, monospace; }
button.hint { border-color:var(--amber); }
button.unusable { color:var(--red); }
button.skip { color:var(--muted); }
.muted { color:var(--muted); }
.warn { color:var(--amber); }
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
h2 { font-size:.8rem; letter-spacing:.08em; text-transform:uppercase; color:var(--amber); margin:0 0 .6rem; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; }
select, input { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.4rem .5rem; font:inherit; }
input[type=number] { width:5rem; }
label { color:var(--muted); font-size:.8rem; }
pre { background:#0d0d0d; border:1px solid #2a2a2a; border-radius:4px; padding:.6rem; max-height:22rem; overflow:auto; font-size:.75rem; white-space:pre-wrap; margin:.6rem 0 0; }
.ok { color:var(--green); }
.bad { color:var(--red); }
button:disabled { opacity:.45; cursor:not-allowed; }
button.small { padding:.25rem .5rem; font-size:.75rem; }
</style>
</head>
<body>
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
<main>
<section class="card" id="item">
<p class="muted">Loading…</p>
</section>
<section class="card">
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
</section>
<section class="card" id="training" hidden>
<h2>Training</h2>
<div id="tr-body"></div>
</section>
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
</main>
<script>
const CLASSES = ${classes};
const skipped = new Set();
let current = null;
async function api(path, init) {
const r = await fetch(path, init);
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
return r.json();
}
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
async function loadStats() {
const s = await api('/api/stats');
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
const tb = document.querySelector('#stats tbody');
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
}
async function next() {
const { items } = await api('/api/items?status=pending&limit=25');
current = items.find(i => !skipped.has(i.id)) || null;
const el = document.getElementById('item');
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
const it = current;
const opClasses = JSON.parse(it.operatorClasses || '[]');
el.innerHTML =
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
'<dl style="margin-top:.8rem">' +
(it.kind === 'entry'
? '<dt>sample</dt><dd><span class="muted">entry stream — no wash, no operator decision; label the vehicle</span></dd>'
: '<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>') +
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
(it.kind === 'entry' ? '<dt>booth</dt><dd class="mono">' + esc(it.booth) + '</dd>' :
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>') +
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
'</dl>' +
'<div class="buttons" style="margin-top:.8rem">' +
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
'<button class="unusable" data-label="unusable">unusable</button>' +
'<button class="skip" data-skip="1">skip</button>' +
'</div>';
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
el.querySelector('button[data-skip]').addEventListener('click', skip);
}
async function verdict(label) {
if (!current) return;
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
await Promise.all([next(), loadStats()]);
}
function skip() { if (current) { skipped.add(current.id); next(); } }
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'u') verdict('unusable');
else if (e.key === 's') skip();
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
});
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
loadStats().catch(() => {});
// ---- Training: the trainer's job API, proxied by the collector -------------------------
// Readiness (labels per class vs the minimum), one job at a time with a live log, the
// versions a run produced (written or refused) with Report / Evaluate / Publish. Pinning a
// published version into the vision image stays a git commit — that is the deploy control.
let trPoll = null;
let trShownReport = null;
const trDefaults = { mode: 'features', backbone: 'resnet18', minAccuracy: 0.85 };
function pct(x) { return x == null ? '—' : Math.round(x * 100) + ' %'; }
async function training() {
const box = document.getElementById('training');
const el = document.getElementById('tr-body');
let s;
try { s = await api('/api/training/status'); } catch (e) { box.hidden = false; el.innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; return; }
if (!s.configured) { box.hidden = true; return; }
box.hidden = false;
if (!s.reachable) { el.innerHTML = '<p class="warn">trainer not reachable: ' + esc(s.error || '') + '</p>'; schedule(true); return; }
const r = s.readiness, run = r.run || {}, minPer = run.minPerClass || 20;
const byClass = (r.labelled && r.labelled.byClass) || {};
const classes = Object.keys(byClass);
const cur = s.current;
const readyLine = r.ready
? '<span class="ok">enough labels to train</span> — classes this run: ' + esc((run.classes || []).join(', '))
: '<span class="warn">not enough labels yet</span> — a class needs ' + minPer + ' reviewed crops; two classes must clear it';
let html = '<p>' + readyLine + ' <span class="muted">(' + (r.labelled ? r.labelled.total : 0) + ' labelled, ' + (r.missingCrops || 0) + ' missing crop files)</span></p>';
html += '<table><thead><tr><th>class</th><th>reviewed</th><th>train</th><th>val</th><th></th></tr></thead><tbody>' +
(classes.map(c => '<tr><td class="mono">' + esc(c) + '</td><td>' + byClass[c] + '</td><td>' + ((run.train || {})[c] ?? '—') + '</td><td>' + ((run.val || {})[c] ?? '—') + '</td><td class="muted">' + (byClass[c] < minPer ? 'below ' + minPer + ' — dropped' : '') + '</td></tr>').join('') || '<tr><td colspan="5" class="muted">no labels yet — review crops above</td></tr>') +
'</tbody></table>';
const d = Object.assign({}, trDefaults, r.defaults || {});
html += '<div class="row" style="margin-top:.8rem">' +
'<label>mode <select id="tr-mode">' + (r.modes || ['features', 'finetune']).map(m => '<option' + (m === d.mode ? ' selected' : '') + '>' + m + '</option>').join('') + '</select></label>' +
'<label>backbone <select id="tr-backbone">' + (r.backbones || ['resnet18']).map(b => '<option' + (b === d.backbone ? ' selected' : '') + '>' + b + '</option>').join('') + '</select></label>' +
'<label>floor <input id="tr-floor" type="number" min="0" max="1" step="0.01" value="' + d.minAccuracy + '"></label>' +
'<button id="tr-train"' + (r.ready && !cur ? '' : ' disabled') + '>Train</button>' +
(cur ? '<span class="warn">running: ' + esc(cur.kind) + ' ' + esc(cur.id) + '</span>' : '') +
'</div>';
const last = cur || (s.jobs && s.jobs[0]);
if (last) {
const cls = last.status === 'done' ? 'ok' : last.status === 'running' ? 'warn' : 'bad';
html += '<p style="margin:.8rem 0 0"><span class="' + cls + '">' + esc(last.status) + '</span> <span class="mono">' + esc(last.kind) + ' ' + esc(last.id) + '</span> <span class="muted">' + esc(last.startedAt || '') + (last.exitCode != null ? ' · exit ' + last.exitCode : '') + '</span> <button class="small" data-job="' + esc(last.id) + '">log</button></p>' +
'<pre id="tr-log" hidden></pre>';
}
const vs = s.versions || [];
html += '<h2 style="margin-top:1rem">Versions</h2>';
html += vs.length
? '<table><thead><tr><th>version</th><th>model</th><th>accuracy</th><th>classes</th><th>mode</th><th></th></tr></thead><tbody>' +
vs.map(v => '<tr><td class="mono">' + esc(v.version) + '</td><td>' + (v.written ? '<span class="ok">written</span>' : '<span class="bad">refused</span>') + '</td><td>' + pct(v.accuracy) + (v.floor != null ? ' <span class="muted">/ floor ' + pct(v.floor) + '</span>' : '') + '</td><td class="muted">' + esc((v.classes || []).join(', ')) + '</td><td class="muted">' + esc(v.mode || '') + '</td><td>' +
'<button class="small" data-report="' + esc(v.version) + '">report</button> ' +
(v.written ? '<button class="small" data-eval="' + esc(v.version) + '"' + (cur ? ' disabled' : '') + '>evaluate</button> <button class="small" data-publish="' + esc(v.version) + '"' + (cur ? ' disabled' : '') + '>publish</button>' : '') +
'</td></tr>').join('') + '</tbody></table>'
: '<p class="muted">no runs yet</p>';
html += '<pre id="tr-report" hidden></pre>';
html += '<p class="muted" style="margin:.8rem 0 0">A written model is only a file here. To put it on a booth: publish, then pin the version in <span class="mono">apps/vision/models/bodytype.version</span>, commit, and bump the TAG of the booth.</p>';
el.innerHTML = html;
const trainBtn = document.getElementById('tr-train');
if (trainBtn) trainBtn.addEventListener('click', () => startJob({ kind: 'train', mode: document.getElementById('tr-mode').value, backbone: document.getElementById('tr-backbone').value, minAccuracy: Number(document.getElementById('tr-floor').value) }));
el.querySelectorAll('button[data-eval]').forEach(b => b.addEventListener('click', () => startJob({ kind: 'evaluate', version: b.dataset.eval })));
el.querySelectorAll('button[data-publish]').forEach(b => b.addEventListener('click', () => { if (confirm('Publish ' + b.dataset.publish + ' to the package registry?')) startJob({ kind: 'publish', version: b.dataset.publish }); }));
el.querySelectorAll('button[data-job]').forEach(b => b.addEventListener('click', () => showLog(b.dataset.job)));
el.querySelectorAll('button[data-report]').forEach(b => b.addEventListener('click', () => showReport(b.dataset.report)));
if (cur) showLog(cur.id).catch(() => {});
if (trShownReport) showReport(trShownReport).catch(() => {});
schedule(!!cur);
}
function schedule(soon) {
if (trPoll) clearTimeout(trPoll);
trPoll = setTimeout(() => training().catch(() => {}), soon ? 4000 : 60000);
}
async function startJob(body) {
try {
const r = await fetch('/api/training/jobs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) { const e = await r.json().catch(() => ({})); alert('trainer: ' + (e.error || ('HTTP ' + r.status))); }
} catch (e) { alert(e.message); }
training().catch(() => {});
}
async function showLog(id) {
const j = await api('/api/training/jobs/' + encodeURIComponent(id));
const pre = document.getElementById('tr-log');
if (!pre) return;
pre.hidden = false;
pre.textContent = j.log || '(no output yet)';
pre.scrollTop = pre.scrollHeight;
}
async function showReport(v) {
const r = await fetch('/api/training/versions/' + encodeURIComponent(v) + '/report');
const pre = document.getElementById('tr-report');
if (!pre) return;
trShownReport = v;
pre.hidden = false;
pre.textContent = r.ok ? await r.text() : 'no report for ' + v + ' (HTTP ' + r.status + ')';
}
training().catch(() => {});
</script>
</body>
</html>`;
}
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildCollector, type CollectorApp } from "./app.js";
// The Training section's proxy: reviewer-gated, forwards a fixed set of paths to the
// trainer's job API, passes its status codes through, and degrades cleanly when the trainer
// is not configured or not reachable. The trainer is faked with a bare node http server.
const REVIEWER = { user: "julian", pass: "review-pass-123" };
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
let dir: string;
let fake: Server;
let fakeUrl: string;
let seen: { method: string; url: string; body: string }[];
let app: CollectorApp;
async function start(trainerUrl: string | null): Promise<void> {
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: new Map(), reviewer: REVIEWER, trainerUrl }, { dbFile: ":memory:" });
await app.ready();
}
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
seen = [];
fake = createServer((req: IncomingMessage, res: ServerResponse) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
seen.push({ method: req.method ?? "", url: req.url ?? "", body });
const json = (code: number, obj: unknown) => {
res.writeHead(code, { "content-type": "application/json" });
res.end(JSON.stringify(obj));
};
if (req.url === "/health") return json(200, { ok: true, busy: false });
if (req.url === "/readiness") return json(200, { ready: false, labelled: { total: 3 } });
if (req.url === "/versions") return json(200, { versions: [{ version: "v1", written: true }] });
if (req.url === "/jobs" && req.method === "GET") return json(200, { jobs: [{ id: "j1" }], current: null });
if (req.url === "/jobs" && req.method === "POST") return body.includes('"busy"') ? json(409, { error: "a job is already running" }) : json(202, { id: "j2", status: "running" });
if (req.url === "/jobs/j1") return json(200, { id: "j1", status: "done", log: "ok" });
if (req.url === "/versions/v1/report") {
res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
return res.end("# Body-type classifier v1\n");
}
return json(404, { error: "not found" });
});
});
await new Promise<void>((r) => fake.listen(0, "127.0.0.1", r));
const a = fake.address() as { port: number };
fakeUrl = `http://127.0.0.1:${a.port}`;
});
afterEach(async () => {
await app?.close();
await new Promise<void>((r) => fake.close(() => r()));
await rm(dir, { recursive: true, force: true });
});
describe("review page script", () => {
it("parses as JavaScript (an apostrophe in a template literal once broke the whole page)", async () => {
const { reviewPage } = await import("./review-page.js");
const html = reviewPage();
const script = html.slice(html.indexOf("<script>") + 8, html.lastIndexOf("</script>"));
expect(() => new Function(script)).not.toThrow();
});
});
describe("training proxy", () => {
it("is hidden when no trainer is configured", async () => {
await start(null);
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.json()).toEqual({ configured: false });
const j = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "train" } });
expect(j.statusCode).toBe(503);
});
it("aggregates status and forwards jobs and reports behind the reviewer login", async () => {
await start(fakeUrl);
expect((await app.inject({ method: "GET", url: "/api/training/status" })).statusCode).toBe(401);
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.statusCode).toBe(200);
const body = s.json();
expect(body.configured).toBe(true);
expect(body.reachable).toBe(true);
expect(body.readiness.labelled.total).toBe(3);
expect(body.versions[0].version).toBe("v1");
expect(body.jobs[0].id).toBe("j1");
const j = await app.inject({
method: "POST",
url: "/api/training/jobs",
headers: { authorization: basic },
payload: { kind: "train", mode: "features", minAccuracy: 0.9, secret: "nope", version: "v2" },
});
expect(j.statusCode).toBe(202);
expect(j.json().id).toBe("j2");
const posted = seen.find((r) => r.method === "POST")!;
expect(JSON.parse(posted.body)).toEqual({ kind: "train", mode: "features", minAccuracy: 0.9, version: "v2" }); // unknown keys dropped
const busy = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "evaluate", version: "busy" } });
expect(busy.statusCode).toBe(409); // the trainer's answer passes through
const bad = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "rm-rf" } });
expect(bad.statusCode).toBe(400);
const one = await app.inject({ method: "GET", url: "/api/training/jobs/j1", headers: { authorization: basic } });
expect(one.json().status).toBe("done");
expect((await app.inject({ method: "GET", url: "/api/training/jobs/..%2Fx", headers: { authorization: basic } })).statusCode).toBe(400);
const rep = await app.inject({ method: "GET", url: "/api/training/versions/v1/report", headers: { authorization: basic } });
expect(rep.statusCode).toBe(200);
expect(rep.headers["content-type"]).toContain("text/markdown");
expect(rep.body).toContain("# Body-type classifier v1");
});
it("reports an unreachable trainer without failing the page", async () => {
await start("http://127.0.0.1:9"); // nothing listens on the discard port
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.statusCode).toBe(200);
expect(s.json().reachable).toBe(false);
const j = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "train" } });
expect(j.statusCode).toBe(502);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["src/**/*.test.ts"], env: { LOG_LEVEL: "silent" } },
});
+10
View File
@@ -0,0 +1,10 @@
# Desktop (Tauri) build — the @parking/web SPA needs to know where Fastify is.
#
# In a BROWSER (dev via the Vite proxy, or prod where Fastify serves the SPA),
# leave VITE_API_BASE UNSET — requests stay relative/same-origin.
#
# For the DESKTOP build, the bundled SPA loads from tauri://localhost and has no
# proxy, so point it at the appliance's Fastify origin. This is read at WEB build
# time, so export it before `pnpm --filter @parking/desktop build` (or put it in
# apps/web/.env.production).
VITE_API_BASE=http://127.0.0.1:3000
+3
View File
@@ -0,0 +1,3 @@
# Rust / Tauri build artifacts
src-tauri/target/
src-tauri/gen/
+79
View File
@@ -0,0 +1,79 @@
# @parking/desktop — Tauri v2 kiosk shell
A **thin native desktop window** over the `@parking/web` SPA. It contains **no UI and no business
logic** of its own: the window renders the *same* web app the browser does, so the desktop and the
browser stay identical and never drift. Device/auth/ledger logic stays in `@parking/server`. See
`wiki/decisions/desktop-shell-tauri.md`.
## How the "same look & functionality" guarantee works
| | Source of the UI |
| --- | --- |
| **Dev** (`tauri dev`) | the window loads `http://localhost:5173` — the **`@parking/web` Vite dev server**. Edit a component in `apps/web` → HMR updates the desktop window live. |
| **Prod** (`tauri build`) | the window bundles `apps/web`'s built `dist/`. `beforeBuildCommand` rebuilds the SPA first. |
There is only one UI codebase (`apps/web`); this package just wraps it.
## Backend connection
The SPA talks to Fastify over HTTP/WS. In a browser that's same-origin (relative `/api`). In the
desktop build the bundled assets load from `tauri://localhost`, so set **`VITE_API_BASE`** (read at
web build time — see `.env.example`) to the appliance's Fastify origin, e.g.
`http://127.0.0.1:3000`. The CSP `connect-src` in `tauri.conf.json` is already allowed for that
origin, and the backend must include the Tauri origin in `WS_ALLOWED_ORIGINS` for the live feed.
## Commands
```bash
pnpm --filter @parking/desktop dev # native window over the web dev server (HMR)
pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app (.deb/.rpm/.AppImage)
```
> `build` is a **no-op** in this package so `turbo run build` stays fast — the real desktop bundle
> (compiles Rust, minutes long) is the explicit `bundle` script above.
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
window needs a display (WSLg or an X server).
## Auto-update
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
committed.
**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`,
and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app
was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`,
so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected,
and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not
supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old
version; the failure is logged to the server's Logs viewer.
## Release gate — run the REAL bundle locally before tagging
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug
found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS
`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie)
depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`"
carries no information about a release. Before pushing a `vX.Y.Z` tag:
1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and
`tauri://localhost` in `WS_ALLOWED_ORIGINS`).
2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from
`src-tauri/target/release/bundle/appimage/` (WSLg is enough).
3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**.
4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds.
5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF).
6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists
(proves the desktop log channel; historically it was silently 403'd).
Only then tag. If a release still fails in the field, the gap is in this list — fix the list.
## Not here (deliberately)
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@parking/desktop",
"version": "0.0.0",
"private": true,
"//": "Tauri v2 desktop shell — a THIN native window over the @parking/web SPA. No business logic lives here (device/auth/ledger stay in @parking/server); see wiki/decisions/desktop-shell-tauri.md. Dev loads the web dev server (HMR); build bundles the web app's dist/, so the desktop UI and the browser UI are the SAME codebase and never drift.",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "echo 'no-op in the Turbo graph — the real desktop bundle is a deliberate `pnpm --filter @parking/desktop bundle` (compiles Rust + packages installers, minutes long)'",
"bundle": "tauri build",
"tauri": "tauri",
"lint": "echo 'no JS lint (Tauri shell; Rust checked via cargo)'"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.1"
},
"dependencies": {
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1"
}
}
+5469
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "parking-desktop"
version = "0.0.0"
description = "Parking System — desktop kiosk shell"
edition = "2021"
rust-version = "1.77"
# Thin Tauri v2 shell. Deliberately holds NO business logic — it loads the
# @parking/web SPA and lets it talk to the local Fastify server. Device/auth/
# ledger stay server-side. See wiki/decisions/desktop-shell-tauri.md.
[lib]
name = "parking_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde_json = "1"
# Auto-update: prompt the operator, download a signed update, relaunch.
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
# long-standing WebKit limitation, not fixable via CSP). Routing through this
# plugin sends the request via Tauri's Rust side instead of the webview's own
# fetch, sidestepping the browser mixed-content check entirely.
tauri-plugin-http = "2"
# Same mixed-content problem as above, but for the live-feed WebSocket
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
# are separate browser checks, so this needs its own plugin.
tauri-plugin-websocket = "2"
# Persists the operator-configured backend URL (host:port of the Fastify
# server this install talks to) across restarts. Read before any API call —
# see apps/web/src/lib/backend-config.ts.
tauri-plugin-store = "2"
[features]
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,23 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Minimal capability set for the kiosk shell. The window only needs to render the SPA; it is granted NOTHING that touches the filesystem, shell, or devices — those stay server-side. Add a named permission here only when a concrete need arises (deny-by-default). See wiki/decisions/desktop-shell-tauri.md.",
"windows": ["main"],
"permissions": [
"core:default",
"updater:default",
"process:default",
"websocket:default",
"store:default",
{
"identifier": "http:default",
"//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.",
"allow": [
{ "url": "http://*" },
{ "url": "https://*" },
{ "url": "http://*:*" },
{ "url": "https://*:*" }
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+32
View File
@@ -0,0 +1,32 @@
// Parking System desktop shell — entry point.
//
// Intentionally minimal: build the default Tauri app and run it. The window
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
// the backend over HTTP to a Fastify server (address operator-configured at
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
// Tauri IPC. This keeps the shell a thin presentation wrapper with a
// deny-by-default native surface (see wiki/decisions/desktop-shell-tauri.md).
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// Auto-update: the JS side (apps/web) checks on launch, prompts the
// operator, and installs + relaunches on confirm. These plugins expose
// the update check/install and the relaunch to that flow. The updater
// endpoint + signing pubkey live in tauri.conf.json.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
// server through Tauri's native HTTP client — see the Cargo.toml
// comment on why the webview's own fetch() can't reach it directly.
.plugin(tauri_plugin_http::init())
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
// above, but WS needs its own plugin (separate browser check).
.plugin(tauri_plugin_websocket::init())
// Persists the operator-configured backend URL across restarts (JSON
// file in the app's config dir) — see backend-config.ts.
.plugin(tauri_plugin_store::Builder::new().build())
.run(tauri::generate_context!())
.expect("error while running the Parking System desktop shell");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
parking_desktop_lib::run()
}
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System",
"version": "0.2.0",
"identifier": "com.parking.desktop",
"build": {
"devUrl": "http://localhost:5173",
"frontendDist": "../../web/dist",
"beforeDevCommand": "pnpm --filter @parking/web dev",
"beforeBuildCommand": "pnpm --filter @parking/web build"
},
"app": {
"windows": [
{
"label": "main",
"title": "Parking System",
"width": 1280,
"height": 800,
"minWidth": 1024,
"minHeight": 640,
"resizable": true,
"maximized": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
"endpoints": [
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"//": "Tauri shell as a first-class Turbo node. build outputs [] so `turbo run build` doesn't try to cache/compile the Rust bundle on every pass (a real desktop bundle is a deliberate `pnpm --filter @parking/desktop build`).",
"tasks": {
"build": {
"outputs": []
}
}
}
+87 -1
View File
@@ -8,13 +8,99 @@
# Generate one with: openssl rand -hex 32 # Generate one with: openssl rand -hex 32
JWT_SECRET= JWT_SECRET=
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
# Generate with: openssl rand -hex 32
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
# dev, but set a dedicated key before production. Events store the key that signed
# 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). A daily timer + an admin
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
# not a console call. See wiki/concepts/backup-recovery.md.
#
# 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. Backups stay a no-op until BOTH this key and an in-UI
# target directory are set. The target directory AND retention (keep-last / keep-daily) are
# admin-chosen in the UI (Setup → Backup), NOT env — only this key is an env secret.
# BACKUP_KEY=
# Optional ---------------------------------------------------------------- # Optional ----------------------------------------------------------------
# PORT=3000 # PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
# LOG_LEVEL=info # LOG_LEVEL=info
# DATABASE_URL=./parking.sqlite # DATABASE_URL=./parking.sqlite
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only) #
# Auth-cookie Secure flag. FAIL-SAFE: cookies are Secure (HTTPS-only) BY DEFAULT —
# you only ever opt OUT, never in. Set COOKIE_SECURE=0 for a plain-HTTP deployment
# (e.g. the LAN appliance serving the SPA same-origin over http, where a Secure
# cookie would never be sent and would lock operators out). Local dev over
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
# COOKIE_SECURE=0
# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged
# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted
# items forever (manual purge only). See wiki/concepts/soft-delete.md.
# RECYCLE_BIN_RETENTION_DAYS=30
# First admin (seed once): pnpm --filter @parking/server seed-admin # First admin (seed once): pnpm --filter @parking/server seed-admin
# ADMIN_USER=admin # ADMIN_USER=admin
# ADMIN_PASS= # ADMIN_PASS=
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
# The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
# http://tauri.localhost), which is NOT same-origin with the backend — add both
# so the desktop app's live feed connects. See apps/desktop.
# To open the dev SPA from another LAN device (phone over wifi), Vite must bind
# 0.0.0.0 (vite.config.ts) AND the host's LAN origin must be listed here, e.g.
# http://10.0.10.203:5173 — the WS handshake's Origin is that LAN address.
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
# Vision / ANPR (optional) -------------------------------------------------
# OFF by default. The Node SERVER's view of the vision microservice (apps/vision),
# which runs as a separate process with its OWN apps/vision/.env. Both sides share the
# VISION_ prefix but are different processes — keep the two .env files separate.
# See wiki/entities/opencv-anpr-service.md "Configuration".
# ANPR rides the entry/exit snapshot (button / QR / RFID triggers it) — no polling.
# VISION_ENABLED=1 # master switch — nothing runs without it
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
# VISION_MIN_CONFIDENCE=0.5 # advisory confidence floor; keep in sync with the service
#
# ANPR subscriber-entry bridge (anpr-entry.ts): a subscriber's plate, read off a lane
# camera's vehicle detection, admits them through the gated SubscriptionFlow. Opt-in per
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
# Venue modules --------------------------------------------------------------
# Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment
# decision — set in the Komodo stack env, never by a site role). The site admin then
# ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or
# blank = every registered module (parking,validation,carwash) — a DEV convenience. In
# Docker, docker-compose.yml forwards it with a default of parking,validation, so a booth
# is never entitled to a module its Komodo stack env does not name. Required modules
# (parking) are always on. See wiki/decisions/venue-modules.md.
#MODULES_ENTITLED=parking,validation
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
# CARWASH_REVIEW_URL=
# CARWASH_REVIEW_TOKEN=
# CARWASH_REVIEW_BOOTH_ID=
# CARWASH_REVIEW_INTERVAL_SEC=60
# Entry-stream sampling: also queue one in N ENTRY vehicle reads (no wash, no operator) as
# pure training material in the gate view — many times the wash stream, zero domain shift.
# 1 = every entry (the reviewer labels what they have time for; the rest waits and stays
# useful), N = one in N, 0/unset = off. Needs the three settings above.
# CARWASH_REVIEW_ENTRY_SAMPLE=1
+84
View File
@@ -0,0 +1,84 @@
# syntax=docker/dockerfile:1.7
# Parking SERVER image: Fastify API + the bundled React SPA (one container serves both —
# offline-first single appliance). Build CONTEXT is the REPO ROOT (it's a pnpm/turbo
# monorepo). better-sqlite3 is a native module → build stage needs node-gyp toolchain,
# runtime needs libstdc++. Mirrors the house multi-stage pattern (cf. trm/processor).
# See wiki/decisions/container-deployment.md.
# ---- deps: cache-friendly pnpm fetch (only manifests change the layer) ----
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
# Workspace manifests + lock first, so the fetch layer caches across source edits.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/
COPY apps/vision/package.json apps/vision/
COPY apps/collector/package.json apps/collector/
COPY packages/db/package.json packages/db/
COPY packages/devices/package.json packages/devices/
COPY packages/shared/package.json packages/shared/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm fetch
# ---- build: install (offline from the fetched store) + turbo build everything ----
FROM deps AS build
ENV CI=true
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --offline
# Force the SPA to use a SAME-ORIGIN (relative) API base for THIS image. Vite auto-loads
# apps/web/.env.production, which sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
# DESKTOP build — but here Fastify serves the SPA same-origin, so an absolute base would
# make the browser hit 127.0.0.1:3000 cross-origin and fail CORS. `.env.production.local`
# has higher precedence than `.env.production`, so this empties it for the server image only.
RUN echo 'VITE_API_BASE=' > apps/web/.env.production.local
# Builds shared/db/devices, the server dist, AND the web SPA dist (apps/web/dist).
RUN pnpm turbo run build --filter=@parking/server --filter=@parking/web
# `pnpm deploy` produces a SELF-CONTAINED prod bundle for the server in /deploy: a hoisted
# node_modules with only @parking/server's prod deps (incl. the workspace packages' built
# dist + their native deps like better-sqlite3 — properly linked, unlike `prune` at root).
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter=@parking/server --legacy deploy --prod /deploy
# The server's own dist + scripts (deploy copies the package's package.json + files, but we
# copy dist explicitly so the layout under /deploy is predictable). The web SPA + db
# migrations are copied in the runtime stage from their build locations.
# ---- runtime: slim, non-root ----
FROM node:22-alpine AS runtime
WORKDIR /app
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
ARG BUILD_VERSION=""
ENV BUILD_VERSION=$BUILD_VERSION
ENV NODE_ENV=production
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
RUN addgroup -S app && adduser -S -G app app
# The self-contained deploy bundle: dist/ + a hoisted node_modules carrying the server's
# prod deps AND the workspace packages (@parking/db|devices|shared) with their built dist,
# the drizzle migrations, and the native better-sqlite3 binding. Single COPY — no scattered
# package dirs, no root node_modules.
COPY --from=build --chown=app:app /deploy ./
# The built SPA — served by Fastify static at WEB_DIST_DIR. (Not part of the server's deploy
# bundle, so copied from the web build output.)
COPY --from=build --chown=app:app /app/apps/web/dist ./web/dist
# DB lives on a mounted volume (never in the image). Default points at /data.
ENV DATABASE_URL=/data/parking.sqlite
ENV WEB_DIST_DIR=/app/web/dist
ENV HOST=0.0.0.0
ENV PORT=3000
RUN mkdir -p /data && chown app:app /data
VOLUME ["/data"]
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- "http://localhost:${PORT:-3000}/health" >/dev/null 2>&1 || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Container entrypoint for the parking server. Applies DB migrations against the mounted
# volume (DATABASE_URL), optionally seeds the first admin, then execs the server. Idempotent:
# the runtime migrator (drizzle-orm migrator, no drizzle-kit) only applies pending migrations,
# so a restart is a no-op. See packages/db/scripts/migrate-runtime.mjs.
set -e
echo "[entrypoint] DATABASE_URL=${DATABASE_URL}"
# Apply migrations against the mounted DB file (creates it + the schema on first boot).
# The migrator ships inside the @parking/db package in the deploy bundle's node_modules.
node node_modules/@parking/db/scripts/migrate-runtime.mjs
# Optional first-boot admin seed: set SEED_ADMIN=1 plus ADMIN_USER + ADMIN_PASS (the seed
# script PROMPTS when these are unset, which would hang a container — so require ADMIN_PASS).
# The seed is idempotent: it won't overwrite an existing user unless FORCE=1.
if [ "${SEED_ADMIN}" = "1" ]; then
if [ -z "${ADMIN_PASS}" ]; then
echo "[entrypoint] SEED_ADMIN=1 but ADMIN_PASS is unset — skipping seed (would hang on prompt)"
else
echo "[entrypoint] seeding admin (${ADMIN_USER:-admin})"
node scripts/seed-admin.mjs || echo "[entrypoint] seed-admin skipped/failed (non-fatal)"
fi
fi
echo "[entrypoint] starting server"
exec "$@"
+7 -3
View File
@@ -9,24 +9,28 @@
"start": "node --env-file-if-exists=.env dist/index.js", "start": "node --env-file-if-exists=.env dist/index.js",
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs", "seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@fastify/jwt": "10.1.0", "@fastify/jwt": "10.1.0",
"@fastify/static": "9.1.3", "@fastify/static": "9.1.3",
"@fastify/websocket": "^11.2.0",
"@parking/db": "workspace:*", "@parking/db": "workspace:*",
"@parking/devices": "workspace:*", "@parking/devices": "workspace:*",
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"fastify": "5.8.5", "fastify": "5.8.5",
"fastify-plugin": "6.0.0" "fastify-plugin": "6.0.0",
"sharp": "^0.35.2"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/node": "25.9.3", "@types/node": "25.9.3",
"tsx": "4.22.4", "tsx": "4.22.4",
"typescript": "6.0.3" "typescript": "6.0.3",
"vitest": "^4.1.9"
} }
} }
+37 -3
View File
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
const { createDb, users, eq } = require("@parking/db"); const { createDb, users, roles, eq } = require("@parking/db");
const DEFAULT_USERNAME = "admin"; const DEFAULT_USERNAME = "admin";
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
} }
const db = createDb(); const db = createDb();
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
// the row alone is all the FK needs.
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
const existing = await db.select().from(users).where(eq(users.username, username)).get(); const existing = await db.select().from(users).where(eq(users.username, username)).get();
if (existing && process.env.FORCE !== "1") { if (existing && process.env.FORCE !== "1") {
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`); console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
@@ -62,15 +70,41 @@ if (existing && process.env.FORCE !== "1") {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
if (existing) { if (existing) {
await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id)); await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id));
console.log(`reset password for admin "${username}"`); console.log(`reset password for admin "${username}"`);
} else { } else {
await db.insert(users).values({ await db.insert(users).values({
id: randomUUID(), id: randomUUID(),
username, username,
passwordHash, passwordHash,
role: "admin", roleId: "admin",
}); });
console.log(`created admin "${username}"`); console.log(`created admin "${username}"`);
} }
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
// fact (the chain is the audit record; whoever holds root can reset a password, they
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
// admin out to protect an audit line would invert the priority.
try {
const { EventLog } = await import("../dist/event-log.js");
const { buildSigner } = await import("../dist/signer.js");
const log = new EventLog(db, buildSigner());
await log.append({
type: "config_change",
source: "manual",
identity: `user:${username}`,
payload: {
setting: existing ? "admin.passwordReset" : "admin.seeded",
username,
operator: "console:seed-admin",
},
});
console.log("recorded to the signed ledger (config_change)");
} catch (err) {
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
}
process.exit(0); process.exit(0);
+330
View File
@@ -0,0 +1,330 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
import type { VisionClient, VisionResult } from "./vision-client.js";
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
// own logic only. See anpr-entry.ts.
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
// (no registry, no network). The factory returns a fresh shot each call.
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
// suite stays focused on the bridge's own match/debounce/emit logic.
vi.mock("./snapshot.js", () => ({
buildCamera: () => ({ captureSnapshot }),
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
camera.captureSnapshot(ctx as never),
}));
// Import AFTER the mock is registered.
const { AnprBridge } = await import("./anpr-entry.js");
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
captureSnapshot.mockClear();
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
delete process.env.ANPR_DEBOUNCE_MS;
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
// bridge reads these in its constructor, so set them before `new AnprBridge`.
process.env.ANPR_POLL_MS = "1";
process.env.ANPR_POLL_WINDOW_MS = "5";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.ANPR_POLL_MS;
delete process.env.ANPR_POLL_WINDOW_MS;
delete process.env.ANPR_POLL_MAX_MS;
});
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
* per-camera auto-open gate (absent ⇒ defaults on). */
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
const controllerId = randomUUID();
db.insert(devices).values({
id: controllerId,
category: "access",
driverId: "dingtian",
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
enabled: true,
}).run();
const camId = randomUUID();
db.insert(devices).values({
id: camId,
category: "camera",
driverId: "hikvision",
config: {
host: "10.0.0.9",
controllerId,
relay: 1,
...(opts.anpr ? { anpr: true } : {}),
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
},
enabled: true,
}).run();
return camId;
}
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
const enabled = opts.enabled ?? true;
const result: VisionResult | null =
opts.plate == null
? null
: {
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
};
return {
enabled,
analyze: vi.fn(async () => (enabled ? result : null)),
} as unknown as VisionClient;
}
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
function fakeSubFlow(
match: SubscriptionMatch | null,
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
// credential closing an occurrence mid-poll → count changes).
openCounts: number | number[] = 1,
): SubscriptionFlow {
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
return {
match: vi.fn(() => match),
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
} as unknown as SubscriptionFlow;
}
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
/** Capture read events emitted during `fn` (async). */
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
const got: DeviceReadEvent[] = [];
const off = deviceEvents.onRead((e) => got.push(e));
try {
await fn();
} finally {
off();
}
return got;
}
describe("AnprBridge", () => {
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
const cam = seedCamera({ anpr: false });
const vision = fakeVision({ plate: "AA111BB" });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
expect(vision.analyze).not.toHaveBeenCalled();
expect(captureSnapshot).not.toHaveBeenCalled();
});
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
});
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
});
it("ignores a plate below the entry confidence floor", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
});
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
// must re-pull until one frame clears the floor, not give up on the first bad frame.
const cam = seedCamera({ anpr: true });
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
const confs = [0.2, 0.2, 0.97];
let i = 0;
const vision = {
enabled: true,
analyze: vi.fn(async () => ({
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
})),
} as unknown as VisionClient;
// Generous window so all 3 escalation attempts run deterministically under suite load
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
process.env.ANPR_POLL_MS = "1";
process.env.ANPR_POLL_WINDOW_MS = "2000";
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
});
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
// extends the deadline, so the loop keeps polling and reads the car that settles at the
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
// the start bumps the deadline so attempt 5's confident read still lands.
const cam = seedCamera({ anpr: true });
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
let i = 0;
const vision = {
enabled: true,
analyze: vi.fn(async () => ({
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
})),
} as unknown as VisionClient;
process.env.ANPR_POLL_MS = "5";
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
const loop = bridge.onVehicleDetected(cam); // starts the loop
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
for (let k = 0; k < 5; k++) {
await new Promise((r) => setTimeout(r, 5));
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
}
await loop;
});
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ value: "AA111BB" });
});
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
// The car's plate is read (identity known) but stays below the floor; meanwhile the
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
const bridge = new AnprBridge(db, vision, sub, silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]); // aborted — the credential already handled it
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
expect(skips).toHaveLength(1);
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
});
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
process.env.ANPR_POLL_WINDOW_MS = "0";
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
await captureReads(() => bridge.onVehicleDetected(cam));
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
expect(skips).toHaveLength(1);
});
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
await bridge.onVehicleDetected(cam);
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
});
expect(reads).toHaveLength(1);
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
});
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
const cam = seedCamera({ anpr: true });
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
await disabled.onVehicleDetected(cam);
await noPlate.onVehicleDetected(cam);
});
expect(reads).toEqual([]);
});
it("never throws on an unknown device id", async () => {
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
});
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
const cam = seedCamera({ anpr: true });
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
expect(captureSnapshot).not.toHaveBeenCalled();
expect(vision.analyze).not.toHaveBeenCalled();
});
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
const cam = seedCamera({ anpr: true });
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
});
});
+328
View File
@@ -0,0 +1,328 @@
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { directionOf, type FlowDirection } from "./device-resolve.js";
import { buildCamera } from "./snapshot.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import type { VisionClient } from "./vision-client.js";
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
//
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
//
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
// barrier open stay inside the existing flow. A spoofed printed plate is just another
// credential through the same gate.
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
// transient plate-as-ticket exit flow.
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
// never throws into the push handler, never awaited on the camera's 200 response.
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
interface CameraConfig {
readonly anpr?: boolean;
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
* shared entry/exit lane. */
readonly anprAutoTrigger?: boolean;
readonly [k: string]: unknown;
}
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
function entryMinConfidence(): number {
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
}
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
function debounceMs(): number {
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
}
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
function pollMs(): number {
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
}
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
* push for the camera extends the deadline by this much from now — so a loop started by a
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
* barrier (the loop tracks "whoever is here now", not the car that started it). */
function pollWindowMs(): number {
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
}
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
function pollMaxMs(): number {
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
* subscriber by plate during the poll loop without re-building the whole event. */
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
}
export class AnprBridge {
readonly #db: Db;
readonly #vision: VisionClient | null;
readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger;
readonly #entryMinConfidence: number;
readonly #debounceMs: number;
readonly #pollMs: number;
readonly #pollWindowMs: number;
readonly #pollMaxMs: number;
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
readonly #lastFire = new Map<string, number>();
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
* while the car sits) must NOT start a second concurrent loop on the same camera. */
readonly #polling = new Set<string>();
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
readonly #pollDeadline = new Map<string, number>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
this.#vision = vision;
this.#subscription = subscription;
this.#logger = logger;
this.#entryMinConfidence = entryMinConfidence();
this.#debounceMs = debounceMs();
this.#pollMs = pollMs();
this.#pollWindowMs = pollWindowMs();
this.#pollMaxMs = pollMaxMs();
}
/**
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
*/
async onVehicleDetected(deviceId: string): Promise<void> {
try {
if (!this.#vision?.enabled) return; // no recognizer configured
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (site && site.anprEntryEnabled === false) return;
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row || !row.enabled || row.category !== "camera") return;
const cfg = row.config as CameraConfig;
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
if (cfg.anprAutoTrigger === false) return;
// Post-success debounce: once we've emitted a read for this camera, ignore the
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
// window is a new presentation and may start a new poll loop.
if (this.#debounced(deviceId)) return;
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
const now = Date.now();
if (this.#polling.has(deviceId)) {
const cur = this.#pollDeadline.get(deviceId) ?? now;
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
return;
}
this.#polling.add(deviceId);
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
const camera = buildCamera(row);
if (!camera) {
this.#polling.delete(deviceId);
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
return;
}
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
// the gated flow infers the verb from the camera's bound relay direction).
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
// While polling, watch whether THIS subscriber transacts by another credential
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
let watchedSubId: string | null = null;
let baselineOpen = 0;
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
const hardCap = Date.now() + this.#pollMaxMs;
let attempts = 0;
try {
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
// host — a plain while could zero-iterate if the window elapsed between setting
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
// window; on a busy booth it would silently drop a real car's detection). Exit
// is via the breaks below (confident read, or next tick would pass the deadline).
do {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
// Identify the subscriber from ANY readable plate (even below the barrier floor),
// and baseline their open count once — so we can detect a credential beating us.
if (r?.plate?.text) {
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
if (m0 && watchedSubId == null) {
watchedSubId = m0.subscriptionId;
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
}
}
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
// they already transacted; stop polling and do NOT emit.
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
this.#logger.info(
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
);
return;
}
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
result = r;
break;
}
if (r?.plate) {
this.#logger.info(
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
);
}
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
if (Date.now() + this.#pollMs >= effDeadline) break;
await sleep(this.#pollMs);
} while (true);
} finally {
this.#polling.delete(deviceId);
this.#pollDeadline.delete(deviceId);
}
if (!result || !result.plate) {
this.#logger.info(
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
`in ${this.#pollWindowMs}ms — gave up`,
);
return;
}
const plate = result.plate.text.trim().toUpperCase();
if (!plate) return;
const e: DeviceReadEvent = {
driverId: row.driverId,
deviceId,
value: plate,
kind: "plate",
at: new Date().toISOString(),
};
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
const match = this.#subscription.match(e);
if (!match) {
this.#recordSkip(deviceId, plate, result.plate.confidence);
return;
}
// Final guard against the credential-mid-poll race: if the subscriber transacted between
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
return;
}
// Plate-level debounce — belt-and-suspenders against a gap that slips the
// camera-level gate re-emitting the SAME plate.
const plateKey = `${deviceId}:${plate}`;
if (this.#debounced(plateKey)) return;
this.#stamp(plateKey);
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
// re-fires (and any new poll loop) for #debounceMs.
this.#stamp(deviceId);
this.#logger.info(
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
);
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
} catch (err) {
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
}
}
#debounced(key: string): boolean {
const last = this.#lastFire.get(key);
return last != null && Date.now() - last < this.#debounceMs;
}
#stamp(key: string): void {
this.#lastFire.set(key, Date.now());
}
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
#recordSkip(deviceId: string, plate: string, confidence: number): void {
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId,
category: "camera",
kind: "anpr-skip",
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
}
}
}
// DeviceRow is re-exported for the test's seed typing convenience.
export type { DeviceRow };
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { secureCookies } from "./auth.js";
// The auth/CSRF cookies' Secure flag must be FAIL-SAFE: Secure by default, dropped only
// on a deliberate opt-out. The old behaviour (Secure iff NODE_ENV==="production") leaked
// cookies over plain HTTP on an appliance that forgot to set NODE_ENV — this pins the
// corrected matrix.
let savedCookieSecure: string | undefined;
let savedNodeEnv: string | undefined;
beforeEach(() => {
savedCookieSecure = process.env.COOKIE_SECURE;
savedNodeEnv = process.env.NODE_ENV;
delete process.env.COOKIE_SECURE;
delete process.env.NODE_ENV;
});
afterEach(() => {
restore("COOKIE_SECURE", savedCookieSecure);
restore("NODE_ENV", savedNodeEnv);
});
function restore(key: string, val: string | undefined) {
if (val === undefined) delete process.env[key];
else process.env[key] = val;
}
describe("secureCookies — fail-safe Secure flag", () => {
it("defaults to Secure when nothing is set (the appliance-forgot-NODE_ENV case)", () => {
expect(secureCookies()).toBe(true);
});
it("stays Secure in production", () => {
process.env.NODE_ENV = "production";
expect(secureCookies()).toBe(true);
});
it("drops Secure only for an explicit local-dev NODE_ENV", () => {
process.env.NODE_ENV = "development";
expect(secureCookies()).toBe(false);
});
it("COOKIE_SECURE override wins: falsey values opt OUT", () => {
for (const v of ["0", "false", "no", "off", "FALSE", " Off "]) {
process.env.COOKIE_SECURE = v;
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(false);
}
});
it("COOKIE_SECURE override wins: any other value opts IN (even in dev)", () => {
process.env.NODE_ENV = "development";
for (const v of ["1", "true", "yes", "on", ""]) {
process.env.COOKIE_SECURE = v;
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(true);
}
});
});
+163 -16
View File
@@ -1,16 +1,22 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify"; import type { FastifyReply, FastifyRequest } from "fastify";
import type { Role } from "@parking/shared"; import { eq, rolePermissions, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
// Local JWT auth helpers — fully local, no external identity provider // Local JWT auth helpers — fully local, no external identity provider
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it); // (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
// a separate readable CSRF cookie + matching header defends mutations // a separate readable CSRF cookie + matching header defends mutations
// (double-submit). See wiki/entities/local-jwt-auth.md. // (double-submit). See wiki/entities/local-jwt-auth.md.
//
// Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each
// guarded route resolves that role's PERMISSION SET (cached in memory) and checks
// the permission it requires. Editing a role takes effect on the next request —
// no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS.
declare module "@fastify/jwt" { declare module "@fastify/jwt" {
interface FastifyJWT { interface FastifyJWT {
payload: { sub: string; username: string; role: Role; csrf: string }; payload: { sub: string; username: string; roleId: string; csrf: string };
user: { sub: string; username: string; role: Role; csrf: string }; user: { sub: string; username: string; roleId: string; csrf: string };
} }
} }
@@ -18,9 +24,15 @@ export const TOKEN_COOKIE = "parking_token";
export const CSRF_COOKIE = "parking_csrf"; export const CSRF_COOKIE = "parking_csrf";
export const CSRF_HEADER = "x-csrf-token"; export const CSRF_HEADER = "x-csrf-token";
/** Token lifetime, also used as the cookie maxAge. */ // Session lifetime: the JWT has NO expiry — a login is valid until explicit
export const TOKEN_TTL = "8h"; // logout. Booth reality breaks any fixed clock (relief late/absent, forced double
export const TOKEN_TTL_SECONDS = 8 * 60 * 60; // shifts), and a shift is a separate explicit boundary, not the token's lifetime.
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
//
// The cookie still needs a maxAge so it survives a browser restart (a session
// cookie would log out an active operator on browser close — the opposite of
// "until logout"). Use a long fixed window; the server clears it on logout.
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
/** /**
* Resolve the JWT signing secret, refusing to start without a strong one. * Resolve the JWT signing secret, refusing to start without a strong one.
@@ -38,9 +50,28 @@ export function requireJwtSecret(): string {
return secret; return secret;
} }
/** Cookies are secure in production; relaxed for local http dev. */ /**
function secureCookies(): boolean { * Whether to set the `Secure` flag on the auth/CSRF cookies. FAIL-SAFE: default is
return process.env.NODE_ENV === "production"; * `true` (Secure) — a misconfigured/forgotten env can only ever make cookies MORE
* restrictive, never silently drop the flag.
*
* The previous gate keyed off `NODE_ENV === "production"`, which meant an appliance
* deployed without that var leaked cookies over plain HTTP. Now `Secure` is the
* default and is dropped ONLY for an explicit, deliberate opt-out — `COOKIE_SECURE`
* set to a falsey value (`0/false/no/off`), or the legacy `NODE_ENV !== production`
* signal kept as a fallback so existing dev setups still work over http://localhost.
*
* The parking appliance often serves the SPA same-origin over the LAN with no TLS;
* THAT box sets `COOKIE_SECURE=0` on purpose (a Secure cookie would never be sent
* over its http origin and would lock operators out). Everything else stays secure.
*/
export function secureCookies(): boolean {
const override = process.env.COOKIE_SECURE;
if (override !== undefined) {
return !/^(0|false|no|off)$/i.test(override.trim());
}
// No explicit override: secure unless this is an obvious local-dev run.
return process.env.NODE_ENV !== "development";
} }
export function newCsrfToken(): string { export function newCsrfToken(): string {
@@ -55,7 +86,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict", sameSite: "strict",
secure, secure,
path: "/", path: "/",
maxAge: TOKEN_TTL_SECONDS, maxAge: COOKIE_MAX_AGE_SECONDS,
}); });
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit). // Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
reply.setCookie(CSRF_COOKIE, csrf, { reply.setCookie(CSRF_COOKIE, csrf, {
@@ -63,7 +94,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict", sameSite: "strict",
secure, secure,
path: "/", path: "/",
maxAge: TOKEN_TTL_SECONDS, maxAge: COOKIE_MAX_AGE_SECONDS,
}); });
} }
@@ -90,17 +121,133 @@ function assertCsrf(req: FastifyRequest): void {
} }
} }
// --- Permission resolution + cache -------------------------------------------
// A role's permission set is read from `role_permissions` and cached in memory.
// SQLite is single-writer/single-process here, so a module-level Map is a correct
// cache: every role / role-permission mutation calls bumpPermsCache() to clear it,
// and the next request re-reads. The built-in `admin` role always resolves to the
// FULL permission set in code (never trusts the DB rows for it), so administration
// can't be accidentally narrowed.
const ADMIN_PERMS: ReadonlySet<Permission> = new Set(PERMISSIONS);
const permsCache = new Map<string, ReadonlySet<Permission>>();
// The DB handle the permission resolver reads from. Set ONCE at startup via
// initAuth() so route guards don't each have to thread `db` (several route
// modules only receive a monitor/service, not the db). Single-process server.
let authDb: Db | null = null;
/** Wire the permission resolver to the app's DB. Call once in buildServer(). */
export function initAuth(db: Db): void {
authDb = db;
permsCache.clear();
}
/** Clear the permission + role caches. Call after ANY write to roles / role_permissions
* or to a user's roleId / deletion, so the change takes effect on the next request. */
export function bumpPermsCache(): void {
permsCache.clear();
roleCache.clear();
}
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
const roleCache = new Map<string, string | null>();
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
* admin reassigning a user's role (or deleting the user) must take effect on the next
* request exactly like editing a role does — otherwise the reassigned user keeps the
* old role's rights until they log out (found 2026-09-05: a user moved to a new
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
export function currentRoleId(sub: string): string | null {
if (!authDb) throw new Error("auth not initialised (call initAuth)");
const hit = roleCache.get(sub);
if (hit !== undefined) return hit;
const row = authDb
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
.from(users)
.where(eq(users.id, sub))
.get();
const roleId = row && row.deletedAt == null ? row.roleId : null;
roleCache.set(sub, roleId);
return roleId;
}
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
* end the session if the user no longer exists. */
function refreshRole(req: FastifyRequest): void {
const roleId = currentRoleId(req.user.sub);
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
if (roleId !== req.user.roleId) req.user.roleId = roleId;
}
/** The permission set for a role id, cached. `admin` is always the full set. */
export function permissionsFor(roleId: string): ReadonlySet<Permission> {
if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS;
const hit = permsCache.get(roleId);
if (hit) return hit;
if (!authDb) throw new Error("auth not initialised (call initAuth)");
const rows = authDb
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, roleId))
.all();
const set = new Set(rows.map((r) => r.permission as Permission));
permsCache.set(roleId, set);
return set;
}
/** True if the role grants every listed permission. */
export function roleHasPermissions(
roleId: string,
required: readonly Permission[],
): boolean {
const granted = permissionsFor(roleId);
return required.every((p) => granted.has(p));
}
/** /**
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces * preHandler permission guard. Verifies the JWT (from the HttpOnly cookie),
* CSRF on mutations, then checks the role. Authorization is a simple per-route * enforces CSRF on mutations, then requires the user's role to grant ALL of the
* role check — no Casbin/RBAC engine needed at this scale. * listed permissions. Authorization is a per-route permission check against the
* dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale.
*/ */
export function requireRole(...allowed: Role[]) { export function requirePermission(...required: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => { return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify(); // reads the token cookie (configured in server.ts) await req.jwtVerify(); // reads the token cookie (configured in server.ts)
assertCsrf(req); assertCsrf(req);
if (!req.user || !allowed.includes(req.user.role)) { refreshRole(req);
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
}; };
} }
/**
* preHandler guard satisfied by ANY ONE of the listed permissions — for a read that
* two jobs legitimately share (a module's master data: the desk that works with it
* reads it under the module's own permission, Setup reads it under site:read).
*/
export function requireAnyPermission(...anyOf: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify();
assertCsrf(req);
refreshRole(req);
if (!req.user || !anyOf.some((p) => roleHasPermissions(req.user!.roleId, [p]))) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
};
}
/**
* preHandler that requires a valid signed-in session but NO specific permission —
* for "about me" routes (/me, change own language) every authenticated user may
* call regardless of role. Still enforces CSRF on mutations.
*/
export async function requireAuth(
req: FastifyRequest,
_reply: FastifyReply,
): Promise<void> {
await req.jwtVerify();
assertCsrf(req);
refreshRole(req);
}
+139
View File
@@ -0,0 +1,139 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, siteConfig } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BackupService } from "./backup-service.js";
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
// See wiki/concepts/backup-recovery.md.
const KEY = "a-test-backup-key-that-is-long-enough";
let workDir: string;
let target: string;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
target = join(workDir, "target");
process.env.BACKUP_KEY = KEY;
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
delete process.env.BACKUP_KEY;
});
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (existing) {
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
}
}
describe("BackupService — persisted status survives a restart", () => {
it("a fresh instance sees the previous instance's last success", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const first = new BackupService(t.db);
expect(first.status().lastSuccessAt).toBeNull();
const result = await first.run("manual");
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
// production this would be a fresh process re-opening the same sqlite file).
const second = new BackupService(t.db);
const status = second.status();
expect(status.lastSuccessAt).not.toBeNull();
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
expect(status.lastError).toBeNull();
t.close();
});
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
const t = createTestDb();
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
// throw, giving us a real, deterministic failure without needing to mock anything.
const badTarget = join(workDir, "not-a-dir");
writeFileSync(badTarget, "x");
setTargetDir(t.db, badTarget);
const first = new BackupService(t.db);
await expect(first.run("manual")).rejects.toThrow();
const second = new BackupService(t.db);
const status = second.status();
expect(status.lastError).not.toBeNull();
expect(status.lastErrorAt).not.toBeNull();
expect(status.lastSuccessAt).toBeNull();
// Now point at a real directory and succeed — the persisted error must clear.
setTargetDir(t.db, target);
await second.run("manual");
const third = new BackupService(t.db);
const finalStatus = third.status();
expect(finalStatus.lastSuccessAt).not.toBeNull();
expect(finalStatus.lastError).toBeNull();
expect(finalStatus.lastErrorAt).toBeNull();
t.close();
});
});
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
it("is due immediately when no success has ever been recorded", () => {
const t = createTestDb();
const svc = new BackupService(t.db);
expect(svc.isDue()).toBe(true);
t.close();
});
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const first = new BackupService(t.db);
await first.run("manual");
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
const second = new BackupService(t.db);
expect(second.isDue()).toBe(false);
t.close();
});
it("is due once the persisted last-success timestamp is old enough", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const svc = new BackupService(t.db);
await svc.run("manual");
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
expect(svc.isDue(almostADayLater)).toBe(false);
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
expect(svc.isDue(overADayLater)).toBe(true);
t.close();
});
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const svc = new BackupService(t.db);
await svc.run("manual");
const afterFirst = svc.status().lastSuccessAt;
await svc.runScheduled(); // not due yet — must not run again
expect(svc.status().lastSuccessAt).toBe(afterFirst);
t.close();
});
});
+222
View File
@@ -0,0 +1,222 @@
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { eq, siteConfig, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js";
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
// wiki/concepts/backup-recovery.md.
//
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
// memory — an earlier version tracked these as plain in-process fields only, so every server
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
export function backupKeyFromEnv(): string {
return process.env.BACKUP_KEY ?? "";
}
export interface TargetCheck {
readonly ok: boolean;
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
readonly reason?: string;
}
export interface BackupStatus {
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
readonly configured: boolean;
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
readonly targetDir: string | null;
/** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */
readonly keepLast: number;
readonly keepDailyDays: number;
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
readonly keyPresent: 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;
}
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
const trimmed = dir.trim();
if (!trimmed) return { ok: false, reason: "empty" };
const path = resolve(trimmed);
let st: Awaited<ReturnType<typeof stat>>;
try {
st = await stat(path);
} catch {
return { ok: false, reason: "missing" };
}
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
try {
await access(path, constants.W_OK);
} catch {
return { ok: false, reason: "not_writable" };
}
return { ok: true };
}
export class BackupService {
readonly #db: Db;
readonly #logger?: FastifyBaseLogger;
#running = false;
constructor(db: Db, logger?: FastifyBaseLogger) {
this.#db = db;
this.#logger = logger;
}
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
* or reset on restart). */
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
}
#persist(patch: {
backupLastSuccessAt?: string | null;
backupLastResultJson?: string | null;
backupLastErrorAt?: string | null;
backupLastError?: string | null;
}): void {
const updatedAt = new Date().toISOString();
const existing = this.#row();
if (existing) {
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
}
}
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
targetDir(): string | null {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const dir = row?.backupTargetDir?.trim();
return dir ? dir : null;
}
/** Resolved retention from site_config, falling back to the code default per field. Read fresh. */
retention(): BackupRetention {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const keepLast = row?.backupKeepLast;
const keepDailyDays = row?.backupKeepDailyDays;
return {
keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast,
keepDailyDays:
keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays,
};
}
get keyPresent(): boolean {
return backupKeyFromEnv().length >= 16;
}
get configured(): boolean {
return this.targetDir() !== null && this.keyPresent;
}
status(): BackupStatus {
const r = this.retention();
const row = this.#row();
let lastResult: BackupStatus["lastResult"] = null;
if (row?.backupLastResultJson) {
try {
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
} catch {
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
}
}
return {
configured: this.configured,
targetDir: this.targetDir(),
keepLast: r.keepLast,
keepDailyDays: r.keepDailyDays,
keyPresent: this.keyPresent,
running: this.#running,
lastSuccessAt: row?.backupLastSuccessAt ?? null,
lastResult,
lastErrorAt: row?.backupLastErrorAt ?? null,
lastError: row?.backupLastError ?? null,
};
}
/**
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
* last-success/last-error. 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;
const targetDir = this.targetDir();
const key = backupKeyFromEnv();
if (!targetDir) throw new Error("backup: no target directory configured");
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
this.#running = true;
this.#inflight = (async () => {
try {
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
this.#persist({
backupLastSuccessAt: new Date().toISOString(),
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
backupLastErrorAt: null,
backupLastError: null,
});
return res;
} catch (err) {
const message = (err as Error).message;
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
throw err;
} finally {
this.#running = false;
this.#inflight = null;
}
})();
return this.#inflight;
}
/**
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
* frequent backups.
*/
async runScheduled(): Promise<void> {
if (!this.configured) return; // silent no-op when backups aren't set up
if (!this.isDue()) return;
try {
await this.run("scheduled");
} catch {
/* recorded in last-error; already logged */
}
}
/**
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
* (or skips a whole day) across every restart, since the countdown restarts from zero each
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
*/
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
const last = new Date(lastSuccessAt).getTime();
if (Number.isNaN(last)) return true;
return now.getTime() - last >= intervalMs;
}
}
+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);
});
});
+217
View File
@@ -0,0 +1,217 @@
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;
}
// Code defaults — the fallback when the admin hasn't set a value in site_config (the source of
// truth). NOT env-driven: retention is operational policy tuned from the Backup screen.
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
keepLast: 7,
keepDailyDays: 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;
}
+190
View File
@@ -0,0 +1,190 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import {
printWithFailover,
registry,
type PrinterDevice,
type PrinterInstance,
type ReceiptData,
type TicketHeader,
printerRoleOf,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js";
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
// from the exit, the customer pays at the booth and walks a printed voucher to the
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
//
// This mirrors the entry flow's printer selection + header build, but prints on the
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
/** Park identity for the voucher header, from site_config (all fields optional). */
function ticketHeader(db: Db): TicketHeader | undefined {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
/** Build live printer instances for failover selection (entry direction covers the
* booth-receipt role too — the booth printer is configured on the entry side). */
function loadPrinters(db: Db): PrinterInstance[] {
const rows = devicesByDirection(db, "printer", "entry");
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = printerRoleOf(cfg);
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** The receipt figures for a paid session, folded from the SIGNED ledger
* (authoritative). Null if there's no entry or no payment for this id — the
* caller should have validated paid + open before printing. */
function receiptFigures(
db: Db,
ticketId: string,
): Omit<ReceiptData, "voucher" | "header"> | null {
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, ticketId))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
let payment: (typeof rows)[number] | undefined;
for (const r of rows) if (r.type === "payment") payment = r;
if (!payment) return null;
const p = (payment.payload ?? {}) as {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
graceExitMin?: number;
grossMinor?: number;
validationLines?: { label: string; discountMinor: number }[];
};
return {
ticketId,
enteredAt: entry.occurredAt,
paidAt: payment.occurredAt,
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
currency: p.currency ?? "ALL",
tender: p.tender === "card" ? "card" : "cash",
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
// Merchant validations, as settled on the signed payment (gross → lines → net).
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
};
}
/**
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
* to the entry dispenser). The receipt is the customer's transparency record:
* entry time, payment time, duration, amount + tender — folded from the signed
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
* walk-back grace, so the one slip both proves payment AND self-exits at a
* distant exit reader (this replaces the old barcode-only voucher). In standalone
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
* at the exit. Returns the id of the printer that printed it.
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
*/
export async function printPaymentReceipt(
db: Db,
ticketId: string,
opts: { voucher: boolean },
logger: FastifyBaseLogger,
): Promise<string> {
const figures = receiptFigures(db, ticketId);
if (!figures) {
throw new Error(`no paid session to receipt for ${ticketId}`);
}
const printers = loadPrinters(db);
const data: ReceiptData = {
...figures,
voucher: opts.voucher,
header: ticketHeader(db),
};
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReceipt(data),
);
logger.info(
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
);
return printedBy;
}
/**
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
* a scannable QR of the credential code + holder/validity, so the operator can hand
* it to the customer. Used on subscription creation and on a "reprint" action.
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
*/
export async function printSubscriptionCard(
db: Db,
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const data = {
code: card.code,
holderName: card.holderName ?? null,
validFrom: card.validFrom ?? null,
validTo: card.validTo ?? null,
header: ticketHeader(db),
};
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printSubscriptionCard(data),
);
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
/**
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
* the total is computed at the booth on settlement (early-entry AND any late-exit time
* combined). It just gives the subscriber paper proof that a fee is pending against this
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
* the caller swallows failures so a missing printer never blocks the barrier.
*/
export async function printWindowChargeNotice(
db: Db,
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printWindowChargeNotice({
occurrenceId: notice.occurrenceId,
holderName: notice.holderName ?? null,
at: notice.at,
edge: notice.edge,
windowOpensMin: notice.windowOpensMin ?? null,
header: ticketHeader(db),
}),
);
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
return printedBy;
}
+424
View File
@@ -0,0 +1,424 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { eq, devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { AuxOutputDevice } from "@parking/devices";
import { ButtonLightController } from "./button-light.js";
import { deviceEvents } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
// trigger active + lane busy -> SOLID on
// trigger active + lane free -> BLINK (~1 Hz)
// otherwise -> OFF
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
// carry several alert relays (each its own row + trigger input), keyed independently.
let db: Db;
const CONTROLLER = "ctl-1";
const RADAR_INPUT = 2; // I2
const LAMP_RELAY = 3; // spare relay R3
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
return {
async setAux(channel: number, on: boolean): Promise<void> {
if (throwOnce.v) {
throwOnce.v = false;
throw new Error("UDP down");
}
record.push({ ch: channel, on });
},
};
}
beforeEach(() => {
({ db } = createTestDb());
vi.useFakeTimers();
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
db.insert(devices).values({
id: CONTROLLER,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: 2, direction: "exit" },
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
],
},
enabled: true,
}).run();
});
afterEach(() => {
vi.useRealTimers();
});
/** Emit a radar (presence input) edge for the controller. */
function radar(present: boolean): void {
deviceEvents.emitInput({
driverId: "dingtian",
deviceId: CONTROLLER,
input: RADAR_INPUT,
edge: present ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
});
}
/** Emit a lane status (entry busy/free). */
function lane(entryBusy: boolean): void {
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
}
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
async function flush(): Promise<void> {
for (let i = 0; i < 6; i++) await Promise.resolve();
}
describe("ButtonLightController truth table", () => {
it("OFF at start (no radar, no car)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
// device is confirmed OFF and at most one call was made.
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
ctl.stop();
});
it("radar present + lane busy -> SOLID on", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
// Solid = no blinking: advancing time produces no further sends.
const n = calls.length;
vi.advanceTimersByTime(2000);
await flush();
expect(calls.length).toBe(n);
ctl.stop();
});
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
radar(true); // lane still free
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
vi.advanceTimersByTime(500);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
vi.advanceTimersByTime(500);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
ctl.stop();
});
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
radar(true); // blink
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
lane(true); // camera confirms
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
// No more toggles (blink torn down) — the device stays ON over time.
vi.advanceTimersByTime(2000);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("radar clears -> OFF", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true); // solid
await flush();
radar(false); // car gone
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
ctl.stop();
});
it("de-dupes redundant writes (no spam on repeat events)", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
lane(true);
radar(true); // solid, on
await flush();
const n = calls.length;
radar(true); // same state — no new edge (present unchanged)
lane(true); // same lane — no change
await flush();
expect(calls.length).toBe(n);
ctl.stop();
});
it("fails OFF: a setAux error does not throw or escalate", async () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const throwOnce = { v: true };
const aux = fakeAux(calls, throwOnce);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
// First write (initial off) throws — must be swallowed.
expect(() => ctl.start()).not.toThrow();
await flush();
// The failure arms a backoff (1s) rather than retrying inline; desired-state
// changes during the window just update the target the retry will assert.
lane(true);
radar(true);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off
await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON
ctl.stop();
});
it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => {
let attempts = 0;
const aux: AuxOutputDevice = {
async setAux() {
attempts += 1;
throw new Error("send ENETUNREACH 10.0.10.5:60000");
},
};
const errors: string[] = [];
const logger = silentLogger();
(logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg);
const ctl = new ButtonLightController(db, logger, () => aux);
ctl.start(); // initial OFF write → attempt 1 fails at t=0
await flush();
expect(attempts).toBe(1); // the old code hot-looped here
// Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first
// minute instead of thousands.
await vi.advanceTimersByTimeAsync(60_000);
expect(attempts).toBeGreaterThanOrEqual(5);
expect(attempts).toBeLessThanOrEqual(7);
// Only the FIRST failure was logged so far; the next log is a ≥60s summary.
expect(errors).toHaveLength(1);
await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary
expect(errors.length).toBe(2);
expect(errors[1]).toContain("still failing");
ctl.stop();
});
it("logs a single recovery line and resets the backoff after success", async () => {
let failing = true;
let attempts = 0;
const aux: AuxOutputDevice = {
async setAux() {
attempts += 1;
if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000");
},
};
const infos: string[] = [];
const logger = silentLogger();
(logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg);
const ctl = new ButtonLightController(db, logger, () => aux);
ctl.start();
await flush();
await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail
const failed = attempts;
expect(failed).toBeGreaterThanOrEqual(3);
failing = false; // controller reachable again
await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device
expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1);
// Backoff reset: a fresh state change sends immediately (no lingering retryAt).
const before = attempts;
lane(true);
radar(true);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
expect(attempts).toBe(before + 1);
ctl.stop();
});
it("ignores controllers without an alert relay", () => {
// A second controller, no alert relay.
db.insert(devices).values({
id: "ctl-2",
category: "access",
driverId: "dingtian",
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
enabled: true,
}).run();
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
expect(ctl.stateOf("ctl-2")).toBeNull();
ctl.stop();
});
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
// Fresh controller with a radar input but NO alert relay yet.
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
// Replace the seeded controller with one that has the radar but no lamp.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
ctl.start();
await flush();
// No lamp configured → an input does nothing.
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBeNull();
expect(calls.length).toBe(0);
radar(false);
await flush();
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
// The very next radar edge reconciles + blinks (lane still free).
radar(true);
await flush();
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("drives two alert relays on one controller independently", async () => {
const R3 = 3;
const R4 = 4;
const I2 = 2;
const I3 = 3;
// Controller with two alert lamps, each on its own trigger input.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
// I2 active → only R3 blinks; R4 stays off (different trigger).
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
// I3 active → R4 blinks too, independently.
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
lane(true);
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
// I2 clears → R3 off, R4 still solid (its trigger still active).
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
ctl.stop();
});
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
const R4 = 4;
const I5 = 5; // exit radar
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
const aux = fakeAux([]);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
// Exit radar active → blink.
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
deviceEvents.emitLaneStatus({ entry: true, exit: false });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// EXIT camera busy → SOLID.
deviceEvents.emitLaneStatus({ entry: true, exit: true });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
ctl.stop();
});
});
+381
View File
@@ -0,0 +1,381 @@
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
// indicator:
// trigger active + lane busy (camera confirms a car) → SOLID on
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
// otherwise → OFF
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
// misleading solid "go". A controller may have several alert relays (each its own row +
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
type LightState = "off" | "solid" | "blink";
const DEFAULT_BLINK_MS = 500;
// Failed-send retry backoff: 1s doubling to 30s, reset on success. Without this an
// unreachable controller (ENETUNREACH) became a hot loop — the failure re-pump retried
// instantly, thousands of sends + error lines per minute (field incident 2026-07-07).
const RETRY_BASE_MS = 1_000;
const RETRY_MAX_MS = 30_000;
/** After the first failure of a streak, log at most one summary line per this window. */
const FAIL_LOG_EVERY_MS = 60_000;
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
interface LampState {
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
readonly controllerId: string;
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
* place when the admin changes the alert config without a restart. */
spec: RelaySpec;
/** Is the lamp's trigger input (the radar) currently active? */
present: boolean;
/** The high-level state we're rendering (to avoid restarting a running blink). */
rendered: LightState | null;
/** Active blink timer, if blinking. */
blink: ReturnType<typeof setInterval> | null;
/** Blink phase (true = currently on). */
blinkOn: boolean;
/** The output we WANT the relay to be in. The serialized worker drives the device
* toward this. The blink timer only flips this flag — it never sends directly. */
desiredOn: boolean;
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
confirmedOn: boolean | null;
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
sending: boolean;
/** Consecutive failed sends (0 = healthy). Drives the backoff delay + log summaries. */
failCount: number;
/** Epoch ms before which #pump must not send (0 = no backoff). The armed retry
* timer re-pumps when it elapses; desired-state changes in between just update
* `desiredOn` and are picked up by that same retry. */
retryAt: number;
/** The armed backoff retry, if any. */
retryTimer: ReturnType<typeof setTimeout> | null;
/** Epoch ms of the last failure line we actually logged (rate-limits the flood). */
lastFailLogAt: number;
}
/** Resolves a controller's live aux-output adapter. The default goes through the
* driver registry; tests inject a spy. Returns null when the controller has no
* aux-output capability (or won't build). */
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
export class ButtonLightController {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #resolveAux: AuxResolver;
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
readonly #lamps = new Map<string, LampState>();
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
* exit camera, not the entry one. */
#entryBusy = false;
#exitBusy = false;
/** Controllers we've already warned lack the aux-output capability (warn once). */
readonly #warned = new Set<string>();
#unsubInput: (() => void) | null = null;
#unsubLane: (() => void) | null = null;
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
this.#db = db;
this.#logger = logger;
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
}
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
start(): void {
this.#reconcile();
// All lamps start OFF (known-safe baseline) regardless of prior device state.
for (const lamp of this.#lamps.values()) this.#apply(lamp);
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
}
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
* before handling each event, so a just-saved lamp takes effect immediately. */
#reconcile(): void {
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
const seen = new Set<string>();
for (const row of rows) {
if (!row.enabled) continue;
for (const spec of alertRelaysOf(row)) {
const key = lampKey(row.id, spec.relay);
seen.add(key);
const existing = this.#lamps.get(key);
if (existing) {
existing.spec = spec; // pick up a changed trigger input / blink cadence
} else {
this.#lamps.set(key, {
controllerId: row.id,
spec,
present: false,
rendered: null,
blink: null,
blinkOn: false,
desiredOn: false,
confirmedOn: null,
sending: false,
failCount: 0,
retryAt: 0,
retryTimer: null,
lastFailLogAt: 0,
});
}
}
}
// Drop lamps whose controller no longer declares one (or was disabled/removed).
for (const [key, lamp] of this.#lamps) {
if (seen.has(key)) continue;
this.#disarm(lamp);
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(key);
}
}
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
#onInput(e: DeviceInputEvent): void {
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
this.#reconcile();
const present = e.edge === "on";
for (const lamp of this.#lamps.values()) {
if (lamp.controllerId !== e.deviceId) continue;
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
// lamp and the one-car-one-ticket gate always agree on "a car is here".
const trigger =
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
if (trigger !== e.input) continue; // not this lamp's trigger terminal
if (present === lamp.present) continue;
lamp.present = present;
this.#apply(lamp);
}
}
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
#onLane(s: LaneStatusEvent): void {
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
this.#entryBusy = s.entry;
this.#exitBusy = s.exit;
// Re-render every lamp (each picks its own lane's camera in #apply).
for (const lamp of this.#lamps.values()) this.#apply(lamp);
}
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
* timer/state machine is synchronous; the UDP write resolves on its own). */
#apply(lamp: LampState): void {
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
if (target === lamp.rendered) return; // already rendering this state
// Tear down any running blink before switching states.
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
lamp.rendered = target;
if (target === "off") {
lamp.desiredOn = false;
this.#pump(lamp);
} else if (target === "solid") {
lamp.desiredOn = true;
this.#pump(lamp);
} else {
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
// relay can't get stuck on a stale packet.
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
lamp.blinkOn = true;
lamp.desiredOn = true;
const tick = () => {
lamp.blinkOn = !lamp.blinkOn;
lamp.desiredOn = lamp.blinkOn;
this.#pump(lamp);
if (onMs !== offMs && lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
lamp.blink.unref?.();
}
};
lamp.blink = setInterval(tick, onMs);
lamp.blink.unref?.();
this.#pump(lamp);
}
}
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
* (`sending` guard); when it resolves, if the desired state moved on we send again —
* so the LAST desired state is always the one finally asserted on the device.
*
* Failures back off (1s → 30s, reset on success) instead of retrying inline: an
* unreachable controller rejects instantly, and an immediate re-pump was a hot loop.
* During backoff `desiredOn` keeps tracking the truth table; the armed retry timer
* converges to whatever it says when it fires. Only the FIRST failure of a streak is
* logged, then one summary per minute, and an info line on recovery. */
#pump(lamp: LampState): void {
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
if (Date.now() < lamp.retryAt) return; // backing off — the retry timer will re-pump
const aux = this.#resolveAux(lamp.controllerId);
if (!aux) return;
const target = lamp.desiredOn;
lamp.sending = true;
void aux
.setAux(lamp.spec.relay, target)
.then(() => {
lamp.confirmedOn = target;
if (lamp.failCount > 0) {
this.#logger.info(
`button-light setAux recovered (${lamp.controllerId} R${lamp.spec.relay}) after ${lamp.failCount} failed attempts`,
);
}
lamp.failCount = 0;
lamp.retryAt = 0;
lamp.lastFailLogAt = 0;
})
.catch((err: unknown) => {
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
// desired state. Never escalates — a dead lamp is "no hint", never a fault.
lamp.failCount += 1;
const delay = Math.min(RETRY_BASE_MS * 2 ** (lamp.failCount - 1), RETRY_MAX_MS);
lamp.retryAt = Date.now() + delay;
const now = Date.now();
if (lamp.failCount === 1 || now - lamp.lastFailLogAt >= FAIL_LOG_EVERY_MS) {
lamp.lastFailLogAt = now;
const streak =
lamp.failCount > 1 ? ` — still failing (attempt ${lamp.failCount}, retrying ≤${RETRY_MAX_MS / 1000}s)` : "";
this.#logger.error(
`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}${streak}`,
);
}
if (lamp.retryTimer) clearTimeout(lamp.retryTimer);
lamp.retryTimer = setTimeout(() => {
lamp.retryTimer = null;
this.#pump(lamp);
}, delay);
lamp.retryTimer.unref?.();
})
.finally(() => {
lamp.sending = false;
// Desired state may have changed while we were busy — re-pump to converge (the
// backoff gate above makes this a no-op right after a failure). This is what
// makes the final state authoritative.
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
});
}
/** Build the live aux-output adapter for a controller, or null (logged once). */
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
if (!row) return null;
const driver = registry.get(row.driverId);
if (!driver) return null;
let device: unknown;
try {
device = driver.create(row.config as never);
} catch {
return null;
}
if (!hasAuxOutput(device)) {
if (!this.#warned.has(controllerId)) {
this.#warned.add(controllerId);
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
}
return null;
}
return device;
}
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
stop(): void {
this.#unsubInput?.();
this.#unsubLane?.();
this.#unsubInput = null;
this.#unsubLane = null;
for (const lamp of this.#lamps.values()) {
this.#disarm(lamp);
// Best-effort fail-OFF on shutdown.
this.#finalOff(lamp);
}
}
/** Stop a lamp's timers (blink + backoff retry) without touching the device. */
#disarm(lamp: LampState): void {
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
if (lamp.retryTimer) {
clearTimeout(lamp.retryTimer);
lamp.retryTimer = null;
}
}
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
* OFF and pump. The serialized worker still applies, so this can't collide with an
* in-flight send — it converges to OFF. Any backoff is waived so the last-gasp OFF
* gets one immediate try (a lamp mid-backoff may just have recovered). */
#finalOff(lamp: LampState): void {
lamp.desiredOn = false;
lamp.retryAt = 0;
this.#pump(lamp);
}
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
stateOf(controllerId: string, relay?: number): LightState | null {
return this.#lamp(controllerId, relay)?.rendered ?? null;
}
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
confirmedOf(controllerId: string, relay?: number): boolean | null {
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
}
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
* controller's single lamp (the common single-alert case); ambiguous if several. */
#lamp(controllerId: string, relay?: number): LampState | undefined {
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
return undefined;
}
}
/** Composite key for the lamp map (a controller may carry several alert relays). */
function lampKey(controllerId: string, relay: number): string {
return `${controllerId}:${relay}`;
}
/** Build a controller row's live aux device (exported for reuse/tests). */
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
const device = driver.create(row.config as never);
return hasAuxOutput(device) ? device : null;
} catch {
return null;
}
}
+89
View File
@@ -0,0 +1,89 @@
// Credential capture ("enroll a card"): lets an operator present a physical RFID
// card/chip (or a QR) to ONE chosen reader and have its value captured for a
// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the
// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is
// never affected and keeps serving the live entry/exit flow.
//
// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next
// read from that armed reader is captured (NOT dispatched to the access flow — the
// barrier must not open for a card being enrolled) and capture auto-disarms. The
// booth form polls result() until the value appears (or it times out / is cancelled).
//
// In-memory + single-site single-writer (one booth) → no DB, no cross-process
// concerns. See wiki/entities/subscription.md.
const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000);
export type CaptureState =
| { status: "idle" }
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
| { status: "expired"; deviceId: string };
export class CredentialCapture {
#armedDeviceId: string | null = null;
#expiresAt = 0;
#captured: { deviceId: string; value: string; capturedAt: number } | null = null;
#lastExpiredDeviceId: string | null = null;
/** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any
* prior arming (only one capture at a time). Clears a stale captured/expired
* result so the form starts fresh. */
arm(deviceId: string): { expiresAt: number } {
this.#armedDeviceId = deviceId;
this.#expiresAt = Date.now() + CAPTURE_TTL_MS;
this.#captured = null;
this.#lastExpiredDeviceId = null;
return { expiresAt: this.#expiresAt };
}
/** Cancel any pending arming (operator closed the form / clicked cancel). */
cancel(): void {
this.#armedDeviceId = null;
this.#expiresAt = 0;
}
/**
* Called by the reader route on EVERY read. If this reader is the armed one (and
* not expired), capture the value, disarm, and return true → the caller must NOT
* dispatch this read to the access flow. Otherwise false → dispatch normally.
*/
tryConsume(deviceId: string, value: string): boolean {
if (this.#armedDeviceId == null) return false;
if (Date.now() > this.#expiresAt) {
// Window lapsed before a card was presented — disarm, mark expired.
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return false;
}
if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader
if (!value) return false;
this.#captured = { deviceId, value, capturedAt: Date.now() };
this.#armedDeviceId = null; // single-shot
this.#expiresAt = 0;
return true;
}
/** Current state for the booth form's poll. Lazily transitions armed→expired. */
state(): CaptureState {
if (this.#captured) return { status: "captured", ...this.#captured };
if (this.#armedDeviceId != null) {
if (Date.now() > this.#expiresAt) {
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return { status: "expired", deviceId: this.#lastExpiredDeviceId };
}
return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt };
}
if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId };
return { status: "idle" };
}
/** Clear a consumed/expired result once the form has read it. */
clear(): void {
this.#captured = null;
this.#lastExpiredDeviceId = null;
}
}
+179 -4
View File
@@ -1,5 +1,7 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices"; import type { PrinterStatus } from "@parking/devices";
import type { LedgerEventRow } from "@parking/db";
import type { VehicleRead } from "@parking/shared";
// Internal event bus for device-originated events (button presses, etc.). // Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry // Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -8,22 +10,123 @@ import type { PrinterStatus } from "@parking/devices";
export interface DeviceInputEvent { export interface DeviceInputEvent {
readonly driverId: string; // e.g. "dingtian" readonly driverId: string; // e.g. "dingtian"
readonly deviceId: string; // which configured device (lane_devices id) readonly deviceId: string; // which configured device (devices id)
readonly input: number; // 1-based input/channel readonly input: number; // 1-based input/channel
readonly edge: "on" | "off"; // active / inactive readonly edge: "on" | "off"; // active / inactive
readonly at: string; // ISO-8601 (server receive time) readonly at: string; // ISO-8601 (server receive time)
readonly source: "push" | "poll"; readonly source: "push" | "poll";
} }
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
// mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent {
readonly driverId: string;
readonly deviceId: string; // devices id of the reader/scanner/camera
readonly value: string; // the ticket id / plate / card number
readonly kind: "ticket" | "plate" | "qr" | "card";
/** The CONFIRMED physical channel the value arrived on, when the reader tags it
* (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by
* the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader
* with no prefixes configured (channel unknown — flows must not assume). Lets the
* subscription match refuse an OPTICAL decode claiming an RF credential (a printed
* copy of a card's UID must not clone the card). */
readonly channel?: "optical" | "rf";
readonly at: string; // ISO-8601
}
/**
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
* device. A fire-and-forget reader simply ignores it. See wiki/entities/dingtian-dt008-reader.md.
*/
export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
readonly accepted: boolean;
/** Which way it went, when known (subscription/exit infer this). */
readonly direction?: "entry" | "exit";
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
readonly reason?: string;
}
/** A printer's status as tracked by the live monitor (status + identity). */ /** A printer's status as tracked by the live monitor (status + identity). */
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // lane_devices id readonly deviceId: string; // devices id
readonly lane: number;
readonly driverId: string; readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
/**
* The unified live status of ANY configured device — what the booth footer shows.
* Every enabled device is polled: printers via their rich `readStatus()`
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
* reachability probe. `state` is the common traffic-light; `detail` carries the
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
* and wiki/concepts/device-status-monitoring.md.
*/
export interface DeviceStatusEvent {
readonly deviceId: string; // devices id
readonly driverId: string;
readonly category: "access" | "reader" | "camera" | "printer" | "vision";
/**
* The device's ROLE descriptor for the footer label — NOT the vendor. A
* direction-style token the client localises and pairs with the category, so the
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
* - undetermined: null (chip shows the category alone)
*/
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
readonly state: "ready" | "degraded" | "offline";
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
}
/** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free"
* the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a
* gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle
* `active` event; it auto-clears to "free" after a timeout (this camera class sends
* no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */
export interface LaneStatusEvent {
readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity)
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
}
/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate
* recognition is async/advisory (a vision round-trip off the snapshot), so it lands a
* moment after the signed event — too late for the event's own WS push to carry it. This
* notifies the booth so it can fill in the plate badge on the already-rendered feed row /
* active session in place, no refresh. Advisory; never touches the signed ledger. See
* snapshot.ts (recognizePlate) + event-enrich.ts. */
export interface PlateRecognizedEvent {
readonly identity: string; // the session identity the plate is tied to
readonly plate: string; // normalized plate text (trimmed, upper)
readonly direction: "entry" | "exit";
}
/** Emitted when vision classified the vehicle in an entry/exit frame (advisory; stored on
* the read row like the plate). A module may sample these — the Car Wash review outbox
* queues one in N ENTRY reads for the remote reviewer, in the gate view the classifier
* will be trained on (wiki/concepts/vision-review-outbox.md). The core emits; it never
* knows who listens. */
export interface VehicleReadEvent {
readonly identity: string;
readonly direction: "entry" | "exit";
readonly read: VehicleRead;
}
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
export interface LanePresenceEvent {
readonly entry: boolean; // true = a presence input on an entry barrier is active
readonly exit: boolean; // true = a presence input on an exit barrier is active
}
class DeviceEventBus extends EventEmitter { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -33,6 +136,15 @@ class DeviceEventBus extends EventEmitter {
return () => this.off("input", cb); return () => this.off("input", cb);
} }
/** A credential read (ticket scan, plate, card). */
emitRead(event: DeviceReadEvent): void {
this.emit("read", event);
}
onRead(cb: (event: DeviceReadEvent) => void): () => void {
this.on("read", cb);
return () => this.off("read", cb);
}
/** Emitted by the printer monitor whenever a printer's status CHANGES. */ /** Emitted by the printer monitor whenever a printer's status CHANGES. */
emitPrinterStatus(event: PrinterStatusEvent): void { emitPrinterStatus(event: PrinterStatusEvent): void {
this.emit("printer-status", event); this.emit("printer-status", event);
@@ -41,6 +153,69 @@ class DeviceEventBus extends EventEmitter {
this.on("printer-status", cb); this.on("printer-status", cb);
return () => this.off("printer-status", cb); return () => this.off("printer-status", cb);
} }
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
* (all categories — relays, readers, cameras, printers). Drives the booth
* device-status footer over the WS. */
emitDeviceStatus(event: DeviceStatusEvent): void {
this.emit("device-status", event);
}
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
this.on("device-status", cb);
return () => this.off("device-status", cb);
}
/**
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
* payment, void, …). The payload is the persisted row — business facts only, no
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
* This is a read-side notification ONLY: it never feeds back into append/sign/
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
*/
emitLedger(event: LedgerEventRow): void {
this.emit("ledger", event);
}
onLedger(cb: (event: LedgerEventRow) => void): () => void {
this.on("ledger", cb);
return () => this.off("ledger", cb);
}
/** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle
* detection). Drives the booth's barrier lights. Advisory only. */
emitLaneStatus(event: LaneStatusEvent): void {
this.emit("lane-status", event);
}
onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void {
this.on("lane-status", cb);
return () => this.off("lane-status", cb);
}
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
emitLanePresence(event: LanePresenceEvent): void {
this.emit("lane-presence", event);
}
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
this.on("lane-presence", cb);
return () => this.off("lane-presence", cb);
}
/** Emitted when an async plate recognition completes for a session (after its event
* already shipped). Lets the booth backfill the plate badge in place. Advisory only. */
emitPlateRecognized(event: PlateRecognizedEvent): void {
this.emit("plate-recognized", event);
}
onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void {
this.on("plate-recognized", cb);
return () => this.off("plate-recognized", cb);
}
emitVehicleRead(event: VehicleReadEvent): void {
this.emit("vehicle-read", event);
}
onVehicleRead(cb: (event: VehicleReadEvent) => void): () => void {
this.on("vehicle-read", cb);
return () => this.off("vehicle-read", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { localIsoWithOffset } from "./device-monitor.js";
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
describe("localIsoWithOffset (camera clock sync payload)", () => {
it("Tirane summer = +02:00 (CEST)", () => {
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
"2026-07-07T12:00:00+02:00",
);
});
it("Tirane winter = +01:00 (CET)", () => {
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
"2026-01-15T11:00:00+01:00",
);
});
it("UTC = +00:00", () => {
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
"2026-07-07T10:00:00+00:00",
);
});
});
+264
View File
@@ -0,0 +1,264 @@
import type { FastifyBaseLogger } from "fastify";
import { devices, type Db, type DeviceRow } from "@parking/db";
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
import { directionOf, relaysOf } from "./device-resolve.js";
import type { VisionClient } from "./vision-client.js";
import { siteTz } from "./subscription-window.js";
/** Synthetic device id for the vision service in the status footer (it's a service,
* not a device row, but shares the footer's traffic-light + WS plumbing). */
const VISION_STATUS_ID = "vision-service";
// Unified live DEVICE monitor — the source for the booth's device-status footer.
// Every enabled, configured device is probed on an interval, regardless of
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
// generic healthCheck() reachability probe every Device implements. The result is
// flattened to a common traffic-light (ready | degraded | offline) + a detail
// string, cached per device id, and emitted on the bus ONLY when it changes.
//
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
// and read-only — polling a device never drives a relay or mutates the ledger.
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
// clock (the site's offline time authority) at the offline→ready edge — exactly the
// power-restored moment — plus a daily backstop; drift under the threshold is left
// alone. See wiki/entities/lpr-camera.md (clock sync).
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
const CLOCK_MAX_DRIFT_SEC = 60;
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
export function localIsoWithOffset(tz: string, at = new Date()): string {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
const wallAsUtcMs = Date.UTC(
Number(p.year), Number(p.month) - 1, Number(p.day),
Number(p.hour), Number(p.minute), Number(p.second),
);
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
const sign = offMin < 0 ? "-" : "+";
const abs = Math.abs(offMin);
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
const mm = String(abs % 60).padStart(2, "0");
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
}
/**
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
* tokens the client localises next to the category:
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
*/
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) {
case "reader":
case "camera": {
const d = directionOf(db, row); // entry | exit | both
return d;
}
case "access": {
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
const dirs = new Set(
relaysOf(row)
.map((r) => r.direction)
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
);
if (dirs.size === 0) return null;
if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both
return only ?? null;
}
case "printer": {
const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane";
if (role === "wash-desk") return "wash";
return null;
}
default:
return null;
}
}
export class DeviceMonitor {
readonly #db: Db;
readonly #log: FastifyBaseLogger;
readonly #pollMs: number;
/** Latest unified status per device id. */
readonly #latest = new Map<string, DeviceStatusEvent>();
#timer: ReturnType<typeof setInterval> | null = null;
#ticking = false;
/** Optional: the vision service client. When present + enabled, the monitor probes
* its /health each tick and shows it as a "vision" chip in the footer. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#pollMs = pollMs;
this.#vision = vision;
}
/** Begin polling. Idempotent. */
start(): void {
if (this.#timer) return;
void this.#tick(); // immediate first pass so the footer fills without a wait
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
this.#timer.unref?.();
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
}
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
/** Current snapshot for the API / a freshly-connected WS client. */
snapshot(): DeviceStatusEvent[] {
return [...this.#latest.values()];
}
async #tick(): Promise<void> {
if (this.#ticking) return; // never overlap polls
this.#ticking = true;
try {
// Re-read the device set each tick so a newly-assigned/removed device is
// picked up without a restart.
const rows = await this.#db.select().from(devices).all();
const enabled = rows.filter((r) => r.enabled);
const present = new Set(enabled.map((r) => r.id));
// The vision service is a pseudo-device — keep it in the present set when enabled
// so the cleanup below doesn't evict it.
if (this.#vision?.enabled) present.add(VISION_STATUS_ID);
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
for (const id of [...this.#latest.keys()]) {
if (!present.has(id)) this.#latest.delete(id);
}
await Promise.all([...enabled.map((r) => this.#poll(r)), this.#pollVision()]);
} catch (err) {
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
} finally {
this.#ticking = false;
}
}
async #poll(row: DeviceRow): Promise<void> {
const cfg = (row.config ?? {}) as Record<string, unknown>;
const base = {
deviceId: row.id,
driverId: row.driverId,
category: row.category,
roleKind: roleKindOf(this.#db, row),
};
let next: DeviceStatusEvent;
let device: Device | null = null;
const driver = registry.get(row.driverId);
if (!driver) {
// Configured against a driver that's no longer registered — surface it,
// don't silently hide it.
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
} else {
try {
device = driver.create(cfg as never);
// Printers expose richer paper/cover/cutter status; everything else uses
// the generic reachability probe. Both flatten to the same traffic-light.
if (isMonitorable(device)) {
const s = await device.readStatus();
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
} else {
const h = await device.healthCheck();
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
}
} catch (err) {
// A probe that throws (build error, timeout) reads as offline — never crash
// the tick, and fail toward "there's a problem" rather than false-healthy.
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
}
}
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
// camera is retried at backstop cadence, never every poll.
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
const prev = this.#latest.get(row.id);
const cameBack = !prev || prev.state === "offline";
const last = this.#clockSyncedAt.get(row.id) ?? 0;
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
this.#clockSyncedAt.set(row.id, Date.now());
const cam = device;
void (async () => {
try {
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
if (r.synced) {
// A large jump is the 1970 power-cut signature — warn (persisted) so
// the reboot stays visible; a small correction is routine info.
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
else this.#log.info(msg);
}
} catch (err) {
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
}
})();
}
}
this.#publish(row.id, next);
}
/** Probe the vision service /health and publish it as a "vision" footer chip. Skipped
* entirely when no client is wired or it's disabled (no chip then). */
async #pollVision(): Promise<void> {
if (!this.#vision?.enabled) return;
const h = await this.#vision.health();
const state: DeviceStatusEvent["state"] = h.ok && h.ready ? "ready" : h.ready ? "degraded" : "offline";
this.#publish(VISION_STATUS_ID, {
deviceId: VISION_STATUS_ID,
driverId: "vision",
category: "vision",
roleKind: null,
state,
detail: h.ready ? h.recognizer : (h.detail ?? "not ready"),
checkedAt: new Date().toISOString(),
});
}
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
readonly #clockSyncedAt = new Map<string, number>();
/** Cache + emit a status, but only when it CHANGED (state or detail). */
#publish(id: string, next: DeviceStatusEvent): void {
const prev = this.#latest.get(id);
this.#latest.set(id, next);
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
this.#log.info(
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
);
deviceEvents.emitDeviceStatus(next);
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it } from "vitest";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
// either shape, so an exit radar = just another presence row.
let db: Db;
const CTL = "ctl-1";
function seed(config: Record<string, unknown>): void {
({ db } = createTestDb());
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
}
describe("inputsOf back-compat synth", () => {
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
seed({
relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
{ relay: 2, direction: "exit" },
],
});
const row = db.select().from(devices).get()!;
const inputs = inputsOf(row);
expect(inputs).toEqual([
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
]);
});
it("prefers an explicit inputs[] over the legacy fields", () => {
seed({
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
inputs: [{ input: 1, role: "button", relay: 1 }],
});
const row = db.select().from(devices).get()!;
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
});
});
describe("relayForButton / relayForPresence", () => {
it("resolves a button + presence from inputs[]", () => {
seed({
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: 1, role: "button", relay: 1 },
{ input: 2, role: "presence", relay: 1, kind: "radar" },
],
});
const byBtn = relayForButton(db, CTL, 1);
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
const byPres = relayForPresence(db, CTL, 2);
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
});
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
});
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
seed({
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
],
inputs: [
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
],
});
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
});
it("a button on an exit-only relay is not a transient-entry trigger", () => {
seed({
relays: [{ relay: 2, direction: "exit" }],
inputs: [{ input: 1, role: "button", relay: 2 }],
});
expect(relayForButton(db, CTL, 1)).toBeNull();
});
});
+336
View File
@@ -0,0 +1,336 @@
import { and, eq, devices, type Db, type DeviceRow } from "@parking/db";
// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one
// pool with a flexible set of entry/exit points. Direction lives on each RELAY
// inside an access controller, and readers/cameras BIND to a (controller, relay).
// See wiki/concepts/entry-exit-points.md.
/** A flow direction. "both" = one relay/barrier serving entry AND exit. */
export type Direction = "entry" | "exit" | "both";
/** A concrete flow a credential/button drives (never "both"). */
export type FlowDirection = "entry" | "exit";
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
export type RelayEvent = Direction | "radarAlert";
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
export type InputRole = "button" | "presence" | "alertTrigger";
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
* An exit radar is just another `presence` row serving the exit relay. */
export interface InputSpec {
/** 1-based input terminal the host reads. */
readonly input: number;
readonly role: InputRole;
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
readonly relay?: number;
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
readonly kind?: "loop" | "radar";
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
readonly activeLow?: boolean;
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
readonly cooldownSec?: number;
}
/** One relay on an access controller: the event it reacts to. Input wiring (button,
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
* (back-compat) but no longer written by the UI. */
export interface RelaySpec {
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
readonly relay: number;
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
readonly direction: RelayEvent;
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
readonly button?: number;
readonly presenceInput?: number;
readonly presenceKind?: "loop" | "radar";
readonly presenceActiveLow?: boolean;
readonly entryCooldownSec?: number;
// ── radarAlert-only (direction === "radarAlert") ──
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
// camera lane status: blink while the trigger is active + lane free, SOLID once the
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
/** 1-based input terminal whose active edge starts the blink (the radar). */
readonly triggerInput?: number;
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
* "entry". An exit radar's lamp must lock on the EXIT camera. */
readonly lockLane?: FlowDirection;
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
readonly blinkOnMs?: number;
readonly blinkOffMs?: number;
}
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
interface AccessConfig {
readonly relays?: RelaySpec[];
readonly inputs?: InputSpec[];
readonly [k: string]: unknown;
}
/** Reader/camera config: optional binding to a controller relay. */
interface BoundConfig {
/** The access `devices.id` this reader/camera sits at. */
readonly controllerId?: string;
/** The relay on that controller it opens. */
readonly relay?: number;
/** Fallback direction when not bound to a relay. */
readonly direction?: Direction;
readonly [k: string]: unknown;
}
/** A resolved barrier: the controller row + the specific relay to pulse. Carries the
* transient-entry anti-double-press config (presence loop / cooldown) when resolved
* from a button press, so the entry flow can enforce one-car-one-ticket. */
export interface ResolvedRelay {
readonly controller: DeviceRow;
readonly relay: number;
readonly direction: Direction;
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
readonly presenceInput?: number;
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
readonly presenceKind?: "loop" | "radar";
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
readonly entryCooldownSec?: number;
}
/** All enabled access controller rows. */
function accessRows(db: Db): DeviceRow[] {
return db
.select()
.from(devices)
.where(eq(devices.category, "access"))
.all()
.filter((r) => r.enabled);
}
/** The relay specs declared on an access controller (defaults to none). */
export function relaysOf(row: DeviceRow): RelaySpec[] {
const cfg = row.config as AccessConfig;
return Array.isArray(cfg.relays) ? cfg.relays : [];
}
/**
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
* inputs goes through here, so the legacy fold lives in exactly one place.
*/
export function inputsOf(row: DeviceRow): InputSpec[] {
const cfg = row.config as AccessConfig;
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
const synth: InputSpec[] = [];
for (const r of relaysOf(row)) {
if (typeof r.button === "number") {
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
}
if (typeof r.presenceInput === "number") {
synth.push({
input: r.presenceInput,
role: "presence",
relay: r.relay,
kind: r.presenceKind ?? "loop",
activeLow: r.presenceActiveLow,
});
}
}
return synth;
}
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
if (typeof spec.relay !== "number") return null;
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
if (!relay) return null;
if (relay.direction !== "entry" && relay.direction !== "both") return null;
return { ...relay, direction: relay.direction };
}
/**
* Resolve a button press to the relay it fires: the access controller with this deviceId,
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
* Returns null otherwise.
*/
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const inputs = inputsOf(row);
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
if (!btn) return null;
const relay = barrierForInput(row, btn);
if (!relay) return null;
// The presence sensor (if any) serving the SAME relay supplies the gate.
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
return {
controller: row,
relay: relay.relay,
direction: relay.direction,
presenceInput: presence?.input,
presenceKind: presence?.kind ?? "loop",
entryCooldownSec: btn.cooldownSec,
};
}
/**
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
* per car. Only entry/both relays gate transient entry. Null otherwise.
*/
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
if (!presence) return null;
const relay = barrierForInput(row, presence);
if (!relay) return null;
return {
controller: row,
relay: relay.relay,
direction: relay.direction,
presenceInput: presence.input,
presenceKind: presence.kind ?? "loop",
};
}
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
}
/**
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
*/
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
if (!presence || typeof presence.relay !== "number") return null;
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
if (!relay) return null;
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
}
/**
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
* spec. Fallback (unbound): the device's config.direction + the first relay site-
* wide matching that direction — keeps the single-barrier case trivial. Null if
* nothing resolves (no barrier to open).
*/
export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null {
const cfg = deviceRow.config as BoundConfig;
// Bound: follow controllerId + relay to the exact barrier.
if (cfg.controllerId && typeof cfg.relay === "number") {
const controller = db
.select()
.from(devices)
.where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access")))
.get();
if (controller && controller.enabled) {
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
if (spec && spec.direction !== "radarAlert") {
return { controller, relay: spec.relay, direction: spec.direction };
}
}
return null;
}
// Unbound: fall back to the device's declared direction + first matching relay.
const want = cfg.direction;
if (want === "entry" || want === "exit" || want === "both") {
return firstRelayByDirection(db, want === "both" ? "entry" : want);
}
return null;
}
/**
* The first relay site-wide serving a direction ("both" relays match either).
* Used as the unbound fallback and where a flow only needs "an exit barrier".
*/
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
for (const controller of accessRows(db)) {
const spec = relaysOf(controller).find(
(r): r is RelaySpec & { direction: Direction } =>
r.direction === direction || r.direction === "both",
);
if (spec) {
// Attach the presence sensor (if any) serving the SAME relay, so callers that gate on
// presence (the operator-issued entry) see it. Without this the ResolvedRelay carried
// no presenceInput and the presence gate read as "unavailable". Mirrors relayForButton.
const presence = inputsOf(controller).find((i) => i.role === "presence" && i.relay === spec.relay);
return {
controller,
relay: spec.relay,
direction: spec.direction,
presenceInput: presence?.input,
presenceKind: presence?.kind ?? "loop",
};
}
}
return null;
}
/** Enabled devices of a category whose direction matches `want` (or is "both").
* Direction is inherited from each device's bound relay, else its config fallback.
* Used for snapshots: every entry/exit camera fires on an entry/exit. */
export function devicesByDirection(
db: Db,
category: DeviceRow["category"],
want: FlowDirection,
): DeviceRow[] {
return db
.select()
.from(devices)
.where(eq(devices.category, category))
.all()
.filter((r) => {
if (!r.enabled) return false;
const d = directionOf(db, r);
return d === want || d === "both";
});
}
/** The direction a reader/camera operates in (inherited from its bound relay, or
* its config fallback). "both" when undetermined → the flow infers. */
export function directionOf(db: Db, deviceRow: DeviceRow): Direction {
const resolved = relayForDevice(db, deviceRow);
if (resolved) return resolved.direction;
const cfg = deviceRow.config as BoundConfig;
return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both";
}
@@ -0,0 +1,107 @@
import { randomUUID } from "node:crypto";
import { beforeEach, describe, expect, it } from "vitest";
import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { flagDuplicateEntryPlate } from "./snapshot.js";
import { makeLog, silentLogger } from "./test-helpers.js";
import type { EventLog } from "./event-log.js";
// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on
// a fresh transient entry and that plate is already OPEN under another RECENT session,
// the same car most likely minted a second ticket (a motion radar dropped the stationary
// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator
// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier —
// exactly the non-blocking role the plate can play here.
let db: Db;
let log: EventLog;
const PLATE = "AA111BB";
const OLD = "11111111111";
const NEW = "22222222222";
beforeEach(() => {
({ db } = createTestDb());
log = makeLog(db);
});
/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */
function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) {
db.insert(deviceEventsTable).values({
id: randomUUID(),
deviceId: "cam-entry",
category: "camera",
kind: "read",
detail: {
identity: opts.identity ?? OLD,
direction: opts.direction ?? "entry",
plate: opts.plate ?? PLATE,
snapshotId: "snap-old",
source: "entry-exit-snapshot",
},
occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(),
}).run();
}
function seedSession(id: string, state: "open" | "closed") {
db.insert(sessions).values({
id,
identity: id,
source: "ticket",
enteredAt: new Date(Date.now() - 60_000).toISOString(),
state,
}).run();
}
const flag = () =>
flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() });
const anomalies = () =>
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
describe("flagDuplicateEntryPlate", () => {
it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => {
seedPriorRead();
seedSession(OLD, "open");
await flag();
expect(anomalies()).toHaveLength(1);
const a = anomalies()[0];
expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket
expect(a.payload).toMatchObject({
reasonCode: "entry.duplicatePlate",
duplicateEntrySuspected: true,
plate: PLATE,
otherIdentity: OLD,
snapshotId: "snap-new",
});
});
it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => {
seedPriorRead();
seedSession(OLD, "closed");
await flag();
expect(anomalies()).toHaveLength(0);
});
it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => {
seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window
seedSession(OLD, "open");
await flag();
expect(anomalies()).toHaveLength(0);
});
it("own read (same identity) never flags itself", async () => {
seedPriorRead({ identity: NEW });
seedSession(NEW, "open");
await flag();
expect(anomalies()).toHaveLength(0);
});
it("different plate / exit-side reads are ignored", async () => {
seedPriorRead({ plate: "ZZ999ZZ" });
seedPriorRead({ direction: "exit" });
seedSession(OLD, "open");
await flag();
expect(anomalies()).toHaveLength(0);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { validateTicketCode } from "./entry-flow.js";
// validateTicketCode is the manual-entry typo guard: an all-digit code whose last digit
// is the Luhn check of the rest. The booth uses it to reject a mistyped ticket up front
// (instead of a confusing "session not found"). The capacity-gate / print-hold / sign-
// before-open paths of EntryFlow need device fakes and are exercised in the device +
// route phases; here we pin the pure, exported checksum contract.
describe("validateTicketCode (Luhn)", () => {
it("accepts a well-formed 11-digit id", () => {
// 10-digit body + its Luhn check digit. 0000000000 → check digit 0.
expect(validateTicketCode("00000000000")).toBe(true);
});
it("rejects a single-digit typo", () => {
expect(validateTicketCode("00000000000")).toBe(true);
expect(validateTicketCode("00000000010")).toBe(false); // flipped a digit, checksum now wrong
});
it("rejects non-digit and out-of-length strings", () => {
expect(validateTicketCode("abc")).toBe(false);
expect(validateTicketCode("123")).toBe(false); // too short
expect(validateTicketCode("123456789012345")).toBe(false); // too long
expect(validateTicketCode("")).toBe(false);
});
it("round-trips a generated body+check (Luhn is self-consistent)", () => {
// Construct a valid code: pick a body, compute its check the same way the issuer does.
const body = "4992739871";
// brute the check digit 0..9 — exactly one makes a valid code.
const valid = Array.from({ length: 10 }, (_, d) => body + d).filter(validateTicketCode);
expect(valid).toHaveLength(1);
});
it("accepts a legacy 13-digit id shape", () => {
// 12-digit body 000000000000 → check 0; the validator is length-agnostic in 10..14.
expect(validateTicketCode("0000000000000")).toBe(true);
});
});
+614
View File
@@ -0,0 +1,614 @@
import { randomInt, randomUUID } from "node:crypto";
import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
import {
NoPrinterAvailableError,
printWithFailover,
registry,
type AccessControlDevice,
type PrinterDevice,
type PrinterInstance,
type TicketData,
type TicketHeader,
printerRoleOf,
} from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
// → open the barrier. The button is wired into an access controller's input; the
// admin maps that input terminal to a relay (config.relays[].button), so a press
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
//
// Two invariants from the threat model + safety analysis:
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
// signal (wiki/concepts/append-only-event-chain.md).
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
// unprinted) and leave the barrier closed; the operator handles the held car.
// Crucially, NO vehicle_entry is written in that case — we never record an
// "entered" event for a car that didn't get in (decision 2026-06-15).
//
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
// (fail) sign anomaly, stop.
//
// ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held
// or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry
// (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The
// guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what
// barrier feedback exists at the lane:
// - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A
// press prints only while a car is present, and NO second ticket issues until the
// loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's
// input edges to track presence + "armed" per relay.
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
// When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion
// radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the
// cooldown bounds how fast that re-armed press can mint a second ticket.
// - CAMERA (when an entry camera is configured): a press is live only while the entry
// lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts).
// A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing.
// Camera-less sites keep the radar-only gate; a faulty camera is dropped via the
// admin bypass (wiki/concepts/entry-presence-bypass.md).
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
// See wiki/concepts/entry-double-press.md.
/** A presence signal the entry gate can require (or, when a device is faulty, the admin
* can bypass): the radar/loop presence input, or the camera vehicle-detection. */
export type PresenceSignal = "radar" | "camera";
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
interface RelayGuardState {
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
lastTicketAt: number;
/** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */
present: boolean;
/** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket
* prints; re-armed when the loop CLEARS (the car drove through). */
armed: boolean;
}
export class EntryFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
/** Guard against double-fire from the same physical press (on edge only). */
readonly #inFlight = new Set<string>();
/** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */
readonly #guard = new Map<string, RelayGuardState>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
readonly #vision: VisionClient | null;
/** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the
* physical press when an entry camera is configured — advisory sensor, but here it
* only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */
#entryBusy = false;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/** Handle a device input edge. Two kinds of edge matter to this flow:
* (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay
* anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update
* presence state so the guard knows when a car arrives/leaves. The same physical
* input is never both, so we resolve each independently. */
async onInput(e: DeviceInputEvent): Promise<void> {
// Presence-loop edge (both directions matter): keep the per-relay state current.
const presence = relayForPresence(this.#db, e.deviceId, e.input);
if (presence) {
this.#onPresenceEdge(presence, e.edge);
return; // a loop input is not a button — nothing else to do
}
if (e.edge !== "on") return; // for buttons, the release edge is just telemetry
// The firing device must be an access controller, and the pressed input terminal
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
const resolved = relayForButton(this.#db, e.deviceId, e.input);
if (!resolved) return;
// ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown)
const suppressed = this.#suppressReason(resolved);
if (suppressed) {
this.#recordSuppressedPress(e, resolved, suppressed);
this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`);
return;
}
const key = `${e.deviceId}:${e.input}`;
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
this.#inFlight.add(key);
try {
await this.#runEntry(resolved);
} catch (err) {
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
} finally {
this.#inFlight.delete(key);
}
}
/** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in
* server.ts). LaneStatus emits on every flip, so this mirror stays current. */
onLaneStatus(s: LaneStatusEvent): void {
this.#entryBusy = s.entry;
}
/** Stable per-relay key for the guard map. */
#relayKey(r: ResolvedRelay): string {
return `${r.controller.id}:${r.relay}`;
}
/** Lazily get (or create) the guard state for a relay. New relays start ARMED and
* with no car present, so the first press on a fresh lane works immediately. */
#guardState(r: ResolvedRelay): RelayGuardState {
const key = this.#relayKey(r);
let s = this.#guard.get(key);
if (!s) {
s = { lastTicketAt: 0, present: false, armed: true };
this.#guard.set(key, s);
}
return s;
}
/** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing;
* the car LEAVING the loop (after its entry) re-arms for the NEXT car. */
#onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void {
const s = this.#guardState(r);
if (edge === "on") {
s.present = true; // a vehicle is at the barrier
} else {
// Loop cleared: the car drove through (or backed off). Re-arm for the next car —
// this is the gate that makes a *new* car necessary before another ticket.
s.present = false;
s.armed = true;
}
}
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
* Three layered gates: CAMERA (when an entry camera is configured), PRESENCE
* (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown
* runs as a backstop BEHIND presence, because a motion radar can drop a
* stationary car and spuriously re-arm one-car-one-ticket. */
#suppressReason(r: ResolvedRelay): string | null {
const s = this.#guardState(r);
const bypass = this.#presenceBypass();
// CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry
// camera configured, a press is live only once the camera confirms a vehicle in the
// entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints
// nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept).
// A camera-less site skips this; a faulty camera is dropped via the admin bypass.
if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) {
return "no camera-confirmed vehicle in the entry zone";
}
// Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints.
// A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a
// held button minting a burst. If no cooldown is configured there's no anti-double-press
// left — that's the admin's accepted tradeoff while bypassed. See
// wiki/concepts/entry-presence-bypass.md.
if (typeof r.presenceInput === "number" && !bypass.radar) {
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
// ticket already issued for this still-present car).
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
if (!s.armed) return "ticket already issued for the car at the barrier";
// Fall THROUGH to the cooldown backstop: a presence-approved press can still be the
// SAME stationary car after a radar dropout re-armed the guard.
}
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
const elapsed = Date.now() - s.lastTicketAt;
if (elapsed < r.entryCooldownSec * 1000) {
const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000);
return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`;
}
}
return null;
}
/** Is at least one enabled camera bound to the entry lane? The camera gate applies only
* then — a site with no entry camera keeps the radar-only press gate. Read live (like
* the bypass flags) so adding/removing a camera needs no restart. */
#entryCameraConfigured(): boolean {
return devicesByDirection(this.#db, "camera", "entry").length > 0;
}
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: e.deviceId,
category: "access",
kind: "input",
detail: {
driverId: e.driverId,
input: e.input,
edge: e.edge,
entrySuppressed: true,
relay: r.relay,
reason,
},
occurredAt: e.at,
})
.run();
} catch (err) {
this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`);
}
}
async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
// they aren't locked out. "Full" is a soft policy seam for valet over-
// capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db);
if (occ.full) {
// No ticket id exists for a refused entry, so mint a synthetic ref to key the
// anomaly + its evidence snapshot together. The operator wants the photo of WHO
// was turned away (a fraud/dispute signal), so we still fire the entry camera.
const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
await this.#log.append({
type: "anomaly",
identity: refusedRef,
payload: {
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
entryRefused: true,
full: true,
},
});
this.#fireSnapshot("entry", refusedRef);
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
return;
}
await this.#issueTicket(resolved, { source: "ticket" });
}
/**
* The shared "issue a transient ticket" sequence used by BOTH the physical button
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
* signed entry AND append a companion `anomaly` (the operator-adversary path always
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
*/
async #issueTicket(
resolved: ResolvedRelay,
opts: {
source: "ticket" | "manual";
operator?: string;
overCapacity?: { count: number; capacity: number | null };
/** Presence signals that were BYPASSED (admin dropped them due to faulty hardware).
* Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */
presenceBypassed?: PresenceSignal[];
},
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
const printers = this.#loadPrinters();
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
// + operatorInitiated:true in the payload. The button path is source "ticket".
const operatorInitiated = opts.source === "manual";
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket),
);
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
// ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm +
// stamp the cooldown so a repeat press (held button / mashing) issues no second
// ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode
// re-allows after entryCooldownSec. Done on the print success, NOT the open.
const guard = this.#guardState(resolved);
guard.lastTicketAt = Date.now();
guard.armed = false;
} catch (err) {
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
// failed attempt is in the tamper-evident record for the operator.
const reason =
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
await this.#log.append({
type: "anomaly",
identity: ticketId,
payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
});
// Capture who is held at the barrier (evidence for the operator handling the car).
this.#fireSnapshot("entry", ticketId);
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return { ok: false, reason };
}
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
// `category` is FROZEN here (in the signed payload) so the tariff prices and
// later reprices the same way at exit. Today every transient takes the SITE
// default category (operator policy, site_config.default_vehicle_category;
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const category =
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
? cfg.defaultVehicleCategory
: DEFAULT_VEHICLE_CATEGORY;
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: opts.source,
identity: ticketId,
payload: {
sessionRef: ticketId,
ticketPrinted: true,
category,
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
...(opts.presenceBypassed && opts.presenceBypassed.length > 0
? { presenceBypassed: opts.presenceBypassed }
: {}),
},
occurredAt: issuedAt,
});
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
// always leaves a red-flag row in the tamper-evident record for reconciliation.
if (operatorInitiated) {
await this.#log.append({
type: "anomaly",
identity: ticketId,
payload: {
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
source: "booth",
operatorInitiated: true,
...(opts.operator ? { operator: opts.operator } : {}),
...(opts.overCapacity ? { lotFull: true } : {}),
},
});
}
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
const access = this.#buildAccess(resolved.controller);
let opened = false;
if (access) {
await access.pulseOpen(resolved.relay);
opened = true;
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
// camera failure must not delay or block the already-open barrier). This is ALSO
// what records the plate that plate-reconciliation reads at exit.
this.#fireSnapshot("entry", ticketId);
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
try {
this.#db
.insert(sessions)
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
}
return { ok: true, ticketId, opened };
}
/**
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
* presence is this flow's own per-relay guard state. If a site has no presence loop the
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
* See wiki/concepts/operator-issued-entry.md.
*/
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
> {
const resolved = firstRelayByDirection(this.#db, "entry");
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
// PRESENCE GATE — normally require BOTH radar/loop presence AND camera detection. An
// admin may BYPASS a signal when its device is faulty (site_config, signed config_change);
// the bypassed signal is dropped as a requirement and RECORDED on the issued ticket.
const bypass = this.#presenceBypass();
const bypassed: PresenceSignal[] = [];
// Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED;
// if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass).
const radarRequired = !bypass.radar;
let radarPresent: boolean | null = null;
if (radarRequired) {
if (typeof resolved.presenceInput !== "number") {
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" };
}
radarPresent = this.#guardState(resolved).present;
} else {
bypassed.push("radar");
}
// Camera side.
const cameraRequired = !bypass.camera;
if (!cameraRequired) bypassed.push("camera");
// Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle.
const radarOk = !radarRequired || radarPresent === true;
const cameraOk = !cameraRequired || cameraBusy;
if (!radarOk || !cameraOk) {
await this.#log.append({
type: "anomaly",
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
payload: {
...reasonPayload("entry.issue.noPresence", { operator }),
source: "booth",
operator,
radarPresent,
cameraBusy,
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
},
});
this.#logger.warn(
`operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`,
);
return { ok: false, reason: "no vehicle detected at the entry" };
}
const key = `operator-issue:${this.#relayKey(resolved)}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
this.#inFlight.add(key);
try {
const occ = getOccupancy(this.#db);
const res = await this.#issueTicket(resolved, {
source: "manual",
operator,
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
});
if (!res.ok) return res;
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
} finally {
this.#inFlight.delete(key);
}
}
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
* held car is exactly when the operator wants the photo. */
#fireSnapshot(direction: "entry", identity: string): void {
// `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) —
// still fire-and-forget; recognition never gates the open. See snapshot.ts.
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch(
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
);
}
/** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect
* with no restart. Default: nothing bypassed (the normal both-required gate). */
#presenceBypass(): { radar: boolean; camera: boolean } {
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false };
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
/** Build live ENTRY printer instances (for failover selection). */
#loadPrinters(): PrinterInstance[] {
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = printerRoleOf(cfg);
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** Park identity for the ticket header, from site_config (all fields optional;
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
#ticketHeader(): TicketHeader | undefined {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
}
/**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
*
* Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles.
*
* Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
* Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
* The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of
* failing as "session not found".
*/
function newTicketId(): string {
let body = "";
for (let i = 0; i < 10; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body);
}
/** The Luhn (mod-10) check digit for an all-digit string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Walk right-to-left; the check digit sits at position 0 from the right, so the
// last body digit is an "even" position that gets doubled.
let double = true;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return String((10 - (sum % 10)) % 10);
}
/**
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
* Lets a manual-entry path (operator types the code off the ticket when readers are
* down) reject a typo up front. A scanned/looked-up id that predates this format
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
* never reject an id that already exists in the ledger. See ticket-encoding.md.
*/
export function validateTicketCode(code: string): boolean {
// Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
// Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
// (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
if (!/^\d{10,14}$/.test(code)) return false;
const body = code.slice(0, -1);
return luhnCheckDigit(body) === code[code.length - 1];
}
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it } from "vitest";
import { devices, siteConfig, ledgerEvents, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { EntryFlow } from "./entry-flow.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// The entry presence gate normally requires BOTH radar/loop presence AND camera detection.
// An admin may BYPASS a signal when its device is faulty (site_config, set via a signed
// endpoint). These tests pin the GATE decision in EntryFlow.issueForOperator under each
// bypass combination: a still-required-but-absent signal refuses (+ signs an anomaly); a
// bypassed signal is dropped and recorded. We assert the gate outcome via the refuse path
// (deterministic, no printer needed); the allow path is proven by getting PAST the gate
// (it then fails at printing — a different reason — which is exactly "the gate opened").
let db: Db;
let flow: EntryFlow;
const CTL = "ctl-entry";
const PRESENCE_INPUT = 2;
beforeEach(() => {
({ db } = createTestDb());
// A controller with an entry barrier (R1), a presence loop on input 2, and an entry button
// on input 1 — the shape device-resolve expects (relays[] + inputs[]).
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "stub-access",
config: {
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: 1, role: "button", relay: 1 },
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "loop" },
],
},
enabled: true,
}).run();
flow = new EntryFlow(db, makeLog(db), silentLogger());
});
function setBypass(patch: { radar?: boolean; camera?: boolean }) {
db.insert(siteConfig)
.values({ id: 1, bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false })
.onConflictDoUpdate({
target: siteConfig.id,
set: { bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false },
})
.run();
}
/** Drive a presence loop edge so the flow's per-relay guard marks a car present/clear. */
async function setRadarPresent(present: boolean) {
await flow.onInput({
driverId: "stub-access",
deviceId: CTL,
input: PRESENCE_INPUT,
edge: present ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
});
}
const anomalies = () =>
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
describe("entry presence-gate bypass", () => {
it("no bypass + no vehicle → refuses and signs a noPresence anomaly", async () => {
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
expect(res.ok).toBe(false);
expect(anomalies()).toHaveLength(1);
expect(anomalies()[0].payload).toMatchObject({ reasonCode: "entry.issue.noPresence" });
});
it("camera bypassed + radar present → gate OPENS (no refuse anomaly)", async () => {
setBypass({ camera: true });
await setRadarPresent(true);
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false); // camera absent but bypassed
// Gate passed: no noPresence refusal. (It then proceeds to print — no printer configured,
// so it HOLDS with a print reason, not a presence reason. Either way the gate opened.)
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
expect(refusals).toHaveLength(0);
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
});
it("radar bypassed + camera busy → gate OPENS even with NO presence loop reading", async () => {
setBypass({ radar: true });
// radar NOT set present; camera busy=true → radar dropped, camera satisfies.
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
expect(refusals).toHaveLength(0);
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
});
it("camera bypassed but radar STILL required and absent → refuses (only the faulty signal is dropped)", async () => {
setBypass({ camera: true });
await setRadarPresent(false); // radar required (not bypassed) and clear
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
expect(res.ok).toBe(false);
const refusal = anomalies().find((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
expect(refusal, "the still-required radar gates the button").toBeTruthy();
// The refusal records which signal was bypassed (audit).
expect(refusal!.payload).toMatchObject({ presenceBypassed: ["camera"] });
});
it("both bypassed → gate OPENS with no radar and no camera (press-to-print)", async () => {
setBypass({ radar: true, camera: true });
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
expect(refusals).toHaveLength(0);
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
});
});
+213
View File
@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, it } from "vitest";
import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { registry, type PrinterDevice } from "@parking/devices";
import { EntryFlow } from "./entry-flow.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04):
// CAMERA — with an entry camera configured, a press is live only while the entry lane
// camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints
// nothing. Camera-less sites skip this; the admin camera bypass drops it.
// PRESENCE — one-car-one-ticket off the loop (unchanged).
// COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a
// stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds
// how fast that re-armed press can mint a second ticket for the same car.
// A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly.
let db: Db;
let flow: EntryFlow;
const CTL = "ctl-entry";
const BUTTON_INPUT = 1;
const PRESENCE_INPUT = 2;
// A no-op printer that always succeeds, so the happy path reaches the signed
// vehicle_entry (the real drivers need hardware). Registered once (registry is global).
const noopPrinter: PrinterDevice = {
driverId: "test-printer-ok",
connect: async () => {},
disconnect: async () => {},
healthCheck: async () => ({ status: "ready" as const }),
printTicket: async () => {},
printReport: async () => {},
printSubscriptionCard: async () => {},
printReceipt: async () => {},
printWindowChargeNotice: async () => {},
};
if (!registry.get("test-printer-ok")) {
registry.register({
id: "test-printer-ok",
category: "printer",
label: "Test printer",
description: "always-succeeds stub for tests",
transports: [],
configFields: [],
create: () => noopPrinter,
});
}
beforeEach(() => {
({ db } = createTestDb());
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "stub-access",
config: {
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: BUTTON_INPUT, role: "button", relay: 1 },
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
],
},
enabled: true,
}).run();
db.insert(devices).values({
id: "printer-entry",
category: "printer",
driverId: "test-printer-ok",
config: { direction: "entry" },
enabled: true,
}).run();
flow = new EntryFlow(db, makeLog(db), silentLogger());
});
/** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE
* matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */
function addEntryCamera() {
db.insert(devices).values({
id: "cam-entry",
category: "camera",
driverId: "no-such-camera-driver",
config: { direction: "entry" },
enabled: true,
}).run();
}
function setCameraBypass(on: boolean) {
db.insert(siteConfig)
.values({ id: 1, bypassPresenceCamera: on })
.onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } })
.run();
}
async function edge(input: number, edge: "on" | "off") {
await flow.onInput({
driverId: "stub-access",
deviceId: CTL,
input,
edge,
at: new Date().toISOString(),
source: "poll",
});
}
const press = () => edge(BUTTON_INPUT, "on");
const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off");
const entries = () =>
db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry");
const suppressed = () =>
db.select().from(deviceEventsTable).all()
.map((r) => r.detail as { entrySuppressed?: boolean; reason?: string })
.filter((d) => d.entrySuppressed === true);
describe("entry press gate — camera (blink vs solid)", () => {
it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => {
addEntryCamera();
await radar(true); // lamp would blink: radar sees something, camera does not
await press();
expect(entries()).toHaveLength(0);
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only
expect(suppressed()).toHaveLength(1);
expect(suppressed()[0].reason).toMatch(/camera/);
});
it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => {
addEntryCamera();
await radar(true);
flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID
await press();
expect(entries()).toHaveLength(1);
expect(suppressed()).toHaveLength(0);
});
it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => {
await radar(true); // no camera row; lane state irrelevant
await press();
expect(entries()).toHaveLength(1);
});
it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => {
addEntryCamera();
setCameraBypass(true);
await radar(true);
await press();
expect(entries()).toHaveLength(1);
});
it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => {
addEntryCamera();
setCameraBypass(true);
await press(); // radar never went on
expect(entries()).toHaveLength(0);
expect(suppressed()[0].reason).toMatch(/presence loop clear/);
});
});
describe("entry press gate — cooldown backstop behind presence", () => {
/** Same lane but the button carries a cooldown, making it a backstop behind the loop. */
function setButtonCooldown(sec: number) {
db.delete(devices).run();
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "stub-access",
config: {
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec },
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
],
},
enabled: true,
}).run();
db.insert(devices).values({
id: "printer-entry",
category: "printer",
driverId: "test-printer-ok",
config: { direction: "entry" },
enabled: true,
}).run();
}
it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => {
setButtonCooldown(60);
await radar(true);
await press(); // ticket 1 (no camera configured — radar-only site)
expect(entries()).toHaveLength(1);
// The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on.
await radar(false);
await radar(true);
await press(); // presence gate says yes (present + re-armed) — the backstop must catch it
expect(entries()).toHaveLength(1);
expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true);
});
it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => {
await radar(true);
await press();
await radar(false);
await radar(true);
await press();
expect(entries()).toHaveLength(2);
});
it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => {
await radar(true);
await press();
await press(); // car never left the loop → not re-armed
expect(entries()).toHaveLength(1);
expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { eq, subscriptions, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
// We resolve them when serializing an event for the API / WS feed — never on the
// signed record itself.
//
// Today: a subscription occurrence's identity is an opaque `SUBSESS-…` key. The human
// who matters is the subscription HOLDER, whose name lives on the subscriptions row
// (mutable master data — NOT signed into the event). We resolve payload.permitId →
// holder_name so the feed reads "Aqif Kopertoni" rather than "SUBSESS-08cd1c52e219".
/** Fallback label when a subscription has no holder name (or was deleted). Matches the
* i18n key `booth.subscriberFallback`; kept here in English for the API/log layer. */
const SUBSCRIBER_FALLBACK = "Subscriber";
/** Tiny holder-name cache. Single-writer SQLite; a subscription rename is rare and the
* feed is not security-sensitive, so a short-lived cache is plenty. Invalidate by
* process lifetime — restart picks up renames; for live correctness the lookup is
* cheap enough that we just read per miss. */
const holderCache = new Map<string, string | null>();
/** Resolve a subscription id to its holder name (or null), memoized. */
function holderName(db: Db, permitId: string): string | null {
if (holderCache.has(permitId)) return holderCache.get(permitId) ?? null;
const row = db
.select({ holderName: subscriptions.holderName })
.from(subscriptions)
.where(eq(subscriptions.id, permitId))
.get();
const name = row?.holderName?.trim() || null;
holderCache.set(permitId, name);
return name;
}
/** Drop a cached holder name (call after a subscription create/update/delete). */
export function invalidateHolder(permitId: string): void {
holderCache.delete(permitId);
}
/** Clear the whole holder cache (call on bulk subscription changes). */
export function clearHolderCache(): void {
holderCache.clear();
}
/**
* Attach read-time display fields to a raw ledger row before it goes to a client:
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
* - `plate` for an entry/exit event whose session has an advisory ANPR read.
* Idempotent and cheap; events without either pass through unchanged. Used by the WS
* feed (per event). For the bulk feed page prefer `enrichEvents` (one plate scan).
*/
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
let out: T = event;
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if ((event.type === "vehicle_entry" || event.type === "vehicle_exit") && event.identity) {
const p = plateForIdentity(db, event.identity);
if (p) out = { ...out, plate: p.plate };
}
return out;
}
/**
* Bulk variant for the feed page: enriches a list of events with subscriber labels AND
* plates using a SINGLE device_events scan for all the plates (instead of one per row).
* Order preserved.
*/
export function enrichEvents<T extends LedgerEvent>(db: Db, events: T[]): T[] {
// Collect identities of entry/exit events to resolve their plates in one scan.
const wanted = new Set<string>();
for (const e of events) {
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) wanted.add(e.identity);
}
const plates = wanted.size ? platesForIdentities(db, wanted) : new Map();
return events.map((e) => {
let out: T = e;
const permitId = e.payload && typeof e.payload.permitId === "string" ? e.payload.permitId : null;
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) {
const p = plates.get(e.identity);
if (p) out = { ...out, plate: p.plate };
}
return out;
});
}
+129
View File
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { EventLog, canonicalize, hashEvent } from "./event-log.js";
import { SoftwareSigner, buildVerifier } from "./signer.js";
// The append-only, hash-chained, signed event log is THE anti-fraud primitive
// (threat model: the operator at the booth). These tests pin every integrity rule:
// monotonic index, prevHash linkage, payload-in-signature, and that verifyChain()
// catches each class of tamper (content edit, reorder, deletion gap, forged sig,
// missing key). No live DB is touched — a fresh in-memory SQLite per test.
const SECRET = "test-event-signing-key-0123456789";
let db: Db;
let close: () => void;
let log: EventLog;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
});
afterEach(() => close());
describe("EventLog.append — chain construction", () => {
it("assigns a monotonic index starting at 1", async () => {
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
expect(a.index).toBe(1);
expect(b.index).toBe(2);
});
it("genesis event has a null prevHash; the next chains to it", async () => {
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
expect(a.prevHash).toBeNull();
expect(b.prevHash).toBe(hashEvent(canonicalize(a)));
});
it("signs each row under the active keyId", async () => {
const row = await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 100 } });
expect(row.keyId).toBe("sw-hmac-v2");
expect(new SoftwareSigner(SECRET).verify(canonicalize(row), row.signature)).toBe(true);
});
it("serializes concurrent appends without index collisions", async () => {
const rows = await Promise.all(
Array.from({ length: 25 }, (_, i) => log.append({ type: "vehicle_entry", identity: `T${i}` })),
);
const indices = rows.map((r) => r.index).sort((a, b) => a - b);
expect(indices).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
});
});
describe("EventLog.verifyChain — integrity", () => {
async function seed() {
await log.append({ type: "vehicle_entry", identity: "T1", direction: "entry" });
await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 200, tariffVersionId: "tv1" } });
await log.append({ type: "vehicle_exit", identity: "T1", direction: "exit" });
}
it("accepts an untampered chain", async () => {
await seed();
expect(log.verifyChain()).toEqual({ ok: true });
});
it("accepts an empty chain", () => {
expect(log.verifyChain()).toEqual({ ok: true });
});
it("detects a tampered payload (the money amount)", async () => {
await seed();
// Rewrite the payment amount directly in the DB — exactly the booth-operator
// fraud the signed payload defends against.
db.update(ledgerEvents).set({ payload: { amountMinor: 1, tariffVersionId: "tv1" } }).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.index).toBe(2);
expect(r.reason).toMatch(/signature invalid/);
}
});
it("detects a deleted row as an index gap", async () => {
await seed();
db.delete(ledgerEvents).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/index gap/);
});
it("detects a broken prevHash link (reordering / re-chaining)", async () => {
await seed();
db.update(ledgerEvents).set({ prevHash: "0".repeat(64) }).where(eq(ledgerEvents.index, 3)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.index).toBe(3);
expect(r.reason).toMatch(/prevHash/);
}
});
it("detects an event signed under a key that is no longer configured", async () => {
await seed();
// Re-sign row 2 under an unknown keyId — buildVerifier can't resolve it.
db.update(ledgerEvents).set({ keyId: "atecc608-slot9" }).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/no signer for keyId/);
});
});
describe("canonicalize — byte-stability", () => {
it("is independent of payload key order (sorted recursively)", () => {
const base = { index: 1, type: "payment", direction: null, source: null, identity: "T1", occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
const a = canonicalize({ ...base, payload: { amountMinor: 100, tariffVersionId: "tv1" } });
const b = canonicalize({ ...base, payload: { tariffVersionId: "tv1", amountMinor: 100 } });
expect(a).toBe(b);
});
it("changes when any signed field changes", () => {
const base = { index: 1, type: "payment" as const, direction: null, source: null, identity: "T1", payload: { amountMinor: 100 }, occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, payload: { amountMinor: 101 } }));
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, identity: "T2" }));
});
});
+85 -19
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { desc, events, type Db, type EventRow } from "@parking/db"; import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared"; import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
// The append-only, hash-chained, signed event log — the system's core anti-fraud // The append-only, hash-chained, signed event log — the system's core anti-fraud
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device // primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
@@ -16,11 +16,12 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
// so we guard it with an in-process async lock as well. // so we guard it with an in-process async lock as well.
export interface AppendInput { export interface AppendInput {
readonly type: ParkingEventType; readonly type: LedgerEventType;
readonly lane: number;
readonly direction?: Direction | null; readonly direction?: Direction | null;
readonly source?: IdentitySource | null; readonly source?: IdentitySource | null;
readonly identity?: string | null; readonly identity?: string | null;
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
readonly payload?: LedgerPayload | null;
/** Event time (ISO-8601). Defaults to now. */ /** Event time (ISO-8601). Defaults to now. */
readonly occurredAt?: string; readonly occurredAt?: string;
} }
@@ -36,9 +37,9 @@ export function canonicalize(e: {
index: number; index: number;
type: string; type: string;
direction: string | null; direction: string | null;
lane: number;
source: string | null; source: string | null;
identity: string | null; identity: string | null;
payload: Record<string, unknown> | null;
occurredAt: string; occurredAt: string;
prevHash: string | null; prevHash: string | null;
}): string { }): string {
@@ -46,57 +47,107 @@ export function canonicalize(e: {
e.index, e.index,
e.type, e.type,
e.direction ?? null, e.direction ?? null,
e.lane,
e.source ?? null, e.source ?? null,
e.identity ?? null, e.identity ?? null,
// Payload is part of the signed form so business data is tamper-evident.
// Serialize with sorted keys for byte-stability (object key order must not
// change a signature). null when the event type carries no payload.
canonicalPayload(e.payload),
e.occurredAt, e.occurredAt,
e.prevHash ?? null, e.prevHash ?? null,
]); ]);
} }
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
if (p == null) return null;
const sort = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sort);
if (v && typeof v === "object") {
return Object.keys(v as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((o, k) => {
o[k] = sort((v as Record<string, unknown>)[k]);
return o;
}, {});
}
return v;
};
return sort(p);
}
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */ /** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
export function hashEvent(canonical: string): string { export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex"); return createHash("sha256").update(canonical, "utf8").digest("hex");
} }
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
* Returns undefined when the key that signed an event is not available. */
export type SignerResolver = (keyId: string) => Signer | undefined;
export class EventLog { export class EventLog {
readonly #db: Db; readonly #db: Db;
readonly #signer: Signer; readonly #signer: Signer;
/** Picks the verifying signer per event keyId; lets a chain span key rotations
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
* callers that don't pass one (single-key chains, tests). */
readonly #resolveVerifier: SignerResolver;
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
* to fan the event out to live booth clients (WS). It is best-effort and must
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
readonly #onAppended?: (row: LedgerEventRow) => void;
/** Serialize appends: each waits for the previous to finish. */ /** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve(); #tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) { constructor(
db: Db,
signer: Signer,
resolveVerifier?: SignerResolver,
onAppended?: (row: LedgerEventRow) => void,
) {
this.#db = db; this.#db = db;
this.#signer = signer; this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
this.#onAppended = onAppended;
} }
/** Append one event to the chain. Returns the persisted row. Serialized. */ /** Append one event to the chain. Returns the persisted row. Serialized. */
append(input: AppendInput): Promise<EventRow> { append(input: AppendInput): Promise<LedgerEventRow> {
const run = this.#tail.then(() => this.#appendNow(input)); const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock). // Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined); this.#tail = run.catch(() => undefined);
return run; // Read-side notification, AFTER the row is durably written. Wrapped so a
// failing sink can never reject the append or break the chain lock above.
return run.then((row) => {
try {
this.#onAppended?.(row);
} catch {
// best-effort fan-out only — swallow.
}
return row;
});
} }
#appendNow(input: AppendInput): EventRow { #appendNow(input: AppendInput): LedgerEventRow {
const prev = this.#db const prev = this.#db
.select() .select()
.from(events) .from(ledgerEvents)
.orderBy(desc(events.index)) .orderBy(desc(ledgerEvents.index))
.limit(1) .limit(1)
.get(); .get();
const index = (prev?.index ?? 0) + 1; const index = (prev?.index ?? 0) + 1;
const prevHash = prev ? hashEvent(canonicalize(prev)) : null; const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
const occurredAt = input.occurredAt ?? new Date().toISOString(); const occurredAt = input.occurredAt ?? new Date().toISOString();
const payload = input.payload ?? null;
const canonical = canonicalize({ const canonical = canonicalize({
index, index,
type: input.type, type: input.type,
direction: input.direction ?? null, direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload,
occurredAt, occurredAt,
prevHash, prevHash,
}); });
@@ -106,26 +157,33 @@ export class EventLog {
index, index,
type: input.type, type: input.type,
direction: input.direction ?? null, direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload,
occurredAt, occurredAt,
prevHash, prevHash,
signature: this.#signer.sign(canonical), signature: this.#signer.sign(canonical),
keyId: this.#signer.keyId,
}; };
this.#db.insert(events).values(row).run(); this.#db.insert(ledgerEvents).values(row).run();
return row as EventRow; return row as LedgerEventRow;
} }
/** /**
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the * Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an * first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted * integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature. * row (index gap), a forged/invalid signature, and an event signed under a key
* that is no longer configured.
*
* Each row is verified against the signer for ITS OWN `keyId`, not the current
* append signer — so a chain that spans a key rotation (e.g. early events under
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
* verifies end to end. See signer.buildVerifier.
*/ */
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(events).orderBy(events.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
let expectedIndex = 1; let expectedIndex = 1;
let prevHash: string | null = null; let prevHash: string | null = null;
for (const row of rows) { for (const row of rows) {
@@ -135,8 +193,16 @@ export class EventLog {
if ((row.prevHash ?? null) !== prevHash) { if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" }; return { ok: false, index: row.index, reason: "prevHash does not match chain" };
} }
const verifier = this.#resolveVerifier(row.keyId);
if (!verifier) {
return {
ok: false,
index: row.index,
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
};
}
const canonical = canonicalize(row); const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) { if (!verifier.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" }; return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
} }
prevHash = hashEvent(canonical); prevHash = hashEvent(canonical);
+192
View File
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
import { randomUUID } from "node:crypto";
import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
// The exit flow is the anti-fraud GATE: no car leaves without a covering payment within
// the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has
// no bypass. With no relay configured a clean exit returns { opened:false } — we assert
// the DECISION (refuse vs. sign the exit), not the hardware open.
let db: Db;
let close: () => void;
let log: EventLog;
let exit: ExitFlow;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
exit = new ExitFlow(db, log, silentLogger());
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
}
function exitsSigned(identity: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
}
function anomalies(reason?: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
}
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
* plate-reconciliation check can see this identity's plate against open sessions. */
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
db.insert(deviceEventsTable).values({
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
detail: { identity, direction: "entry", plate, confidence },
}).run();
}
describe("exitForBooth — refusal gates", () => {
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
const r = await exit.exitForBooth("ghost");
expect(r).toMatchObject({ ok: false, status: "no_session" });
const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all();
expect(anomalies).toHaveLength(1);
expect(exitsSigned("ghost")).toHaveLength(0);
});
it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const r = await exit.exitForBooth("T1");
expect(r).toMatchObject({ ok: false, status: "unpaid" });
expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave
});
it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(200));
// A payment made 60 min ago → its 15-min walk-back grace lapsed long ago.
await log.append({
type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60),
payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 },
});
const r = await exit.exitForBooth("T1");
expect(r).toMatchObject({ ok: false, status: "grace_expired" });
expect(exitsSigned("T1")).toHaveLength(0);
});
});
describe("exitForBooth — valid exit signs the vehicle_exit", () => {
it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
await pay.pay("T1", "cash"); // fresh payment → within grace
const r = await exit.exitForBooth("T1");
expect(r.ok).toBe(true);
if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests
expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain
expect(log.verifyChain()).toEqual({ ok: true });
});
// NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential
// path), not exitForBooth — the booth's transient exit has no subscription bypass and
// applies the same paid/grace gate to any identity it's handed. Asserting that here so
// the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a
// subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead.
it("does NOT give the booth transient-exit path a subscription bypass", async () => {
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
const r = await exit.exitForBooth("SUBSESS-1");
expect(r).toMatchObject({ ok: false, status: "unpaid" });
expect(exitsSigned("SUBSESS-1")).toHaveLength(0);
});
it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => {
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
const r = await exit.reopenBarrier("SUBSESS-1", "op1");
expect(r.ok).toBe(true);
expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence
});
});
describe("reopenBarrier — no unpaid re-open", () => {
it("refuses to re-open an unpaid transient session", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const r = await exit.reopenBarrier("T1", "op1");
expect(r.ok).toBe(false);
expect(exitsSigned("T1")).toHaveLength(0);
});
it("re-opening a paid OPEN session also closes it (signs the exit)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
await pay.pay("T1", "cash");
const r = await exit.reopenBarrier("T1", "op1");
expect(r.ok).toBe(true);
// The open session is closed by the human-intervention exit so it leaves the list.
expect(exitsSigned("T1")).toHaveLength(1);
});
});
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
});
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
expect(r.ok).toBe(true);
expect(exitsSigned("1237")).toHaveLength(1); // released
const ov = anomalies("released a suspected ticket-swap");
expect(ov.length).toBe(1);
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
});
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r.ok).toBe(true); // no warning — exits normally
expect(exitsSigned("1237")).toHaveLength(1);
});
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1237", minutesAgo(90));
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
expect(exitsSigned("1237")).toHaveLength(1);
});
});
+597
View File
@@ -0,0 +1,597 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
// the session → validate it is PAID and within the walk-back grace → sign a
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
//
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
// projection cache: find the open vehicle_entry for this identity, then a covering
// payment within grace. The cache is updated after, for fast reads.
//
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
// is sent back to the pay station, the rejection is logged.
//
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
// passable once the pay-station + `payment` events land.
interface SessionView {
readonly identity: string;
readonly enteredAt: string;
readonly open: boolean; // no vehicle_exit yet
readonly paidAt: string | null; // latest payment time, if any
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
* exit / re-open without a `payment`. */
readonly subscription: boolean;
readonly graceExitMin: number | null; // from the payment's tariff context, if known
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
// active tariff resolves (then we fall back to the normal paid check).
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
}
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
// See wiki/concepts/plate-reconciliation.md.
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
* intervention was recorded (signed anomaly) but the relay did not fire. */
export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
* exiting read and the matched open session's entry read. Below this, the read is advisory-
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
export class ExitFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the exit image. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/**
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
* relay site-wide, pulses it, and fires the exit snapshot.
*
* Returns a discriminated result so the route can react precisely:
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
* — nothing is signed beyond the existing anomaly; the operator takes payment.
* - { ok: true, opened: true } on a clean exit.
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
const key = `booth:${id}`;
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
this.#inFlight.add(key);
try {
const view = this.#sessionFor(id);
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
// path) so a booth attempt on a bad ticket is auditable.
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
}
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
const freeGrace = view.paidAt == null && view.freeGrace != null;
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!freeGrace && (!paid || !withinGrace)) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
}
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
// (unless they consciously override). A denial here never traps the car — exit fails
// open and the operator can override; the anomaly is the control either way.
const swap = this.#reconcilePlateAtExit(id);
if (swap) {
if (!opts?.override) {
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
await this.#log.append({
type: "anomaly",
identity: id,
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
});
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
}
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
source: "booth",
plateSwapOverride: true,
plate: swap.plate,
otherIdentity: swap.otherIdentity,
...(opts.operator ? { operator: opts.operator } : {}),
},
});
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
await this.#log.append({
type: "payment",
identity: id,
payload: {
sessionRef: id,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
}
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
// to let the car out has been made and validated. Then attempt the open.
await this.#signExit(id);
if (!resolved) {
await this.#openFailedAnomaly(id, "no exit relay configured");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
await this.#openFailedAnomaly(id, "exit controller would not build");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
this.#fireExitSnapshot(id);
this.#closeSessionCache(id);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/**
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
* opens the barrier with a signed trace.
*
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
* the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
*
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
* second exit (that would double-count occupancy): anomaly only, as before.
* See wiki/concepts/booth-exit-flow.md.
*/
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
const id = identity.trim();
if (!id) return { ok: false, reason: "ticket id required" };
const view = this.#sessionFor(id);
if (!view) return { ok: false, reason: "no session for ticket" };
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
// the operator must assist when the exit reader / card fails) OR a transient whose
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
// authorize a free open: a car that paid once and then sat inside past grace owes a
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
// path. So we mirror the exit flow's grace check here (not just in the UI): an
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!view.subscription && (!paid || !withinGrace)) {
return {
ok: false,
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
};
}
const key = `reopen:${id}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
this.#inFlight.add(key);
try {
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
...reasonPayload("exit.manualOpen"),
source: "booth",
barrierReopen: true,
...(operator ? { operator } : {}),
},
});
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
// session leaves the active list + occupancy settles. Skip when already exited
// (no double-count). Recorded as a human-intervention exit for the audit trail.
if (view.open) {
await this.#signExit(id, "manual");
this.#closeSessionCache(id);
this.#fireExitSnapshot(id);
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
}
if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher from the reader's binding, which has ruled out a subscription match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#runExit(resolved, e);
} catch (err) {
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const view = this.#sessionFor(e.value);
// No matching open session — unknown/duplicate ticket. Reject + log.
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { ...rp, exitRefused: true },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused: no open session for ${e.value}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
// its "an exit is covered by a payment" invariant, then fall through to open.
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
if (view.paidAt == null && view.freeGrace) {
await this.#log.append({
type: "payment",
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
identity: e.value,
payload: {
sessionRef: e.value,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
this.#logger.info(`exit free within entry-grace (${e.value})`);
return this.#signExitAndOpen(resolved, e);
}
// PAID + within walk-back grace?
const paid = view.paidAt != null;
const withinGrace =
paid &&
view.graceExitMin != null &&
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!paid || !withinGrace) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { ...rp, exitRefused: true, sessionRef: e.value },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
// automated lane to make the override decision, and exit fails open for safety, so we
// sign the suspicion anomaly (the control here) and still let the car out. The booth
// path (operator-mediated) is where the hold + override lives.
const swap = this.#reconcilePlateAtExit(e.value);
if (swap) {
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: {
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
plateSwapSuspected: true,
plate: swap.plate,
otherIdentity: swap.otherIdentity,
},
});
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
}
// Valid (a real payment within walk-back grace): sign + open.
return this.#signExitAndOpen(resolved, e);
}
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
* Shared by the paid-exit and free-entry-grace paths. The caller has already
* established the session is allowed out (and, for grace, minted the $0 payment). */
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
this.#fireExitSnapshot(e.value);
this.#closeSessionCache(e.value);
return { accepted: true, direction: "exit" };
}
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
* or "manual" (a human-intervention barrier re-open that closes an open session —
* see reopenBarrier). */
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity,
payload: {
sessionRef: identity,
...(source === "manual" ? reasonPayload("exit.manualOpen") : {}),
},
});
}
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
#fireExitSnapshot(identity: string): void {
void snapshotAsync({
db: this.#db,
direction: "exit",
identity,
logger: this.#logger,
vision: this.#vision,
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
}
/** Update the (rebuildable) session projection cache to closed. */
#closeSessionCache(identity: string): void {
try {
this.#db
.update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "closed" })
.where(eq(sessions.id, identity))
.run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
}
}
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
* The payment + exit STAND; this tells the operator to open manually. */
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity,
payload: { ...reasonPayload("exit.open.failed"), detail, source: "booth", exitOpenFailed: true },
});
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
}
/**
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
* or two tickets were mixed up. We compare the EXITING plate against every open session's
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
* or null. See wiki/concepts/plate-reconciliation.md.
*/
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
// The exiting car's plate: prefer its own exit read, else its entry read.
const mine = plateForIdentity(this.#db, exitingId);
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
const wanted = mine.plate.trim().toUpperCase();
// All currently-open sessions (from the projection cache — a fast read-model; the check
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
const openIds = this.#db
.select({ id: sessions.id })
.from(sessions)
.where(eq(sessions.state, "open"))
.all()
.map((r) => r.id)
.filter((id) => id !== exitingId);
if (openIds.length === 0) return null;
const plates = platesForIdentities(this.#db, openIds);
for (const [otherId, pv] of plates) {
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
if (pv.plate.trim().toUpperCase() !== wanted) continue;
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
}
return null;
}
/** Fold the signed ledger into a session view for one identity (authoritative). */
#sessionFor(identity: string): SessionView | null {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
if (rows.length === 0) return null;
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
// A `void` (cancelled ticket) closes the session like an exit, so a voided ticket
// presented at exit reads as "already closed" — never re-opens. See void-flow.ts.
const exited = rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
// Free entry-grace: if the tariff prices entry→now at 0 (a quick in-and-out),
// the exit may open at the gate. Resolve against the tariff in force at entry,
// same as the pay station. Null when no payment is needed yet and no tariff
// resolves — then exit falls back to the normal paid check.
let freeGrace: SessionView["freeGrace"] = null;
if (!exited && paidAt == null) {
const tv = this.#tariffVersionFor(entry.occurredAt);
if (tv) {
const structure = tv.structure as unknown as TariffStructure;
// Same frozen-at-entry category the pay station uses, so the free-grace
// check agrees with the booth quote for V2 category tariffs.
const category = (entry.payload as { category?: string } | null)?.category;
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
if (fee === 0) {
freeGrace = {
tariffVersionId: tv.id,
currency: tv.currency,
graceExitMin: structure.gracePeriodExitMin,
};
}
}
}
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
return {
identity,
enteredAt: entry.occurredAt,
open: !exited,
paidAt,
subscription,
graceExitMin,
freeGrace,
};
}
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
-30
View File
@@ -1,30 +0,0 @@
import { laneDevices, type Db } from "@parking/db";
// Resolves a device instance id (lane_devices.id) to its lane number.
//
// Device pushes/events carry the `lane_devices` id (which device fired), not a
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
// rebuilt from the DB at startup and refreshed whenever assignments change
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
// every input event, so a cached map beats a per-event DB lookup.
export class LaneMap {
readonly #db: Db;
#byDeviceId = new Map<string, number>();
constructor(db: Db) {
this.#db = db;
}
/** (Re)load the id->lane map from the lane_devices table. */
refresh(): void {
const rows = this.#db.select().from(laneDevices).all();
const next = new Map<string, number>();
for (const r of rows) next.set(r.id, r.lane);
this.#byDeviceId = next;
}
/** Lane for a device instance id, or null if the device isn't known. */
laneFor(deviceId: string): number | null {
return this.#byDeviceId.get(deviceId) ?? null;
}
}
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it } from "vitest";
import { eq, devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LanePresence } from "./lane-presence.js";
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
// emits a lane-presence change only when a lane's present/clear state actually flips.
let db: Db;
const CTL = "ctl-1";
const ENTRY_RADAR = 2;
const EXIT_RADAR = 5;
beforeEach(() => {
({ db } = createTestDb());
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
],
inputs: [
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
],
},
enabled: true,
}).run();
});
function edge(input: number, on: boolean): void {
const e: DeviceInputEvent = {
driverId: "dingtian",
deviceId: CTL,
input,
edge: on ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
};
deviceEvents.emitInput(e);
}
/** Collect lane-presence emissions while running `fn`. */
function capture(fn: () => void): LanePresenceEvent[] {
const seen: LanePresenceEvent[] = [];
const off = deviceEvents.onLanePresence((p) => seen.push(p));
try {
fn();
} finally {
off();
}
return seen;
}
describe("LanePresence", () => {
it("starts clear and snapshots clear", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
lp.stop();
});
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(ENTRY_RADAR, true);
edge(ENTRY_RADAR, false);
});
expect(events).toEqual([
{ entry: true, exit: false },
{ entry: false, exit: false },
]);
lp.stop();
});
it("an EXIT radar edge marks the exit lane independently", () => {
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(EXIT_RADAR, true);
});
expect(events).toEqual([{ entry: false, exit: true }]);
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
lp.stop();
});
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
// Two radars both serving the entry lane.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: 2, role: "presence", relay: 1, kind: "radar" },
{ input: 3, role: "presence", relay: 1, kind: "radar" },
],
},
})
.where(eq(devices.id, CTL))
.run();
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => {
edge(2, true); // entry → present (emit)
edge(3, true); // still present (no emit — same lane)
edge(2, false); // still present via I3 (no emit)
edge(3, false); // now clear (emit)
});
expect(events).toEqual([
{ entry: true, exit: false },
{ entry: false, exit: false },
]);
lp.stop();
});
it("ignores a non-presence input (e.g. a button terminal)", () => {
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry" }],
inputs: [{ input: 1, role: "button", relay: 1 }],
},
})
.where(eq(devices.id, CTL))
.run();
const lp = new LanePresence(db, silentLogger());
lp.start();
const events = capture(() => edge(1, true));
expect(events).toEqual([]);
lp.stop();
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
import { presenceLaneOf } from "./device-resolve.js";
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
//
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
export class LanePresence {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
* serve one lane). A lane is "present" while its set is non-empty. */
readonly #entry = new Set<string>();
readonly #exit = new Set<string>();
#unsub: (() => void) | null = null;
constructor(db: Db, logger: FastifyBaseLogger) {
this.#db = db;
this.#logger = logger;
}
/** Subscribe to presence input edges. */
start(): void {
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
}
/** Current snapshot (for the WS hello). */
snapshot(): LanePresenceEvent {
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
}
#onInput(e: DeviceInputEvent): void {
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
if (!lane) return; // not a presence terminal on a barrier relay
const key = `${e.deviceId}:${e.input}`;
const set = lane === "entry" ? this.#entry : this.#exit;
const before = set.size > 0;
if (e.edge === "on") set.add(key);
else set.delete(key);
const after = set.size > 0;
if (before !== after) {
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
deviceEvents.emitLanePresence(this.snapshot());
}
}
/** Unsubscribe on shutdown. */
stop(): void {
this.#unsub?.();
this.#unsub = null;
}
}
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LaneStatus } from "./lane-status.js";
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears
// after a timeout (this camera class sends no leave signal). Advisory; emits a
// lane-status change only when the busy/free state actually flips.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
/** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay
* whose direction we want, so directionOf resolves from the real bound relay. */
function seedCamera(direction: "entry" | "exit" | "both"): string {
const controllerId = randomUUID();
db.insert(devices).values({
id: controllerId,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
{ relay: 3, direction: "both" },
],
},
enabled: true,
}).run();
const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3;
const camId = randomUUID();
db.insert(devices).values({
id: camId,
category: "camera",
driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay },
enabled: true,
}).run();
return camId;
}
/** Capture lane-status events emitted during `fn`. */
function captureEmits(fn: () => void): LaneStatusEvent[] {
const got: LaneStatusEvent[] = [];
const off = deviceEvents.onLaneStatus((e) => got.push(e));
try {
fn();
} finally {
off();
}
return got;
}
describe("LaneStatus", () => {
it("marks the camera's bound lane busy on a vehicle detection, free until then", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
const emits = captureEmits(() => lane.vehicleDetected(cam));
expect(lane.snapshot()).toEqual({ entry: true, exit: false });
expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip
});
it("auto-clears to free after the TTL (no leave signal from the camera)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot().entry).toBe(true);
const emits = captureEmits(() => vi.advanceTimersByTime(90_001));
expect(lane.snapshot().entry).toBe(false);
expect(emits).toEqual([{ entry: false, exit: false }]);
});
it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
// Re-fire just before the TTL — should NOT clear, and should push the clear out.
vi.advanceTimersByTime(80_000);
lane.vehicleDetected(cam);
vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect
expect(lane.snapshot().entry).toBe(true);
// Now let it lapse fully.
vi.advanceTimersByTime(90_001);
expect(lane.snapshot().entry).toBe(false);
});
it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam); // flip -> emits
const emits = captureEmits(() => {
lane.vehicleDetected(cam); // already busy -> no emit
lane.vehicleDetected(cam);
});
expect(emits).toEqual([]);
});
it("a 'both'-direction camera marks BOTH lanes busy", () => {
const cam = seedCamera("both");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot()).toEqual({ entry: true, exit: true });
});
it("exit camera marks only the exit lane", () => {
const cam = seedCamera("exit");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot()).toEqual({ entry: false, exit: true });
});
it("ignores an unknown device id", () => {
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected("nope");
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
});
});
+103
View File
@@ -0,0 +1,103 @@
import { eq, devices, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
import { directionOf } from "./device-resolve.js";
// Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection
// is a hint the booth shows as barrier lights; it never gates a ticket or opens a
// barrier (see wiki/entities/lpr-camera.md, the advisory-only rule).
//
// A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and
// (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so
// "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone
// (each refreshing the timer); once the car leaves, the actives stop and the lane
// flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes.
/** How long after the last vehicle detection a lane stays "busy" before clearing.
* Must exceed the camera's `active` re-fire interval so a still-present car keeps the
* lane busy. MEASURED on the test unit (controlled in/out test): the re-fire rate is
* MOVEMENT-driven, not a fixed rate — ~1-3s apart while the car moves, but stretching
* to ~15-25s when it sits MOTIONLESS in the zone. So the TTL must clear the still-car
* gap (~25s) or a parked car flickers free. The camera has ~no dwell lag (it goes
* silent within a second of the car leaving), so 30s clears promptly after departure
* while keeping a motionless car solidly busy. Override with LANE_BUSY_TTL_MS. */
export function busyTtlMs(): number {
const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
export class LaneStatus {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #ttlMs: number;
#entry = false;
#exit = false;
#entryTimer: ReturnType<typeof setTimeout> | null = null;
#exitTimer: ReturnType<typeof setTimeout> | null = null;
constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) {
this.#db = db;
this.#logger = logger;
this.#ttlMs = ttlMs;
}
/** Current snapshot (for the WS hello). */
snapshot(): LaneStatusEvent {
return { entry: this.#entry, exit: this.#exit };
}
/**
* A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction
* and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera
* or a non-vehicle caller is the caller's concern — this only handles a confirmed
* vehicle detection. Emits a lane-status change only when the state actually flips.
*/
vehicleDetected(deviceId: string): void {
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row) return;
const dir = directionOf(this.#db, row);
if (dir === "entry" || dir === "both") this.#mark("entry");
if (dir === "exit" || dir === "both") this.#mark("exit");
}
#mark(lane: "entry" | "exit"): void {
const was = lane === "entry" ? this.#entry : this.#exit;
if (lane === "entry") this.#entry = true;
else this.#exit = true;
// (Re)arm the auto-clear — each detection pushes the free-flip further out.
const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer;
if (existing) clearTimeout(existing);
const timer = setTimeout(() => this.#clear(lane), this.#ttlMs);
timer.unref?.(); // never hold the process open
if (lane === "entry") this.#entryTimer = timer;
else this.#exitTimer = timer;
if (!was) {
this.#logger.info(`lane-status: ${lane} -> busy`);
this.#emit();
}
}
#clear(lane: "entry" | "exit"): void {
if (lane === "entry") {
this.#entry = false;
this.#entryTimer = null;
} else {
this.#exit = false;
this.#exitTimer = null;
}
this.#logger.info(`lane-status: ${lane} -> free`);
this.#emit();
}
#emit(): void {
deviceEvents.emitLaneStatus(this.snapshot());
}
/** Clear timers on shutdown. */
stop(): void {
if (this.#entryTimer) clearTimeout(this.#entryTimer);
if (this.#exitTimer) clearTimeout(this.#exitTimer);
}
}
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { appLogs, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LogService, pinoDbStream } from "./log-service.js";
// pinoDbStream feeds backend warn+ lines into app_logs. Since 2026-07-04 the logger
// emits level NAMES ("warn") instead of pino's numeric codes (40) — for human-readable
// container logs — and the stream must accept BOTH encodings (numeric covers any
// default-configured pino). A level the tee can't resolve falls back to info → not
// persisted, never a crash.
let db: Db;
let stream: { write: (line: string) => void };
let teed: string[];
beforeEach(() => {
({ db } = createTestDb());
teed = [];
stream = pinoDbStream(new LogService(db), {
write: (line: string) => {
teed.push(line);
return true;
},
} as unknown as NodeJS.WritableStream);
});
const rows = () => db.select().from(appLogs).all();
describe("pinoDbStream level encodings", () => {
it("persists a LABEL-level warn line (the current logger format)", () => {
stream.write(`{"level":"warn","time":"2026-07-04T18:14:11.453Z","msg":"label warn"}\n`);
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({ level: "warn", source: "backend", message: "label warn" });
});
it("still persists a NUMERIC-level error line (legacy/default pino)", () => {
stream.write(`{"level":50,"time":1783179038453,"msg":"numeric error"}\n`);
expect(rows()[0]).toMatchObject({ level: "error", message: "numeric error" });
});
it("info stays stdout-only in both encodings (teed, not persisted)", () => {
stream.write(`{"level":"info","msg":"label info"}\n`);
stream.write(`{"level":30,"msg":"numeric info"}\n`);
expect(rows()).toHaveLength(0);
expect(teed).toHaveLength(2); // stdout tee always happens
});
it("an unresolvable level falls back to info (dropped), never throws", () => {
stream.write(`{"level":"loud","msg":"weird"}\n`);
stream.write(`not json at all\n`);
expect(rows()).toHaveLength(0);
expect(teed).toHaveLength(2);
});
});
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
// context._repeat) instead of inserting — one screaming device can't evict unrelated
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
// context._firstAt.
describe("storm coalescing", () => {
afterEach(() => {
vi.useRealTimers();
});
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
for (let i = 0; i < 200; i++) {
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
}
const all = rows();
expect(all).toHaveLength(1);
expect(all[0].context).toMatchObject({ _repeat: 200 });
expect(teed).toHaveLength(200); // stdout still gets every line
});
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
stream.write(`{"level":"warn","msg":"same"}\n`);
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
stream.write(`{"level":"warn","msg":"same"}\n`);
const [row] = rows();
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
});
it("a different message (or level) breaks the run — separate rows", () => {
stream.write(`{"level":"error","msg":"boom A"}\n`);
stream.write(`{"level":"error","msg":"boom A"}\n`);
stream.write(`{"level":"error","msg":"boom B"}\n`);
stream.write(`{"level":"warn","msg":"boom B"}\n`);
expect(rows()).toHaveLength(3);
});
it("an occurrence past the 5-minute window starts a fresh row", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
stream.write(`{"level":"error","msg":"slow leak"}\n`);
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
stream.write(`{"level":"error","msg":"slow leak"}\n`);
expect(rows()).toHaveLength(2);
});
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
vi.useFakeTimers();
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
for (let i = 0; i < 10; i++) {
vi.setSystemTime(new Date(t));
stream.write(`{"level":"error","msg":"storm"}\n`);
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
}
const all = rows();
expect(all).toHaveLength(1);
expect(all[0].context).toMatchObject({ _repeat: 10 });
});
});
+294
View File
@@ -0,0 +1,294 @@
import { randomUUID } from "node:crypto";
import { and, appLogs, desc, eq, sql, type Db } from "@parking/db";
import {
LOG_LEVEL_ORDER,
type AppLogRecord,
type ClientLogInput,
type LogLevel,
type LogSource,
} from "@parking/shared";
// Application/diagnostic LOG SINK — the host-side store behind the third log stream
// (app_logs), distinct from the signed ledger and device telemetry. It persists:
// - BACKEND warn/error/fatal, fed by a pino stream (see pinoDbStream) so any
// app.log.warn/error lands in the DB without changing call sites.
// - FRONTEND errors POSTed to /api/logs (failed requests, uncaught errors).
// Everything here is UNSIGNED + prunable. Pruned by age AND a row cap so an offline
// appliance with finite disk can't be filled by a log storm. See
// wiki/concepts/app-logs.md, decisions/event-streams-split.md.
/** Only warn and above are persisted from the backend (info/debug stay stdout-only). */
const BACKEND_PERSIST_MIN: LogLevel = "warn";
/** Defensive caps so one runaway log can't bloat a row (chars). */
const MAX_MESSAGE = 4_000;
const MAX_STACK = 16_000;
const MAX_CONTEXT_JSON = 16_000;
/** Storm coalescing: a line identical to the LAST persisted one (level+source+message+
* path) within this window of its previous occurrence UPDATES that row (bumping a
* `_repeat` counter in its context) instead of inserting a new one. A continuous storm
* keeps refreshing the window, so it stays ONE row however long it rages — repeated
* errors can't evict unrelated history or grind the appliance disk (field incident
* 2026-07-07: one unreachable controller ≈ hundreds of identical rows/minute). */
const COALESCE_WINDOW_MS = 300_000;
export interface LogRetention {
/** Delete logs older than this many days. */
readonly maxAgeDays: number;
/** Hard cap on total rows — the oldest beyond this are pruned. */
readonly maxRows: number;
}
export const DEFAULT_RETENTION: LogRetention = {
// 60 days (~2 months) — the operator's chosen diagnostic window (2026-07-04),
// matched by the container-log rotation caps in docker-compose.prod.yml. The row
// cap below still bounds a burst regardless of age.
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 60),
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
};
function clamp(s: string | null | undefined, max: number): string | null {
if (s == null) return null;
return s.length > max ? s.slice(0, max) : s;
}
/** Serialize context to JSON, bounded — never throw on a circular/huge object. */
function safeContext(ctx: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (ctx == null) return null;
try {
const json = JSON.stringify(ctx);
if (json.length <= MAX_CONTEXT_JSON) return ctx;
return { _truncated: true, preview: json.slice(0, MAX_CONTEXT_JSON) };
} catch {
return { _unserializable: true };
}
}
export class LogService {
readonly #db: Db;
readonly #retention: LogRetention;
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
#writing = false;
/** The last persisted row, for storm coalescing (in-memory only; a restart just
* starts a fresh row — best-effort, like everything in this sink). */
#last: {
id: string;
key: string;
count: number;
firstAt: string;
lastAtMs: number;
baseContext: Record<string, unknown> | null;
} | null = null;
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
this.#db = db;
this.#retention = retention;
}
/** Low-level insert. Best-effort: a logging failure must never break a request or
* recurse (a DB error here would otherwise log → insert → error → log …). */
#insert(row: {
level: LogLevel;
source: LogSource;
message: string;
context?: Record<string, unknown> | null;
httpStatus?: number | null;
path?: string | null;
stack?: string | null;
userId?: string | null;
userAgent?: string | null;
createdAt?: string;
}): void {
if (this.#writing) return;
this.#writing = true;
try {
const createdAt = row.createdAt ?? new Date().toISOString();
const message = clamp(row.message, MAX_MESSAGE) ?? "";
const path = clamp(row.path, 512);
const key = `${row.level}|${row.source}|${message}|${path ?? ""}`;
const nowMs = Date.now();
// Storm coalescing: identical to the last persisted row, within the window →
// bump that row instead of inserting. createdAt moves to the LATEST occurrence
// (keeps the storm visible at the top of the newest-first viewer); the first
// occurrence's time is preserved in context._firstAt.
const last = this.#last;
if (last && last.key === key && nowMs - last.lastAtMs <= COALESCE_WINDOW_MS) {
const res = this.#db
.update(appLogs)
.set({
context: { ...(last.baseContext ?? {}), _repeat: last.count + 1, _firstAt: last.firstAt },
createdAt,
})
.where(eq(appLogs.id, last.id))
.run();
if ((res.changes ?? 0) > 0) {
last.count += 1;
last.lastAtMs = nowMs;
return;
}
// The row was pruned out from under us — fall through to a fresh insert.
}
const id = randomUUID();
const baseContext = safeContext(row.context);
this.#db
.insert(appLogs)
.values({
id,
level: row.level,
source: row.source,
message,
context: baseContext,
httpStatus: row.httpStatus ?? null,
path,
stack: clamp(row.stack, MAX_STACK),
userId: row.userId ?? null,
userAgent: clamp(row.userAgent, 512),
createdAt,
})
.run();
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
} catch {
// Swallow — diagnostics must never take down the path they observe. (Can't log
// it; that's the recursion we're guarding against.)
} finally {
this.#writing = false;
}
}
/** Persist a BACKEND log line (called by the pino stream). Below warn is dropped. */
recordBackend(level: LogLevel, message: string, context?: Record<string, unknown> | null): void {
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
this.#insert({ level, source: "backend", message, context });
}
/** Persist a FRONTEND-reported log (from POST /api/logs). The server stamps the
* user + receive time; the client supplies level/message/context. */
recordClient(
input: ClientLogInput,
meta: { userId?: string | null; userAgent?: string | null },
): void {
this.#insert({
level: input.level,
source: "frontend",
message: input.message,
context: input.context ?? null,
httpStatus: input.httpStatus ?? null,
path: input.path ?? null,
stack: input.stack ?? null,
userId: meta.userId ?? null,
userAgent: meta.userAgent ?? null,
// Keep the client's capture time in context for ordering; createdAt is server time.
createdAt: new Date().toISOString(),
});
}
/** Read recent logs, newest first, with optional level/source/since filters. */
query(opts: {
limit: number;
level?: LogLevel;
source?: LogSource;
since?: string;
}): AppLogRecord[] {
const conds = [];
if (opts.level) conds.push(eq(appLogs.level, opts.level));
if (opts.source) conds.push(eq(appLogs.source, opts.source));
if (opts.since) conds.push(sql`${appLogs.createdAt} >= ${opts.since}`);
const rows = this.#db
.select()
.from(appLogs)
.where(conds.length ? and(...conds) : undefined)
.orderBy(desc(appLogs.createdAt))
.limit(opts.limit)
.all();
return rows as unknown as AppLogRecord[];
}
/** Prune by age then by row cap. Returns how many rows were deleted. Safe to call
* on a timer; cheap (indexed on created_at). */
prune(): number {
let deleted = 0;
try {
const cutoff = new Date(Date.now() - this.#retention.maxAgeDays * 86_400_000).toISOString();
const byAge = this.#db.delete(appLogs).where(sql`${appLogs.createdAt} < ${cutoff}`).run();
deleted += byAge.changes ?? 0;
// Row cap: keep the newest maxRows, delete the rest. One subquery — find the
// created_at boundary of the keep-window, delete older.
const total = this.#db.select({ c: sql<number>`count(*)` }).from(appLogs).get();
const count = total?.c ?? 0;
if (count > this.#retention.maxRows) {
const boundary = this.#db
.select({ createdAt: appLogs.createdAt })
.from(appLogs)
.orderBy(desc(appLogs.createdAt))
.limit(1)
.offset(this.#retention.maxRows - 1)
.get();
if (boundary) {
const byCap = this.#db
.delete(appLogs)
.where(sql`${appLogs.createdAt} < ${boundary.createdAt}`)
.run();
deleted += byCap.changes ?? 0;
}
}
} catch {
// best-effort
}
return deleted;
}
}
/**
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
* Pino writes one JSON object per line to this stream; we parse, resolve the level
* (name or numeric encoding), and persist. Returned as `{ write }` so it can be passed
* as pino's stream. stdout still receives the same line (we tee), so console logging is
* unchanged.
*/
export function pinoDbStream(
service: LogService,
tee: NodeJS.WritableStream,
): { write: (line: string) => void } {
const NUM_TO_LEVEL: Record<number, LogLevel> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
};
return {
write(line: string): void {
// Always tee to the original destination first (don't lose stdout logging).
try {
tee.write(line);
} catch {
/* ignore */
}
try {
const obj = JSON.parse(line) as {
level?: number | string;
msg?: string;
err?: { stack?: string; message?: string };
[k: string]: unknown;
};
// The logger emits level NAMES (formatters.level in server.ts, for human-
// readable container logs); a default pino config emits numbers. Accept both.
const level: LogLevel =
typeof obj.level === "string" && obj.level in LOG_LEVEL_ORDER
? (obj.level as LogLevel)
: NUM_TO_LEVEL[typeof obj.level === "number" ? obj.level : 30] ?? "info";
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
// Strip pino's noisy standard fields from the persisted context.
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
service.recordBackend(level, typeof msg === "string" ? msg : "", rest);
} catch {
// A non-JSON line (shouldn't happen with pino) — ignore for persistence.
}
},
};
}
+221
View File
@@ -0,0 +1,221 @@
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";
// Venue modules — entitled ∩ activated, enforced server-side (wiki/decisions/
// venue-modules.md). Boots the real app over an in-memory DB and drives it with
// app.inject, like routes.test.ts.
let db: Db;
let close: () => void;
let app: FastifyInstance;
const savedEnv = process.env.MODULES_ENTITLED;
async function boot(): Promise<void> {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
}
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
await boot();
});
afterEach(async () => {
await app.close();
close();
if (savedEnv === undefined) delete process.env.MODULES_ENTITLED;
else process.env.MODULES_ENTITLED = savedEnv;
});
async function admin() {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
return login(app, username, password);
}
describe("defaults (no env, nothing activated)", () => {
it("every registered module is entitled, activated and effective; /me carries the set", async () => {
const { cookie } = await admin();
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.statusCode).toBe(200);
const body = cfg.json();
expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]);
expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]);
expect(body.modules).toEqual(["parking", "validation", "carwash"]);
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().modules).toEqual(["parking", "validation", "carwash"]);
// A module route answers normally while the module is on.
const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
expect(programs.statusCode).toBe(200);
});
});
describe("activation (site admin)", () => {
it("deactivating validation 403s its routes with module_disabled, signs a config_change, and is reversible", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking"] },
});
expect(put.statusCode).toBe(200);
expect(put.json().modules).toEqual(["parking"]);
expect(put.json().modulesActivated).toEqual(["parking"]);
// The merchant scan routes are the module → 403; the PROGRAM routes are core (the
// discount engine serves Car Wash too) → still 200 with validation off.
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
expect(off.statusCode).toBe(403);
expect(off.json().code).toBe("module_disabled");
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().modules).toEqual(["parking"]);
// The flip is on the signed ledger, attributed.
const events = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
expect(events.statusCode).toBe(200);
const list = (events.json().events ?? events.json()) as Array<{ type: string; payload: Record<string, unknown> }>;
const flip = list.find((e) => e.type === "config_change" && e.payload?.setting === "modules.validation");
expect(flip).toBeTruthy();
expect(flip!.payload).toMatchObject({ value: false, prev: true, operator: "boss" });
// Nothing was deleted: re-enable and the route is back.
const back = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "validation"] },
});
expect(back.json().modules).toEqual(["parking", "validation"]);
const on = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
expect(on.statusCode).toBe(200);
});
it("required modules cannot be deactivated (parking is always included)", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: [] },
});
expect(put.statusCode).toBe(200);
expect(put.json().modules).toEqual(["parking"]);
});
it("rejects unknown ids with 400", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "bar"] },
});
expect(put.statusCode).toBe(400);
});
it("carwash runs without the validation module (the discount engine is core)", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "carwash"] },
});
expect(put.statusCode).toBe(200);
expect(put.json().modules).toEqual(["parking", "carwash"]);
// The wash's sponsorship program is still composable and readable.
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie } })).statusCode).toBe(200);
});
it("dependency rule: a module cannot be on while a module it depends on is off", async () => {
const { cookie, csrf } = await admin();
// Every non-required module depends on parking, and parking is required — so the rule
// is exercised through the effective-set helper directly.
const shared = await import("@parking/shared");
expect(shared.resolveModuleActivation(["parking", "validation", "carwash"], ["carwash"])).toMatchObject({ ok: true });
expect(shared.effectiveModules(["parking", "carwash"], ["parking", "carwash"])).toEqual(["parking", "carwash"]);
expect(cookie && csrf).toBeTruthy();
});
it("a no-op resave signs nothing", async () => {
const { cookie, csrf } = await admin();
const before = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
const countBefore = ((before.json().events ?? before.json()) as unknown[]).length;
await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "validation", "carwash"] },
});
const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore);
});
});
describe("entitlement (vendor env)", () => {
it("MODULES_ENTITLED=parking: validation is neither offered nor activatable, and its routes 403", async () => {
await app.close();
close();
process.env.MODULES_ENTITLED = "parking";
await boot();
const { cookie, csrf } = await admin();
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.json().modulesEntitled).toEqual(["parking"]);
expect(cfg.json().modules).toEqual(["parking"]);
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "validation"] },
});
expect(put.statusCode).toBe(400);
expect(put.json().error).toMatch(/not entitled/);
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
expect(off.statusCode).toBe(403);
});
it("required modules are entitled even when the env omits them; unknown ids are ignored", async () => {
await app.close();
close();
process.env.MODULES_ENTITLED = "validation,bogus";
await boot();
const { cookie } = await admin();
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]);
expect(cfg.json().modules).toEqual(["parking", "validation"]);
});
});
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
const shared = await import("@parking/shared");
it("each till is guarded by its own module's permissions", () => {
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
const wash = new Set(["carwash:read", "carwash:cash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
// Module off → its till is not even addressable.
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
});
it("the live feed admits by watch permission and filters ledger events by their module", () => {
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
);
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
expect(shared.feedPermissionFor("payment")).toBe("event:read");
expect(shared.feedPermissionFor("validation")).toBe("event:read");
});
it("every job's permissions exist in the grid", () => {
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
});
});
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyReply, FastifyRequest } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import {
effectiveModules,
isModuleId,
isTillId,
parseEntitledModules,
tillGuards,
tillsFor,
tillsOf,
type ModuleId,
type TillGuards,
type TillId,
} from "@parking/shared";
import { requireAuth, roleHasPermissions } from "./auth.js";
declare module "fastify" {
interface FastifyRequest {
/** Set by requireTill(): the till this request addresses (already authorized). */
till?: TillId;
}
}
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
// @parking/shared; design in wiki/decisions/venue-modules.md).
//
// entitled MODULES_ENTITLED env (vendor, Komodo stack) — unset = everything.
// activated site_config.modules_json (site admin, Setup → Site) — null = everything
// entitled.
// effective what requireModule() enforces and what /api/auth/me + /api/site-config
// hand the SPA so it can hide nav. The web only HIDES; this file ENFORCES.
//
// Both inputs are re-read per request: one env read and one single-row SELECT on the
// site_config singleton — cheap, and it means a change takes effect on the next request
// with no cache to invalidate (the same reason the presence-bypass flags aren't cached).
/** The modules this deployment is entitled to. Unknown ids in the env are ignored
* (logged once at boot by registerModules). */
export function entitledModules(): ModuleId[] {
return parseEntitledModules(process.env.MODULES_ENTITLED).entitled;
}
/** Parse the persisted activation list off a site_config row. null = never set. A
* corrupt/unknown value is treated as "never set" rather than locking modules off. */
export function activatedModulesOf(row: { modulesJson?: string | null } | undefined): ModuleId[] | null {
const raw = row?.modulesJson;
if (raw == null) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed.filter(isModuleId);
} catch {
return null;
}
}
/** The effective set for this site right now. */
export function effectiveModulesFor(db: Db): ModuleId[] {
const row = db.select({ modulesJson: siteConfig.modulesJson }).from(siteConfig).where(eq(siteConfig.id, 1)).get();
return effectiveModules(entitledModules(), activatedModulesOf(row));
}
/** The tills available at this site right now: the booth, plus each effective
* money-taking module's own till (registry order). */
export function effectiveTillsFor(db: Db): TillId[] {
return tillsOf(effectiveModulesFor(db));
}
/** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective
* tills whose module guard the role holds (each desk's money is guarded by that desk's
* own permissions — venue-modules.md §"Permissions matrix"). */
export function tillsReadableBy(db: Db, roleId: string, kind: keyof TillGuards = "read"): TillId[] {
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]), kind);
}
/** preHandler factory for the shift/drawer routes: authenticate, parse the `till`
* (query on GET, body on POST; absent = booth; 400 `bad_till` when unknown or its
* module is off), then require the role to hold THAT TILL's guard for `kind` (403
* `till_forbidden`). The authorized till lands on `req.till`. The permission is thus
* resolved from the till, never fixed: the booth checks `shift:read`/`shift:create`/
* `drawer:create`, the wash `carwash:read`/`carwash:cash`. */
export function requireTill(db: Db, kind: keyof TillGuards, from: "query" | "body") {
return async (req: FastifyRequest, reply: FastifyReply): Promise<void | FastifyReply> => {
await requireAuth(req, reply);
const raw = from === "query" ? (req.query as { till?: unknown } | undefined)?.till : (req.body as { till?: unknown } | undefined)?.till;
const till = parseTill(db, raw);
if (!till) {
await reply.code(400).send({ error: "unknown till", code: "bad_till" });
return reply;
}
if (!roleHasPermissions(req.user.roleId, [tillGuards(till)[kind]])) {
await reply
.code(403)
.send({ error: `your role cannot ${kind === "read" ? "see" : "work"} the ${till} till`, code: "till_forbidden", till });
return reply;
}
req.till = till;
};
}
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
* whose module is not effective here, → null (the caller answers 400). */
export function parseTill(db: Db, raw: unknown): TillId | null {
if (raw == null || raw === "") return "booth";
if (!isTillId(raw)) return null;
return effectiveTillsFor(db).includes(raw) ? raw : null;
}
/** preHandler: reject the call when `id` is not effective at this site. Compose it
* BEFORE requirePermission in a preHandler array so a disabled module answers the
* same way for every role — 403 with code "module_disabled" — and never reaches
* the permission/CSRF path. */
export function requireModule(db: Db, id: ModuleId) {
return async (_req: FastifyRequest, _reply: FastifyReply): Promise<void> => {
if (!effectiveModulesFor(db).includes(id)) {
throw Object.assign(new Error(`module disabled: ${id}`), { statusCode: 403, code: "module_disabled" });
}
};
}
@@ -0,0 +1,646 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { deviceEvents, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
// settings → intake against an open parking session → done applies the sponsorship
// validation → bay payment settles the parking session at zero (what the exit reader
// checks) / booth payment carries the wash as a charge line → module off = 403.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
type Auth = { cookie: string; csrf: string };
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
async function admin(): Promise<Auth> {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
return login(app, username, password);
}
/** An open transient session that has been parked long enough to owe money. */
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
await makeLog(db).append({
type: "vehicle_entry",
source: "manual",
identity,
occurredAt: minutesAgo(enteredMinutesAgo),
payload: { sessionRef: identity, category: "default" },
});
}
async function seedSettings(a: Auth) {
const res = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: [{ name: "Car" }, { name: "SUV" }],
services: [{ name: "Standard" }, { name: "Inside" }],
prices: [],
},
});
expect(res.statusCode).toBe(200);
const s = res.json();
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
const priced = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: s.categories, services: s.services,
prices: [
{ categoryId: car, serviceId: std, priceMinor: 50000 },
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
],
},
});
expect(priced.statusCode).toBe(200);
expect(priced.json().prices).toHaveLength(3);
return { car, suv, std, inside };
}
/** Flip the site's wash-payment policy (Setup → Car wash). */
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
expect(res.statusCode).toBe(200);
expect(res.json().payAt).toBe(payAt);
}
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
const res = await app.inject({
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
});
expect(res.statusCode).toBeLessThan(300);
}
async function events(a: Auth) {
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
}
describe("settings", () => {
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
const a = await admin();
const ids = await seedSettings(a);
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
const bad = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
});
expect(bad.statusCode).toBe(400);
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
expect(flips.length).toBeGreaterThanOrEqual(2);
});
});
describe("orders", () => {
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
const noSession = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
});
expect(noSession.statusCode).toBe(404);
await openSession("T-1");
const noPrice = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
});
expect(noPrice.statusCode).toBe(409);
expect(noPrice.json().code).toBe("no_price");
const created = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
await openSession("T-2");
await setPayAt(a, "bay");
await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
});
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
expect(chain).toHaveLength(2);
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
});
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await openSession("T-B");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
})).json();
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
const s = look.json();
expect(s.chargeLines).toHaveLength(1);
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
expect(s.chargesMinor).toBe(50000);
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
expect(pay.statusCode).toBeLessThan(300);
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
expect(payment.payload.chargesMinor).toBe(50000);
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
expect(o.paidAt).toBeTruthy();
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
expect(o.tender).toBe("cash");
// A second lookup no longer carries the line (it's settled).
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
expect(again.json().chargeLines).toEqual([]);
// The booth's Z-report: the wash money is inside cash (it is in the drawer) but
// OUT of the ticket bucket, under its own module — Bileta is parking money only.
const parking = payment.payload.parkingMinor as number;
const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor);
const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0];
expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
const signed = (await events(a)).find((e) => e.type === "shift_z_report")!;
expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 });
});
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await seedSponsorship(a, "comp");
await openSession("T-Y");
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
})).json();
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
expect(noShift.statusCode).toBe(409);
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
expect(openWash.statusCode).toBe(200);
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
expect(done.statusCode).toBe(200);
expect(done.json().status).toBe("done");
expect(done.json().validationEventId).toBeTruthy();
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
expect(mid.json().amountMinor).toBe(0);
expect(mid.json().paidAt).toBeNull();
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
expect(paid.statusCode).toBe(200);
expect(paid.json().closed).toBe(true);
const evs = await events(a);
const bay = evs.find((e) => e.type === "carwash_payment")!;
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
// The wash Z-report carries the bay money; the booth's carries none of it.
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
expect(boothZ.till).toBe("booth");
expect(boothZ.cardTotalMinor).toBe(0);
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
// The $0 parking payment exists → the exit reader's paid+grace check passes.
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
expect(parkingPay).toBeTruthy();
expect(parkingPay.payload.amountMinor).toBe(0);
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
expect(after.json().paidAt).toBeTruthy();
expect(after.json().withinGrace).toBe(true);
// The queue is empty (done + paid = closed).
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(queue.json().orders).toEqual([]);
});
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await seedSponsorship(a, "percent");
await openSession("T-P");
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
expect(s.paidAt).toBeNull();
expect(s.amountMinor).toBeGreaterThan(0);
expect(s.discountMinor).toBeGreaterThan(0);
});
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await seedSponsorship(a, "comp");
await openSession("T-V");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
expect(before.validationLines).toHaveLength(1);
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
expect(voided.statusCode).toBe(200);
expect(voided.json().status).toBe("void");
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
expect(after.validationLines).toEqual([]);
expect(after.chargeLines).toEqual([]);
});
});
describe("wash-only discount modes", () => {
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
const a = await admin();
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "doneTolerance", 15);
await openSession("T-D", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
})).json();
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
expect(before.amountMinor).toBe(20000);
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
expect(v.payload.mode).toBe("timeCredit");
expect(v.payload.programMode).toBe("doneTolerance");
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
expect(after.amountMinor).toBe(20000);
expect(after.discountMinor).toBe(0);
});
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "doneTolerance", 120);
await openSession("T-D2", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
expect(after.amountMinor).toBe(0);
});
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
const a = await admin();
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "washPrice");
await openSession("T-W", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
})).json();
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
expect(after.discountMinor).toBe(50000);
expect(after.amountMinor).toBe(before.amountMinor - 50000);
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
});
it("a merchant scan cannot apply a wash-only program", async () => {
const a = await admin();
seedTariff(db);
await seedSponsorship(a, "washPrice");
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
await app.inject({
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
});
await openSession("T-M");
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/car wash order/);
});
});
describe("module gate", () => {
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await openSession("T-G");
await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
});
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
expect(off.json().modules).toEqual(["parking", "validation"]);
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(q.statusCode).toBe(403);
expect(q.json().code).toBe("module_disabled");
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
expect(look.chargeLines).toEqual([]);
});
});
describe("where the money is taken is a SITE setting", () => {
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
await openSession("T-S1");
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
expect(o1.payAt).toBe("booth");
await setPayAt(a, "bay");
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
await openSession("T-S2");
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
expect(o2.payAt).toBe("bay");
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
// A stale client insisting on the other place is refused, never silently overridden.
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
expect(stale.statusCode).toBe(409);
expect(stale.json().code).toBe("pay_at_policy");
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
expect(bad.statusCode).toBe(400);
});
});
describe("tills are gated by the module permission", () => {
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
const a = await admin();
seedTariff(db);
await seedSettings(a);
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
const washer = await seedUser(db, {
username: "lavazhier", roleId: "washer",
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
});
const w = await login(app, washer.username, washer.password);
// The desk's category/service pickers come from the settings read — the job has no
// site:read, so the module permission must open it (found on park dev, 2026-09-06).
const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } });
expect(list.statusCode).toBe(200);
expect(list.json().categories.length).toBeGreaterThan(0);
expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403);
// What the UI offers: only the wash till.
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
// The booth's shift is refused outright (the role holds no shift:*).
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
expect(booth.statusCode).toBe(403);
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
expect(boothState.statusCode).toBe(403);
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
expect(boothCash.statusCode).toBe(403);
// The wash till works.
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
expect(wash.statusCode).toBe(200);
expect(wash.json().till).toBe("carwash");
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
expect(washCash.statusCode).toBe(200);
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
// sees the state and gets canWork=false; opening is refused.
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
const l = await login(app, looker.username, looker.password);
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
const booth1 = await seedUser(db, {
username: "boothie", roleId: "booth-op",
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
});
const b = await login(app, booth1.username, booth1.password);
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
expect(noWash.statusCode).toBe(403);
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
});
});
describe("a role reassignment takes effect without re-login", () => {
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await openSession("T-R");
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
const l = await login(app, looker.username, looker.password);
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
expect(before.statusCode).toBe(403);
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
expect(moved.statusCode).toBe(200);
// Same cookie, no re-login: the token's pinned role is refreshed per request.
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
expect(after.statusCode).toBe(201);
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
expect(me.roleId).toBe("wash-op");
});
});
describe("a shift's activity log is per till", () => {
it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await openSession("T-L");
await setPayAt(a, "bay");
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } });
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } });
const types = async (qs: string, auth: Auth = a) => {
const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } });
expect(r.statusCode).toBe(200);
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`);
};
// The wash till's log: its shift, its order (no money moved, but wash-desk activity),
// its bay payment and its voucher — none of the booth's.
const wash = await types("&till=carwash");
expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"]));
expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false);
// The booth's log: entry, its shift, its voucher — and no wash-desk activity.
const booth = await types("&till=booth");
expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"]));
expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false);
// No till → everything (unchanged).
const all = await types("");
expect(all.length).toBe(wash.length + booth.length);
expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400);
// A wash operator holds carwash:read but not event:read: the log opens for them
// with ONLY the module's own event types (the live-socket rule, feedPermissionFor).
const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] });
const w = await login(app, washer.username, washer.password);
const mine = await types("&till=carwash", w);
expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"]));
expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true);
// A role with neither event:read nor any module feed permission reads nothing.
const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] });
const c = await login(app, clerk.username, clerk.password);
expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403);
});
});
describe("vision category — advisory, flagged, never authoritative", () => {
/** What snapshot.ts records when vision classifies the entry frame. */
function seeVehicle(identity: string, bodyType: string, bodyConfidence: number) {
db.insert(deviceEvents).values({
id: `read-${identity}-${bodyType}`, deviceId: "cam-1", category: "camera", kind: "read",
detail: { identity, direction: "entry", bodyType, bodyConfidence, snapshotId: "snap-1", source: "entry-exit-snapshot" },
occurredAt: new Date().toISOString(),
}).run();
}
async function mapClasses(a: Auth, ids: { car: string; suv: string }) {
const cur = (await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json();
const r = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: cur.categories.map((c: { id: string }) => ({ ...c, visionClasses: c.id === ids.suv ? ["suv", "pickup"] : c.id === ids.car ? ["car", "sedan", "hatchback"] : [] })),
visionThreshold: 0.75,
},
});
expect(r.statusCode).toBe(200);
return r.json();
}
it("Setup maps the vocabulary onto site categories; the lookup suggests the mapped category", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
const saved = await mapClasses(a, ids);
expect(saved.categories.find((c: { id: string }) => c.id === ids.suv).visionClasses).toEqual(["suv", "pickup"]);
expect(saved.visionThreshold).toBe(0.75);
expect((await events(a)).some((e) => e.type === "config_change" && e.payload.setting === "carwash.visionThreshold")).toBe(true);
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { categories: [{ id: ids.car, name: "Car", visionClasses: ["spaceship"] }] } });
expect(bad.statusCode).toBe(400);
await openSession("T-V1");
seeVehicle("T-V1", "suv", 0.91);
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
expect(look.suggestedCategoryId).toBe(ids.suv);
// Unmapped class → shown, nothing suggested.
await openSession("T-V2");
seeVehicle("T-V2", "bus", 0.99);
const look2 = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V2", headers: { cookie: a.cookie } })).json();
expect(look2.vision.bodyType).toBe("bus");
expect(look2.suggestedCategoryId).toBeNull();
});
it("a confident downgrade signs an anomaly with both categories and the snapshot; equal, upgrade or unsure reads do not; the order is never blocked", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await mapClasses(a, ids);
const order = async (identity: string, categoryId: string) => {
const r = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity, categoryId, serviceId: ids.std } });
expect(r.statusCode).toBe(201);
return r.json();
};
// Camera: SUV (0.91) — operator picks Car (cheaper) → flagged, recorded, still created.
await openSession("T-D1"); seeVehicle("T-D1", "suv", 0.91);
const down = await order("T-D1", ids.car);
expect(down).toMatchObject({ visionClass: "suv", visionConfidence: 0.91, visionCategoryId: ids.suv, categoryId: ids.car });
expect(down.downgradeEventId).toBeTruthy();
const flag = (await events(a)).find((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")!;
expect(flag).toBeTruthy();
expect(flag.payload).toMatchObject({
visionClass: "suv", visionCategoryName: "SUV", chosenCategoryName: "Car", operator: "boss",
visionPriceMinor: 70000, chosenPriceMinor: 50000, snapshotId: "snap-1",
});
// Same category as the camera → nothing.
await openSession("T-D2"); seeVehicle("T-D2", "suv", 0.91);
expect((await order("T-D2", ids.suv)).downgradeEventId).toBeNull();
// Upgrade (camera Car, operator SUV) → recorded on the order, no anomaly.
await openSession("T-D3"); seeVehicle("T-D3", "sedan", 0.95);
const up = await order("T-D3", ids.suv);
expect(up).toMatchObject({ visionClass: "sedan", visionCategoryId: ids.car, downgradeEventId: null });
// Below the site threshold → shown, never flagged.
await openSession("T-D4"); seeVehicle("T-D4", "suv", 0.6);
expect((await order("T-D4", ids.car)).downgradeEventId).toBeNull();
// No read at all → nulls.
await openSession("T-D5");
expect(await order("T-D5", ids.car)).toMatchObject({ visionClass: null, visionCategoryId: null, downgradeEventId: null });
expect((await events(a)).filter((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")).toHaveLength(1);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { deviceEvents } from "../../device-events.js";
import type { ServerModule } from "../index.js";
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
import { carwashRoutes } from "./routes.js";
import { CarwashService } from "./service.js";
// Car Wash — the pilot venue module (wiki/decisions/venue-modules.md). Everything the
// module is lives in this folder: its service (master data, the order queue, the bay
// payment, the parking sponsorship + settlement), its routes, and the booth charge
// provider it registers with the core's PayStation. The core knows it only through the
// registry line in ../index.ts and the manifest in @parking/shared.
export const carwashModule: ServerModule = {
id: "carwash",
async register(app, deps) {
// The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
// queueing at all. One-way, background, never on the intake path.
const cfg = reviewUploadConfigFromEnv();
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
outbox.start();
// Entry-stream sampling: one in N entry vehicle reads goes to the reviewer as pure
// training material (the gate view, no order attached). The core announces the read;
// the module decides. Off unless CARWASH_REVIEW_ENTRY_SAMPLE is set.
const offVehicleRead = deviceEvents.onVehicleRead((e) => {
if (e.direction === "entry" && outbox.sampleEntry()) void outbox.enqueueEntry(e.read);
});
app.addHook("onClose", async () => offVehicleRead());
app.addHook("onClose", async () => outbox.stop());
const service = new CarwashService(deps, app.log, outbox);
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
// the core calls back after the payment is signed so the order is marked paid.
deps.payStation.registerChargeProvider(service.chargeProvider());
await carwashRoutes(app, deps, service, outbox);
},
};
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { createTestDb } from "@parking/db/testing";
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { deviceEvents as deviceEventBus } from "../../device-events.js";
import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
// backoff, never blocking the wash, never naming the site.
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
async function frame(): Promise<Buffer> {
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
.composite([
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
])
.jpeg()
.toBuffer();
}
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
* blurred plate drops far below 255 as the red bleeds in. */
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
let sum = 0;
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
return sum / (data.length / info.channels);
}
describe("makeReviewCrop", () => {
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
const shot = await frame();
const crop = await makeReviewCrop(shot, CAR, PLATE);
expect(crop.plateBlurred).toBe(true);
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
expect(crop.width).toBeGreaterThanOrEqual(228);
expect(crop.width).toBeLessThanOrEqual(236);
expect(crop.height).toBeGreaterThanOrEqual(135);
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
// frame is white, the review crop is the red bleeding in.
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
// Without a plate box: same crop, nothing blurred.
const noPlate = await makeReviewCrop(shot, CAR, null);
expect(noPlate.plateBlurred).toBe(false);
// A big frame is capped to the max edge.
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
expect(Math.max(capped.width, capped.height)).toBe(640);
});
});
describe("config + pseudonyms", () => {
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
expect(reviewUploadConfigFromEnv({})).toBeNull();
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
});
});
describe("queue + drain", () => {
let db: Db;
let close: () => void;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
});
afterEach(() => close());
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60, entrySample: 0 };
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
async function seed(): Promise<void> {
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
db.insert(carwashOrders).values({
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
}).run();
}
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
await seed();
const calls: { url: string; init: RequestInit }[] = [];
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, init });
return new Response("ok", { status: 200 });
});
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
expect(await ob.enqueue(item, read)).toBe(true);
const row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row.status).toBe("queued");
expect(row.image!.length).toBeGreaterThan(500);
expect(row.payload).toMatchObject({ v: 1, kind: "wash", booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe(cfg.url);
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
const form = calls[0]!.init.body as FormData;
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
expect((form.get("image") as File).type).toBe("image/jpeg");
const after = db.select().from(carwashReviewOutbox).all()[0]!;
expect(after.status).toBe("sent");
expect(after.image).toBeNull();
expect(after.sentAt).toBeTruthy();
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
});
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
await seed();
let status = 503;
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
await ob.enqueue(item, read);
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
let row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
// Not due yet → untouched.
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
// Due again: a network error defers too; a 422 abandons.
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
status = 0;
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
status = 422;
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
expect(row.image).toBeNull();
// A voided order is not a sample.
status = 200;
await ob.enqueue({ ...item, orderId: "o-1" }, read);
db.update(carwashOrders).set({ status: "void" }).run();
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
// Expired items are abandoned without a request.
await ob.enqueue(item, read);
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
db.update(carwashOrders).set({ status: "open" }).run();
const before = fetchFn.mock.calls.length;
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
expect(fetchFn.mock.calls.length).toBe(before);
expect(ob.status().failed).toBe(3);
// Entry sampling: one in N entry reads becomes a package with the crop and the
// camera's class only — no order, no operator, no category.
const sampler = new ReviewOutbox(db, silentLogger(), { ...cfg, entrySample: 3 }, fetchFn);
expect([sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry()]).toEqual([false, false, true, false]);
expect(ob.sampleEntry()).toBe(false); // entrySample 0 = off
expect(await sampler.enqueueEntry(read)).toBe(true);
const entryRow = db.select().from(carwashReviewOutbox).where(eq(carwashReviewOutbox.orderId, "entry:snap-1")).get()!;
expect(entryRow.payload).toMatchObject({ v: 1, kind: "entry", booth: "booth-7", vision: { class: "car", confidence: 0.86 }, image: { plateBlurred: true } });
expect(entryRow.payload).not.toHaveProperty("operator");
expect(entryRow.payload).not.toHaveProperty("operatorCategory");
expect(entryRow.image!.length).toBeGreaterThan(500);
// No vehicle box, no snapshot, or upload off → nothing queued.
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
});
});
import { eq } from "@parking/db";
describe("through the app", () => {
let db: Db;
let close: () => void;
let app: FastifyInstance;
const saved = { ...process.env };
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
process.env.CARWASH_REVIEW_TOKEN = "tok";
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1";
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID", "CARWASH_REVIEW_ENTRY_SAMPLE"]) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const a = await login(app, username, password);
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
seedTariff(db);
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
const cat = s.categories[0].id, svc = s.services[0].id;
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
db.insert(deviceEvents).values({
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
occurredAt: new Date().toISOString(),
}).run();
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
expect(order.statusCode).toBe(201);
// Enqueue is fire-and-forget: give the crop a moment.
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0, entrySample: 1 });
// An ENTRY vehicle read announced by the core (snapshot.ts) is sampled by the module
// (1 in 1 here) into an entry package; an exit read is not.
deviceEventBus.emitVehicleRead({ identity: "T-X", direction: "exit", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
deviceEventBus.emitVehicleRead({ identity: "T-R", direction: "entry", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(2));
const rows = db.select().from(carwashReviewOutbox).all();
expect(rows.map((r) => (r.payload as { kind: string }).kind).sort()).toEqual(["entry", "wash"]);
});
});
@@ -0,0 +1,376 @@
import { createHash, randomUUID } from "node:crypto";
import sharp from "sharp";
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
import type { NormBox, VehicleRead } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
//
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
// the operator may err or cheat). So every wash order that has a vehicle read queues a
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
// The reviewer's verdict becomes the training label for the body-type classifier (phase
// B) and, per operator, the honest-mistake / fraud rate.
//
// Rules that shape this file:
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
// a background loop drains the queue when the private overlay (Netbird) is up, with
// backoff, and gives up loudly after EXPIRE_DAYS.
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
// signed ledger stays the only record of what happened at the wash.
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
// the operator as a keyed hash. The mapping back to people and places stays with the
// reviewer, off the collector.
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
// booth can do nothing at the collector but this one POST.
export interface ReviewUploadConfig {
/** The collector's ingest URL (reachable only over the overlay). */
readonly url: string;
/** Per-booth bearer token. */
readonly token: string;
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
readonly boothId: string;
readonly intervalSec: number;
/** Queue one in N ENTRY vehicle reads (no order attached) for the reviewer — the gate
* view is exactly what the classifier is trained on, and the entry stream is many times
* the wash stream. 0 = off. */
readonly entrySample: number;
}
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
if (!url || !token || !boothId) return null;
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
const sample = Number(env.CARWASH_REVIEW_ENTRY_SAMPLE ?? 0);
return {
url, token, boothId,
intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60,
entrySample: Number.isInteger(sample) && sample > 0 ? sample : 0,
};
}
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
* enough that a day of washes is a few megabytes. */
export const CROP_MAX_EDGE = 640;
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
const CROP_MARGIN = 0.08;
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
* should not resurface a fortnight of crops in one burst. */
export const EXPIRE_DAYS = 14;
/** Backoff: 1 min · 2^attempts, capped. */
const BACKOFF_BASE_MS = 60_000;
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
const UPLOAD_TIMEOUT_MS = 20_000;
/** What one order contributes to the package (the service hands this over at intake). */
export interface ReviewItemInput {
readonly orderId: string;
readonly createdAt: string;
readonly createdBy: string;
readonly categoryId: string;
readonly categoryName: string;
/** The vision classes the chosen category covers at this site (its mapping) — lets the
* reviewer's class be judged against the operator's category without the site's setup. */
readonly categoryClasses: readonly string[];
readonly serviceName: string;
readonly visionCategoryId: string | null;
readonly downgraded: boolean;
}
/**
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
*/
export async function makeReviewCrop(
snapshotBytes: Buffer,
box: NormBox,
plateBox: NormBox | null | undefined,
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
const meta = await img.metadata();
const W = meta.width ?? 0;
const H = meta.height ?? 0;
if (!W || !H) throw new Error("snapshot has no dimensions");
const px = (b: NormBox) => ({
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
});
const v = px(box);
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
const left = Math.max(0, v.left - mw);
const top = Math.max(0, v.top - mh);
const right = Math.min(W, v.right + mw);
const bottom = Math.min(H, v.bottom + mh);
const width = right - left;
const height = bottom - top;
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
let crop = img.clone().extract({ left, top, width, height });
let plateBlurred = false;
if (plateBox) {
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
const p = px(plateBox);
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
const pl = Math.max(0, p.left - pad - left);
const pt = Math.max(0, p.top - pad - top);
const pr = Math.min(width, p.right + pad - left);
const pb = Math.min(height, p.bottom + pad - top);
if (pr - pl >= 2 && pb - pt >= 2) {
const region = await sharp(await crop.clone().toBuffer())
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
.blur(Math.max(6, Math.round((pr - pl) / 6)))
.toBuffer();
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
plateBlurred = true;
}
}
const out = await crop
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 85, mozjpeg: true })
.toBuffer({ resolveWithObject: true });
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
}
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
* meaningless anywhere else. */
export function operatorRef(boothId: string, username: string): string {
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
}
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
export interface OutboxStatus {
readonly enabled: boolean;
readonly boothId: string | null;
readonly queued: number;
readonly sent: number;
readonly failed: number;
readonly lastSentAt: string | null;
readonly lastError: string | null;
/** 0 = entry sampling off; N = one in N entry reads is queued. */
readonly entrySample: number;
}
export class ReviewOutbox {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #cfg: ReviewUploadConfig | null;
readonly #fetch: FetchLike;
#timer: NodeJS.Timeout | null = null;
#draining = false;
#entrySeen = 0;
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
this.#db = db;
this.#logger = logger;
this.#cfg = cfg;
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
}
get enabled(): boolean {
return this.#cfg != null;
}
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
* intake path; every failure is logged, none is thrown. Skipped when there is no
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
* sample) or when upload is not configured (an unbounded queue nobody drains). */
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
if (!this.#cfg) return false;
return this.#queue(item.orderId, read, (id, crop) => ({
v: 1,
kind: "wash",
booth: this.#cfg!.boothId,
item: id,
order: item.orderId,
at: item.createdAt,
operator: operatorRef(this.#cfg!.boothId, item.createdBy),
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
service: item.serviceName,
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
downgraded: item.downgraded,
image: crop,
}));
}
/** Every Nth entry read is a sample (N = entrySample); the caller queues it. Counted
* in-process, so "1 in 5" is exactly that across a booth's day. */
sampleEntry(): boolean {
const n = this.#cfg?.entrySample ?? 0;
if (n <= 0) return false;
this.#entrySeen += 1;
return this.#entrySeen % n === 0;
}
/** Queue an ENTRY sample: the crop and the camera's class only — no order, no operator,
* no category. Pure training material in the gate view; the reviewer labels it. */
async enqueueEntry(read: VehicleRead): Promise<boolean> {
if (!this.#cfg) return false;
return this.#queue(`entry:${read.snapshotId ?? "?"}`, read, (id, crop) => ({
v: 1,
kind: "entry",
booth: this.#cfg!.boothId,
item: id,
at: new Date().toISOString(),
vision: { class: read.bodyType, confidence: read.confidence },
image: crop,
}));
}
async #queue(
ref: string,
read: VehicleRead,
build: (id: string, image: { width: number; height: number; plateBlurred: boolean }) => Record<string, unknown>,
): Promise<boolean> {
if (!read.box || !read.snapshotId) return false;
try {
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
if (!snap) {
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — ${ref} not queued`);
return false;
}
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
const id = randomUUID();
const payload = build(id, { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred });
this.#db
.insert(carwashReviewOutbox)
.values({ id, orderId: ref, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
.run();
return true;
} catch (err) {
this.#logger.warn(`carwash review: could not queue ${ref}: ${(err as Error).message}`);
return false;
}
}
start(): void {
if (!this.#cfg || this.#timer) return;
const tick = () => {
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
};
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
this.#timer.unref?.();
setTimeout(tick, 5_000).unref?.();
}
stop(): void {
if (this.#timer) clearInterval(this.#timer);
this.#timer = null;
}
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
const tally = { sent: 0, failed: 0, deferred: 0 };
if (!this.#cfg || this.#draining) return tally;
this.#draining = true;
try {
const now = new Date().toISOString();
const due = this.#db
.select()
.from(carwashReviewOutbox)
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
.orderBy(asc(carwashReviewOutbox.createdAt))
.limit(limit)
.all();
for (const row of due) {
const outcome = await this.#send(row);
tally[outcome] += 1;
}
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
} finally {
this.#draining = false;
}
return tally;
}
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
const cfg = this.#cfg!;
const ageMs = Date.now() - Date.parse(row.createdAt);
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
// A wash voided before delivery is not a sample (and not a decision to review).
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
if (order?.status === "void") return this.#fail(row, "order voided");
if (!row.image) return this.#fail(row, "image missing");
const form = new FormData();
form.set("meta", JSON.stringify(row.payload));
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
try {
const res = await this.#fetch(cfg.url, {
method: "POST",
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
body: form,
signal: ac.signal,
});
if (res.ok) {
this.#db
.update(carwashReviewOutbox)
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
return "sent";
}
// The collector refused the package itself → no retry will help.
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
return this.#defer(row, `HTTP ${res.status}`);
} catch (err) {
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
} finally {
clearTimeout(t);
}
}
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
this.#db
.update(carwashReviewOutbox)
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
return "failed";
}
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
const attempts = row.attempts + 1;
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
this.#db
.update(carwashReviewOutbox)
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
return "deferred";
}
status(): OutboxStatus {
const count = (s: "queued" | "sent" | "failed") =>
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
const lastErr = this.#db
.select({ e: carwashReviewOutbox.lastError })
.from(carwashReviewOutbox)
.where(sql`${carwashReviewOutbox.lastError} is not null`)
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
.limit(1)
.get()?.e ?? null;
return {
enabled: this.enabled,
boothId: this.#cfg?.boothId ?? null,
entrySample: this.#cfg?.entrySample ?? 0,
queued: count("queued"),
sent: count("sent"),
failed: count("failed"),
lastSentAt: lastSent,
lastError: lastErr,
};
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { Tender } from "@parking/shared";
import { requireAnyPermission, requirePermission } from "../../auth.js";
import { requireModule } from "../../modules.js";
import { NoShiftOpenError } from "../../shift-service.js";
import type { ServerModuleDeps } from "../index.js";
import type { ReviewOutbox } from "./review-outbox.js";
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
// FIRST (403 module_disabled), then a permission:
// settings (master data) site:read / site:update — the site admin's job
// queue / ticket lookup carwash:read — the wash desk
// intake carwash:create
// done / bay payment / void carwash:update
// The sponsorship PROGRAM itself is a validation program row (id "carwash") and is
// composed through the existing /api/validation/programs/:id route (site:update).
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
if (err instanceof CarwashError) {
return reply.code(err.status).send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
}
if (err instanceof NoShiftOpenError) {
// The bay takes money on the CARWASH till: the wash operator's own shift must be
// open (the booth's does not count). The desk shows its shift control on this code.
return reply.code(409).send({ error: err.message, code: "no_shift", till: err.till });
}
throw err;
}
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
const moduleOn = requireModule(deps.db, "carwash");
// The price list is the desk's working data as much as Setup's: the wash operator
// reads it under the module's own permission (the Wash operator job holds no site:*).
const settingsRead = [moduleOn, requireAnyPermission("carwash:read", "site:read")];
const settingsWrite = [moduleOn, requirePermission("site:update")];
const read = [moduleOn, requirePermission("carwash:read")];
const create = [moduleOn, requirePermission("carwash:create")];
const update = [moduleOn, requirePermission("carwash:update")];
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
// The review outbox's health (Setup → Car wash): how many decisions wait for the
// reviewer, how many went, the last error. Site admin's read.
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null, entrySample: 0 },
);
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
try {
return await service.saveSettings(req.body ?? {}, req.user.username);
} catch (err) {
return sendError(reply, err);
}
});
app.get<{ Params: { identity: string } }>("/api/carwash/session/:identity", { preHandler: read }, async (req) =>
service.lookup(req.params.identity),
);
app.get<{ Querystring: { scope?: string; limit?: string } }>("/api/carwash/orders", { preHandler: read }, async (req) => {
if (req.query.scope === "recent") return { orders: service.recentOrders(Number(req.query.limit) || 100) };
return { orders: service.openOrders() };
});
app.post<{ Body: { identity?: string; categoryId?: string; serviceId?: string; payAt?: string } }>(
"/api/carwash/orders",
{ preHandler: create },
async (req, reply) => {
const b = req.body ?? {};
// payAt is a SITE setting now; the desk no longer sends it. Accept it only when it
// matches (the service refuses a mismatch) so a stale client cannot pick the till.
if (b.payAt !== undefined && !isPayAt(b.payAt)) return reply.code(400).send({ error: "payAt must be booth|bay" });
try {
const order = await service.createOrder({
identity: String(b.identity ?? ""),
categoryId: String(b.categoryId ?? ""),
serviceId: String(b.serviceId ?? ""),
...(b.payAt !== undefined ? { payAt: b.payAt } : {}),
actor: req.user.username,
});
return reply.code(201).send(order);
} catch (err) {
return sendError(reply, err);
}
},
);
app.post<{ Params: { id: string } }>("/api/carwash/orders/:id/done", { preHandler: update }, async (req, reply) => {
try {
return await service.markDone(req.params.id, req.user.username);
} catch (err) {
return sendError(reply, err);
}
});
app.post<{ Params: { id: string }; Body: { tender?: Tender } }>(
"/api/carwash/orders/:id/pay",
{ preHandler: update },
async (req, reply) => {
try {
return await service.payAtBay(req.params.id, (req.body?.tender ?? "cash") as Tender, req.user.username);
} catch (err) {
return sendError(reply, err);
}
},
);
app.post<{ Params: { id: string }; Body: { reason?: string } }>(
"/api/carwash/orders/:id/void",
{ preHandler: update },
async (req, reply) => {
try {
return await service.voidOrder(req.params.id, String(req.body?.reason ?? "").trim(), req.user.username);
} catch (err) {
return sendError(reply, err);
}
},
);
}
+765
View File
@@ -0,0 +1,765 @@
import { randomUUID } from "node:crypto";
import type { FastifyBaseLogger } from "fastify";
import {
and,
asc,
carwashCategories,
carwashConfig,
carwashOrders,
carwashPrices,
carwashServices,
desc,
eq,
inArray,
isNull,
type CarwashOrderRow,
type Db,
} from "@parking/db";
import {
CARWASH_PAY_AT,
CARWASH_PAY_AT_DEFAULT,
CARWASH_PROGRAM_ID,
type CarWashPayAt,
type CarwashOrderView,
type CarwashSettingsView,
type ChargeLine,
CARWASH_VISION_THRESHOLD_DEFAULT,
isVehicleClass,
reasonPayload,
type VehicleClass,
type VehicleRead,
type Tender,
type TillId,
} from "@parking/shared";
import type { EventLog } from "../../event-log.js";
import { vehicleForIdentity } from "../../plate-lookup.js";
import type { ReviewOutbox } from "./review-outbox.js";
import { effectiveModulesFor } from "../../modules.js";
import type { ChargeProvider, PayStation } from "../../pay-station.js";
import type { ShiftService } from "../../shift-service.js";
import { applyValidation, liveValidations } from "../../validations.js";
import type { ServerModuleDeps } from "../index.js";
// Car Wash — the module's whole behaviour (wiki/decisions/venue-modules.md, "Car Wash —
// the pilot module" + "v1 answers"). Master data is mutable rows; every order freezes
// what it sold (names + price) and signs its life onto the ledger; money at the bay is
// a signed `carwash_payment`; money at the booth rides the parking `payment` as a
// charge line (ChargeProvider below). The parking sponsorship is the site's "carwash"
// VALIDATION program, applied through the shared applyValidation() when a wash is done
// — the wash never touches parking code, it talks to the core through ServerModuleDeps.
/** A refusal the route maps to an HTTP status. */
/** The till bay money lands on — declared by the module manifest (MODULES). */
const CARWASH_TILL: TillId = "carwash";
export class CarwashError extends Error {
constructor(
readonly status: 400 | 404 | 409,
message: string,
readonly code?: string,
) {
super(message);
this.name = "CarwashError";
}
}
export interface SettingsBody {
categories?: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[];
services?: { id?: string; name?: string; active?: boolean }[];
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
/** Where wash money is taken at this site (site-level policy). */
payAt?: unknown;
visionThreshold?: unknown;
}
export interface CreateOrderInput {
identity: string;
categoryId: string;
serviceId: string;
/** Optional — the SITE policy decides; a stale client that sends a different value
* is refused (409 pay_at_policy) rather than silently overridden. */
payAt?: CarWashPayAt;
actor: string;
}
export interface TicketLookup {
identity: string;
found: boolean;
open: boolean;
subscription: boolean;
plate: string | null;
enteredAt: string | null;
currency: string | null;
orders: CarwashOrderView[];
/** What the camera saw at entry (advisory) and the category the site mapping
* suggests for it — the desk pre-selects it; the operator may change it. */
vision: VehicleRead | null;
suggestedCategoryId: string | null;
}
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
/** Stable slug for a new master-data row: from the name, else a random id. */
function slugify(name: string): string {
const s = name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return s || randomUUID();
}
export class CarwashService {
readonly #db: Db;
readonly #log: EventLog;
readonly #pay: PayStation;
readonly #shift: ShiftService;
readonly #logger: FastifyBaseLogger;
readonly #outbox: ReviewOutbox | null;
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
this.#db = deps.db;
this.#log = deps.eventLog;
this.#pay = deps.payStation;
this.#shift = deps.shiftService;
this.#logger = logger;
this.#outbox = outbox;
}
#enabled(): boolean {
return effectiveModulesFor(this.#db).includes("carwash");
}
// --- Settings (master data) -------------------------------------------------
settings(): CarwashSettingsView {
const categories = this.#db
.select()
.from(carwashCategories)
.where(isNull(carwashCategories.deletedAt))
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
.all()
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active, visionClasses: r.visionClasses.filter(isVehicleClass) }));
const services = this.#db
.select()
.from(carwashServices)
.where(isNull(carwashServices.deletedAt))
.orderBy(asc(carwashServices.sortOrder), asc(carwashServices.name))
.all()
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
const live = new Set([...categories.map((c) => c.id), ...services.map((s) => s.id)]);
const prices = this.#db
.select()
.from(carwashPrices)
.all()
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt(), visionThreshold: this.visionThreshold() };
}
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
payAt(): CarWashPayAt {
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
}
/** Confidence floor for a vision class to flag a category downgrade (site config). */
visionThreshold(): number {
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
return row?.visionThreshold ?? CARWASH_VISION_THRESHOLD_DEFAULT;
}
/** The category the site mapping suggests for a vision class (first active category
* listing it, in display order), or null when unmapped. */
#categoryForClass(cls: VehicleClass): { id: string; name: string } | null {
const rows = this.#db
.select()
.from(carwashCategories)
.where(isNull(carwashCategories.deletedAt))
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
.all();
const hit = rows.find((r) => r.active && r.visionClasses.includes(cls));
return hit ? { id: hit.id, name: hit.name } : null;
}
/** The site's currency = the active tariff's (the wash is priced in the same money
* the booth takes). null when no tariff is published yet. */
#currency(): string | null {
try {
// Any open session's quote carries it; without one, fall back to the tariff table.
const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get();
if (row) return row.currency;
} catch {
/* fall through */
}
return this.#pay.activeCurrency();
}
/** Full-replacement save of the three lists. Rows missing from the body are
* soft-deleted (orders already reference names + prices by value, so nothing
* historical changes). Signs one config_change. */
async saveSettings(body: SettingsBody, actor: string): Promise<CarwashSettingsView> {
const now = new Date().toISOString();
const upsertList = (
table: typeof carwashCategories | typeof carwashServices,
items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined,
label: string,
): string[] => {
if (items === undefined) {
return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id);
}
if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`);
const keep: string[] = [];
let sort = 0;
const seen = new Set<string>();
for (const it of items) {
const name = String(it?.name ?? "").trim();
if (!name) throw new CarwashError(400, `${label}: every item needs a name`);
let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name);
if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`);
// Two new items slugging to the same id → disambiguate rather than merge.
while (seen.has(id)) id = `${id}-${sort}`;
seen.add(id);
const active = it.active !== false;
// Vision mapping lives on CATEGORIES only; absent = keep what the row has.
let visionClasses: string[] | undefined;
if (table === carwashCategories && it.visionClasses !== undefined) {
if (!Array.isArray(it.visionClasses) || !it.visionClasses.every(isVehicleClass)) {
throw new CarwashError(400, `${label}: visionClasses must be an array of vehicle classes`);
}
visionClasses = [...new Set(it.visionClasses as string[])];
}
const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
if (existing) {
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run();
} else {
this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).run();
}
keep.push(id);
sort += 1;
}
const live = this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all();
for (const r of live) {
if (!keep.includes(r.id)) {
this.#db.update(table).set({ deletedAt: now, deletedBy: actor }).where(eq(table.id, r.id)).run();
}
}
return keep;
};
const categoryIds = upsertList(carwashCategories, body.categories, "categories");
const serviceIds = upsertList(carwashServices, body.services, "services");
if (body.prices !== undefined) {
if (!Array.isArray(body.prices)) throw new CarwashError(400, "prices must be an array");
const rows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
for (const p of body.prices) {
const categoryId = String(p?.categoryId ?? "");
const serviceId = String(p?.serviceId ?? "");
const priceMinor = p?.priceMinor;
if (!categoryIds.includes(categoryId)) throw new CarwashError(400, `prices: unknown category "${categoryId}"`);
if (!serviceIds.includes(serviceId)) throw new CarwashError(400, `prices: unknown service "${serviceId}"`);
if (!Number.isInteger(priceMinor) || (priceMinor as number) < 0) {
throw new CarwashError(400, "prices: priceMinor must be a non-negative integer");
}
rows.push({ categoryId, serviceId, priceMinor: priceMinor as number });
}
this.#db.delete(carwashPrices).run();
for (const r of rows) this.#db.insert(carwashPrices).values(r).run();
}
await this.#log.append({
type: "config_change",
source: "manual",
identity: "module:carwash",
payload: {
setting: "carwash.settings",
value: { categories: categoryIds.length, services: serviceIds.length, prices: body.prices?.length ?? null },
operator: actor,
},
});
// Where the money is taken — a site policy, signed on its own when it flips (it
// decides which till the cash lands on and whether the booth barrier or the exit
// reader releases the car; fraud-relevant, so it is attributed like other config).
if (body.payAt !== undefined) {
if (!isPayAt(body.payAt)) throw new CarwashError(400, "payAt must be booth|bay");
const prev = this.payAt();
if (body.payAt !== prev) {
this.#db
.insert(carwashConfig)
.values({ id: 1, payAt: body.payAt, updatedAt: now, updatedBy: actor })
.onConflictDoUpdate({ target: carwashConfig.id, set: { payAt: body.payAt, updatedAt: now, updatedBy: actor } })
.run();
await this.#log.append({
type: "config_change",
source: "manual",
identity: "module:carwash",
payload: { setting: "carwash.payAt", value: body.payAt, prev, operator: actor },
});
}
}
if (body.visionThreshold !== undefined) {
const v = Number(body.visionThreshold);
if (!Number.isFinite(v) || v < 0 || v > 1) throw new CarwashError(400, "visionThreshold must be between 0 and 1");
const prev = this.visionThreshold();
if (v !== prev) {
this.#db
.insert(carwashConfig)
.values({ id: 1, visionThreshold: v, updatedAt: now, updatedBy: actor })
.onConflictDoUpdate({ target: carwashConfig.id, set: { visionThreshold: v, updatedAt: now, updatedBy: actor } })
.run();
await this.#log.append({
type: "config_change",
source: "manual",
identity: "module:carwash",
payload: { setting: "carwash.visionThreshold", value: v, prev, operator: actor },
});
}
}
return this.settings();
}
// --- Orders ---------------------------------------------------------------------
#view(r: CarwashOrderRow): CarwashOrderView {
return {
id: r.id,
identity: r.identity,
plate: r.plate,
categoryId: r.categoryId,
categoryName: r.categoryName,
serviceId: r.serviceId,
serviceName: r.serviceName,
priceMinor: r.priceMinor,
currency: r.currency,
payAt: r.payAt,
status: r.status,
createdAt: r.createdAt,
createdBy: r.createdBy,
doneAt: r.doneAt,
doneBy: r.doneBy,
paidAt: r.paidAt,
paidBy: r.paidBy,
tender: (r.tender as Tender | null) ?? null,
closed: r.status === "void" || (r.status === "done" && r.paidAt != null),
validationEventId: r.validationEventId,
voidBy: r.voidBy,
voidReason: r.voidReason,
visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null,
visionConfidence: r.visionConfidence,
visionCategoryId: r.visionCategoryId,
downgradeEventId: r.downgradeEventId,
};
}
#row(id: string): CarwashOrderRow {
const r = this.#db.select().from(carwashOrders).where(eq(carwashOrders.id, id)).get();
if (!r) throw new CarwashError(404, "order not found");
return r;
}
/** The desk's queue: every order still needing something, oldest first. */
openOrders(): CarwashOrderView[] {
return this.#db
.select()
.from(carwashOrders)
.where(inArray(carwashOrders.status, ["open", "done"]))
.orderBy(asc(carwashOrders.createdAt))
.all()
.map((r) => this.#view(r))
.filter((o) => !o.closed);
}
/** Recent history (closed included), newest first. */
recentOrders(limit = 100): CarwashOrderView[] {
return this.#db
.select()
.from(carwashOrders)
.orderBy(desc(carwashOrders.createdAt))
.limit(Math.min(Math.max(limit, 1), 500))
.all()
.map((r) => this.#view(r));
}
#ordersFor(identity: string): CarwashOrderView[] {
return this.#db
.select()
.from(carwashOrders)
.where(eq(carwashOrders.identity, identity))
.orderBy(asc(carwashOrders.createdAt))
.all()
.map((r) => this.#view(r));
}
/** Ticket → session facts the desk needs (the parking ticket IS the customer). */
lookup(identity: string): TicketLookup {
const id = identity.trim();
const s = this.#pay.lookup(id);
const vision = s.found ? vehicleForIdentity(this.#db, id) : null;
return {
identity: id,
found: s.found,
open: s.open,
subscription: s.subscription,
plate: s.plate,
enteredAt: s.enteredAt,
currency: s.currency,
orders: this.#ordersFor(id),
vision,
suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null,
};
}
async createOrder(input: CreateOrderInput): Promise<CarwashOrderView> {
const identity = input.identity.trim();
if (!identity) throw new CarwashError(400, "identity (ticket) required");
const s = this.#pay.lookup(identity);
if (!s.found) throw new CarwashError(404, "no session for ticket");
if (!s.open) throw new CarwashError(409, "session is closed");
if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription");
const category = this.#db
.select()
.from(carwashCategories)
.where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt)))
.get();
if (!category || !category.active) throw new CarwashError(404, "category not found or inactive");
const service = this.#db
.select()
.from(carwashServices)
.where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt)))
.get();
if (!service || !service.active) throw new CarwashError(404, "service not found or inactive");
const price = this.#db
.select()
.from(carwashPrices)
.where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id)))
.get();
if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price");
// The SITE decides where wash money is taken (Setup → Car wash); the order freezes
// the policy in force. A client that still sends a different value is stale.
const payAt = this.payAt();
if (input.payAt !== undefined && input.payAt !== payAt) {
throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy");
}
const currency = s.currency ?? this.#pay.activeCurrency();
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
// Vision, advisory: what the camera saw at entry and the category the site maps it
// to. A DOWNGRADE — the operator chose a category that prices LOWER than the mapped
// one for this service, with the read above the site threshold — is signed as an
// anomaly for the reviewer (both categories, operator, snapshot). Recorded only:
// never blocks, no reason prompt (user, 2026-09-06).
const vision = vehicleForIdentity(this.#db, identity);
const visionCategory = vision ? this.#categoryForClass(vision.bodyType) : null;
let downgradeEventId: string | null = null;
if (vision && visionCategory && visionCategory.id !== category.id && vision.confidence >= this.visionThreshold()) {
const visionPrice = this.#db
.select()
.from(carwashPrices)
.where(and(eq(carwashPrices.categoryId, visionCategory.id), eq(carwashPrices.serviceId, service.id)))
.get();
if (visionPrice && visionPrice.priceMinor > price.priceMinor) {
const ev = await this.#log.append({
type: "anomaly",
source: "manual",
identity,
payload: {
...reasonPayload("carwash.categoryDowngrade", {
visionClass: vision.bodyType,
visionCategory: visionCategory.name,
operator: input.actor,
chosenCategory: category.name,
}),
sessionRef: identity,
visionClass: vision.bodyType,
visionConfidence: vision.confidence,
visionCategoryId: visionCategory.id,
visionCategoryName: visionCategory.name,
chosenCategoryId: category.id,
chosenCategoryName: category.name,
serviceName: service.name,
visionPriceMinor: visionPrice.priceMinor,
chosenPriceMinor: price.priceMinor,
currency,
snapshotId: vision.snapshotId,
operator: input.actor,
},
});
downgradeEventId = ev.id;
}
}
const now = new Date().toISOString();
const row: CarwashOrderRow = {
id: randomUUID(),
identity,
plate: s.plate,
categoryId: category.id,
categoryName: category.name,
serviceId: service.id,
serviceName: service.name,
priceMinor: price.priceMinor,
currency,
payAt,
status: "open",
createdAt: now,
createdBy: input.actor,
doneAt: null,
doneBy: null,
paidAt: null,
paidBy: null,
tender: null,
paymentEventId: null,
validationEventId: null,
voidAt: null,
voidBy: null,
voidReason: null,
visionClass: vision?.bodyType ?? null,
visionConfidence: vision?.confidence ?? null,
visionCategoryId: visionCategory?.id ?? null,
downgradeEventId,
};
this.#db.insert(carwashOrders).values(row).run();
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
if (vision && this.#outbox?.enabled) {
void this.#outbox.enqueue(
{
orderId: row.id,
createdAt: now,
createdBy: input.actor,
categoryId: category.id,
categoryName: category.name,
categoryClasses: category.visionClasses,
serviceName: service.name,
visionCategoryId: visionCategory?.id ?? null,
downgraded: downgradeEventId != null,
},
vision,
);
}
await this.#log.append({
type: "carwash_order",
source: "manual",
identity,
payload: {
sessionRef: identity,
orderId: row.id,
action: "created",
categoryName: row.categoryName,
serviceName: row.serviceName,
priceMinor: row.priceMinor,
currency,
payAt: row.payAt,
operator: input.actor,
},
});
return this.#view(row);
}
/** The wash is finished: apply the site's sponsorship program to the parking session
* (if one is configured and active), then — for a bay order already paid — settle
* the parking session so the exit reader opens. */
async markDone(id: string, actor: string): Promise<CarwashOrderView> {
const r = this.#row(id);
if (r.status === "void") throw new CarwashError(409, "order is void");
if (r.status === "done") throw new CarwashError(409, "order is already done");
const now = new Date().toISOString();
let validationEventId: string | null = null;
// Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from
// the order's intake to now (= done) — and the order's frozen price. NOT the time
// since entry: a car parked for hours before it asks for a wash still pays for those
// hours (found 2026-09-05 on a long-open ticket that would have been fully comped).
// The credit lands at the start of the billed period (that is how timeCredit
// folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff
// may differ by an increment. See applyValidation().
const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000));
const applied = await applyValidation(this.#db, this.#log, {
programId: CARWASH_PROGRAM_ID,
identity: r.identity,
actor,
wash: { washMinutes, priceMinor: r.priceMinor },
});
if (applied.ok) validationEventId = applied.eventId;
else if (applied.status !== 404 && !/already applied/.test(applied.error)) {
// A real refusal (session closed, daily cap …) — the wash is still done; the
// customer simply gets no sponsorship. Keep it visible in the log.
this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`);
}
this.#db
.update(carwashOrders)
.set({ status: "done", doneAt: now, doneBy: actor, validationEventId })
.where(eq(carwashOrders.id, id))
.run();
await this.#log.append({
type: "carwash_order",
source: "manual",
identity: r.identity,
payload: {
sessionRef: r.identity,
orderId: id,
action: "done",
categoryName: r.categoryName,
serviceName: r.serviceName,
priceMinor: r.priceMinor,
currency: r.currency,
payAt: r.payAt,
...(validationEventId ? { validationEventId } : {}),
operator: actor,
},
});
const updated = this.#row(id);
if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor);
return this.#view(updated);
}
/** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's
* drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a
* carwash_payment on that till; then, if the wash is also done, settles the
* parking session. */
async payAtBay(id: string, tender: Tender, actor: string): Promise<CarwashOrderView> {
const r = this.#row(id);
if (r.status === "void") throw new CarwashError(409, "order is void");
if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth");
if (r.paidAt != null) throw new CarwashError(409, "order is already paid");
if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card");
this.#shift.requireOpenShift(CARWASH_TILL);
const ev = await this.#log.append({
type: "carwash_payment",
source: "manual",
identity: r.identity,
payload: {
sessionRef: r.identity,
orderId: id,
amountMinor: r.priceMinor,
currency: r.currency,
tender,
till: CARWASH_TILL,
categoryName: r.categoryName,
serviceName: r.serviceName,
operator: actor,
},
});
const now = new Date().toISOString();
this.#db
.update(carwashOrders)
.set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id })
.where(eq(carwashOrders.id, id))
.run();
const updated = this.#row(id);
if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender);
return this.#view(updated);
}
/** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign
* the $0 parking payment now — that is what the exit READER checks (a validation
* alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */
async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise<void> {
try {
const s = this.#pay.lookup(r.identity);
if (!s.open || s.subscription || s.paidAt != null) return;
const q = this.#pay.quote(r.identity);
if (q.amountMinor !== 0) return;
await this.#pay.pay(r.identity, tender);
this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`);
} catch (err) {
this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`);
}
}
async voidOrder(id: string, reason: string, actor: string): Promise<CarwashOrderView> {
const r = this.#row(id);
if (r.status === "void") throw new CarwashError(409, "order is already void");
if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid");
const now = new Date().toISOString();
// Take back the sponsorship if it is still live (not consumed by a payment).
if (r.validationEventId) {
const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId);
if (live) {
await this.#log.append({
type: "validation",
source: "manual",
identity: r.identity,
payload: {
sessionRef: r.identity,
refId: r.validationEventId,
programId: live.programId,
programLabel: live.label,
operator: actor,
},
});
}
}
this.#db
.update(carwashOrders)
.set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null })
.where(eq(carwashOrders.id, id))
.run();
await this.#log.append({
type: "carwash_order",
source: "manual",
identity: r.identity,
payload: {
sessionRef: r.identity,
orderId: id,
action: "void",
categoryName: r.categoryName,
serviceName: r.serviceName,
priceMinor: r.priceMinor,
currency: r.currency,
payAt: r.payAt,
reason: reason || undefined,
operator: actor,
},
});
return this.#view(this.#row(id));
}
// --- Booth settlement hook ------------------------------------------------------
/** Orders with payAt = "booth" ride the parking payment as charge lines; the core
* calls back after the payment is signed so they are marked paid. Off = no lines. */
chargeProvider(): ChargeProvider {
return {
lines: (identity) => {
if (!this.#enabled()) return [];
return this.#db
.select()
.from(carwashOrders)
.where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt)))
.all()
.filter((r) => r.status !== "void")
.map((r) => ({
module: "carwash" as const,
ref: r.id,
label: `Lavazh — ${r.categoryName} · ${r.serviceName}`,
amountMinor: r.priceMinor,
}));
},
onPaid: async (_identity, lines, payment) => {
const now = new Date().toISOString();
for (const l of lines) {
if (l.module !== "carwash") continue;
this.#db
.update(carwashOrders)
.set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId })
.where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt)))
.run();
}
},
};
}
}
/** Type guard for the void body etc. */
export function isPayAt(v: unknown): v is CarWashPayAt {
return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v);
}
+61
View File
@@ -0,0 +1,61 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared";
import type { EventLog } from "../event-log.js";
import type { PayStation } from "../pay-station.js";
import type { ShiftService } from "../shift-service.js";
import { effectiveModulesFor } from "../modules.js";
import { carwashModule } from "./carwash/index.js";
import { validationModule } from "./validation/index.js";
// The server-side module registry. A module's routes live in its own folder
// (apps/server/src/modules/<id>/index.ts) and are registered by iterating
// @parking/shared's MODULES — so adding a module is one manifest entry + one folder +
// one line in SERVER_MODULES below, with nothing else in the core touched
// (wiki/decisions/venue-modules.md, "A module = a manifest + three folders").
//
// `parking` is registered in the manifest but has NO folder yet: its routes are still
// the flat list in server.ts. That is deliberate — the seam is drawn, the code moves
// across it subsystem by subsystem as each is touched, not in one big move.
/** What the core hands a module at registration. Modules reach the core ONLY through
* these (never by importing another module): the DB, the signed ledger, the booth
* settlement (to fold charges in / settle a session — PayStation.registerChargeProvider,
* quote, pay) and the shift service (money needs an open shift). */
export interface ServerModuleDeps {
db: Db;
eventLog: EventLog;
payStation: PayStation;
shiftService: ShiftService;
}
export interface ServerModule {
id: ModuleId;
register(app: FastifyInstance, deps: ServerModuleDeps): Promise<void>;
}
const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = {
validation: validationModule,
carwash: carwashModule,
};
/** Register every folder-based module in registry order, then log what this site
* is entitled to / has effective, so a "why is X missing" question is answerable
* from the container log alone. */
export async function registerModules(app: FastifyInstance, deps: ServerModuleDeps): Promise<void> {
for (const manifest of MODULES) {
const impl = SERVER_MODULES[manifest.id];
if (impl) {
if (impl.id !== manifest.id) throw new Error(`module registry mismatch: ${impl.id} registered under ${manifest.id}`);
await impl.register(app, deps);
}
}
const { entitled, unknown } = parseEntitledModules(process.env.MODULES_ENTITLED);
if (unknown.length > 0) {
app.log.warn({ unknown }, "MODULES_ENTITLED names unknown module ids — ignored");
}
app.log.info(
{ entitled, effective: effectiveModulesFor(deps.db) },
"venue modules (entitled = MODULES_ENTITLED env; effective = entitled ∩ site activation)",
);
}
@@ -0,0 +1,13 @@
import { validationRoutes } from "../../routes/validations.js";
import type { ServerModule } from "../index.js";
// Merchant-scan ticket validation as a venue module. Kept for the Bar until a Bar
// module absorbs it (wiki/decisions/venue-modules.md, decision 1). The routes
// themselves still live in routes/validations.ts (unchanged location, now guarded by
// requireModule("validation")); this folder is the registry hook.
export const validationModule: ServerModule = {
id: "validation",
async register(app, { db, eventLog }) {
await validationRoutes(app, db, eventLog);
},
};

Some files were not shown because too many files have changed in this diff Show More