135 Commits

Author SHA1 Message Date
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 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 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 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
257 changed files with 26809 additions and 1887 deletions
+39 -1
View File
@@ -1,6 +1,6 @@
name: Build & push images
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
# 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
@@ -13,6 +13,8 @@ on:
- 'apps/server/**'
- 'apps/web/**'
- 'apps/vision/**'
- 'apps/collector/**'
- 'apps/trainer/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
@@ -60,6 +62,11 @@ jobs:
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
@@ -92,24 +99,55 @@ jobs:
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
+5
View File
@@ -48,6 +48,11 @@ jobs:
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.
+183 -26
View File
@@ -1,12 +1,24 @@
name: Release desktop
# Build the signed Tauri desktop installers on a version tag and publish them as
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
# fetches these; latest.json + each installer + its .sig are what it needs.
# 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, and uploads
# everything to the Release for that tag.
# 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:
@@ -63,6 +75,27 @@ jobs:
- 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
@@ -73,43 +106,88 @@ jobs:
- name: Collect artifacts
id: collect
# Gather the installers + their .sig into a flat dist/ for upload.
# 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' \) \
-exec cp {} dist/ \;
-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}. We point the AppImage target at
# this release's asset URL. Adjust the platform keys you actually ship.
# 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 }}
REPO: ${{ github.repository }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
VERSION="${TAG#v}"
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
SIG=$(cat "dist/${APPIMAGE}.sig")
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
cat > dist/latest.json <<JSON
{
"version": "${VERSION}",
"notes": "Parking System ${TAG}",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"linux-x86_64": {
"signature": "${SIG}",
"url": "${ASSET_URL}"
}
}
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);
}
JSON
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)
@@ -129,12 +207,12 @@ jobs:
-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)
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)
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
echo "release id: ${REL_ID}"
for f in dist/*; do
@@ -147,3 +225,82 @@ jobs:
"${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"
+6
View File
@@ -27,3 +27,9 @@ dist/
# 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
+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" } },
});
+40 -3
View File
@@ -35,8 +35,45 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
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), auto-update, code signing, 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.
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.
+578 -8
View File
@@ -318,6 +318,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -346,10 +363,39 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -399,6 +445,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -506,6 +561,18 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dbus"
version = "0.9.11"
@@ -635,6 +702,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -721,6 +797,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1209,6 +1299,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1298,6 +1407,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1321,6 +1431,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.9",
]
[[package]]
@@ -1341,9 +1452,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1744,6 +1857,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1759,6 +1878,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2178,8 +2303,11 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-http",
"tauri-plugin-process",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-websocket",
]
[[package]]
@@ -2330,6 +2458,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2398,6 +2535,22 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -2407,6 +2560,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -2428,6 +2637,61 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2503,6 +2767,49 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.9",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -2616,6 +2923,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -2625,7 +2933,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
@@ -2663,6 +2971,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2745,7 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -2885,6 +3199,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -2948,6 +3274,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2955,7 +3292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3137,6 +3474,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -3157,6 +3505,27 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3178,7 +3547,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -3268,7 +3637,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3367,6 +3736,54 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -3377,6 +3794,22 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
@@ -3393,7 +3826,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
@@ -3410,6 +3843,26 @@ dependencies = [
"zip",
]
[[package]]
name = "tauri-plugin-websocket"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca243c7f0bf935cd81123e07f82188ccb919b19fbfc74518b947eedc4619bbb"
dependencies = [
"futures-util",
"http",
"log",
"rand 0.9.5",
"rustls",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3639,9 +4092,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -3652,6 +4117,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -3837,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -3877,6 +4370,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4141,6 +4652,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
@@ -4206,6 +4727,24 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4391,6 +4930,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4820,6 +5370,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
+15
View File
@@ -22,6 +22,21 @@ 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.
@@ -6,6 +6,18 @@
"permissions": [
"core:default",
"updater:default",
"process: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://*:*" }
]
}
]
}
+14 -3
View File
@@ -3,9 +3,10 @@
// 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 the local Fastify server, 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).
// 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() {
@@ -16,6 +17,16 @@ pub fn run() {
// 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");
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System",
"version": "0.0.0",
"version": "0.2.0",
"identifier": "com.parking.desktop",
"build": {
"devUrl": "http://localhost:5173",
@@ -24,7 +24,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
}
},
"bundle": {
@@ -41,9 +41,9 @@
},
"plugins": {
"updater": {
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). 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.",
"//": "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/parking_solution/releases/latest/download/latest.json"
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
}
+27
View File
@@ -77,3 +77,30 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
# 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
+6
View File
@@ -15,6 +15,7 @@ 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/
@@ -47,6 +48,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
# ---- 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
+35 -1
View File
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const bcrypt = require("bcrypt");
const { createDb, users, eq } = require("@parking/db");
const { createDb, users, roles, eq } = require("@parking/db");
const DEFAULT_USERNAME = "admin";
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
}
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();
if (existing && process.env.FORCE !== "1") {
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
@@ -73,4 +81,30 @@ if (existing) {
});
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);
+19
View File
@@ -84,6 +84,7 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
};
@@ -177,6 +178,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
})),
@@ -207,6 +209,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
})),
@@ -256,6 +259,22 @@ describe("AnprBridge", () => {
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 });
+7 -2
View File
@@ -192,7 +192,12 @@ export class AnprBridge {
const hardCap = Date.now() + this.#pollMaxMs;
let attempts = 0;
try {
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
// 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);
@@ -229,7 +234,7 @@ export class AnprBridge {
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);
+52 -3
View File
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify";
import { eq, rolePermissions, type Db } from "@parking/db";
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
@@ -143,10 +143,41 @@ export function initAuth(db: Db): void {
permsCache.clear();
}
/** Clear the permission cache. Call after ANY write to roles / role_permissions
* (or a user's roleId) so the change takes effect on the next request. */
/** 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. */
@@ -184,12 +215,29 @@ export function requirePermission(...required: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
assertCsrf(req);
refreshRole(req);
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
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
@@ -201,4 +249,5 @@ export async function requireAuth(
): 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();
});
});
+71 -17
View File
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
// 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 {
@@ -65,16 +71,33 @@ export class BackupService {
readonly #logger?: FastifyBaseLogger;
#running = false;
#lastSuccessAt: string | null = null;
#lastResult: BackupResult | null = null;
#lastErrorAt: string | null = null;
#lastError: string | null = null;
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();
@@ -104,6 +127,15 @@ export class BackupService {
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(),
@@ -111,12 +143,10 @@ export class BackupService {
keepDailyDays: r.keepDailyDays,
keyPresent: this.keyPresent,
running: this.#running,
lastSuccessAt: this.#lastSuccessAt,
lastResult: this.#lastResult
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
: null,
lastErrorAt: this.#lastErrorAt,
lastError: this.#lastError,
lastSuccessAt: row?.backupLastSuccessAt ?? null,
lastResult,
lastErrorAt: row?.backupLastErrorAt ?? null,
lastError: row?.backupLastError ?? null,
};
}
@@ -139,14 +169,17 @@ export class BackupService {
try {
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
this.#lastResult = res;
this.#lastSuccessAt = new Date().toISOString();
this.#lastError = null;
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) {
this.#lastError = (err as Error).message;
this.#lastErrorAt = new Date().toISOString();
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
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;
@@ -156,13 +189,34 @@ export class BackupService {
return this.#inflight;
}
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
/**
* 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;
}
}
+7 -1
View File
@@ -6,6 +6,7 @@ import {
type PrinterInstance,
type ReceiptData,
type TicketHeader,
printerRoleOf,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js";
@@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
const role = printerRoleOf(cfg);
try {
out.push({
id: row.id,
@@ -80,6 +81,8 @@ function receiptFigures(
currency?: string;
tender?: "cash" | "card";
graceExitMin?: number;
grossMinor?: number;
validationLines?: { label: string; discountMinor: number }[];
};
return {
ticketId,
@@ -89,6 +92,9 @@ function receiptFigures(
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,
};
}
+68 -1
View File
@@ -192,11 +192,78 @@ describe("ButtonLightController truth table", () => {
// First write (initial off) throws — must be swallowed.
expect(() => ctl.start()).not.toThrow();
await flush();
// Subsequent writes work; driving to solid still converges to ON.
// 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();
});
+78 -14
View File
@@ -21,6 +21,14 @@ 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. */
@@ -44,6 +52,16 @@ interface LampState {
/** 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
@@ -111,6 +129,10 @@ export class ButtonLightController {
desiredOn: false,
confirmedOn: null,
sending: false,
failCount: 0,
retryAt: 0,
retryTimer: null,
lastFailLogAt: 0,
});
}
}
@@ -118,10 +140,7 @@ export class ButtonLightController {
// Drop lamps whose controller no longer declares one (or was disabled/removed).
for (const [key, lamp] of this.#lamps) {
if (seen.has(key)) continue;
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
this.#disarm(lamp);
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(key);
}
@@ -207,10 +226,17 @@ export class ButtonLightController {
* 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. */
* 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;
@@ -219,15 +245,42 @@ export class ButtonLightController {
.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 next pump retries this state. Never escalates.
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
// 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 (or the send failed) while we were busy —
// re-pump to converge. This is what makes the final state authoritative.
// 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);
});
}
@@ -261,20 +314,31 @@ export class ButtonLightController {
this.#unsubInput = null;
this.#unsubLane = null;
for (const lamp of this.#lamps.values()) {
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
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. */
* 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);
}
+29 -3
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from "node:events";
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.).
// Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -24,6 +25,13 @@ export interface DeviceReadEvent {
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
}
@@ -45,7 +53,7 @@ export interface ReadOutcome {
export interface PrinterStatusEvent {
readonly deviceId: string; // devices id
readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt
readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
readonly status: PrinterStatus;
}
@@ -67,10 +75,10 @@ export interface DeviceStatusEvent {
* 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)
* - 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" | null;
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
readonly state: "ready" | "degraded" | "offline";
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
@@ -98,6 +106,17 @@ export interface PlateRecognizedEvent {
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:
@@ -190,6 +209,13 @@ class DeviceEventBus extends EventEmitter {
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. */
+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",
);
});
});
+70 -3
View File
@@ -1,9 +1,10 @@
import type { FastifyBaseLogger } from "fastify";
import { devices, type Db, type DeviceRow } from "@parking/db";
import { isMonitorable, registry } from "@parking/devices";
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). */
@@ -23,13 +24,47 @@ const VISION_STATUS_ID = "vision-service";
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)
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
*/
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) {
@@ -54,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
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:
@@ -139,6 +175,7 @@ export class DeviceMonitor {
};
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,
@@ -146,7 +183,7 @@ export class DeviceMonitor {
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
} else {
try {
const device = driver.create(cfg as never);
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)) {
@@ -163,6 +200,33 @@ export class DeviceMonitor {
}
}
// 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);
}
@@ -183,6 +247,9 @@ export class DeviceMonitor {
});
}
/** 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);
+13 -1
View File
@@ -289,7 +289,19 @@ export function firstRelayByDirection(db: Db, direction: FlowDirection): Resolve
(r): r is RelaySpec & { direction: Direction } =>
r.direction === direction || r.direction === "both",
);
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
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;
}
@@ -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);
});
});
+107 -16
View File
@@ -9,10 +9,11 @@ import {
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 } from "./device-events.js";
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";
@@ -48,9 +49,21 @@ import type { VisionClient } from "./vision-client.js";
// 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. */
@@ -72,6 +85,10 @@ export class EntryFlow {
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;
@@ -121,6 +138,12 @@ export class EntryFlow {
}
}
/** 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}`;
@@ -153,17 +176,35 @@ export class EntryFlow {
}
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
* 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();
if (typeof r.presenceInput === "number") {
// 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";
return null;
// 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) {
@@ -176,6 +217,13 @@ export class EntryFlow {
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 {
@@ -244,7 +292,14 @@ export class EntryFlow {
*/
async #issueTicket(
resolved: ResolvedRelay,
opts: { source: "ticket" | "manual"; operator?: string; overCapacity?: { count: number; capacity: number | null } },
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();
@@ -304,6 +359,9 @@ export class EntryFlow {
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,
});
@@ -366,13 +424,33 @@ export class EntryFlow {
const resolved = firstRelayByDirection(this.#db, "entry");
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
// PRESENCE GATE — require BOTH a presence loop (configured + currently occupied) AND
// the camera confirming a vehicle. No loop configured → feature unavailable here.
if (typeof resolved.presenceInput !== "number") {
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable" };
// 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");
}
const present = this.#guardState(resolved).present;
if (!present || !cameraBusy) {
// 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)}`,
@@ -380,11 +458,14 @@ export class EntryFlow {
...reasonPayload("entry.issue.noPresence", { operator }),
source: "booth",
operator,
radarPresent: present,
radarPresent,
cameraBusy,
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
},
});
this.#logger.warn(`operator entry refused by ${operator}: no vehicle present (radar=${present}, camera=${cameraBusy})`);
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" };
}
@@ -397,6 +478,7 @@ export class EntryFlow {
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 };
@@ -409,11 +491,20 @@ export class EntryFlow {
* 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 {
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
// `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);
@@ -433,7 +524,7 @@ export class EntryFlow {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
const role = printerRoleOf(cfg);
try {
out.push({
id: row.id,
@@ -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);
});
});
+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 });
});
});
+69 -11
View File
@@ -25,6 +25,14 @@ 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;
@@ -33,7 +41,10 @@ export interface LogRetention {
}
export const DEFAULT_RETENTION: LogRetention = {
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
// 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),
};
@@ -59,6 +70,16 @@ export class LogService {
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;
@@ -82,22 +103,53 @@ export class LogService {
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: randomUUID(),
id,
level: row.level,
source: row.source,
message: clamp(row.message, MAX_MESSAGE) ?? "",
context: safeContext(row.context),
message,
context: baseContext,
httpStatus: row.httpStatus ?? null,
path: clamp(row.path, 512),
path,
stack: clamp(row.stack, MAX_STACK),
userId: row.userId ?? null,
userAgent: clamp(row.userAgent, 512),
createdAt: row.createdAt ?? new Date().toISOString(),
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.)
@@ -192,9 +244,10 @@ export class LogService {
/**
* 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, map the numeric level
* to a name, 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.
* 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,
@@ -218,12 +271,17 @@ export function pinoDbStream(
}
try {
const obj = JSON.parse(line) as {
level?: number;
level?: number | string;
msg?: string;
err?: { stack?: string; message?: string };
[k: string]: unknown;
};
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
// 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;
+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);
},
};
+130 -4
View File
@@ -1,9 +1,10 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
import { BOOTH_TILL, priceSession, type ChargeLine, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { windowOwedBetween } from "./subscription-window.js";
import { liveValidations } from "./validations.js";
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
// car (pay-on-foot — payment is decoupled from exit). Two steps:
@@ -28,6 +29,18 @@ export class NoTariffError extends Error {
}
}
/**
* A module that folds its own charges into a booth settlement (venue-modules.md):
* `lines(identity)` returns the open charges for the session (e.g. wash orders with
* payAt = "booth"); after the `payment` is signed, `onPaid` lets the module mark them
* settled. Registered by the module at boot (registerChargeProvider) — PayStation
* never imports a module.
*/
export interface ChargeProvider {
lines(identity: string): ChargeLine[];
onPaid(identity: string, lines: ChargeLine[], payment: { eventId: string; tender: Tender; operator?: string }): Promise<void>;
}
export interface Quote {
readonly identity: string;
/** Vehicle entry time (the session's original entry; for display/audit). */
@@ -38,8 +51,23 @@ export interface Quote {
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now]. */
/** Amount owed now: the parking fee for [periodStart → now] NET of merchant
* validations, PLUS any module charge lines (a wash paid at the booth). */
readonly amountMinor: number;
/** The parking-only net (amountMinor − chargesMinor). */
readonly parkingMinor: number;
/** Non-parking charges folded in by modules (see ChargeProvider). */
readonly chargeLines: ChargeLine[];
readonly chargesMinor: number;
/** The pre-validation fee (= amountMinor when no validations apply). */
readonly grossMinor: number;
/** Total the merchant validations took off (gross − net). */
readonly discountMinor: number;
/** Per-validation receipt/display lines (empty when none apply). */
readonly validationLines: ValidationLine[];
/** The validation event ids this quote applied — the payment stamps them as
* CONSUMED so an overstay's fresh period never re-applies them. */
readonly validationIds: string[];
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean;
readonly currency: string;
@@ -117,12 +145,22 @@ export interface SessionLookup {
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */
readonly plate: string | null;
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
* fee, the total taken off, and the per-validation lines for the modal/receipt.
* grossMinor/discountMinor are null when no quote resolved. */
readonly grossMinor: number | null;
readonly discountMinor: number | null;
readonly validationLines: ValidationLine[];
/** Module charge lines folded into `amountMinor` (e.g. a wash paid at the booth). */
readonly chargeLines: ChargeLine[];
readonly chargesMinor: number | null;
}
export class PayStation {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #chargeProviders: ChargeProvider[] = [];
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
@@ -130,6 +168,30 @@ export class PayStation {
this.#logger = logger;
}
/** Let a module fold its charges into booth settlements (see ChargeProvider). */
registerChargeProvider(p: ChargeProvider): void {
this.#chargeProviders.push(p);
}
/** The currency of the tariff in force right now (null = none published). Modules
* price their own goods in the same money the booth takes. */
activeCurrency(): string | null {
return this.#tariffVersionFor(new Date().toISOString())?.currency ?? null;
}
#chargeLines(identity: string): ChargeLine[] {
const out: ChargeLine[] = [];
for (const p of this.#chargeProviders) {
try {
out.push(...p.lines(identity));
} catch (err) {
// A module's fault must never block a parking settlement — log and price without it.
this.#logger.error(`charge provider failed for ${identity}: ${(err as Error).message}`);
}
}
return out;
}
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
@@ -155,19 +217,33 @@ export class PayStation {
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
// matters for grace/overstay; pass it through. Overstay → fresh period from
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
// prior payment) so the quote is NET — the payment then stamps their ids as
// consumed. See wiki/concepts/validation-discounts.md.
const last = this.#lastPayment(identity);
const validations = liveValidations(this.#db, identity);
const p = priceSession(
entry.occurredAt,
new Date().toISOString(),
structure,
last ? [last] : [],
category,
validations,
);
const chargeLines = this.#chargeLines(identity);
const chargesMinor = chargeLines.reduce((sum, l) => sum + l.amountMinor, 0);
return {
identity,
enteredAt: entry.occurredAt,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
amountMinor: p.amountMinor + chargesMinor,
parkingMinor: p.amountMinor,
chargeLines,
chargesMinor,
grossMinor: p.grossMinor,
discountMinor: p.discountMinor,
validationLines: p.validationLines,
validationIds: validations.map((v) => v.eventId),
overstay: p.overstay,
currency: tv.currency,
tariffVersionId: tv.id,
@@ -221,6 +297,7 @@ export class PayStation {
amountMinor,
currency: subWindow.currency ?? undefined,
tender,
till: BOOTH_TILL,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
@@ -233,7 +310,7 @@ export class PayStation {
const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({
const paymentEvent = await this.#log.append({
type: "payment",
source: "manual",
identity,
@@ -242,10 +319,34 @@ export class PayStation {
amountMinor,
currency: q.currency,
tender,
// Parking money is BOOTH money (a wash paid at the booth rides along as
// chargeLines, so it is booth money too). See wiki/concepts/shift.md "Tills".
till: BOOTH_TILL,
tariffVersionId: q.tariffVersionId,
// Module charges (e.g. a wash paid at the booth): frozen as lines so the
// receipt reproduces and reporting can split parking from the rest.
...(q.chargeLines.length
? {
chargeLines: q.chargeLines.map((l) => ({ ...l })),
chargesMinor: q.chargesMinor,
parkingMinor: q.parkingMinor,
}
: {}),
// The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin,
// Merchant validations: record the gross/discount split + CONSUME the applied
// validation ids, so reporting sees the leakage and a later overstay period
// never re-applies them. A zero-net settlement (full comp) is still a signed
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
...(q.validationIds.length
? {
grossMinor: q.grossMinor,
discountMinor: q.discountMinor,
validationIds: q.validationIds,
validationLines: q.validationLines.map((l) => ({ ...l })),
}
: {}),
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
},
});
@@ -257,6 +358,17 @@ export class PayStation {
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
}
// Let each module mark the charge lines it contributed as settled by this payment.
if (q.chargeLines.length) {
for (const p of this.#chargeProviders) {
try {
await p.onPaid(identity, q.chargeLines, { eventId: paymentEvent.id, tender });
} catch (err) {
this.#logger.error(`charge provider onPaid failed for ${identity}: ${(err as Error).message}`);
}
}
}
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
return { amountMinor, currency: q.currency };
}
@@ -282,6 +394,8 @@ export class PayStation {
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
grossMinor: null, discountMinor: null, validationLines: [],
chargeLines: [], chargesMinor: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -319,11 +433,21 @@ export class PayStation {
// exit gate clears. See wiki/entities/subscription.md.
let amountMinor: number | null = null;
let currency: string | null = null;
let grossMinor: number | null = null;
let discountMinor: number | null = null;
let validationLines: ValidationLine[] = [];
let chargeLines: ChargeLine[] = [];
let chargesMinor: number | null = null;
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
grossMinor = q.grossMinor;
discountMinor = q.discountMinor;
validationLines = q.validationLines;
chargeLines = q.chargeLines;
chargesMinor = q.chargesMinor;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
@@ -344,6 +468,8 @@ export class PayStation {
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
grossMinor, discountMinor, validationLines,
chargeLines, chargesMinor,
};
}
+33
View File
@@ -1,4 +1,5 @@
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
import { isNormBox, isVehicleClass, type VehicleRead } from "@parking/shared";
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
@@ -23,6 +24,38 @@ interface ReadDetail {
plate?: string;
confidence?: number;
direction?: string;
bodyType?: string;
bodyConfidence?: number;
snapshotId?: string;
vehicleBox?: unknown;
plateBox?: unknown;
}
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
* preference as the plate (entry over exit, newest first). Null when vision never
* classified the vehicle. See venue-modules.md §Vehicle category from vision. */
export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null {
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
let fallback: VehicleRead | null = null;
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
const v: VehicleRead = {
bodyType: d.bodyType,
confidence: d.bodyConfidence,
snapshotId: d.snapshotId ?? null,
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
plateBox: isNormBox(d.plateBox) ? d.plateBox : null,
};
if (d.direction === "entry") return v;
if (!fallback) fallback = v;
}
return fallback;
}
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
@@ -0,0 +1,103 @@
import { randomUUID } from "node:crypto";
import { beforeEach, describe, expect, it } from "vitest";
import { devices, deviceEvents as deviceEventsTable, ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { ReadDispatcher } from "./read-dispatch.js";
import { ExitFlow } from "./exit-flow.js";
import { SubscriptionFlow } from "./subscription-flow.js";
import type { DeviceReadEvent } from "./device-events.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// STRUCTURAL FILTER at the dispatcher (2026-07-04): a reader value that matched
// nothing AND can't possibly be a credential we issued (no ticket Luhn shape, no
// SUB-/SUBSESS- prefix, not a confirmed-RF read) is refused with UNSIGNED telemetry
// instead of reaching the exit flow and signing a noSession anomaly. Born from the
// park-buzi phantom optical decodes: red "who is exiting?" rows for NOBODY train the
// operator to ignore the signed feed. Anything plausibly ours STILL signs normally.
let db: Db;
let dispatcher: ReadDispatcher;
const READER = "reader-exit";
beforeEach(() => {
({ db } = createTestDb());
db.insert(devices).values({
id: "ctl-exit",
category: "access",
driverId: "stub-access",
config: { relays: [{ relay: 1, direction: "exit" }] },
enabled: true,
}).run();
db.insert(devices).values({
id: READER,
category: "reader",
driverId: "dingtian-qr-reader",
config: { serial: "H05MA5B0", direction: "exit" },
enabled: true,
}).run();
const log = makeLog(db);
dispatcher = new ReadDispatcher(db, new ExitFlow(db, log, silentLogger()), new SubscriptionFlow(db, log, silentLogger()), silentLogger());
});
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
return {
driverId: "dingtian-qr-reader",
deviceId: READER,
value,
kind: opts.kind ?? "qr",
...(opts.channel ? { channel: opts.channel } : {}),
at: new Date().toISOString(),
};
}
const ledger = () => db.select().from(ledgerEvents).all();
const unrecognized = () =>
db.select().from(deviceEventsTable).all()
.map((r) => r.detail as { unrecognizedRead?: boolean; value?: string })
.filter((d) => d.unrecognizedRead === true);
describe("read-dispatch structural filter", () => {
it("phantom 6-digit optical decode → refused, telemetry only, NOTHING signed", async () => {
const out = await dispatcher.dispatch(read("999459", { channel: "optical" }));
expect(out.accepted).toBe(false);
expect(out.reason).toMatch(/unrecognized/);
expect(ledger()).toHaveLength(0); // the whole point: no red row in the feed
expect(unrecognized()).toHaveLength(1);
expect(unrecognized()[0].value).toBe("999459");
});
it("legacy untagged garbage ('C') → filtered too (works before prefixes are deployed)", async () => {
const out = await dispatcher.dispatch(read("C"));
expect(out.accepted).toBe(false);
expect(ledger()).toHaveLength(0);
expect(unrecognized()).toHaveLength(1);
});
it("Luhn-valid unknown ticket → NOT filtered: the exit flow signs the noSession anomaly", async () => {
const out = await dispatcher.dispatch(read("00000000000")); // valid shape, no session
expect(out.accepted).toBe(false);
expect(unrecognized()).toHaveLength(0);
const anomalies = ledger().filter((r) => r.type === "anomaly");
expect(anomalies.length).toBeGreaterThan(0); // a real probe stays in the signed feed
});
it("unknown card on a CONFIRMED RF channel → NOT filtered (a physical card is a real event)", async () => {
await dispatcher.dispatch(read("1A86A158", { kind: "card", channel: "rf" }));
expect(unrecognized()).toHaveLength(0);
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
});
it("unknown SUB- code → NOT filtered (our own prefix = an interesting probe)", async () => {
await dispatcher.dispatch(read("SUB-DOESNOTEXIST", { channel: "optical" }));
expect(unrecognized()).toHaveLength(0);
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
});
it("an ENROLLED credential is matched BEFORE the filter (never hidden by it)", async () => {
// A card UID that would fail every shape test — enrolled, so it must still match.
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: "sub-1", kind: "rf", value: "999459" }).run();
await dispatcher.dispatch(read("999459")); // legacy untagged read of it
expect(unrecognized()).toHaveLength(0); // reached the subscription flow, not the filter
});
});
+68 -1
View File
@@ -1,7 +1,9 @@
import { devices, eq, type Db } from "@parking/db";
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js";
import { validateTicketCode } from "./entry-flow.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import { relayForDevice } from "./device-resolve.js";
@@ -17,6 +19,22 @@ import { relayForDevice } from "./device-resolve.js";
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
// reader the exit side; "both" defers to the flow's own inference (subscription:
// session state; transient: exit).
//
// STRUCTURAL FILTER (2026-07-04, operator-requested). The DT-008's scan engine
// false-decodes sunlight stripe patterns into short garbage codes (phantom reads —
// see wiki/entities/dingtian-dt008-reader.md), and each one was reaching the exit
// flow and signing an exit.refused.noSession anomaly: red "who is trying to exit?"
// rows for NOBODY, training the operator to ignore the feed (alarm fatigue is the
// adversary's friend). So a reader value that matched nothing AND cannot possibly be
// a credential we issued is dropped to UNSIGNED telemetry (device_events, still
// auditable) instead of the signed ledger. "Possibly ours" stays deliberately wide —
// any of these still reaches the flows and signs the normal refusal anomaly:
// - a Luhn-valid ticket shape (validateTicketCode — a forged/expired ticket is a
// real probe),
// - our issued-code prefixes (SUB- / SUBSESS-),
// - ANY read on a CONFIRMED RF channel (a physically present card, enrolled or
// not, is a real event — RF is never sun noise),
// - plates (different population; never shape-filtered here).
export class ReadDispatcher {
readonly #db: Db;
@@ -45,6 +63,20 @@ export class ReadDispatcher {
if (sub) {
return this.#subscription.run(resolved, e, sub);
}
// Matched nothing — if the value can't even BE one of ours, it's scanner noise
// (phantom optical decode): refuse with unsigned telemetry, keep the signed feed
// for events that involve an actual credential or an actual card.
if ((e.kind === "qr" || e.kind === "card" || e.kind === "ticket") && !plausibleCredential(e)) {
this.#recordUnrecognized(e);
this.#logger.info(`read filtered (not a credential shape): '${e.value}' from ${e.deviceId}${e.channel ? ` ch=${e.channel}` : ""}`);
return {
accepted: false,
direction: resolved.direction === "entry" ? "entry" : "exit",
reason: "unrecognized code (no credential shape — telemetry only)",
};
}
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
// transient exit (transient entry is the button flow, not a reader), so reject+log
// rather than treat an entry scan as an exit.
@@ -53,4 +85,39 @@ export class ReadDispatcher {
}
return this.#exit.handleAt(resolved, e);
}
/** Unsigned telemetry for a filtered read — auditable in device_events, out of the
* signed feed. Mirrors the entry flow's suppressed-press pattern. */
#recordUnrecognized(e: DeviceReadEvent): void {
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: e.deviceId,
category: "reader",
kind: "read",
detail: {
unrecognizedRead: true,
value: e.value,
readKind: e.kind,
...(e.channel ? { channel: e.channel } : {}),
reason: "no credential shape (phantom decode / garbage scan)",
},
occurredAt: e.at,
})
.run();
} catch (err) {
this.#logger.error(`unrecognized-read telemetry insert failed: ${(err as Error).message}`);
}
}
}
/** Could this reader value possibly be a credential WE issued (or a real card)?
* Deliberately WIDE — only shapes that can't be anything of ours are filtered. */
function plausibleCredential(e: DeviceReadEvent): boolean {
if (e.channel === "rf") return true; // a physically present card — never sun noise
if (validateTicketCode(e.value)) return true; // ticket shape (10–14 digits + Luhn)
if (/^SUB(SESS)?-/.test(e.value)) return true; // our subscription QR / window-slip ids
return false;
}
+65
View File
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
expect(r.subscriptions.coveredCars).toBe(2);
});
});
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
await entry(at("2026-05-20T08:00:00Z"));
await entry(at("2026-05-20T09:00:00Z"));
await entry(at("2026-05-21T10:00:00Z"));
await exit(at("2026-05-21T12:00:00Z"));
// In range: +2 on the 10th, −1 on the 11th.
await entry(at("2026-06-10T08:00:00Z"));
await entry(at("2026-06-10T09:00:00Z"));
await exit(at("2026-06-11T09:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(2);
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
["2026-06-10", 4],
["2026-06-11", 3],
]);
});
it("a voided pre-range entry does not inflate occupancyStart", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(0);
});
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
await entry(at("2026-06-10T08:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
});
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
expect(counts["30"]).toBe(1);
expect(counts["120"]).toBe(1);
expect(counts["null"]).toBe(1);
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
expect(day.cashMinor).toBe(500);
expect(day.cardMinor).toBe(700);
});
it("counts voids and anomalies in range (the look-closer counters)", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.voids).toBe(1);
expect(r.totals.anomalies).toBe(1);
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
});
});
+105 -14
View File
@@ -4,9 +4,11 @@ import {
desc,
eq,
gte,
lt,
lte,
ledgerEvents,
sessions,
siteConfig,
subscriptions,
tariffVersions,
tariffs,
@@ -46,8 +48,13 @@ export interface SeriesPoint {
readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number;
/** Tender split of the bucket's revenue (cash = everything not card). */
readonly cashMinor: number;
readonly cardMinor: number;
/** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number;
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
readonly occupancyEnd: number;
}
export interface ReportTotals {
@@ -67,6 +74,10 @@ export interface ReportTotals {
readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number;
readonly medianParkedMinutes: number;
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
* (the operator at the booth is the threat model's primary adversary). */
readonly voids: number;
readonly anomalies: number;
}
export interface SubscriptionStats {
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
readonly coveredCars: number;
}
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
export interface StayBucket {
readonly uptoMin: number | null;
readonly count: number;
}
export interface ReportSummary {
readonly from: string;
readonly to: string;
@@ -89,25 +107,43 @@ export interface ReportSummary {
readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[];
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
readonly entriesByDowHour: number[][];
/** Stay-duration histogram over closed sessions in range. */
readonly stayHistogram: StayBucket[];
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
readonly occupancyStart: number;
/** Nominal capacity from site config (null = uncapped) — the reference line. */
readonly capacity: number | null;
readonly subscriptions: SubscriptionStats;
}
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
});
const fmtCache = new Map<string, Intl.DateTimeFormat>();
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
// Cached per tz — this runs once per ledger row in a report.
let fmt = fmtCache.get(tz);
if (!fmt) {
fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
weekday: "short",
});
fmtCache.set(tz, fmt);
}
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
return {
y: Number(parts.year),
mo: Number(parts.month),
d: Number(parts.day),
h: Number(parts.hour),
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
};
}
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0);
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
const totals = {
entries: 0,
exits: 0,
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
ticketMinor: 0,
subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0,
voids: 0,
anomalies: 0,
};
function point(label: string): SeriesPoint {
let p = seriesMap.get(label);
if (!p) {
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
seriesMap.set(label, p);
}
return p;
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
totals.entries++;
p.entries++;
const h = localParts(row.occurredAt, tz).h;
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
const lp = localParts(row.occurredAt, tz);
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
} else if (row.type === "vehicle_exit") {
totals.exits++;
p.exits++;
} else if (row.type === "void") {
totals.voids++;
} else if (row.type === "anomaly") {
totals.anomalies++;
} else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
totals.revenueMinor += amt;
p.payments++;
p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt;
else totals.cashMinor += amt;
if (pl.tender === "card") {
totals.cardMinor += amt;
p.cardMinor += amt;
} else {
totals.cashMinor += amt;
p.cashMinor += amt;
}
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
// (the rest is) transient ticket revenue.
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
// series. Voided pre-range entries cancel out the same way the in-range pass does.
// Sparse buckets (no events) simply carry the previous level — the step line is exact
// at every plotted point.
const prior = db
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
.from(ledgerEvents)
.where(lt(ledgerEvents.occurredAt, q.from))
.all();
const priorVoided = new Set<string>();
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
let occupancyStart = 0;
for (const r of prior) {
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
else if (r.type === "vehicle_exit") occupancyStart--;
}
occupancyStart = Math.max(0, occupancyStart);
let running = occupancyStart;
for (const p of series) {
running = Math.max(0, running + p.entries - p.exits);
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
}
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
// No payment in range? Fall back to the site tariff's latest version currency, so a
// zero-revenue range still labels its money column.
if (!currency) {
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
// the 8h working day, the 24h rolling day) so the chart answers "where should the
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
const stayHistogram: { uptoMin: number | null; count: number }[] = [
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
{ uptoMin: null, count: 0 },
];
for (const mins of durations) {
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
}
// --- Subscriptions: status counts + currently-valid (window covers `to`).
const subs = db.select().from(subscriptions).all();
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
},
series,
entriesByHour,
entriesByDowHour,
stayHistogram,
occupancyStart,
capacity,
subscriptions: subStats,
};
}
+25 -3
View File
@@ -1,6 +1,7 @@
import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, roles, users, type Db } from "@parking/db";
import { effectiveModulesFor } from "../modules.js";
import {
clearAuthCookies,
newCsrfToken,
@@ -65,7 +66,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
/** The session shape the SPA bootstraps from: identity + role + its permission
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
* permissions are the source of truth. */
* permissions are the source of truth.
*
* `csrf`, when passed, echoes the SAME value already sent as the readable
* parking_csrf cookie — not a new secret, just a second channel to learn it.
* The desktop shell needs 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 the tauri://localhost page never sees a cookie set
* on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045).
* The cookie itself IS still sent back to the server by reqwest on
* subsequent requests — only the *client-side read* is broken — so
* api.ts's desktop path stashes this body value in memory instead of
* reading document.cookie, and echoes it in X-CSRF-Token exactly as the
* browser path echoes the cookie. See lib/api.ts and assertCsrf() in
* ../auth.ts (unchanged — this never touches verification, only how the
* desktop client learns what to send). */
function sessionView(
db: Db,
user: {
@@ -78,6 +93,7 @@ function sessionView(
fullName?: string | null;
email?: string | null;
},
csrf?: string,
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)];
@@ -92,6 +108,10 @@ function sessionView(
fontScale: user.fontScale,
fullName: user.fullName ?? null,
email: user.email ?? null,
// Effective venue modules (entitled ∩ activated) so the SPA can hide nav/routes
// on first paint. The server still enforces via requireModule — this is display.
modules: effectiveModulesFor(db),
...(csrf ? { csrfToken: csrf } : {}),
};
}
@@ -126,7 +146,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
setAuthCookies(reply, token, csrf);
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
// read from the DB, so changing it needs no token refresh.
return sessionView(db, user);
return sessionView(db, user, csrf);
});
app.post("/api/auth/logout", async (_req, reply) => {
@@ -146,7 +166,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
return sessionView(db, row);
// req.user.csrf is the value bound into the JWT at login (see assertCsrf in
// ../auth.ts) — same value as the cookie, re-surfaced for the desktop path.
return sessionView(db, row, req.user.csrf);
},
);
+47 -15
View File
@@ -1,16 +1,26 @@
import type { FastifyInstance } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js";
import type { Db } from "@parking/db";
import type { TillId } from "@parking/shared";
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
// only their own; reviewers see all + can filter status.
// - POST /api/drawer/movement : operator records a cash_in/cash_out on a till.
// Guard = the till's `cash` (booth drawer:create,
// wash carwash:cash).
// - GET /api/drawer/movements: list with review status, over the tills the role may
// read (own movements); reviewers (drawer:review) see all
// tills + all operators and can filter status.
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
// - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read).
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
// judgment about the operator settled outside the app, never a cash reversal.
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
// matrix").
interface MovementBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
@@ -20,6 +30,8 @@ interface MovementBody {
amountMinor: number;
reason?: string;
currency?: string;
/** Which drawer (default: the booth). */
till?: string;
}
interface ReviewBody {
@@ -33,15 +45,15 @@ interface ReviewBody {
interface MovementsQuery {
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
status?: MovementStatus;
/** Filter to one till; absent = every till the role may read (reviewers: every till). */
till?: string;
}
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
const createGuard = requirePermission("drawer:create");
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
const reviewGuard = requirePermission("drawer:review");
const readGuard = requirePermission("shift:read");
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, async (req, reply) => {
const b = req.body ?? ({} as MovementBody);
if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
@@ -53,6 +65,7 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
amountMinor: b.amountMinor,
reason: b.reason ?? "",
currency: b.currency,
till: req.till!,
});
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
@@ -60,19 +73,38 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
}
});
// List movements + review status. Operators are hard-scoped to their OWN movements; a
// reviewer sees ALL and may filter by status (the pending review queue).
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
// List movements + review status. Operators are hard-scoped to their OWN movements on
// the tills they may read; a reviewer sees ALL and may filter by status (the pending
// review queue).
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
const readable = tillsReadableBy(db, req.user.roleId);
if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
const q = req.query ?? {};
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
const movements = shift.movementsWithStatus({
operator: canReview ? undefined : req.user.username,
status,
});
let tills: TillId[] | undefined = canReview ? undefined : readable;
if (q.till?.trim()) {
const parsed = parseTill(db, q.till.trim());
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
if (!canReview && !readable.includes(parsed)) {
return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed });
}
tills = [parsed];
}
const operator = canReview ? undefined : req.user.username;
const movements = tills
? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
: shift.movementsWithStatus({ operator, status });
return { movements, scope: canReview ? "all" : "self" };
});
// A till's physical drawer balance now. Same visibility as the open shift's X-report
// (the till's read guard) — a drawer is a shared till, not per-operator data.
app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
till: req.till!,
...shift.drawerBalance(req.till!),
}));
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
const b = req.body ?? ({} as ReviewBody);
+44 -9
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from "fastify";
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db";
import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared";
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { effectiveModulesFor } from "../modules.js";
import { enrichEvents } from "../event-enrich.js";
import type { EventLog } from "../event-log.js";
@@ -15,25 +16,59 @@ export async function eventRoutes(
db: Db,
eventLog: EventLog,
): Promise<void> {
// Reading the log (the audit trail).
const guard = requirePermission("event:read");
// Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it
// may still hold a module's feed permission (a wash operator's `carwash:read`) and
// then reads ONLY that module's event types — the same rule the live socket applies
// (feedPermissionFor; venue-modules.md §Permissions matrix, move 3).
/** The event types a role may read, or null for "everything" (event:read). Empty =
* the role reads nothing → 403 at the route. */
function readableTypes(roleId: string): LedgerEventType[] | null {
if (roleHasPermissions(roleId, ["event:read"])) return null;
const effective = effectiveModulesFor(db);
const out: LedgerEventType[] = [];
for (const m of MODULES) {
if (!m.feedPermission || !effective.includes(m.id)) continue;
if (roleHasPermissions(roleId, [m.feedPermission])) out.push(...m.ledgerEventTypes);
}
return out;
}
/** SQL form of the shared `tillOfEvent` rule: the payload's `till`, else the till of
* the module owning the event type, else the booth. Computed in the query so the
* page limit applies AFTER the till filter (a shift's window can hold thousands of
* device events). */
const tillExpr = (() => {
const cases = MODULES.filter((m) => m.till && m.till !== BOOTH_TILL && m.ledgerEventTypes.length > 0).map(
(m) => sql`when ${ledgerEvents.type} in (${sql.join(m.ledgerEventTypes.map((t) => sql`${t}`), sql`, `)}) then ${m.till}`,
);
return sql`coalesce(json_extract(${ledgerEvents.payload}, '$.till'), case ${sql.join(cases, sql` `)} else ${BOOTH_TILL} end)`;
})();
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes
// the current shift's start so the live feed shows ONLY this shift's activity. An
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
// selected shift's [start, end] to show just that shift's signed activity log.
// (logs are per-shift, not all history). See wiki/concepts/shift.md.
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>(
// (logs are per-shift, not all history). An optional `till` keeps only that till's
// activity (tillOfEvent) — a booth shift's log no longer shows the wash desk's, and
// vice versa. See wiki/concepts/shift.md §Tills.
app.get<{ Querystring: { limit?: string; since?: string; until?: string; till?: string } }>(
"/api/events",
{ preHandler: guard },
async (req) => {
{ preHandler: requireAuth },
async (req, reply) => {
const types = readableTypes(req.user?.roleId ?? "");
if (types && types.length === 0) return reply.code(403).send({ error: "forbidden" });
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const since = (req.query.since ?? "").trim();
const until = (req.query.until ?? "").trim();
const till = (req.query.till ?? "").trim();
if (till && !isTillId(till)) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
const bounds = [
since ? gte(ledgerEvents.occurredAt, since) : undefined,
until ? lte(ledgerEvents.occurredAt, until) : undefined,
till ? sql`${tillExpr} = ${till}` : undefined,
types ? inArray(ledgerEvents.type, types) : undefined,
].filter(Boolean);
const rows = db
.select()
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ledgerEvents, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// PUT /api/site-config/presence-bypass toggles the entry presence-gate bypass. It's a
// DEDICATED, SIGNED endpoint: each signal that actually changes appends a config_change to
// the ledger (attributed), and it persists to site_config. Admin-only.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
const configChanges = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
async function put(body: unknown, auth: { cookie: string; csrf: string }) {
return app.inject({
method: "PUT",
url: "/api/site-config/presence-bypass",
headers: { cookie: auth.cookie, "x-csrf-token": auth.csrf },
payload: body as Record<string, unknown>,
});
}
describe("PUT /api/site-config/presence-bypass", () => {
it("is admin-only: a non-site:update user is 403", async () => {
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
const auth = await login(app, "op", "pw");
const res = await put({ camera: true }, auth);
expect(res.statusCode).toBe(403);
});
it("enabling a signal persists it AND signs an attributed config_change", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
const res = await put({ camera: true }, auth);
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ bypassPresenceCamera: true, bypassPresenceRadar: false });
const changes = configChanges();
expect(changes).toHaveLength(1);
expect(changes[0].source).toBe("manual");
expect(changes[0].signature.length).toBeGreaterThan(0);
expect(changes[0].payload).toMatchObject({
setting: "entryPresenceBypass.camera",
value: true,
prev: false,
operator: "admin",
});
});
it("a no-op toggle (already in that state) signs nothing", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
await put({ camera: true }, auth); // 1st: on → 1 event
await put({ camera: true }, auth); // 2nd: still on → no new event
expect(configChanges()).toHaveLength(1);
});
it("disabling signs the off transition too (auditable both ways)", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
await put({ radar: true }, auth);
await put({ radar: false }, auth);
const changes = configChanges();
expect(changes).toHaveLength(2);
expect(changes[1].payload).toMatchObject({ setting: "entryPresenceBypass.radar", value: false, prev: true });
});
it("rejects a non-boolean and an empty body", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
expect((await put({ camera: "yes" }, auth)).statusCode).toBe(400);
expect((await put({}, auth)).statusCode).toBe(400);
});
});
@@ -0,0 +1,101 @@
import Fastify from "fastify";
import { beforeEach, afterEach, describe, expect, it } from "vitest";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { qrReaderRoutes, splitChannel } from "./qr-reader.js";
import { CredentialCapture } from "../credential-capture.js";
import type { DeviceReadEvent, ReadOutcome } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.js";
// CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output
// Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF.
// The route strips the prefix, tags the read's confirmed channel, and enrollment
// capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so
// an unconfigured reader keeps working. These tests pin the route-side contract;
// the match-side enforcement is pinned in ../subscription-channel.test.ts.
const SERIAL = "H05MA5B0";
const READER_ID = "reader-exit";
let db: Db;
let app: ReturnType<typeof Fastify>;
let capture: CredentialCapture;
let seen: DeviceReadEvent[];
/** Dispatcher stub: records the event the route built, always rejects. */
const fakeDispatcher = {
dispatch: async (e: DeviceReadEvent): Promise<ReadOutcome> => {
seen.push(e);
return { accepted: false, reason: "test" };
},
} as unknown as ReadDispatcher;
beforeEach(async () => {
({ db } = createTestDb());
db.insert(devices).values({
id: READER_ID,
category: "reader",
driverId: "dingtian-qr-reader",
config: { serial: SERIAL },
enabled: true,
}).run();
seen = [];
capture = new CredentialCapture();
app = Fastify({ logger: false });
await qrReaderRoutes(app as never, db, fakeDispatcher, capture);
});
afterEach(async () => {
await app.close();
});
const scan = (cardid: string) =>
app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` });
describe("splitChannel", () => {
it("K: prefix → bare value, kind card, channel rf", () => {
expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" });
});
it("Q: prefix → bare value, kind qr, channel optical", () => {
expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" });
});
it("no prefix → value untouched, legacy untagged qr", () => {
expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" });
});
});
describe("qr-reader route channel tagging", () => {
it("card-prefixed push dispatches a stripped, rf-tagged read", async () => {
const res = await scan("K:86A158");
expect(res.statusCode).toBe(200);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID });
});
it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => {
await scan("Q:00000000000");
expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" });
});
it("unprefixed push stays legacy: kind qr, no channel", async () => {
await scan("86A158");
expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" });
expect(seen[0].channel).toBeUndefined();
});
it("a bare prefix (empty value after strip) dispatches nothing", async () => {
await scan("K:");
expect(seen).toHaveLength(0);
});
it("enrollment capture stores the BARE value, not the prefixed one", async () => {
capture.arm(READER_ID);
const res = await scan("K:86A158");
expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow
const state = capture.state();
expect(state.status).toBe("captured");
if (state.status === "captured") expect(state.value).toBe("86A158");
// Beeps "ok" so the operator knows the card was read.
expect(res.json().data[0].status).toBe(1);
});
});
+42 -9
View File
@@ -27,6 +27,36 @@ interface ReaderQuery {
time?: string;
}
// ── CHANNEL TAGGING (2026-07-04) ────────────────────────────────────────────────
// The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a
// QR/barcode or its RF engine read a card — the server can't tell them apart. That
// enabled a cheap clone: print a card's UID (often written on the card face) as a
// barcode and the optical decode matches the RF credential. Fix: the vendor tool's
// "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every
// reader; the route strips the prefix and tags the read's confirmed channel, and the
// subscription match refuses a channel-mismatched credential. A read with NO prefix
// stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured
// reader keeps working — the enforcement only bites where prefixes are deployed.
// ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that
// setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md.
const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:";
const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:";
/** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */
export function splitChannel(raw: string): {
value: string;
kind: "qr" | "card";
channel?: "optical" | "rf";
} {
if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) {
return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" };
}
if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) {
return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" };
}
return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown
}
export async function qrReaderRoutes(
app: FastifyInstance,
db: Db,
@@ -55,6 +85,7 @@ export async function qrReaderRoutes(
// See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
reply.header("connection", "close");
const cardid = (q.cardid ?? "").trim();
const scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed)
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
const serial = (q.cjihao ?? "").trim();
@@ -65,35 +96,37 @@ export async function qrReaderRoutes(
const deviceId = matchedRowId ?? serial;
let accepted = false;
if (cardid) {
if (scan.value) {
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
// value for the subscription form and do NOT run the access flow (we must not
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
// Reads from the OTHER reader are untouched and dispatch normally below.
if (capture.tryConsume(deviceId, cardid)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
// Captured BARE (prefix stripped) so enrolled values match future stripped reads.
if (capture.tryConsume(deviceId, scan.value)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`);
accepted = true; // beep "ok" so the operator knows the card was read
} else {
const read: DeviceReadEvent = {
driverId: "dingtian-qr-reader",
deviceId,
value: cardid,
kind: "qr",
value: scan.value,
kind: scan.kind,
...(scan.channel ? { channel: scan.channel } : {}),
at: new Date().toISOString(),
};
try {
const outcome = await dispatcher.dispatch(read);
accepted = outcome.accepted;
// Per-read diagnostic: which reader (serial) sent it, which configured device
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
// the logs (e.g. an entry-side scan resolving to the exit relay).
// it mapped to, the confirmed channel (if prefixed), and the verdict — so a
// barrier/serial mismatch or a channel anomaly is visible in the logs.
app.log.info(
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
);
} catch (err) {
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
app.log.error(`QR dispatch failed for ${scan.value}: ${(err as Error).message}`);
}
}
}
+11 -2
View File
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
async (req, reply) => {
const summary = reportSummary(db, parseQuery(req.query));
const lines = [
"bucket,entries,exits,payments,revenue",
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
...summary.series.map((p) =>
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
[
p.bucket,
p.entries,
p.exits,
p.payments,
(p.revenueMinor / 100).toFixed(2),
(p.cashMinor / 100).toFixed(2),
(p.cardMinor / 100).toFixed(2),
p.occupancyEnd,
].join(","),
),
];
reply
+102
View File
@@ -0,0 +1,102 @@
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 { jobsBehind } from "@parking/shared";
import { buildServer } from "../server.js";
import { login, seedUser } from "../test-helpers.js";
// Roles are data composed from the permission grid (venue-modules.md §Permissions
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
// JOBS it was built from so a grown job can be surfaced and re-applied.
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);
}
async function roleChanges(a: Auth) {
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
);
}
describe("role edits are signed and jobs are remembered", () => {
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
const a = await admin();
const created = await app.inject({
method: "POST", url: "/api/roles", headers: hdrs(a),
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
});
expect(created.statusCode).toBe(201);
const role = created.json();
expect(role.jobs).toEqual(["wash-operator"]);
let evs = await roleChanges(a);
expect(evs).toHaveLength(1);
expect(evs[0]!.payload).toMatchObject({
setting: `role.${role.id}`, prev: null, operator: "boss",
value: { name: "Lavazh", jobs: ["wash-operator"] },
});
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
// Same content again → nothing new on the chain.
const same = await app.inject({
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
});
expect(same.statusCode).toBe(200);
expect(await roleChanges(a)).toHaveLength(1);
// A real change: prev is the old shape, value the new.
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
expect(renamed.statusCode).toBe(200);
evs = await roleChanges(a);
expect(evs).toHaveLength(2);
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
expect(gone.statusCode).toBe(200);
evs = await roleChanges(a);
expect(evs).toHaveLength(3);
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
});
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
const a = await admin();
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
expect(bad.statusCode).toBe(400);
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
// — exactly what an older release's chip would have left once the job grew.
const r = (await app.inject({
method: "POST", url: "/api/roles", headers: hdrs(a),
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
})).json();
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
const has = new Set<string>(view.permissions);
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
// Re-apply = the union; then nothing is behind.
const fixed = (await app.inject({
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
})).json();
const has2 = new Set<string>(fixed.permissions);
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
});
});
+74 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the
@@ -18,14 +19,30 @@ import { softDelete } from "../recycle-bin.js";
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
// only put permissions they ALREADY hold onto a role. An admin (full set) is
// unrestricted, which is the intended behaviour.
//
// EVERY role edit is SIGNED on the ledger as a `config_change` (setting `role.<id>`,
// value/prev = the role's name + permissions + jobs, operator = who) — a role edit is a
// privilege change, and under this threat model the only setting an admin could alter
// without a trace. A role also REMEMBERS the manifest JOBS it was composed from
// (role_jobs) so a later release that grows a job's bundle can be surfaced and
// re-applied — the grid is never expanded silently (venue-modules.md §Permissions matrix).
interface RoleBody {
name: string;
permissions: string[];
jobs?: string[];
}
interface UpdateBody {
name?: string;
permissions?: string[];
jobs?: string[];
}
/** What a signed role change records (before/after). */
interface RoleShape {
name: string;
permissions: Permission[];
jobs: string[];
}
const VALID = new Set<string>(PERMISSIONS);
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
return { ok: true, perms: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
/** Validate + dedupe a requested job list against the registry's job presets. */
function cleanJobs(input: unknown): { ok: true; jobs: string[] } | { ok: false; bad: string } {
if (input == null) return { ok: true, jobs: [] };
if (!Array.isArray(input)) return { ok: false, bad: "jobs must be an array" };
const out = new Set<string>();
for (const j of input) {
if (typeof j !== "string" || !jobById(j)) return { ok: false, bad: `unknown job: ${String(j)}` };
out.add(j);
}
return { ok: true, jobs: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog): Promise<void> {
const readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update");
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
name: role.name,
builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
jobs: jobsOf(roleId),
userCount,
};
}
function jobsOf(roleId: string): string[] {
return db.select({ jobId: roleJobs.jobId }).from(roleJobs).where(eq(roleJobs.roleId, roleId)).all().map((r) => r.jobId).sort();
}
/** The role as the ledger records it (sorted so two identical shapes compare equal). */
function shapeOf(roleId: string): RoleShape | null {
const v = roleView(roleId);
if (!v) return null;
return { name: v.name, permissions: [...v.permissions].sort() as Permission[], jobs: v.jobs };
}
/** Replace a role's remembered jobs. */
function setJobs(roleId: string, jobs: string[]): void {
db.delete(roleJobs).where(eq(roleJobs.roleId, roleId)).run();
for (const jobId of jobs) db.insert(roleJobs).values({ roleId, jobId }).run();
}
/** Sign a role change. `prev` null = created; `value` null = deleted. Skipped when
* nothing changed (a no-op resave leaves no trace, like the site-config flips). */
async function signRoleChange(req: { user?: { username?: string } }, roleId: string, prev: RoleShape | null, value: RoleShape | null): Promise<void> {
if (JSON.stringify(prev) === JSON.stringify(value)) return;
await eventLog?.append({
type: "config_change",
source: "manual",
payload: { setting: `role.${roleId}`, value, prev, operator: req.user?.username ?? "unknown" },
});
}
/** Replace a role's permission rows with `perms` (in a single pass). */
function setPermissions(roleId: string, perms: Permission[]): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
@@ -104,13 +162,17 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
const jobs = cleanJobs(req.body?.jobs);
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
const over = escalates(req.user.roleId, cleaned.perms);
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run();
setPermissions(id, cleaned.perms);
setJobs(id, jobs.jobs);
bumpPermsCache();
await signRoleChange(req, id, null, shapeOf(id));
return reply.code(201).send(roleView(id));
});
@@ -125,6 +187,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
}
const prev = shapeOf(id);
if (req.body?.name != null) {
const name = req.body.name.trim();
@@ -140,7 +203,13 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
setPermissions(id, cleaned.perms);
}
if (req.body?.jobs != null) {
const jobs = cleanJobs(req.body.jobs);
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
setJobs(id, jobs.jobs);
}
bumpPermsCache();
await signRoleChange(req, id, prev, shapeOf(id));
return roleView(id);
},
);
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
}
const prev = shapeOf(id);
softDelete(db, "role", id, req.user.sub);
bumpPermsCache();
await signRoleChange(req, id, prev, null);
return { ok: true };
},
);
+50 -1
View File
@@ -30,7 +30,7 @@ describe("health + login", () => {
it("GET /health is open", async () => {
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
expect(res.json()).toEqual({ status: "ok", app: "parking-system" });
});
it("login with bad credentials is rejected", async () => {
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
});
});
describe("GET /api/version", () => {
it("without a session is 401", async () => {
const res = await app.inject({ method: "GET", url: "/api/version" });
expect(res.statusCode).toBe(401);
});
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
const { username, password } = await seedUser(db, {
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
});
it("reflects a real BUILD_VERSION when the env var is set", async () => {
process.env.BUILD_VERSION = "stage-abc1234";
try {
const { username, password } = await seedUser(db, {
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
} finally {
delete process.env.BUILD_VERSION;
}
});
});
describe("RBAC permission gate", () => {
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
const { username, password } = await seedUser(db, {
@@ -101,3 +132,21 @@ describe("CSRF double-submit on mutations", () => {
expect(put.statusCode).toBe(403);
});
});
describe("drawer balance (the till NOW)", () => {
it("shift:read gets the balance; a role without it is 403; no auth 401", async () => {
const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" });
expect(anon.statusCode).toBe(401);
const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] });
const { cookie } = await login(app, viewer.username, viewer.password);
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
expect(ok.statusCode).toBe(200);
expect(ok.json()).toEqual({ till: "booth", balanceMinor: 0, currency: null });
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
const other = await login(app, outsider.username, outsider.password);
const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } });
expect(denied.statusCode).toBe(403);
});
});
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { devices, ledgerEvents, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the
// wiring — it physically opens the barrier. Because "a physical open with no matching
// signed command is the fraud signal" (append-only-event-chain / reconciliation), the
// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it
// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs —
// no real hardware) so they exercise the validate → sign → pulse path safely.
let db: Db;
let close: () => void;
let app: FastifyInstance;
const CTL = "ctl-stub";
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "stub-access",
config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] },
enabled: true,
}).run();
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
async function pulse(
body: unknown,
auth?: { cookie: string; csrf: string },
) {
return app.inject({
method: "POST",
url: "/api/setup/test-relay",
headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {},
payload: body as Record<string, unknown>,
});
}
describe("POST /api/setup/test-relay", () => {
it("is admin-only: a non-site:update user is 403", async () => {
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
const auth = await login(app, "op", "pw");
const res = await pulse({ id: CTL, relay: 1 }, auth);
expect(res.statusCode).toBe(403);
});
it("requires CSRF on the mutation", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const { cookie } = await login(app, "admin", "pw");
const res = await app.inject({
method: "POST",
url: "/api/setup/test-relay",
headers: { cookie }, // no x-csrf-token
payload: { id: CTL, relay: 1 },
});
expect(res.statusCode).toBe(403);
});
it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
const res = await pulse({ id: CTL, relay: 2 }, auth);
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ ok: true });
// The deliberate open is EXPLAINED in the signed ledger — not an anomaly.
const rows = db.select().from(ledgerEvents).all();
const testOpen = rows.find((r) => r.type === "barrier_open_command");
expect(testOpen, "a barrier_open_command must be signed").toBeTruthy();
expect(testOpen!.source).toBe("manual"); // deliberate human action
expect(testOpen!.signature.length).toBeGreaterThan(0);
const payload = testOpen!.payload as Record<string, unknown>;
expect(payload.relayTest).toBe(true);
expect(payload.reasonCode).toBe("setup.relayTest");
expect(payload.relay).toBe(2);
expect(payload.controllerId).toBe(CTL);
expect(payload.operator).toBe("admin"); // attributed to the acting admin
});
it("rejects a relay the controller does not declare (400, no ledger row)", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
const res = await pulse({ id: CTL, relay: 9 }, auth);
expect(res.statusCode).toBe(400);
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed
});
it("404s an unknown controller id", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
const res = await pulse({ id: "nope", relay: 1 }, auth);
expect(res.statusCode).toBe(404);
});
it("rejects a bad relay value (non-positive-integer)", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400);
expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400);
});
});
+125
View File
@@ -15,7 +15,9 @@ import {
type DeviceCategory,
type DeviceConfig,
} from "@parking/devices";
import { reasonPayload } from "@parking/shared";
import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
import type { VisionClient } from "../vision-client.js";
@@ -60,6 +62,12 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out;
}
/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup
* relay test needs it; a stub/reader/camera won't have it). */
function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise<void> } {
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
}
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
// secret may only be re-merged when these match the stored row — otherwise an admin
// could point a test at an attacker host while keeping a real device id and have the
@@ -220,6 +228,7 @@ export async function setupRoutes(
app: FastifyInstance,
db: Db,
vision?: VisionClient | null,
eventLog?: EventLog | null,
): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
@@ -456,6 +465,89 @@ export async function setupRoutes(
},
);
// PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any
// vehicle/session. This physically opens the barrier, so unlike the other tests it
// runs only against a SAVED controller (real id → clean attribution) and it SIGNS a
// `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the
// admin's identity. That is the whole point of doing it this way: a physical open with
// no matching signed command is the fraud signal ([[append-only-event-chain]],
// [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a
// silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is
// recorded whether or not the physical pulse then succeeds. Admin-only (site:update).
app.post<{ Body: { id: string; relay: number } }>(
"/api/setup/test-relay",
{ preHandler: adminGuard },
async (req, reply) => {
const { id, relay } = req.body;
if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" });
if (!Number.isInteger(relay) || relay < 1) {
return reply.code(400).send({ error: "relay must be a 1-based channel number" });
}
// A relay test fires REAL hardware, so it must target a persisted controller — no
// firing an unsaved/redirected config (that would let a probe open an arbitrary host's
// barrier). Load the saved row and build straight from its stored config (relayPassword
// included — it's on the row, never in the request).
const row = db.select().from(devices).where(eq(devices.id, id)).get();
if (!row) return reply.code(404).send({ error: "controller not found" });
if (row.category !== "access") {
return reply.code(400).send({ error: `device ${id} is not a controller` });
}
const cfg = (row.config ?? {}) as Record<string, unknown>;
const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : [];
if (!relays.some((r) => r.relay === relay)) {
return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` });
}
let device;
try {
device = registry.create(row.driverId, cfg as Record<string, string | number | boolean>);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!hasPulseOpen(device)) {
return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` });
}
// Sign the deliberate open FIRST — recorded whether or not the physical pulse then
// succeeds. Skip only if no ledger is wired (test/degraded), in which case we still
// refuse rather than fire an unrecorded open.
const operator = req.user?.username ?? "unknown";
if (!eventLog) {
return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" });
}
await eventLog.append({
type: "barrier_open_command",
// A deliberate human action from the admin console → "manual" (the top-level
// IdentitySource). The relayTest marker + reason distinguish it in the payload.
source: "manual",
identity: `relay-test:${id}:${relay}`,
payload: {
...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }),
relayTest: true,
controllerId: id,
relay,
operator,
},
});
const startedAt = Date.now();
try {
await device.pulseOpen(relay);
} catch (err) {
// The failure we're testing for (relay unreachable, wrong password). The open is
// already signed; report the pulse failure, don't 500.
return reply.send({
ok: false,
reason: "pulse-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
return reply.send({ ok: true, firedAt: new Date().toISOString(), tookMs: Date.now() - startedAt });
},
);
// Candidate backend IPs the device can push to, for a given device host. The
// wizard pre-fills with the on-subnet one and lets the admin override (matters
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
@@ -468,6 +560,39 @@ export async function setupRoutes(
},
);
// USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
// container sees via the /dev/usb bind-mount) and enrich each with the printer's
// self-reported make/model from sysfs (ieee1284_id — readable through Docker's
// default ro /sys). The wizard offers these as a SELECT so the admin never has to
// shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
// park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
const { readdir, readFile } = await import("node:fs/promises");
let names: string[] = [];
try {
names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
} catch {
return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
}
const printers = await Promise.all(
names.map(async (n) => {
// ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
let description: string | null = null;
try {
const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
const mfg = pick("MFG") ?? pick("MANUFACTURER");
const mdl = pick("MDL") ?? pick("MODEL");
description = [mfg, mdl].filter(Boolean).join(" ") || null;
} catch {
/* sysfs not readable / attribute absent — path alone is still useful */
}
return { path: `/dev/usb/${n}`, description };
}),
);
return { printers };
});
// Assign a device. Validates the chosen driver + config, configures the device
// (fix preconditions + set up Digest-authenticated input push — no manual device-
// web-UI step by the admin), then persists. Fails the save if the device can't be
+75 -30
View File
@@ -1,6 +1,9 @@
import type { FastifyInstance } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js";
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
import type { FastifyInstance, FastifyReply } from "fastify";
import type { Db } from "@parking/db";
import { tillGuards, type TillId } from "@parking/shared";
import { requireAuth, roleHasPermissions } from "../auth.js";
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, type ShiftSummary } from "../shift-service.js";
interface ShiftsQuery {
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
@@ -8,82 +11,124 @@ interface ShiftsQuery {
/** ISO window over shift START time. */
from?: string;
to?: string;
/** Filter to one till; absent = every till the role may read. */
till?: string;
}
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
//
// TILLS + PERMISSIONS: every endpoint addresses a `till` (query on GET, body on POST;
// default booth) and its guard is resolved FROM THE TILL (requireTill): the booth's shift
// is `shift:read` / `shift:create`, the wash's is `carwash:read` / `carwash:cash` — each
// desk's money is guarded by that desk's own permissions, so a wash role holds no
// `shift:*` at all and cannot touch the booth. See venue-modules.md §"Permissions matrix".
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift.
const readGuard = requirePermission("shift:read");
const guard = requirePermission("shift:create");
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - open: the open shift { startedAt, operator } or null (site-wide)
// - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity)
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
const me = req.user.username;
const open = shift.currentOpenShift();
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
const statusOf = (till: TillId, me: string, roleId: string) => {
const open = shift.currentOpenShift(till);
const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance();
const drawer = shift.drawerBalance(till);
return {
operator: me,
till,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me,
/** May this role open/close this till's shift? (The UI offers the button only then.) */
canWork: roleHasPermissions(roleId, [tillGuards(till).shift]),
drawerMinor: drawer.balanceMinor,
currency: drawer.currency,
};
};
// The shift state of ONE till (at most one shift open per till). The UI uses this
// to render a till's control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - till: which till this describes
// - open: the open shift { startedAt, operator } or null
// - isMine: true iff the open shift belongs to the requesting operator
// - canWork: may this role open/close it
// - operator: the requesting user (for the UI's own identity)
// - tills: every till THIS ROLE may read — what the UI offers controls for
app.get("/api/shift/current", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
operator: req.user.username,
tills: tillsReadableBy(db, req.user.roleId),
...statusOf(req.till!, req.user.username, req.user.roleId),
}));
// The state of every till this role may read, in one read — the shift hub lists
// each open shift and offers "start" for the idle ones it may work.
app.get("/api/shift/tills", { preHandler: requireAuth }, async (req) => {
const me = req.user.username;
return { operator: me, tills: tillsReadableBy(db, req.user.roleId).map((t) => statusOf(t, me, req.user.roleId)) };
});
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
// as of now. Appends nothing — it's not an accountability mark, just a projection
// (the Z-report at close is the signed record). 204 when no shift is open.
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
const report = shift.currentReport();
app.get("/api/shift/report", { preHandler: requireTill(db, "read", "query") }, async (req, reply) => {
const report = shift.currentReport(req.till!);
if (!report) return reply.code(204).send();
return report;
});
// Completed shift history. SCOPED by permission:
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
// - a till's `read` guard (operators) → own shifts only, on the tills they may read;
// operator/from/to params ignored.
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
// `operator` and a `from`/`to` time window over each shift's START.
// This keeps one operator from reading another's takings while letting admins
// reconcile across the site. The data is the signed shift_z_report chain.
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
// reconcile across the site. The data is the signed shift_z_report chain. Both
// scopes may filter by `till` (must be one the role may read).
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: requireAuth }, async (req, reply) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
const readable = tillsReadableBy(db, req.user.roleId);
if (!canSeeAll && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
const q = req.query ?? {};
// Non-admins are hard-scoped to themselves regardless of any operator param.
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to });
return { shifts, scope: canSeeAll ? "all" : "self" };
let tills: TillId[] = canSeeAll ? [] : readable; // [] = no till filter (admin)
if (q.till?.trim()) {
const parsed = parseTill(db, q.till.trim());
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
if (!canSeeAll && !readable.includes(parsed)) return badTill(reply, parsed);
tills = [parsed];
}
const shifts: ShiftSummary[] =
tills.length === 0
? shift.listShifts({ operator, from, to })
: tills.flatMap((till) => shift.listShifts({ operator, from, to, till })).sort((a, b) => b.index - a.index);
// Admins also get the distinct operator list (unfiltered) for the filter
// dropdown — operators don't see other names, so it's scope-gated.
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: tillsReadableBy(db, req.user.roleId) };
return { shifts, scope: "self", tills: readable };
});
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
app.post("/api/shift/open", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
try {
return await shift.open(req.user.username);
return await shift.open(req.user.username, req.till!);
} catch (err) {
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
});
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
app.post("/api/shift/close", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
try {
return await shift.close(req.user.username);
return await shift.close(req.user.username, req.till!);
} catch (err) {
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
});
}
function badTill(reply: FastifyReply, till: TillId): FastifyReply {
return reply.code(403).send({ error: `your role cannot see the ${till} till`, code: "till_forbidden", till });
}
+134 -2
View File
@@ -1,6 +1,9 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { MODULES, effectiveModules, isModuleId, resolveModuleActivation, type ModuleId } from "@parking/shared";
import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { activatedModulesOf, entitledModules } from "../modules.js";
import { getOccupancy } from "../occupancy.js";
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
@@ -35,16 +38,29 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
anprEntryEnabled?: boolean;
/** Venue modules to ACTIVATE (full desired set). Validated against the entitlement
* and the registry's dependency rules; required modules are always included. Each
* module that actually flips signs a config_change. See wiki/decisions/venue-modules.md. */
modules?: unknown;
}
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
* + every metadata field. */
* + the entry presence-bypass flags + every metadata field. */
type SiteConfig = {
capacity: number | null;
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean;
anprEntryEnabled: boolean;
bypassPresenceRadar: boolean;
bypassPresenceCamera: boolean;
/** Effective venue modules = entitled ∩ activated (what the server enforces). */
modules: ModuleId[];
/** What this deployment is entitled to (MODULES_ENTITLED env) — the Setup → Site
* panel offers exactly these to toggle. */
modulesEntitled: ModuleId[];
/** What the site admin has activated (null in storage = everything entitled). */
modulesActivated: ModuleId[];
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -54,11 +70,24 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
anprEntryEnabled: row?.anprEntryEnabled ?? true,
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
...moduleView(row),
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
}
function moduleView(row: typeof siteConfig.$inferSelect | undefined) {
const entitled = entitledModules();
const activated = activatedModulesOf(row) ?? entitled;
return {
modules: effectiveModules(entitled, activated),
modulesEntitled: entitled,
modulesActivated: activated,
};
}
/** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null {
if (v == null) return null;
@@ -66,13 +95,22 @@ function normText(v: unknown): string | null {
return s === "" ? null : s;
}
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog | null): Promise<void> {
const readGuard = requirePermission("site:read");
const writeGuard = requirePermission("site:update");
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
// build with no CI-supplied value. Purely informational (Setup nav display); not
// site config, so it isn't stored in site_config.
app.get("/api/version", { preHandler: readGuard }, async () => ({
buildVersion: process.env.BUILD_VERSION?.trim() || null,
}));
// Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
@@ -122,6 +160,40 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
// Venue-module activation. The body carries the full DESIRED set; the shared rules
// (required always on, must be entitled, dependencies effective) decide, and every
// module whose effective state actually flips is signed as a config_change — the
// same attribution pattern as the presence-bypass endpoint below. Disabling never
// deletes anything: tables/history/grants stay, routes 403, UI hides.
if ("modules" in body) {
const requested = body.modules;
if (!Array.isArray(requested) || !requested.every(isModuleId)) {
return reply.code(400).send({
error: `modules must be an array of module ids (${MODULES.map((m) => m.id).join(", ")})`,
});
}
const entitled = entitledModules();
const result = resolveModuleActivation(entitled, requested);
if (!result.ok) return reply.code(400).send({ error: result.error });
const prevEffective = new Set(effectiveModules(entitled, activatedModulesOf(existing) ?? entitled));
const nextEffective = new Set(effectiveModules(entitled, result.modules));
const operator = req.user?.username ?? "unknown";
for (const m of MODULES) {
const was = prevEffective.has(m.id);
const now = nextEffective.has(m.id);
if (was !== now) {
await eventLog?.append({
type: "config_change",
source: "manual",
identity: `module:${m.id}`,
payload: { setting: `modules.${m.id}`, value: now, prev: was, operator },
});
}
}
patch.modulesJson = JSON.stringify(result.modules);
}
const updatedAt = new Date().toISOString();
if (existing) {
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
@@ -131,4 +203,64 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
});
// Entry presence-gate BYPASS — a DEDICATED, SIGNED endpoint (not the generic PUT above),
// because dropping a radar/camera requirement weakens an anti-fraud gate. The admin is not
// the adversary (a faulty device blocks legit entry until support fixes it), but the change
// must be attributed + auditable: each toggled signal appends a signed `config_change`
// {setting, value, prev, operator}. Granular per signal. See wiki/concepts/entry-presence-bypass.md.
app.put<{ Body: { radar?: boolean; camera?: boolean } }>(
"/api/site-config/presence-bypass",
{ preHandler: writeGuard },
async (req, reply) => {
const body = req.body ?? {};
for (const k of ["radar", "camera"] as const) {
if (k in body && typeof body[k] !== "boolean") {
return reply.code(400).send({ error: `${k} must be a boolean` });
}
}
if (!("radar" in body) && !("camera" in body)) {
return reply.code(400).send({ error: "nothing to change (send radar and/or camera)" });
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const prev = {
radar: existing?.bypassPresenceRadar ?? false,
camera: existing?.bypassPresenceCamera ?? false,
};
const next = {
radar: "radar" in body ? (body.radar as boolean) : prev.radar,
camera: "camera" in body ? (body.camera as boolean) : prev.camera,
};
// Sign a config_change for each signal that ACTUALLY changed (before persisting, so the
// audit record exists whether or not a later write hiccups). No-op toggles sign nothing.
const operator = req.user?.username ?? "unknown";
for (const signal of ["radar", "camera"] as const) {
if (next[signal] !== prev[signal]) {
await eventLog?.append({
type: "config_change",
source: "manual",
identity: `presence-bypass:${signal}`,
payload: {
setting: `entryPresenceBypass.${signal}`,
value: next[signal],
prev: prev[signal],
operator,
},
});
}
}
const updatedAt = new Date().toISOString();
const patch = { bypassPresenceRadar: next.radar, bypassPresenceCamera: next.camera, updatedAt };
if (existing) {
db.update(siteConfig).set(patch).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, ...patch }).run();
}
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
},
);
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { invalidateHolder } from "../event-enrich.js";
@@ -381,6 +381,7 @@ export async function subscriptionRoutes(
amountMinor,
currency,
tender,
till: BOOTH_TILL,
operator,
// Flags this `payment` as a subscription SALE (not a parking payment) so the
// live feed / activity log can label it distinctly. plan + periods for audit
@@ -0,0 +1,177 @@
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";
// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable
// published versions. The contract under test: drafts are validated + tz-stamped on
// save exactly like a publish (so "publish this draft" can never fail on a card that
// saved fine), mutations need tariff:update, and publishing a draft goes through the
// normal immutable-version path untouched.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
const V1_STRUCTURE = {
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }],
dailyCapMinor: null,
};
// A V2 card with a night package — tz left blank on purpose: the server must stamp it.
const V2_STRUCTURE = {
version: 2,
tz: "",
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null },
windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }],
};
async function editor() {
const { username, password } = await seedUser(db, {
username: "editor",
roleId: "editor",
permissions: ["tariff:read", "tariff:update"],
});
return login(app, username, password);
}
describe("tariff drafts", () => {
it("requires auth", async () => {
const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" });
expect(res.statusCode).toBe(401);
});
it("a tariff:read-only user can list but not create", async () => {
const { username, password } = await seedUser(db, {
username: "viewer",
roleId: "viewer",
permissions: ["tariff:read"],
});
const { cookie, csrf } = await login(app, username, password);
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(list.statusCode).toBe(200);
expect(list.json().drafts).toEqual([]);
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers: { cookie, "x-csrf-token": csrf },
payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE },
});
expect(create.statusCode).toBe(403);
});
it("create → list → update → delete roundtrip", async () => {
const { cookie, csrf } = await editor();
const headers = { cookie, "x-csrf-token": csrf };
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers,
payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE },
});
expect(create.statusCode).toBe(201);
const draft = create.json();
expect(draft.name).toBe("Winter proposal");
expect(draft.currency).toBe("ALL"); // normalised to upper case
expect(draft.createdBy).toBe("editor");
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(list.json().drafts).toHaveLength(1);
const update = await app.inject({
method: "PUT",
url: `/api/tariff/drafts/${draft.id}`,
headers,
payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE },
});
expect(update.statusCode).toBe(200);
expect(update.json().name).toBe("Winter v2");
const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers });
expect(del.statusCode).toBe(204);
const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(after.json().drafts).toEqual([]);
});
it("rejects an invalid structure with problems (validated like a publish)", async () => {
const { cookie, csrf } = await editor();
const res = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers: { cookie, "x-csrf-token": csrf },
payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } },
});
expect(res.statusCode).toBe(400);
expect(res.json().problems?.length).toBeGreaterThan(0);
});
it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => {
const { cookie, csrf } = await editor();
const headers = { cookie, "x-csrf-token": csrf };
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers,
payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE },
});
expect(create.statusCode).toBe(201);
const draft = create.json();
expect(draft.structure.tz).toBe("Europe/Tirane");
// The lab prices the draft by sending its stored structure inline.
const sim = await app.inject({
method: "POST",
url: "/api/tariff/simulate",
headers,
payload: {
enteredAt: "2026-07-03T21:00:00.000+02:00",
asOf: "2026-07-03T23:00:00.000+02:00",
structure: draft.structure,
currency: draft.currency,
},
});
expect(sim.statusCode).toBe(200);
expect(sim.json().pricing.amountMinor).toBe(40000); // one night package
// "Publish this draft" = the normal immutable-version path with the draft's card;
// the draft's name rides along as the version's optional label.
const publish = await app.inject({
method: "POST",
url: "/api/tariff/versions",
headers,
payload: { currency: draft.currency, structure: draft.structure, name: draft.name },
});
expect(publish.statusCode).toBe(201);
const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } });
expect(state.json().active?.name).toBe("Night package");
expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000);
});
});
+107 -9
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
explainFee,
isTariffV2,
priceSession,
validateTariffStructure,
@@ -25,10 +26,19 @@ interface PublishBody {
structure: TariffStructure;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
/** Optional human label (e.g. carried from the lab draft being published). */
name?: string;
}
const SITE_TARIFF_NAME = "Site tariff";
/** Body for saving a lab draft (create + update share the shape). */
interface DraftBody {
name: string;
currency: string;
structure: TariffStructure;
}
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
* neither (uses the active version). */
@@ -80,19 +90,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
"/api/tariff/versions",
{ preHandler: writeGuard },
async (req, reply) => {
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody);
if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" });
}
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
// validation that requires tz passes. A V1 (bare) structure is left untouched.
let toStore: TariffStructure = structure;
if (structure && isTariffV2(structure)) {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
toStore = { ...structure, tz };
}
const toStore = stampSiteTz(structure);
const problems = validateTariffStructure(toStore);
if (problems.length) {
@@ -128,6 +133,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const row = {
id,
tariffId,
name: typeof name === "string" && name.trim() ? name.trim() : null,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record<string, unknown>,
@@ -183,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const payments = Array.isArray(b.payments) ? b.payments : [];
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
// HOW the amount is produced — the same engine walk with a trace collector
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
const breakdown = pricing.withinGrace
? null
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
const enteredMs = Date.parse(b.enteredAt);
@@ -191,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
}));
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
});
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
@@ -230,6 +242,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
},
);
// --- Lab drafts ---------------------------------------------------------------
// The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the
// schema for why mutability is safe here — a draft prices nothing and signs
// nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the
// simulator can always price them and "publish this draft" can never surprise the
// admin with a card that saved fine but won't go live. Publishing a draft is just
// POST /api/tariff/versions with the draft's structure — same guard, same
// validation, same immutability.
app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => {
const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all();
return { drafts };
});
app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => {
const parsed = parseDraftBody(req.body);
if ("error" in parsed) return reply.code(400).send(parsed);
const now = new Date().toISOString();
const row = {
id: randomUUID(),
name: parsed.name,
currency: parsed.currency,
structure: parsed.structure as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
createdAt: now,
updatedAt: now,
};
db.insert(tariffDrafts).values(row).run();
return reply.code(201).send(row);
});
app.put<{ Params: { id: string }; Body: DraftBody }>(
"/api/tariff/drafts/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "draft not found" });
const parsed = parseDraftBody(req.body);
if ("error" in parsed) return reply.code(400).send(parsed);
const patch = {
name: parsed.name,
currency: parsed.currency,
structure: parsed.structure as unknown as Record<string, unknown>,
updatedAt: new Date().toISOString(),
};
db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
return { ...existing, ...patch };
},
);
app.delete<{ Params: { id: string } }>(
"/api/tariff/drafts/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "draft not found" });
db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
return reply.code(204).send();
},
);
/** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
function parseDraftBody(
body: DraftBody | undefined,
): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
const b = body ?? ({} as DraftBody);
const name = (b.name ?? "").trim();
if (!name) return { error: "name required" };
const currency = (b.currency ?? "").trim().toUpperCase();
if (currency.length < 3) return { error: "currency (ISO 4217) required" };
const structure = stampSiteTz(b.structure);
const problems = validateTariffStructure(structure);
if (problems.length) return { error: "invalid tariff structure", problems };
return { name, currency, structure };
}
/** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
* client); a V1 (bare) structure passes through untouched. */
function stampSiteTz(structure: TariffStructure): TariffStructure {
if (structure && isTariffV2(structure)) {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
return { ...structure, tz };
}
return structure;
}
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
function tariffVersionIdFor(whenIso: string): string | null {
const tariffId = ensureSiteTariff();
+4 -1
View File
@@ -3,7 +3,7 @@ import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID } from "@parking/shared";
import { permissionsFor, requirePermission } from "../auth.js";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
// User management (admin). Users are created/edited at runtime here — the
@@ -202,6 +202,8 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return reply.code(400).send({ error: "nothing to update" });
}
db.update(users).set(next).where(eq(users.id, id)).run();
// A role reassignment takes effect on the user's NEXT request (auth.ts refreshRole).
if (next.roleId) bumpPermsCache();
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
},
);
@@ -251,6 +253,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return reply.code(409).send({ error: "cannot delete the last admin" });
}
softDelete(db, "user", id, req.user.sub);
bumpPermsCache(); // their live session ends on its next request
return { ok: true };
},
);
+272
View File
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { eq, ledgerEvents, users, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../test-helpers.js";
import type { EventLog } from "../event-log.js";
// Merchant validations (bar/lavazh): the merchant user scans a ticket and applies
// their program (a SIGNED, attributed ledger event); the booth settlement quotes NET
// and the payment CONSUMES the validation ids. These tests pin the route guards
// (binding, caps, session state), the signed apply/void events, and the money cycle
// through /api/pay/quote + /api/pay. See wiki/concepts/validation-discounts.md.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
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 seedMerchant(username = "bari"): Promise<{ auth: Auth; userId: string }> {
await seedUser(db, { username, password: "pw123456", roleId: "validues", permissions: ["validation:create"] });
const auth = await login(app, username, "pw123456");
const row = db.select().from(users).where(eq(users.username, username)).get()!;
return { auth, userId: row.id };
}
async function seedAdmin(): Promise<Auth> {
await seedUser(db, { username: "admin", password: "pw123456" });
return login(app, "admin", "pw123456");
}
/** Admin-upserts the "bar" program bound to the given user. */
async function putProgram(auth: Auth, body: Record<string, unknown>, id = "bar") {
return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body });
}
const fixedProgram = (userId: string, over: Record<string, unknown> = {}) => ({
name: "Bar",
mode: "fixed",
maxAmountMinor: 100000,
active: true,
userIds: [userId],
...over,
});
describe("merchant validations", () => {
let log: EventLog;
beforeEach(() => {
log = makeLog(db);
});
const mint = (identity: string, minAgo: number, payload: Record<string, unknown> | null = null) =>
log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: minutesAgo(minAgo), payload });
it("program upsert is admin-gated and signs a config_change; a no-op save signs nothing", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
expect((await putProgram(merchant, fixedProgram(userId))).statusCode).toBe(403);
const res = await putProgram(admin, fixedProgram(userId));
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ id: "bar", mode: "fixed", active: true, userIds: [userId] });
const changes = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
expect(changes()).toHaveLength(1);
expect(changes()[0].payload).toMatchObject({ setting: "validationProgram.bar", operator: "admin" });
// Identical second save → no second config_change.
await putProgram(admin, fixedProgram(userId));
expect(changes()).toHaveLength(1);
});
it("per-mode validation: timeCredit needs minutes, percent needs percent, fixed needs a cap", async () => {
const admin = await seedAdmin();
expect((await putProgram(admin, { name: "X", mode: "timeCredit", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "percent", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "fixed", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "timeCredit", minutes: 60, active: true })).statusCode).toBe(200);
});
it("GET /mine returns only MY bound, active programs", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, fixedProgram(userId));
await putProgram(admin, { name: "Lavazh", mode: "comp", active: true, userIds: [] }, "lavazh");
const res = await app.inject({ method: "GET", url: "/api/validation/mine", headers: hdrs(merchant) });
expect(res.statusCode).toBe(200);
const programs = res.json().programs as { id: string }[];
expect(programs.map((p) => p.id)).toEqual(["bar"]);
});
it("apply: binding, session-state, duplicate and amount guards", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
const { auth: other } = await seedMerchant("tjetri");
await putProgram(admin, fixedProgram(userId));
seedTariff(db);
await mint("T1", 120);
const apply = (auth: Auth, payload: Record<string, unknown>) =>
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(auth), payload });
// Unbound merchant → 403; unknown ticket → 404; missing amount (fixed) → 400;
// amount above the cap → 400.
expect((await apply(other, { identity: "T1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(403);
expect((await apply(merchant, { identity: "NOPE", programId: "bar", amountMinor: 5000 })).statusCode).toBe(404);
expect((await apply(merchant, { identity: "T1", programId: "bar" })).statusCode).toBe(400);
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 999999 })).statusCode).toBe(400);
// Subscriber sessions are never validated (prepaid).
await mint("SUB1", 60, { permit: true, permitId: "s-1" });
expect((await apply(merchant, { identity: "SUB1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(409);
// Success → a SIGNED validation event with resolved values + the merchant username.
const ok = await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 5000 });
expect(ok.statusCode).toBe(201);
const ev = db.select().from(ledgerEvents).all().find((r) => r.type === "validation")!;
expect(ev.payload).toMatchObject({
programId: "bar",
programLabel: "Bar",
mode: "fixed",
amountMinor: 5000,
operator: "bari",
});
// Same program twice on one ticket → 409.
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 1000 })).statusCode).toBe(409);
});
it("the money cycle: quote nets the validation, pay records gross/discount and CONSUMES it", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, fixedProgram(userId));
// 100/h flat; 2h → gross 20000.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
await mint("T1", 119);
await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
const q1 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q1.json()).toMatchObject({
grossMinor: 20000,
discountMinor: 5000,
amountMinor: 15000,
});
expect(q1.json().validationLines).toEqual([
{ programId: "bar", label: "Bar", mode: "fixed", discountMinor: 5000 },
]);
// Pay (needs an open shift) → the payment carries the split + consumed ids.
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
expect(pay.statusCode).toBe(201);
expect(pay.json().amountMinor).toBe(15000);
const payment = db.select().from(ledgerEvents).all().find((r) => r.type === "payment")!;
expect(payment.payload).toMatchObject({ amountMinor: 15000, grossMinor: 20000, discountMinor: 5000 });
expect((payment.payload as { validationIds?: string[] }).validationIds).toHaveLength(1);
// Settled: the follow-up quote owes 0 and applies nothing further.
const q2 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q2.json().amountMinor).toBe(0);
expect(q2.json().validationLines).toEqual([]);
});
it("a full comp settles at 0 through the normal pay path (grace starts, chain verifies)", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, { name: "Lavazh falas", mode: "comp", active: true, userIds: [userId] }, "lavazh");
seedTariff(db, { pricePerIncrementMinor: 10000 });
await mint("T1", 90);
await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "lavazh" },
});
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q.json().amountMinor).toBe(0);
expect(q.json().grossMinor).toBeGreaterThan(0);
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
expect(pay.statusCode).toBe(201);
expect(pay.json().amountMinor).toBe(0);
// The 0-net settlement still grants walk-back grace (the session reads settled).
const view = await app.inject({ method: "GET", url: "/api/session/T1", headers: hdrs(admin) });
expect(view.json()).toMatchObject({ withinGrace: true, amountMinor: 0 });
});
it("void: own unused only; a consumed validation is locked", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
const { auth: other, userId: otherId } = await seedMerchant("tjetri");
await putProgram(admin, fixedProgram(userId, { userIds: [userId, otherId] }));
seedTariff(db, { pricePerIncrementMinor: 10000 });
await mint("T1", 90);
const applied = await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
const eventId = applied.json().eventId as string;
const voidReq = (auth: Auth) =>
app.inject({ method: "POST", url: "/api/validation/void", headers: hdrs(auth), payload: { eventId, identity: "T1" } });
// Someone else's validation → 403. Own → ok, and the quote returns to gross.
expect((await voidReq(other)).statusCode).toBe(403);
expect((await voidReq(merchant)).statusCode).toBe(200);
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q.json().discountMinor).toBe(0);
// Re-apply (the void freed the per-session slot), consume it with a payment, then
// a void must refuse — the settlement already happened.
const re = await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
const locked = await app.inject({
method: "POST",
url: "/api/validation/void",
headers: hdrs(merchant),
payload: { eventId: re.json().eventId, identity: "T1" },
});
expect(locked.statusCode).toBe(409);
});
it("maxPerDay caps applications across tickets", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, { name: "Lavazh", mode: "comp", maxPerDay: 1, active: true, userIds: [userId] }, "lavazh");
seedTariff(db);
await mint("T1", 60);
await mint("T2", 30);
const apply = (identity: string) =>
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(merchant), payload: { identity, programId: "lavazh" } });
expect((await apply("T1")).statusCode).toBe(201);
expect((await apply("T2")).statusCode).toBe(409);
});
});
+306
View File
@@ -0,0 +1,306 @@
import type { FastifyInstance } from "fastify";
import {
and,
eq,
isNull,
inArray,
ledgerEvents,
users,
validationProgramUsers,
validationPrograms,
type Db,
} from "@parking/db";
import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { requireModule } from "../modules.js";
import type { EventLog } from "../event-log.js";
import { applyValidation, liveValidations, sessionValidations } from "../validations.js";
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
// customer's ticket on their own device and apply their program — all money and paper
// stay at the booth, which settles net of these events. Program config is admin-composed
// on /setup/site (site:read/update — no dedicated permission); applying is the merchant
// user's `validation:create`, guarded FURTHER by the program↔user binding so a bar user
// can never apply the lavazh program. Every apply/void is a signed, attributed ledger
// event. See wiki/concepts/validation-discounts.md.
// - GET /api/validation/programs : all programs + bound users. (site:read)
// - PUT /api/validation/programs/:id : upsert config + bindings; (site:update)
// signs a config_change.
// - GET /api/validation/mine : my bound ACTIVE programs. (validation:create)
// - GET /api/validation/session/:identity : minimal session view for (validation:create)
// the merchant screen (no money data).
// - POST /api/validation/apply : apply my program (signed). (validation:create)
// - POST /api/validation/void : void my OWN unused apply. (validation:create)
/** Well-formed program ids: kebab slugs ("bar", "lavazh", a future "hotel-2"). */
const ID_RE = /^[a-z][a-z0-9-]{1,31}$/;
interface ProgramBody {
name?: string;
mode?: ValidationMode;
minutes?: number | null;
percent?: number | null;
maxAmountMinor?: number | null;
maxPerDay?: number | null;
active?: boolean;
/** Full replacement set of bound user ids. */
userIds?: string[];
}
interface ApplyBody {
identity: string;
programId: string;
/** fixed mode only: the discount the merchant grants (minor units, ≤ maxAmountMinor). */
amountMinor?: number;
}
interface VoidBody {
eventId: string;
identity: string;
}
/** null when valid, else the 400 message. Checks the per-mode parameter. */
function validateProgram(b: ProgramBody): string | null {
if (!b.name || !String(b.name).trim()) return "name is required";
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
// doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
// minute more"; every other minutes use is a positive credit.
const minutesOk = b.mode === "doneTolerance"
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
: intOrNull(b.minutes);
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "minutes must be a positive integer";
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
return "percent must be 1..100";
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
if (b.mode === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
return null;
}
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
// The PROGRAM routes (compose / read discount programs) are CORE: the discount engine
// serves every module that grants a parking discount (Car Wash's "carwash" program
// rides it), so they are never behind the validation module gate — plain site:read /
// site:update. The MERCHANT routes (mine / lookup / apply / void — the scan screen)
// are the validation module itself: module gate FIRST (403 module_disabled when the
// site has validation off — see ../modules.ts), then the permission.
const siteRead = requirePermission("site:read");
const siteWrite = requirePermission("site:update");
const applyGuard = [requireModule(db, "validation"), requirePermission("validation:create")];
const liveProgram = (id: string) =>
db
.select()
.from(validationPrograms)
.where(and(eq(validationPrograms.id, id), isNull(validationPrograms.deletedAt)))
.get();
const boundUserIds = (programId: string): string[] =>
db
.select({ userId: validationProgramUsers.userId })
.from(validationProgramUsers)
.where(eq(validationProgramUsers.programId, programId))
.all()
.map((r) => r.userId);
// The setup panel's read: every live program with its bound users.
app.get("/api/validation/programs", { preHandler: siteRead }, async () => {
const programs = db.select().from(validationPrograms).where(isNull(validationPrograms.deletedAt)).all();
return {
programs: programs.map((p) => ({ ...p, userIds: boundUserIds(p.id) })),
};
});
// Upsert a program (the /setup/site checkbox + panel). Creates the well-known row on
// first enable; replaces the binding set; signs an attributed config_change when
// anything actually changed (the entry-presence-bypass precedent — enabling a discount
// program is fraud-relevant config).
app.put<{ Params: { id: string }; Body: ProgramBody }>(
"/api/validation/programs/:id",
{ preHandler: siteWrite },
async (req, reply) => {
const id = (req.params.id ?? "").trim();
if (!ID_RE.test(id)) return reply.code(400).send({ error: "invalid program id" });
const b = req.body ?? ({} as ProgramBody);
const bad = validateProgram(b);
if (bad) return reply.code(400).send({ error: bad });
const userIds = Array.isArray(b.userIds) ? [...new Set(b.userIds)] : [];
if (userIds.length) {
const found = db
.select({ id: users.id })
.from(users)
.where(and(inArray(users.id, userIds), isNull(users.deletedAt)))
.all();
if (found.length !== userIds.length) return reply.code(400).send({ error: "unknown user in userIds" });
}
const prev = liveProgram(id);
const prevUserIds = prev ? boundUserIds(id).sort() : [];
const next = {
name: String(b.name).trim(),
mode: b.mode as ValidationMode,
minutes: b.minutes ?? null,
percent: b.percent ?? null,
maxAmountMinor: b.maxAmountMinor ?? null,
maxPerDay: b.maxPerDay ?? null,
active: b.active === true,
};
if (prev) {
db.update(validationPrograms).set(next).where(eq(validationPrograms.id, id)).run();
} else {
db.insert(validationPrograms).values({ id, ...next }).run();
}
db.delete(validationProgramUsers).where(eq(validationProgramUsers.programId, id)).run();
for (const userId of userIds) {
db.insert(validationProgramUsers).values({ programId: id, userId }).run();
}
// Sign the change (attributed) — enabling/reshaping a discount program is
// fraud-relevant config. Compare against the previous row + binding set so a
// no-op save signs nothing.
const summary = (row: typeof next, ids: string[]) => JSON.stringify({ ...row, userIds: [...ids].sort() });
const prevSummary = prev
? summary(
{ name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active },
prevUserIds,
)
: null;
if (prevSummary !== summary(next, userIds)) {
await eventLog.append({
type: "config_change",
source: "manual",
identity: `validation-program:${id}`,
payload: {
setting: `validationProgram.${id}`,
value: { ...next, userCount: userIds.length },
prev: prev
? { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active }
: null,
operator: req.user?.username ?? "unknown",
},
});
}
const row = liveProgram(id);
return { ...row, userIds: boundUserIds(id) };
},
);
// The merchant screen's program list: MY bound, active programs.
app.get("/api/validation/mine", { preHandler: applyGuard }, async (req) => {
const rows = db
.select()
.from(validationPrograms)
.innerJoin(validationProgramUsers, eq(validationProgramUsers.programId, validationPrograms.id))
.where(
and(
eq(validationProgramUsers.userId, req.user.sub),
eq(validationPrograms.active, true),
isNull(validationPrograms.deletedAt),
),
)
.all();
return { programs: rows.map((r) => r.validation_programs) };
});
// Minimal session view for the merchant screen — deliberately NO money data (the
// merchant validates; the booth settles): found/open/entry time + the validations
// already on the session (so the UI can show "already validated" and offer void).
app.get<{ Params: { identity: string } }>(
"/api/validation/session/:identity",
{ preHandler: applyGuard },
async (req, reply) => {
const identity = (req.params.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const rows = db
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return { identity, found: false, open: false, enteredAt: null, subscription: false, validations: [] };
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
const open = !rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
return {
identity,
found: true,
open,
enteredAt: entry.occurredAt,
subscription,
validations: sessionValidations(db, identity),
};
},
);
// APPLY: the merchant's one action. Guards, in order: program live+active → the
// user is BOUND to it → the session is an OPEN TRANSIENT → not already carrying a
// live application of this program → per-day cap → fixed-amount bounds. Appends the
// signed validation event with the RESOLVED values.
app.post<{ Body: ApplyBody }>("/api/validation/apply", { preHandler: applyGuard }, async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
const programId = (req.body?.programId ?? "").trim();
if (!identity || !programId) return reply.code(400).send({ error: "identity and programId required" });
const program = liveProgram(programId);
if (!program || !program.active) return reply.code(404).send({ error: "program not found or inactive" });
if (!boundUserIds(programId).includes(req.user.sub)) {
return reply.code(403).send({ error: "you are not bound to this program" });
}
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
}
// The decision chain + the signed append live in ../validations.ts (applyValidation)
// — shared with the Car Wash module, which applies its own sponsorship program with
// no user binding. Only the binding check above is merchant-specific.
const result = await applyValidation(db, eventLog, {
programId,
identity,
actor: req.user.username,
amountMinor: req.body?.amountMinor,
});
if (!result.ok) return reply.code(result.status).send({ error: result.error });
return reply.code(201).send(result);
});
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
// a validation event with refId, never a delete. Refused once a payment consumed it
// (the settlement already happened — that dispute goes to the booth/admin).
app.post<{ Body: VoidBody }>("/api/validation/void", { preHandler: applyGuard }, async (req, reply) => {
const eventId = (req.body?.eventId ?? "").trim();
const identity = (req.body?.identity ?? "").trim();
if (!eventId || !identity) return reply.code(400).send({ error: "eventId and identity required" });
const target = sessionValidations(db, identity).find((v) => v.eventId === eventId);
if (!target) return reply.code(404).send({ error: "validation not found" });
if (target.operator !== req.user.username) {
return reply.code(403).send({ error: "you may only void your own validation" });
}
if (target.voided) return reply.code(409).send({ error: "already voided" });
if (target.consumedBy != null) {
return reply.code(409).send({ error: "already used in a payment — ask the booth/admin" });
}
await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
refId: eventId,
programId: target.programId,
programLabel: target.label,
operator: req.user.username,
},
});
return { ok: true };
});
}
+117 -26
View File
@@ -1,7 +1,9 @@
import { randomBytes } from "node:crypto";
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { feedPermissionFor, watchPermissions, type LedgerEvent, type Permission } from "@parking/shared";
import { currentRoleId, requireAuth, roleHasPermissions } from "../auth.js";
import { effectiveModulesFor } from "../modules.js";
import {
deviceEvents,
type LaneStatusEvent,
@@ -31,10 +33,61 @@ import { getOccupancy } from "../occupancy.js";
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
//
// Desktop shell (Tauri) exception — the WS TICKET. The desktop app's HTTP goes
// through tauri-plugin-http (reqwest, its own cookie jar) and its WebSocket
// through tauri-plugin-websocket (bare tungstenite, NO cookie jar at all), so
// the JWT cookie set at login can never ride on the WS handshake — jwtVerify()
// would 401 every connect (found 2026-09-04: the desktop live feed reconnected
// every 10s forever). The JWT is HttpOnly and must stay out of JS, so instead
// the desktop client POSTs /api/ws/ticket (normal cookie + CSRF auth) to get a
// single-use, 30-second random ticket bound to its user, and presents it in an
// `x-ws-ticket` header on the handshake. A browser page cannot set custom
// headers on a WebSocket, so this path is unreachable from a browser and adds
// no CSWSH surface; the Origin allowlist still applies to both paths.
/** Permission required to watch the live feed (a read-only stream of ledger +
* device status). Any role granted `report:read` may watch. */
const WATCH_PERMISSION = "report:read" as const;
// WHO may watch, and WHAT they see (venue-modules.md §"Permissions matrix", move 3):
// a role connects if it holds ANY watch permission — the core feed/occupancy/device
// ones or an effective module's own (carwash:read) — and every pushed message is then
// FILTERED per role: a ledger event needs feedPermissionFor(type) (the owning module's,
// else event:read); occupancy + the plate backfill need session:read; device / printer /
// lane / radar need device:read. `report:read` is the REPORTS screen, not the socket: the
// wash desk gets a live queue without the booth's ledger, the booth a feed without reports.
type Viewer = { has: (p: Permission) => boolean };
/** Handshake header carrying a desktop WS ticket (see file header). */
const WS_TICKET_HEADER = "x-ws-ticket";
/** A ticket is only good for the connect that immediately follows its issue. */
const WS_TICKET_TTL_MS = 30_000;
interface WsTicket {
sub: string;
roleId: string;
expiresAt: number;
}
/** Outstanding tickets. Tiny (one per desktop connect attempt), in-memory only —
* a server restart invalidates them, which is fine: the client just asks for
* another on its next reconnect. */
const tickets = new Map<string, WsTicket>();
function issueWsTicket(sub: string, roleId: string): string {
const now = Date.now();
for (const [key, t] of tickets) {
if (t.expiresAt <= now) tickets.delete(key);
}
const ticket = randomBytes(32).toString("hex");
tickets.set(ticket, { sub, roleId, expiresAt: now + WS_TICKET_TTL_MS });
return ticket;
}
/** Single-use: the ticket is removed whether or not it turns out to be valid. */
function consumeWsTicket(ticket: string): WsTicket | null {
const t = tickets.get(ticket);
if (!t) return null;
tickets.delete(ticket);
return t.expiresAt > Date.now() ? t : null;
}
/**
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
@@ -61,18 +114,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
type OutMsg =
| {
kind: "hello";
occupancy: ReturnType<typeof getOccupancy>;
occupancy: ReturnType<typeof getOccupancy> | null;
devices: unknown;
lanes: LaneStatusEvent;
radar: LanePresenceEvent;
lanes: LaneStatusEvent | null;
radar: LanePresenceEvent | null;
}
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> | null }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent }
| { kind: "lane-presence"; radar: LanePresenceEvent }
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
declare module "fastify" {
interface FastifyRequest {
/** The role the WS preHandler authenticated (ticket or cookie path) — for the handler's filter. */
wsRoleId?: string;
}
}
export async function wsRoutes(
app: FastifyInstance,
db: Db,
@@ -80,24 +140,52 @@ export async function wsRoutes(
laneStatus: LaneStatus,
lanePresence: LanePresence,
): Promise<void> {
// Desktop-only: mint a WS ticket for the signed-in session (see file header).
// Ordinary cookie + CSRF auth — the desktop client CAN do that over HTTP (via
// tauri-plugin-http), it just can't carry the cookie onto the WebSocket.
app.post("/api/ws/ticket", { preHandler: requireAuth }, async (req) => ({
ticket: issueWsTicket(req.user.sub, req.user.roleId),
expiresInMs: WS_TICKET_TTL_MS,
}));
app.get(
"/api/ws",
{
websocket: true,
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
// role. Reject a cross/absent origin before touching the token, so a hijack
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
// session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
// cross/absent origin before touching either credential, so a hijack
// attempt never reaches an authenticated socket.
preHandler: async (req) => {
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
}
await req.jwtVerify();
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
const rawTicket = req.headers[WS_TICKET_HEADER];
const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket;
let roleId: string;
if (ticket !== undefined) {
const t = consumeWsTicket(ticket);
if (!t) {
throw Object.assign(new Error("invalid or expired ws ticket"), { statusCode: 401 });
}
roleId = t.roleId;
} else {
await req.jwtVerify(); // reads the HttpOnly cookie (browser path)
if (!req.user) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
roleId = currentRoleId(req.user.sub) ?? "";
}
const may = watchPermissions(effectiveModulesFor(db)).some((p) => roleHasPermissions(roleId, [p]));
if (!may) throw Object.assign(new Error("forbidden"), { statusCode: 403 });
req.wsRoleId = roleId;
},
},
(socket) => {
(socket, req) => {
const roleId = req.wsRoleId ?? req.user?.roleId ?? "";
const viewer: Viewer = { has: (p) => roleHasPermissions(roleId, [p]) };
const seesOccupancy = viewer.has("session:read");
const seesDevices = viewer.has("device:read");
const send = (msg: OutMsg) => {
// readyState 1 = OPEN; never throw out of an event-bus callback.
if (socket.readyState === 1) {
@@ -111,40 +199,43 @@ export async function wsRoutes(
// Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer).
// Each part of the snapshot only for a role that may see it (null otherwise).
send({
kind: "hello",
occupancy: getOccupancy(db),
devices: deviceMonitor.snapshot(),
lanes: laneStatus.snapshot(),
radar: lanePresence.snapshot(),
occupancy: seesOccupancy ? getOccupancy(db) : null,
devices: seesDevices ? deviceMonitor.snapshot() : null,
lanes: seesDevices ? laneStatus.snapshot() : null,
radar: seesDevices ? lanePresence.snapshot() : null,
});
// Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative.
const offLedger = deviceEvents.onLedger((event) => {
// Per-role filter: the event type's feed permission (module's own, else event:read).
if (!viewer.has(feedPermissionFor((event as { type: LedgerEvent["type"] }).type))) return;
// Enrich with read-time display fields (subscriber name) before fan-out.
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
send({ kind: "ledger", event: enriched, occupancy: seesOccupancy ? getOccupancy(db) : null });
});
const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", event });
if (seesDevices) send({ kind: "printer-status", event });
});
// Unified device status (all categories) for the booth footer — pushed on
// change; the initial set rode the hello above.
const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event });
if (seesDevices) send({ kind: "device-status", event });
});
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes });
if (seesDevices) send({ kind: "lane-status", lanes });
});
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
const offPresence = deviceEvents.onLanePresence((radar) => {
send({ kind: "lane-presence", radar });
if (seesDevices) send({ kind: "lane-presence", radar });
});
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
const offPlate = deviceEvents.onPlateRecognized((plate) => {
send({ kind: "plate-recognized", plate });
if (seesOccupancy) send({ kind: "plate-recognized", plate });
});
socket.on("close", () => {
+56 -23
View File
@@ -45,6 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
import { drawerRoutes } from "./routes/drawer.js";
import { entryRoutes } from "./routes/entry.js";
import { siteRoutes } from "./routes/site.js";
import { registerModules } from "./modules/index.js";
import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js";
@@ -71,7 +72,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const logService = new LogService(db);
const app = Fastify({
logger: {
// Level knob: trace|debug|info|warn|error|fatal (pino). Default info; a booth
// being diagnosed can run LOG_LEVEL=debug without a code change.
level: process.env.LOG_LEVEL ?? "info",
// Container logs are read by humans (`docker logs` / Komodo), so stamp
// ISO-8601 UTC instead of pino's epoch-ms, and level NAMES instead of the
// numeric codes (30/40/50). pinoDbStream accepts both encodings.
timestamp: () => `,"time":"${new Date().toISOString()}"`,
formatters: { level: (label) => ({ level: label }) },
stream: pinoDbStream(logService, process.stdout),
},
});
@@ -98,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
cookie: { cookieName: TOKEN_COOKIE, signed: false },
});
app.get("/health", async () => ({ status: "ok" }));
// Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen
// test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any
// other service that happens to answer on the address the operator typed.
app.get("/health", async () => ({ status: "ok", app: "parking-system" }));
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db);
@@ -106,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// RBAC administration: compose roles (role:*) + manage users (user:*). The
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
await userRoutes(app, db);
await roleRoutes(app, db);
// Vision (ANPR) client — built early so the device monitor can include the vision
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
@@ -115,11 +125,25 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const visionClient = new VisionClient(app.log);
if (visionClient.enabled) app.log.info("vision client enabled");
// Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// in device_events. The entry flow turns an input into a signed vehicle_entry once
// a ticket prints + the barrier is commanded. See event-streams-split.md.
// Constructed HERE (before setupRoutes) so the Setup relay-test can sign its
// deliberate barrier open into the ledger; the read routes are wired further down.
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
deviceEvents.emitLedger(row),
);
// Device-agnostic setup: the admin adds controllers (with their relays + entry
// button) and binds readers/cameras to a controller relay at first-run. There is
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
await setupRoutes(app, db, visionClient);
await setupRoutes(app, db, visionClient, eventLog);
await roleRoutes(app, db, eventLog);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from
@@ -158,17 +182,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => deviceMonitor.start());
app.addHook("onClose", async () => deviceMonitor.stop());
// Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// in device_events. The entry flow (TODO) turns an input into a signed
// vehicle_entry once a ticket prints + the barrier is commanded.
// See wiki/decisions/event-streams-split.md.
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
deviceEvents.emitLedger(row),
);
// Read routes for the signed ledger (constructed above, before setupRoutes).
await eventRoutes(app, db, eventLog);
// Admin reporting: read-only charts/totals aggregated from the signed ledger
@@ -200,6 +214,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
void entryFlow.onInput(e);
});
app.addHook("onClose", async () => unsubscribeEntry());
// The camera press-gate: the entry flow mirrors the entry lane's camera state so a
// physical press is live only in the lamp's SOLID state (see entry-flow.ts).
const unsubscribeEntryLane = deviceEvents.onLaneStatus((s) => entryFlow.onLaneStatus(s));
app.addHook("onClose", async () => unsubscribeEntryLane());
// Button-light indicator: drives the entry button's lamp on a spare relay from the
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
@@ -268,15 +286,22 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await subscriptionPlanRoutes(app, db);
// Shift open/close (shiftService constructed above).
await shiftRoutes(app, shiftService);
await shiftRoutes(app, db, shiftService);
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
await drawerRoutes(app, shiftService);
await drawerRoutes(app, db, shiftService);
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
await entryRoutes(app, entryFlow, laneStatus, shiftService);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db);
await siteRoutes(app, db, eventLog);
// Venue modules (wiki/decisions/venue-modules.md): folder-based modules register
// here by iterating the shared registry — today that is `validation` (merchant
// validations for the Bar; the booth settlement folds applied validations into its
// quote, pay-station.ts). `parking` is in the registry too but its routes are still
// the flat list above; they move behind the seam subsystem by subsystem.
await registerModules(app, { db, eventLog, payStation, shiftService });
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
@@ -316,12 +341,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
void runSnapPrune(); // once at startup
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
// persisted — made the admin UI show "Never" despite valid backups already on disk
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
// unreachable/unmounted target by recording the error and trying again next check. NOT run
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
backupTimer.unref();
app.addHook("onClose", async () => clearInterval(backupTimer));
if (backupService.configured) {
+97
View File
@@ -234,4 +234,101 @@ describe("close signs a Z-report; listShifts reads it back", () => {
await shift.open("bob"); await shift.close("bob");
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
});
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
await shift.open("bob"); await shift.close("bob");
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
await shift.open("alice"); // open, no z-report yet
expect(shift.listOperators()).toEqual(["alice", "bob"]);
});
});
describe("tills: one shift per till, one drawer per till", () => {
/** A bay payment as the Car Wash module signs it (till = carwash). */
async function bayPayment(amountMinor: number, tender: "cash" | "card" = "cash") {
await log.append({
type: "carwash_payment", source: "manual", identity: "T",
payload: { sessionRef: "T", orderId: "o1", amountMinor, currency: "ALL", tender, till: "carwash" },
});
}
it("the booth and the carwash till can both be open at once, by different operators", async () => {
await shift.open("alice");
await expect(shift.open("wanda", "carwash")).resolves.toMatchObject({ till: "carwash" });
expect(shift.currentOpenShift()?.identity).toBe("alice");
expect(shift.currentOpenShift("carwash")?.identity).toBe("wanda");
// Each till keeps its own single-open rule.
await expect(shift.open("bob", "carwash")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
});
it("requireOpenShift is per till: a booth shift does not cover the bay", async () => {
await shift.open("alice");
expect(() => shift.requireOpenShift("carwash")).toThrow(NoShiftOpenError);
await shift.open("wanda", "carwash");
expect(shift.requireOpenShift("carwash").identity).toBe("wanda");
});
it("money folds into ITS till only: bay cash is the wash operator's, not the booth's", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await payment(10000); // booth (payment events carry till=booth or nothing)
await bayPayment(70000);
await bayPayment(20000, "card");
const booth = shift.currentReport()!;
expect(booth.till).toBe("booth");
expect(booth.cashTotalMinor).toBe(10000);
expect(booth.paymentCount).toBe(1);
expect(booth.expectedDrawerMinor).toBe(10000);
const wash = shift.currentReport("carwash")!;
expect(wash.till).toBe("carwash");
expect(wash.cashTotalMinor).toBe(70000);
expect(wash.cardTotalMinor).toBe(20000);
expect(wash.paymentCount).toBe(2);
expect(wash.expectedDrawerMinor).toBe(70000);
expect(shift.drawerBalance().balanceMinor).toBe(10000);
expect(shift.drawerBalance("carwash").balanceMinor).toBe(70000);
});
it("vouchers name their till; each till's expected drawer carries forward on its own", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await shift.recordVoucher({ type: "cash_in", operator: "wanda", amountMinor: 5000, reason: "float", till: "carwash" });
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float" });
await bayPayment(70000);
expect(shift.movementsWithStatus({ till: "carwash" }).map((m) => m.amountMinor)).toEqual([5000]);
const washZ = await shift.close("wanda", "carwash");
expect(washZ).toMatchObject({ till: "carwash", cashAddedMinor: 5000, cashTotalMinor: 70000, expectedDrawerMinor: 75000 });
const boothZ = await shift.close("alice");
expect(boothZ).toMatchObject({ till: "booth", cashAddedMinor: 100000, cashTotalMinor: 0, expectedDrawerMinor: 100000 });
// Next shift on each till inherits that till's drawer only.
expect((await shift.open("wanda", "carwash")).openingFloatMinor).toBe(75000);
expect((await shift.open("bob")).openingFloatMinor).toBe(100000);
});
it("close is per till: closing the booth never closes the wash desk", async () => {
await shift.open("alice");
await shift.open("alice", "carwash");
await shift.close("alice");
expect(shift.currentOpenShift()).toBeNull();
expect(shift.currentOpenShift("carwash")?.identity).toBe("alice");
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
});
it("history lists both tills, filterable; pre-till reports read as booth", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await shift.close("wanda", "carwash");
await shift.close("alice");
const all = shift.listShifts();
expect(all.map((s) => s.till).sort()).toEqual(["booth", "carwash"]);
expect(shift.listShifts({ till: "carwash" }).map((s) => s.operator)).toEqual(["wanda"]);
expect(shift.listShifts({ till: "booth" }).map((s) => s.operator)).toEqual(["alice"]);
expect(shift.listOperators("carwash")).toEqual(["wanda"]);
});
});
+281 -124
View File
@@ -1,6 +1,6 @@
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
import type { LedgerPayload } from "@parking/shared";
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
@@ -8,33 +8,46 @@ import type { EventLog } from "./event-log.js";
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
// `payment` events taken during the shift by tender and print a Z-report.
//
// TILLS (2026-09-05): a shift is opened ON A TILL — the booth, or a money-taking
// module's own desk (Car Wash → "carwash"). One shift may be open PER TILL, each with
// its own operator, opening float, expected drawer and Z-report. Every money event
// names its till (`payload.till`; absent = booth, which is what every pre-till event
// is), and every fold in this file filters by it. Every public method takes the till,
// defaulting to the booth so the parking paths read as they always did.
// See wiki/concepts/shift.md.
export class ShiftAlreadyOpenError extends Error {
/** The operator who currently holds the open shift (may be someone else). */
readonly heldBy: string;
constructor(operator: string, heldBy: string) {
constructor(operator: string, heldBy: string, till: TillId = BOOTH_TILL) {
super(
heldBy === operator
? `operator ${operator} already has an open shift`
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
? `operator ${operator} already has an open ${till} shift`
: `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`,
);
this.name = "ShiftAlreadyOpenError";
this.heldBy = heldBy;
}
}
export class NoOpenShiftError extends Error {
constructor(operator: string) {
super(`operator ${operator} has no open shift`);
constructor(operator: string, till: TillId = BOOTH_TILL) {
super(`operator ${operator} has no open ${till} shift`);
this.name = "NoOpenShiftError";
}
}
/** Thrown by the booth money path when NO shift is open site-wide — an operator
* must open a shift before any payment/exit can be attributed to a shift. */
/** Thrown by a money path when NO shift is open on its till — an operator must open
* a shift there before any payment/exit can be attributed to one. */
export class NoShiftOpenError extends Error {
constructor() {
super("no shift is open — open a shift before processing tickets");
readonly till: TillId;
constructor(till: TillId = BOOTH_TILL) {
super(
till === BOOTH_TILL
? "no shift is open — open a shift before processing tickets"
: `no ${till} shift is open — open one before taking money there`,
);
this.name = "NoShiftOpenError";
this.till = till;
}
}
@@ -44,6 +57,8 @@ export class NoShiftOpenError extends Error {
export interface ShiftSummary {
readonly id: string;
readonly index: number;
/** The till this shift reconciled (booth for every pre-till report). */
readonly till: TillId;
readonly operator: string;
readonly startedAt: string;
readonly endedAt: string;
@@ -55,13 +70,22 @@ export interface ShiftSummary {
readonly subscriptionTotalMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number;
readonly chargesByModuleMinor: ChargesByModule;
readonly openingFloatMinor: number;
readonly cashAddedMinor: number;
readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: number;
}
/** Module money folded into this till's payments as `chargeLines`, by owning module —
* a wash paid on the parking ticket lands here as `{ carwash: <minor> }`. Only modules
* that actually charged in the window appear. Cash+card already contain it; it is
* broken OUT of the ticket bucket so "Bileta" is parking money only. */
export type ChargesByModule = Partial<Record<ModuleId, number>>;
export interface ShiftReport {
readonly till: TillId;
readonly operator: string;
readonly startedAt: string;
readonly endedAt: string;
@@ -78,6 +102,11 @@ export interface ShiftReport {
readonly subscriptionSalesMinor: number;
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
readonly subscriptionWindowMinor: number;
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number;
/** Module charges settled on this till's payments (a booth-paid wash), by module. */
readonly chargesByModuleMinor: ChargesByModule;
// --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number;
@@ -98,6 +127,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
export interface DrawerMovement {
readonly id: string;
readonly type: "cash_in" | "cash_out";
/** Which drawer the cash moved in/out of. */
readonly till: TillId;
/** Positive magnitude; direction is the `type`. */
readonly amountMinor: number;
readonly currency: string | null;
@@ -118,6 +149,18 @@ export class InvalidCashMovementError extends Error {
}
}
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
/** The takings line a till's OWN money prints under (the booth sells tickets; the wash
* desk sells washes) and the label a module's charge gets when it rides another
* till's ticket ("Lavazh (në biletë)"). Printed slips are Albanian (i18n.md). */
const TILL_TAKINGS_LABEL: Record<TillId, string> = { booth: "Bileta", carwash: "Lavazh" };
const MODULE_PRINT_LABEL: Partial<Record<ModuleId, string>> = { carwash: "Lavazh", validation: "Validime" };
/** Which printer a till's slips (Z-report, vouchers) want. The wash desk falls back to
* the booth printer when it has none of its own (orderForRole); the booth never falls
* back to the desk. See wiki/concepts/printer-roles-failover.md. */
const TILL_PRINTER_ROLE: Record<TillId, PrinterRole> = { booth: "booth-receipt", carwash: "wash-desk" };
export class ShiftService {
readonly #db: Db;
readonly #log: EventLog;
@@ -129,41 +172,42 @@ export class ShiftService {
this.#logger = logger;
}
/** Current physical drawer balance (cash payments + cash_movements, by time). For
* the UI to show "inherited / in the drawer now". */
drawerBalance(): { balanceMinor: number; currency: string | null } {
return this.#drawerBalanceAt(new Date().toISOString());
/** Current physical drawer balance of a till (cash payments + cash_movements, by
* time). For the UI to show "inherited / in the drawer now". */
drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } {
return this.#drawerBalanceAt(new Date().toISOString(), till);
}
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
openShiftFor(operator: string) {
// Scan shift events for this operator; the shift is open if the most recent
// shift event for them is a `shift_open` (not yet closed by a z_report).
const rows = this.#db
/** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */
#shiftEvents(till: TillId) {
return this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, operator))
.where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"]))
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
.filter((r) => tillOf(r.payload as LedgerPayload | null) === till);
}
/** Is there an open shift for this operator on this till? Returns the open
* `shift_open` row or null. */
openShiftFor(operator: string, till: TillId = BOOTH_TILL) {
// The shift is open if the operator's most recent shift event on the till is a
// `shift_open` (not yet closed by a z_report).
const rows = this.#shiftEvents(till).filter((r) => r.identity === operator);
const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null;
}
/**
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
* period: at most ONE may be open at a time (so booth takings are unambiguously
* attributed to one operator). It's open iff the most recent shift event on the
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
* appended yet). Returns that row so callers can read its operator/startedAt.
* The SINGLE open shift of a till, or null. A shift is the till's accountability
* period: at most ONE may be open per till at a time (so its takings are
* unambiguously attributed to one operator). It's open iff the till's most recent
* shift event is a `shift_open` (the matching `shift_z_report` hasn't been appended
* yet). Returns that row so callers can read its operator/startedAt.
*/
currentOpenShift() {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
currentOpenShift(till: TillId = BOOTH_TILL) {
const rows = this.#shiftEvents(till);
const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null;
}
@@ -177,7 +221,30 @@ export class ShiftService {
* The open shift (no z_report yet) is intentionally excluded — it's not a
* completed accountability period. Use `currentOpenShift()` for the live one.
*/
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
/**
* Every operator that HAS a shift (closed z_reports + the open one, if any),
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
* for an operator that exists (the filter is an exact username match).
*/
listOperators(till?: TillId): string[] {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"]))
.all()
.filter((r) => till == null || tillOf(r.payload as LedgerPayload | null) === till);
// Every operator with a closed report, plus the holder of each open shift (an
// open shift is the last shift_open on its till — but any shift_open's operator
// has or had a shift, which is all the dropdown needs).
const names = new Set<string>();
for (const r of rows) {
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
if (op) names.add(op);
}
return [...names].sort((a, b) => a.localeCompare(b));
}
listShifts(opts: { operator?: string; from?: string; to?: string; till?: TillId } = {}): ShiftSummary[] {
const rows = this.#db
.select()
.from(ledgerEvents)
@@ -198,6 +265,8 @@ export class ShiftService {
subscriptionTotalMinor?: number;
subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number;
discountTotalMinor?: number;
chargesByModuleMinor?: ChargesByModule;
openingFloatMinor?: number;
cashAddedMinor?: number;
cashRemovedMinor?: number;
@@ -205,12 +274,15 @@ export class ShiftService {
};
const operator = pl.operator ?? r.identity ?? "?";
const startedAt = pl.startedAt ?? r.occurredAt;
const till = tillOf(pl);
if (opts.till && till !== opts.till) continue;
if (opts.operator && operator !== opts.operator) continue;
if (opts.from && startedAt < opts.from) continue;
if (opts.to && startedAt > opts.to) continue;
out.push({
id: r.id,
index: r.index,
till,
operator,
startedAt,
endedAt: pl.endedAt ?? r.occurredAt,
@@ -228,6 +300,10 @@ export class ShiftService {
ticketTotalMinor:
pl.ticketTotalMinor ??
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 0,
// Module charges on the ticket (added 2026-09-06). Old reports lack it → none.
chargesByModuleMinor: pl.chargesByModuleMinor ?? {},
openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -238,10 +314,10 @@ export class ShiftService {
return out.reverse();
}
/** Require an open shift for the booth money path; returns it or throws. */
requireOpenShift() {
const open = this.currentOpenShift();
if (!open) throw new NoShiftOpenError();
/** Require an open shift on a till for its money path; returns it or throws. */
requireOpenShift(till: TillId = BOOTH_TILL) {
const open = this.currentOpenShift(till);
if (!open) throw new NoShiftOpenError(till);
return open;
}
@@ -254,9 +330,10 @@ export class ShiftService {
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
* removal) — historical chain events that still fold in unchanged.
* This is what carries across shifts.
* This is what carries across shifts. ONE till: every money event is filtered by
* `tillOf(payload)` (absent = booth).
*/
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
#drawerBalanceAt(at: string, till: TillId): { balanceMinor: number; currency: string | null } {
const rows = this.#db
.select()
.from(ledgerEvents)
@@ -266,16 +343,20 @@ export class ShiftService {
(r) =>
r.occurredAt <= at &&
(r.type === "payment" ||
// Car Wash module: money taken at the bay (cash adds to the drawer, card
// never does — same tender rule as a parking payment).
r.type === "carwash_payment" ||
r.type === "cash_in" ||
r.type === "cash_out" ||
r.type === "cash_movement"),
r.type === "cash_movement") &&
tillOf(r.payload as LedgerPayload | null) === till,
);
let balanceMinor = 0;
let currency: string | null = null;
for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (r.type === "payment") {
if (r.type === "payment" || r.type === "carwash_payment") {
// Only CASH enters the till; card settles to the bank.
if (pl.tender !== "card") balanceMinor += amt;
} else if (r.type === "cash_in") {
@@ -318,8 +399,11 @@ export class ShiftService {
amountMinor: number;
reason: string;
currency?: string;
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
/** Which drawer the cash moved in/out of (default: the booth). */
till?: TillId;
}): Promise<{ type: "cash_in" | "cash_out"; till: TillId; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
const { type, operator, reason } = args;
const till = args.till ?? BOOTH_TILL;
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
}
@@ -336,15 +420,16 @@ export class ShiftService {
...(args.currency ? { currency: args.currency } : {}),
operator,
voucherNo,
till,
},
occurredAt: now,
});
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
const { balanceMinor, currency } = this.#drawerBalanceAt(now, till);
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till });
this.#logger.info(
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
`${type} ${voucherNo} ${amountMinor} by ${operator} on ${till} (${reason || "no reason"}) → drawer ${balanceMinor}`,
);
return { type, amountMinor, voucherNo, balanceMinor, printed };
return { type, till, amountMinor, voucherNo, balanceMinor, printed };
}
/**
@@ -402,7 +487,7 @@ export class ShiftService {
* review queue. `operator` (optional) scopes to one operator's movements (an operator
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
*/
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus; till?: TillId }): DrawerMovement[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Latest review decision per movement id.
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
@@ -423,12 +508,15 @@ export class ShiftService {
const pl = (r.payload ?? {}) as LedgerPayload;
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
if (filter?.operator && operator !== filter.operator) continue;
const till = tillOf(pl);
if (filter?.till && till !== filter.till) continue;
const review = reviewByRef.get(r.id);
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
if (filter?.status && status !== filter.status) continue;
out.push({
id: r.id,
type: r.type,
till,
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
currency: pl.currency ?? null,
reason: pl.reason ?? null,
@@ -445,27 +533,28 @@ export class ShiftService {
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
}
/** Open a shift for the operator (explicit start). The opening float is auto-
* inherited from the chain = the drawer balance at the start instant. */
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
// operator's own (double-open) or another operator's (handover not done). Only
// one accountability period at a time.
const current = this.currentOpenShift();
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
/** Open a shift for the operator on a till (explicit start). The opening float is
* auto-inherited from the chain = that till's drawer balance at the start instant. */
async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
// Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether
// this operator's own (double-open) or another operator's (handover not done).
// One accountability period per drawer at a time. (Another till's shift is
// independent: the booth and the wash desk run side by side.)
const current = this.currentOpenShift(till);
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till);
const startedAt = new Date().toISOString();
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till);
await this.#log.append({
type: "shift_open",
source: "manual",
identity: operator, // the shift's operator; `identity` keys the shift to them
// Record the inherited opening float on the shift_open so it's reproducible
// and the next operator's handover figure is fixed in the chain.
payload: { operator, openingFloatMinor },
payload: { operator, openingFloatMinor, till },
occurredAt: startedAt,
});
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
return { startedAt, openingFloatMinor };
this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`);
return { startedAt, till, openingFloatMinor };
}
/**
@@ -481,15 +570,22 @@ export class ShiftService {
): Omit<ShiftReport, "printed"> {
const operator = open.identity ?? "?";
const startedAt = open.occurredAt;
const till = tillOf(open.payload as LedgerPayload | null);
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
// the operator who handled the money (decision: sum by payment time).
// All payments taken ON THIS TILL in [startedAt, asOf], summed by tender. Payment
// time = the operator who handled the money (decision: sum by payment time).
const payments = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "payment"))
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
// parking payment's amount already, as chargeLines). Both fold into the cash/card
// tender totals so the expected drawer is right; the booth-paid wash is then
// broken OUT of the ticket bucket into chargesByModuleMinor (see below).
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
.all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
.filter(
(r) => r.occurredAt >= startedAt && r.occurredAt <= asOf && tillOf(r.payload as LedgerPayload | null) === till,
);
let cashTotalMinor = 0;
let cardTotalMinor = 0;
@@ -500,11 +596,19 @@ export class ShiftService {
// the subscription sale path).
let subscriptionSalesMinor = 0;
let subscriptionWindowMinor = 0;
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0;
// Module charges folded into this till's payments (chargeLines on a booth payment),
// summed by owning module. Part of cash/card; NOT ticket money.
const chargesByModuleMinor: ChargesByModule = {};
let chargesTotalMinor = 0;
let currency: string | null = null;
for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & {
subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean;
chargeLines?: ChargeLine[];
};
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt;
@@ -512,10 +616,18 @@ export class ShiftService {
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
for (const l of pl.chargeLines ?? []) {
if (typeof l.amountMinor !== "number" || !l.module) continue;
chargesByModuleMinor[l.module] = (chargesByModuleMinor[l.module] ?? 0) + l.amountMinor;
chargesTotalMinor += l.amountMinor;
}
if (pl.currency) currency = pl.currency;
}
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
// Ticket = what is left once subscriber money and module charges are taken out:
// ticket + subscriptions + Σcharges = cash + card, always.
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor - chargesTotalMinor;
// --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start);
@@ -524,7 +636,7 @@ export class ShiftService {
const openingFloatMinor =
typeof openPl.openingFloatMinor === "number"
? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor;
: this.#drawerBalanceAt(startedAt, till).balanceMinor;
// Drawer movements within the window, split into added (+) and removed (−).
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
@@ -537,7 +649,8 @@ export class ShiftService {
(r) =>
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
r.occurredAt >= startedAt &&
r.occurredAt <= asOf,
r.occurredAt <= asOf &&
tillOf(r.payload as LedgerPayload | null) === till,
);
let cashAddedMinor = 0;
let cashRemovedMinor = 0;
@@ -556,6 +669,7 @@ export class ShiftService {
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
return {
till,
operator,
startedAt,
endedAt: asOf,
@@ -567,6 +681,8 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -581,17 +697,18 @@ export class ShiftService {
* projection the Z-report prints, so the operator sees exactly what their close
* will show. See wiki/concepts/shift.md.
*/
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
const open = this.currentOpenShift();
currentReport(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
const open = this.currentOpenShift(till);
if (!open) return null;
const asOf = new Date().toISOString();
return { ...this.#summariseWindow(open, asOf), asOf };
}
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
async close(operator: string): Promise<ShiftReport> {
const open = this.openShiftFor(operator);
if (!open) throw new NoOpenShiftError(operator);
/** Close the operator's open shift on a till: sum its payments in the window, sign +
* print the Z-report. */
async close(operator: string, till: TillId = BOOTH_TILL): Promise<ShiftReport> {
const open = this.openShiftFor(operator, till);
if (!open) throw new NoOpenShiftError(operator, till);
const endedAt = new Date().toISOString();
const report = this.#summariseWindow(open, endedAt);
@@ -605,6 +722,8 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -617,6 +736,7 @@ export class ShiftService {
identity: operator,
payload: {
operator,
till,
startedAt,
endedAt,
cashTotalMinor,
@@ -627,6 +747,9 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
// Only when a module charged in the window (older slips/payloads stay identical).
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -637,25 +760,23 @@ export class ShiftService {
const printed = await this.#printZReport(report);
this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
`${till} shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
);
return { ...report, printed };
}
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
* is the record — a failed print doesn't undo the close). */
/** Print the Z-report on the till's printer (best-effort; the signed event is the
* record — a failed print doesn't undo the close). */
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
return false;
}
const cur = r.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2);
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
const lines = [
// Which drawer this report reconciles — only printed off the booth, so booth
// slips stay byte-identical to before tills existed.
...(r.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[r.till]}`] : []),
`Operatori: ${r.operator}`,
`Nga: ${zStamp(r.startedAt)}`,
`Deri: ${zStamp(r.endedAt)}`,
@@ -665,26 +786,35 @@ export class ShiftService {
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"",
"-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
// The booth prints its three classic lines (byte-identical to before tills); a
// module's till prints its own takings under its own name — it sells no tickets
// and no subscriptions.
...(r.till === BOOTH_TILL
? [
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
]
: [`${TILL_TAKINGS_LABEL[r.till]}: ${money(r.ticketTotalMinor)} ${cur}`]),
// Module money that rode this till's tickets (a booth-paid wash) — its own line,
// only when any was taken, so the operator sees parking and wash money apart.
...Object.entries(r.chargesByModuleMinor)
.filter(([, v]) => (v ?? 0) > 0)
.map(([m, v]) => `${MODULE_PRINT_LABEL[m as ModuleId] ?? m} (në biletë): ${money(v ?? 0)} ${cur}`),
// Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
"",
"-- Arka --",
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
];
try {
await printer.printReport({ title: "RAPORT TURNI", lines });
return true;
} catch (err) {
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
return false;
}
return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
}
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
@@ -698,17 +828,14 @@ export class ShiftService {
operator: string;
currency: string | null;
at: string;
till: TillId;
}): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
return false;
}
const cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
const lines = [
`Mandat Nr.: ${v.voucherNo}`,
...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []),
`Data: ${zStamp(v.at)}`,
"",
`Shuma: ${money(v.amountMinor)} ${cur}`,
@@ -716,27 +843,57 @@ export class ShiftService {
"",
`Regjistroi: ${v.operator}`,
];
try {
await printer.printReport({ title, lines });
return true;
} catch (err) {
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
return false;
}
return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
}
/** First enabled booth-receipt printer, or any enabled printer. */
async #boothPrinter(): Promise<PrinterDevice | null> {
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
const enabled = rows.filter((r) => r.enabled);
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
if (!booth) return null;
const driver = registry.get(booth.driverId);
if (!driver) return null;
try {
return driver.create(booth.config as never) as PrinterDevice;
} catch {
return null;
/** Print a till's slip on its printer with failover (wash desk → booth printer;
* see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
* record — every failure is logged and reported as "not printed", never thrown.
* Legacy fallback: a site whose only printer carries no booth role (one unit,
* configured as the entry dispenser) still prints its slips on it, as before. */
async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
const printers = this.#loadPrinters();
const want = TILL_PRINTER_ROLE[till];
let ordered = orderForRole(printers, want);
if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
if (ordered.length === 0) {
this.#logger.warn(`no ${want} printer — ${what} not printed (event is recorded)`);
return false;
}
const attempts: string[] = [];
for (const p of ordered) {
try {
await job(p.device);
if (p.role !== want) this.#logger.info(`${what} printed on ${p.id} (${p.role}; no ${want} printer reachable)`);
return true;
} catch (err) {
attempts.push(`${p.id} (${(err as Error).message})`);
}
}
this.#logger.warn(`${what} print failed on every candidate: ${attempts.join(", ")} (event recorded)`);
return false;
}
/** Every enabled printer as a live instance (role + rank from its saved config). */
#loadPrinters(): PrinterInstance[] {
const rows = this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
const out: PrinterInstance[] = [];
for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
try {
out.push({
id: row.id,
role: printerRoleOf(cfg),
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;
}
}
+133 -11
View File
@@ -1,10 +1,12 @@
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
import { and, eq, gte, sessions, deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
import { reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
import { deviceEvents } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import type { VisionClient } from "./vision-client.js";
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
@@ -79,6 +81,10 @@ interface SnapshotJob {
/** Optional vision client — when present, ANPR runs on each captured image from an
* `anpr`-enabled camera and records the plate against `identity`. Advisory only. */
readonly vision?: VisionClient | null;
/** Optional signed ledger — when present (the transient ENTRY path passes it), a
* recognized entry plate that is already OPEN under another recent session signs an
* `entry.duplicatePlate` anomaly (same car, second ticket). Post-hoc; never a gate. */
readonly log?: EventLog | null;
}
/** Camera config flag opting it into snapshot-triggered ANPR. */
@@ -93,7 +99,7 @@ interface CameraConfig {
* The caller must NOT block its open path on this.
*/
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
const { db, direction, identity, logger, vision } = job;
const { db, direction, identity, logger, vision, log } = job;
const rows = devicesByDirection(db, "camera", direction);
if (rows.length === 0) return Promise.resolve([]);
@@ -129,7 +135,7 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
// ANPR off the SAME image, tied to the SAME session — when vision is enabled
// and this camera opts in. Fire-and-forget: never delays the open path.
if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) {
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger);
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger, log);
}
return id;
} catch (err) {
@@ -157,25 +163,46 @@ async function recognizePlate(
snapshotId: string,
shot: { bytes: Buffer; contentType: string },
logger: FastifyBaseLogger,
log?: EventLog | null,
): Promise<void> {
try {
const result = await vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record
const plate = result.plate.text.trim().toUpperCase();
if (!plate) return;
if (!result) return;
// Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a
// downscaled copy — see reencodeForStorage), so the wash's review crop can cut the
// vehicle out of whatever copy survives and blur the plate inside it.
const frame = await frameSize(shot.bytes);
const norm = (b: { x1: number; y1: number; x2: number; y2: number } | null | undefined) =>
b && frame
? {
x1: clamp01(b.x1 / frame.w), y1: clamp01(b.y1 / frame.h),
x2: clamp01(b.x2 / frame.w), y2: clamp01(b.y2 / frame.h),
}
: null;
// The vehicle's body type (advisory; the wash desk's category suggestion — see
// venue-modules.md §Vehicle category). Rides the plate's read row when there is one,
// else a row of its own: a car with an unreadable plate is still a car of some class.
const vehicleBox = norm(result.vehicle?.bbox);
const vehicle = result.vehicle
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence, ...(vehicleBox ? { vehicleBox } : {}) }
: {};
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
const plateBox = plate ? norm(result.plate?.bbox) : null;
if (!plate && !result.vehicle) return; // nothing trustworthy to record
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId,
category: "camera",
kind: "read",
// `identity` ties the plate to the session; `snapshotId` to the evidence image.
// `identity` ties the read to the session; `snapshotId` to the evidence image.
detail: {
identity,
direction,
plate,
confidence: result.plate.confidence,
region: result.plate.region ?? null,
...(plate
? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null, ...(plateBox ? { plateBox } : {}) }
: {}),
...vehicle,
modelVersion: result.modelVersion,
snapshotId,
source: "entry-exit-snapshot",
@@ -183,15 +210,110 @@ async function recognizePlate(
occurredAt: new Date().toISOString(),
})
.run();
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
if (result.vehicle) {
logger.info(`vision vehicle '${result.vehicle.bodyType}' (${result.vehicle.confidence.toFixed(3)}) for ${identity}`);
if (vehicleBox) {
deviceEvents.emitVehicleRead({
identity,
direction,
read: { bodyType: result.vehicle.bodyType, confidence: result.vehicle.confidence, snapshotId, box: vehicleBox, plateBox },
});
}
}
if (!plate) return;
logger.info(`anpr plate '${plate}' (${result.plate!.confidence.toFixed(3)}) for ${identity}`);
// The session's entry/exit event already shipped without this (async) plate — tell the
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
deviceEvents.emitPlateRecognized({ identity, plate, direction });
// ENTRY-SIDE duplicate check: this plate already OPEN under another recent session is
// most likely the SAME car that minted a second ticket (a motion radar drops a
// stationary car → the button re-arms). Signed anomaly for the operator to void.
if (direction === "entry" && log) {
await flagDuplicateEntryPlate({ db, log, identity, plate, snapshotId, logger });
}
} catch (err) {
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
}
}
function clamp01(v: number): number {
return Math.max(0, Math.min(1, v));
}
/** Pixel size of the analysed frame (JPEG header only — cheap). Null when unreadable. */
async function frameSize(bytes: Buffer): Promise<{ w: number; h: number } | null> {
try {
const m = await sharp(bytes, { failOn: "none" }).metadata();
return m.width && m.height ? { w: m.width, h: m.height } : null;
} catch {
return null;
}
}
/** How far back a recognized entry plate is compared against other OPEN sessions'
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
* re-pressing within minutes; a long window would flag legit re-visits. */
function dupPlateWindowMs(): number {
const raw = Number(process.env.ENTRY_DUP_PLATE_WINDOW_MIN ?? 15);
return (Number.isFinite(raw) && raw > 0 ? raw : 15) * 60_000;
}
/**
* Flag a freshly-recognized ENTRY plate that is already open under a DIFFERENT recent
* session: sign ONE `entry.duplicatePlate` anomaly keyed to the new session, pointing at
* the prior one. Mirrors the exit-side plateSwapSuspected pattern (advisory, post-hoc —
* the barrier already opened; the operator voids the duplicate ticket). Exported for tests.
*/
export async function flagDuplicateEntryPlate(opts: {
db: Db;
log: EventLog;
/** The session the plate was just recognized for (the NEW ticket). */
identity: string;
plate: string;
snapshotId: string;
logger: FastifyBaseLogger;
}): Promise<void> {
const { db, log, identity, plate, snapshotId, logger } = opts;
try {
const cutoff = new Date(Date.now() - dupPlateWindowMs()).toISOString();
// Recent entry-plate reads (unsigned `kind:"read"` telemetry, written above) for the
// same plate under a different identity. detail is JSON — filter in JS; read volume
// inside the window is tiny (one row per entry).
const reads = db
.select()
.from(deviceEventsTable)
.where(and(eq(deviceEventsTable.kind, "read"), gte(deviceEventsTable.occurredAt, cutoff)))
.all();
const prior = reads
.map((r) => r.detail as { identity?: string; direction?: string; plate?: string })
.find((d) => d.direction === "entry" && d.plate === plate && d.identity && d.identity !== identity);
if (!prior?.identity) return;
// Only a still-OPEN prior session is a duplicate suspect (a closed one drove off).
const open = db
.select()
.from(sessions)
.where(and(eq(sessions.id, prior.identity), eq(sessions.state, "open")))
.get();
if (!open) return;
await log.append({
type: "anomaly",
identity,
payload: {
...reasonPayload("entry.duplicatePlate", { plate, otherIdentity: prior.identity }),
duplicateEntrySuspected: true,
plate,
otherIdentity: prior.identity,
snapshotId,
},
});
logger.warn(`duplicate entry suspected: plate ${plate} on ${identity} already open under ${prior.identity}`);
} catch (err) {
// Best-effort, post-hoc — never let the duplicate check surface on the open path.
logger.error(`duplicate-plate check failed (${identity}): ${(err as Error).message}`);
}
}
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { SubscriptionFlow } from "./subscription-flow.js";
import type { DeviceReadEvent } from "./device-events.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// CHANNEL AGREEMENT in SubscriptionFlow.match (2026-07-04): when the reader CONFIRMED
// the physical channel (DT-008 output prefixes → DeviceReadEvent.channel), the
// credential kind must agree. An OPTICAL decode claiming an RF credential is the
// cheap clone (print the card's UID as a barcode) — refused + ONE signed anomaly.
// Legacy untagged reads (channel undefined) match as before, so readers without
// prefixes keep working.
let db: Db;
let flow: SubscriptionFlow;
const SUB = "sub-1";
const CARD_UID = "86A158";
const QR_CODE = "SUB-TESTQR";
beforeEach(() => {
({ db } = createTestDb());
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "rf", value: CARD_UID }).run();
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "qr", value: QR_CODE }).run();
flow = new SubscriptionFlow(db, makeLog(db), silentLogger());
});
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
return {
driverId: "dingtian-qr-reader",
deviceId: "reader-1",
value,
kind: opts.kind ?? "qr",
...(opts.channel ? { channel: opts.channel } : {}),
at: new Date().toISOString(),
};
}
const anomalies = () =>
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
describe("subscription match — credential channel agreement", () => {
it("OPTICAL read of an RF card's UID → no match + signed channelMismatch anomaly (the clone)", async () => {
const m = flow.match(read(CARD_UID, { kind: "qr", channel: "optical" }));
expect(m).toBeNull();
await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); // append is fire-and-forget
expect(anomalies()[0].identity).toBe(SUB);
expect(anomalies()[0].payload).toMatchObject({
reasonCode: "sub.refused.channelMismatch",
channelMismatch: true,
credentialKind: "rf",
channel: "optical",
value: CARD_UID,
});
});
it("RF read of the same card → matches (via card), nothing signed", () => {
const m = flow.match(read(CARD_UID, { kind: "card", channel: "rf" }));
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
expect(anomalies()).toHaveLength(0);
});
it("legacy untagged read of the card → still matches (unprefixed readers keep working)", () => {
const m = flow.match(read(CARD_UID)); // kind qr, channel undefined — today's shape
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
expect(anomalies()).toHaveLength(0);
});
it("OPTICAL read of a QR credential → matches (the legit path)", () => {
const m = flow.match(read(QR_CODE, { kind: "qr", channel: "optical" }));
expect(m).toMatchObject({ subscriptionId: SUB, via: "qr" });
});
it("RF read claiming a QR credential → refused symmetrically (mis-encoded clone tag)", async () => {
const m = flow.match(read(QR_CODE, { kind: "card", channel: "rf" }));
expect(m).toBeNull();
await vi.waitFor(() => expect(anomalies()).toHaveLength(1));
expect(anomalies()[0].payload).toMatchObject({ credentialKind: "qr", channel: "rf" });
});
it("unknown value → plain no-match, no anomaly (a phantom/typo is not a clone attempt)", () => {
const m = flow.match(read("999459", { kind: "qr", channel: "optical" }));
expect(m).toBeNull();
expect(anomalies()).toHaveLength(0);
});
});
+34
View File
@@ -77,6 +77,40 @@ export class SubscriptionFlow {
.where(eq(subscriptionCredentials.value, e.value))
.get();
if (cred) {
// CHANNEL AGREEMENT (clone defense, 2026-07-04). When the reader CONFIRMED the
// physical channel (DT-008 output prefixes), the credential kind must agree: an
// OPTICAL decode may not claim an RF credential — otherwise printing a card's
// UID (often written on the card face) as a barcode clones the card. Symmetric
// for an RF read claiming a QR credential (a mis-encoded clone tag). A legacy
// untagged read (channel undefined) matches as before — enforcement only bites
// where prefixes are deployed. The attempt itself is a fraud signal → signed
// anomaly, then treated as no-match (the flows refuse it as unknown).
const mismatch =
(e.channel === "optical" && cred.kind === "rf") ||
(e.channel === "rf" && cred.kind === "qr");
if (mismatch) {
this.#logger.warn(
`credential channel mismatch: ${cred.kind} credential '${e.value}' presented via ${e.channel} (sub ${cred.subscriptionId}) — possible clone`,
);
void this.#log
.append({
type: "anomaly",
identity: cred.subscriptionId,
payload: {
...reasonPayload("sub.refused.channelMismatch", {
credentialKind: cred.kind,
channel: e.channel === "optical" ? "optical" : "rf",
}),
channelMismatch: true,
credentialKind: cred.kind,
channel: e.channel,
value: e.value,
deviceId: e.deviceId,
},
})
.catch((err) => this.#logger.error(`channel-mismatch anomaly append failed: ${(err as Error).message}`));
return null;
}
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
+256
View File
@@ -0,0 +1,256 @@
import { eq, ledgerEvents, type Db, and, isNull, validationPrograms } from "@parking/db";
import type { EventLog } from "./event-log.js";
import type { SessionValidation, ValidationMode } from "@parking/shared";
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
// session (never a mutable flag): payload carries the RESOLVED values (programId,
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
// which validations it CONSUMED (so an overstay's fresh period never re-applies
// them). See wiki/concepts/validation-discounts.md.
/** A validation event folded with its lifecycle state. */
export interface AppliedValidation extends SessionValidation {
readonly eventId: string;
readonly occurredAt: string;
/** The merchant username who applied it. */
readonly operator: string | null;
/** Voided by a later validation event referencing it. */
readonly voided: boolean;
/** The payment event id that consumed it, if settled. */
readonly consumedBy: string | null;
}
/** All validations ever applied to a session (newest last), with voided/consumed
* state folded from the chain. One identity-scoped ledger scan. */
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
const rows = db
.select({
id: ledgerEvents.id,
type: ledgerEvents.type,
occurredAt: ledgerEvents.occurredAt,
payload: ledgerEvents.payload,
})
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const voided = new Set<string>();
const consumedBy = new Map<string, string>();
const applies: AppliedValidation[] = [];
for (const r of rows) {
const p = (r.payload ?? {}) as {
refId?: string;
programId?: string;
programLabel?: string;
mode?: ValidationMode;
minutes?: number;
amountMinor?: number;
percent?: number;
operator?: string;
validationIds?: string[];
};
if (r.type === "validation") {
if (p.refId) {
voided.add(p.refId);
} else if (p.programId && p.mode) {
applies.push({
eventId: r.id,
occurredAt: r.occurredAt,
programId: p.programId,
label: p.programLabel ?? p.programId,
mode: p.mode,
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
operator: p.operator ?? null,
voided: false,
consumedBy: null,
});
}
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
}
}
return applies.map((a) => ({
...a,
voided: voided.has(a.eventId),
consumedBy: consumedBy.get(a.eventId) ?? null,
}));
}
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
* payment. This is exactly what `priceSession(..., validations)` expects. */
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
}
// --- Apply (shared by the merchant route and the Car Wash module) ----------------
export interface ApplyValidationInput {
programId: string;
identity: string;
/** Username recorded as the applying operator. */
actor: string;
/** fixed mode only: the amount the operator grants (minor units, ≤ maxAmountMinor). */
amountMinor?: number;
/** Car Wash context — required by the wash-only modes (doneTolerance / washPrice), which
* are RESOLVED here into a plain timeCredit / fixed event the pricing fold already
* understands: `washMinutes` = the wash window (order intake → done), NOT the whole
* stay; `priceMinor` = the wash price. */
wash?: { washMinutes: number; priceMinor: number };
}
export type ApplyValidationResult =
| {
ok: true;
eventId: string;
programId: string;
label: string;
mode: string;
minutes?: number | null;
percent?: number | null;
amountMinor?: number;
}
| { ok: false; status: 400 | 404 | 409; error: string };
/**
* Apply a validation program to an open transient session and append the signed
* `validation` event with the RESOLVED values. The decision chain, in order: program
* live + active → open TRANSIENT session → not already carrying a live application of
* this program → per-day cap → fixed-amount bounds. The merchant route adds its own
* program↔user BINDING check before calling this; a module applying its own program
* (Car Wash sponsorship) has no binding — the actor is attributed on the event instead.
* Returns a result object rather than throwing so each caller maps to its own HTTP
* shape. See wiki/concepts/validation-discounts.md.
*/
export async function applyValidation(
db: Db,
eventLog: EventLog,
input: ApplyValidationInput,
): Promise<ApplyValidationResult> {
const { programId, identity, actor } = input;
const program = db
.select()
.from(validationPrograms)
.where(and(eq(validationPrograms.id, programId), isNull(validationPrograms.deletedAt)))
.get();
if (!program || !program.active) return { ok: false, status: 404, error: "program not found or inactive" };
const rows = db
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return { ok: false, status: 404, error: "no session for ticket" };
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return { ok: false, status: 409, error: "subscription sessions cannot be validated" };
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return { ok: false, status: 409, error: "session is closed" };
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return { ok: false, status: 409, error: "this program is already applied to the ticket" };
}
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
// appliance runs in site time).
if (program.maxPerDay != null) {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const todays = db
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "validation"))
.all()
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
const voidedIds = new Set(
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
);
const count = todays.filter((r) => {
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
}).length;
if (count >= program.maxPerDay) return { ok: false, status: 409, error: "daily cap reached for this program" };
}
// Resolve the program into the event's (mode, minutes/percent/amount). The wash-only
// modes become the plain modes the pricing fold knows; `programMode` keeps the original
// on the signed event for audit.
let mode: "comp" | "timeCredit" | "fixed" | "percent";
let minutes: number | undefined;
let percent: number | undefined;
let amountMinor: number | undefined;
switch (program.mode) {
case "comp":
mode = "comp";
break;
case "timeCredit":
mode = "timeCredit";
minutes = program.minutes ?? undefined;
break;
case "percent":
mode = "percent";
percent = program.percent ?? undefined;
break;
case "fixed": {
const a = input.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return { ok: false, status: 400, error: "amountMinor (positive integer) required for this program" };
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return { ok: false, status: 400, error: `amount exceeds the program cap (${program.maxAmountMinor})` };
}
mode = "fixed";
amountMinor = a;
break;
}
case "doneTolerance": {
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (done time)" };
mode = "timeCredit";
minutes = Math.max(0, input.wash.washMinutes) + Math.max(0, program.minutes ?? 0);
break;
}
case "washPrice": {
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (price)" };
mode = "fixed";
amountMinor = Math.max(0, input.wash.priceMinor);
break;
}
default:
return { ok: false, status: 400, error: `unknown program mode ${String(program.mode)}` };
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
programId,
programLabel: program.name,
mode,
...(program.mode !== mode ? { programMode: program.mode } : {}),
...(minutes != null ? { minutes } : {}),
...(percent != null ? { percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: actor,
},
});
return {
ok: true,
eventId: ev.id,
programId,
label: program.name,
mode,
minutes,
percent,
amountMinor,
};
}
+22 -3
View File
@@ -23,6 +23,8 @@ import type { FastifyBaseLogger } from "fastify";
// transport + contract adapter only.
/** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */
import { isVehicleClass, type VehicleClass } from "@parking/shared";
export interface PlateBBox {
readonly x1: number;
readonly y1: number;
@@ -40,12 +42,21 @@ export interface VisionPlate {
readonly region?: string | null;
}
/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for
* Job 2 (vehicle verification) — not yet produced. */
/** The vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared
* VEHICLE_CLASSES vocabulary (the service's raw label is normalised there); a stub or a
* plate-only recognizer sends null. */
export interface VisionVehicle {
readonly bodyType: VehicleClass;
readonly confidence: number;
/** The vehicle's box in frame pixels, when the stage found one. */
readonly bbox?: PlateBBox | null;
}
/** The raw /analyze response shape (the Python contract). */
interface AnalyzeResponse {
readonly plate: VisionPlate | null;
readonly plates: VisionPlate[];
readonly vehicle: unknown | null;
readonly vehicle: { body_type?: string | null; confidence?: number | null; bbox?: PlateBBox | null } | null;
readonly low_confidence: boolean;
readonly model_version: string;
readonly took_ms: number;
@@ -61,6 +72,8 @@ export interface VisionResult {
/** True when the best plate is below the confidence floor — treat as advisory only
* and fall back to the ticket/manual path. */
readonly lowConfidence: boolean;
/** The vehicle's body type, when the service ran that stage and named a known class. */
readonly vehicle: VisionVehicle | null;
readonly modelVersion: string;
readonly tookMs: number;
}
@@ -124,10 +137,16 @@ export class VisionClient {
const best = res.plate ?? null;
const lowConfidence =
res.low_confidence || (best != null && best.confidence < this.#minConfidence);
const v = res.vehicle;
const vehicle: VisionVehicle | null =
v && isVehicleClass(v.body_type) && typeof v.confidence === "number"
? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)), bbox: v.bbox ?? null }
: null;
return {
plate: best,
plates: Array.isArray(res.plates) ? res.plates : [],
lowConfidence,
vehicle,
modelVersion: res.model_version ?? "unknown",
tookMs: typeof res.took_ms === "number" ? res.took_ms : 0,
};
+7
View File
@@ -0,0 +1,7 @@
.venv/
**/__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
out/
.env
+12
View File
@@ -0,0 +1,12 @@
# Python
__pycache__/
*.py[cod]
.venv/
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
out/
*.onnx
+1
View File
@@ -0,0 +1 @@
3.12
+49
View File
@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1.7
# Parking TRAINER image: the phase-B body-type classifier. Build CONTEXT is apps/trainer
# (self-contained Python package). Runs on the reviewer's host (art-docker-station) beside
# the collector, never on a booth: by default it SERVES the job API the collector's Training
# section drives (`serve`); the same image runs the CLI one-off (`train`, `inspect`, …). It
# reads the wash collector's volume (collector.sqlite + crops/) and writes versioned model
# folders. CPU-only PyTorch — the host has no usable GPU and a few thousand crops train in
# minutes/an hour on four Xeon cores.
# See wiki/decisions/bodytype-classifier-training.md.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
WORKDIR /app
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock .python-version ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev --extra train
COPY trainer/ ./trainer/
COPY README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --extra train
# Pre-warm the ImageNet backbone weights INTO the image so a run needs no network (the
# host has one, but a job that fetches at run time is a job that fails at 2 am). Best-effort:
# without network at build time torchvision fetches lazily on the first run.
ENV TORCH_HOME=/app/torch-home
RUN uv run python -c "import torchvision.models as m; m.resnet18(weights=m.ResNet18_Weights.IMAGENET1K_V1); m.mobilenet_v3_small(weights=m.MobileNet_V3_Small_Weights.IMAGENET1K_V1)" \
|| echo "[build] backbone weights not pre-warmed (no network) — fetched on first run"
RUN useradd --system --create-home --uid 999 trainer \
&& mkdir -p /data /out && chown -R trainer:trainer /app /out
USER trainer
ENV TRAINER_DATA_DIR=/data \
TRAINER_OUT_DIR=/out \
TRAINER_PORT=8091
VOLUME ["/out"]
EXPOSE 8091
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8091/health').status==200 else 1)" || exit 1
ENTRYPOINT ["uv", "run", "--no-sync", "parking-trainer"]
CMD ["serve"]
+36
View File
@@ -0,0 +1,36 @@
# parking-trainer
The phase-B **body-type classifier** job. Reads the wash collector's volume
(`collector.sqlite` + `crops/`), trains a classifier on the reviewer's labels, and writes a
versioned model folder the vision image bakes in — or refuses when validation is below the
floor. Design and decisions: `wiki/decisions/bodytype-classifier-training.md`.
```
parking-trainer inspect --data /data # what a run would train on
parking-trainer train --data /data --out /out # features mode (minutes)
parking-trainer train --mode finetune --epochs 12 ... # full fine-tune (about an hour on 4 cores)
parking-trainer evaluate --model /out/<version>/bodytype.onnx --data /data
parking-trainer publish /out/<version> --url https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype
```
Exit codes: `0` model written · `2` not enough labels · `3` below the floor (report written,
no model) · `1` other.
A passing run writes `<out>/<version>/`:
| file | what |
| --- | --- |
| `bodytype.onnx` | the classifier; input `image` = RGB float32 0–255 `[N,3,S,S]`, output `logits` `[N,K]`; normalisation is inside the graph |
| `bodytype.json` | sidecar: version, class list (in vocabulary order), input size, crop margin, backbone, mode, label counts, validation metrics |
| `report.md` | the human report: accuracy, per-class recall/precision, confusion matrix, dropped classes, loss weights |
| `metrics.json` | the same numbers, machine-readable |
On the reviewer's host the image runs `serve` as the `trainer` service of the
`wash-collector` stack: a job API (`/health`, `/readiness`, `/versions`, `/jobs`) on the compose
network that the collector's **Training section** (`/review`) drives — readiness, Train /
Evaluate / Publish, reports and logs. Jobs run as subprocesses of the CLI, one at a time; state
and logs persist under `/out/jobs/`. The CLI stays for debugging:
`docker compose -f docker-compose.collector.yml exec trainer parking-trainer inspect`.
Local dev: `uv sync --extra train` (CPU torch, ~200 MB), `uv run pytest -q`. The test suite
runs without the extra (torch tests skip), matching CI.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@parking/trainer",
"version": "0.0.0",
"private": true,
"//": "Thin shim so this Python job is a node in the Turbo task graph (NOT a JS package — deps are managed by uv/pyproject.toml). It is a one-off job image, never a booth service: see wiki/decisions/bodytype-classifier-training.md.",
"scripts": {
"lint": "uv run ruff check .",
"format": "uv run ruff format .",
"typecheck": "uv run mypy trainer",
"test": "uv run pytest -q",
"build": "echo 'no build step (Python job; see Dockerfile)'"
}
}
+73
View File
@@ -0,0 +1,73 @@
[project]
name = "parking-trainer"
version = "0.0.0"
description = "Phase-B body-type classifier trainer: reviewer labels + crops off the wash collector's volume → an ONNX classifier the vision image bakes in."
requires-python = ">=3.10,<4.0"
# Core deps are LIGHT on purpose (same rule as the vision service): `inspect`, `evaluate`
# and the data/report code run with only these, so `uv sync` and the test suite work
# in CI without the PyTorch stack. Training itself needs the `train` extra.
# See wiki/decisions/bodytype-classifier-training.md.
dependencies = [
"numpy>=1.26",
# OpenCV does the decode + resize on BOTH sides (trainer and vision service): same
# library, same interpolation, same pixels — the preprocessing contract (preprocess.py).
"opencv-python-headless>=4.10",
"onnxruntime>=1.19",
]
[project.scripts]
parking-trainer = "trainer.cli:main"
[project.optional-dependencies]
# The training stack. CPU-only PyTorch (the reviewer's host has no usable GPU — the
# decision is recorded in the wiki page above): resolved from PyTorch's CPU wheel index,
# ~200 MB instead of the ~5 GB CUDA build. Install with: uv sync --extra train
# torch / torchvision are BSD-3; the ImageNet backbone weights ship under the same
# licence (the licence rule applies to weights as much as code).
train = [
"torch>=2.4",
"torchvision>=0.19",
"onnx>=1.16",
"onnxscript>=0.3", # the torch.export-based ONNX exporter (MIT)
]
[dependency-groups]
dev = [
"ruff>=0.8",
"pytest>=8.3",
"mypy>=1.13",
]
[tool.uv]
# Pick the CPU wheels for torch/torchvision from PyTorch's own index; everything else
# from PyPI. `explicit = true` keeps the index from shadowing PyPI for other packages.
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
torch = [{ index = "pytorch-cpu" }]
torchvision = [{ index = "pytorch-cpu" }]
[tool.ruff]
line-length = 110
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["trainer"]
+102
View File
@@ -0,0 +1,102 @@
"""A synthetic collector volume: the collector's `items` table (same DDL as apps/collector
src/db.ts) + JPEG crops. Classes are told apart by COLOUR so even a random-init backbone's
features separate them — the tests check the plumbing (split, floor, export, sidecar),
not accuracy on real cars."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
import numpy as np
import pytest
DDL = """
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
);
"""
COLOURS = {"sedan": (200, 40, 40), "suv": (40, 200, 40), "van": (40, 40, 200), "truck": (200, 200, 40)}
def write_jpeg(path: Path, colour: tuple[int, int, int], rng: np.random.Generator) -> None:
import cv2
path.parent.mkdir(parents=True, exist_ok=True)
h, w = int(rng.integers(120, 200)), int(rng.integers(160, 260))
img = np.empty((h, w, 3), np.uint8)
img[:] = colour[::-1] # BGR
noise = rng.integers(-20, 20, size=img.shape, dtype=np.int16)
img = np.clip(img.astype(np.int16) + noise, 0, 255).astype(np.uint8)
cv2.imwrite(str(path), img, [cv2.IMWRITE_JPEG_QUALITY, 85])
@pytest.fixture
def collector_dir(tmp_path: Path) -> Path:
"""40 labelled crops per class for sedan/suv/van, 5 for truck (below the minimum), a few
unusable, a few pending, one labelled row whose file is missing."""
rng = np.random.default_rng(1)
con = sqlite3.connect(tmp_path / "collector.sqlite")
con.executescript(DDL)
t0 = datetime(2026, 9, 1, tzinfo=timezone.utc)
n = 0
def add(label: str | None, reviewed: bool, kind: str = "wash", missing: bool = False) -> None:
nonlocal n
n += 1
item = f"item-{n:04d}"
rel = f"crops/booth-2/{item}.jpg"
colour = COLOURS.get(label or "sedan", (128, 128, 128))
if not missing:
write_jpeg(tmp_path / rel, colour, rng)
at = (t0 + timedelta(minutes=10 * n)).isoformat().replace("+00:00", "Z")
reviewed_at = (
(t0 + timedelta(days=1, minutes=n)).isoformat().replace("+00:00", "Z") if reviewed else None
)
con.execute(
"INSERT INTO items (id, booth, kind, order_ref, at, service, vision_class, vision_confidence, "
"image_width, image_height, plate_blurred, image_path, received_at, review_label, "
"reviewed_at, reviewer) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
item,
"booth-2",
kind,
"o",
at,
"wash",
"car" if label != "truck" else "truck",
0.9,
200,
150,
1,
rel,
at,
label if reviewed else None,
reviewed_at,
"reviewer" if reviewed else None,
),
)
# Interleaved in time so every class exists on both sides of the time split.
for i in range(40):
for label in ("sedan", "suv", "van"):
add(label, True)
if i % 8 == 0:
add("truck", True)
add("unusable", True)
add("unusable", True)
add("sedan", True, missing=True)
for _ in range(6):
add(None, False, kind="entry")
con.commit()
con.close()
return tmp_path
+98
View File
@@ -0,0 +1,98 @@
"""Data rules, torch-free: labels, the time split, thin classes, weights, the report."""
from __future__ import annotations
import json
from pathlib import Path
from trainer.cli import main
from trainer.data import (
class_weights,
load_labelled,
load_reviewed_since,
load_unlabelled,
make_split,
summarise,
)
from trainer.preprocess import CROP_MARGIN, Sidecar, load_input
from trainer.report import compute_metrics, render_report
def test_loads_only_reviewed_usable_rows_with_a_crop_on_disk(collector_dir: Path) -> None:
samples, missing = load_labelled(collector_dir)
assert missing == 1 # the labelled row whose file is gone
assert len(samples) == 125 # 3×40 + 5 trucks; unusable and pending excluded
assert all(s.path.is_file() for s in samples)
assert {s.label for s in samples} == {"sedan", "suv", "van", "truck"}
assert summarise(samples)["byClass"] == {"sedan": 40, "suv": 40, "van": 40, "truck": 5}
assert len(load_unlabelled(collector_dir)) == 6
assert len(load_reviewed_since(collector_dir, "2026-09-02T00:00:00Z")) == 125
assert load_reviewed_since(collector_dir, "2030-01-01T00:00:00Z") == []
def test_split_is_by_time_and_drops_thin_classes(collector_dir: Path) -> None:
samples, _ = load_labelled(collector_dir)
split = make_split(samples, val_fraction=0.2, min_per_class=20)
assert split.classes == ("sedan", "suv", "van") # canonical order, truck dropped
assert split.dropped == {"truck": 5}
assert len(split.train) + len(split.val) == 120
assert len(split.val) == 24
assert max(s.at for s in split.train) < min(s.at for s in split.val) # newest = validation
assert all(v > 0 for v in split.counts("val").values())
def test_class_weights_lean_against_imbalance_but_gently(collector_dir: Path) -> None:
samples, _ = load_labelled(collector_dir)
vans = [s for s in samples if s.label == "van"]
keep = set(vans[::10]) # 4 of 40 vans survive
split = make_split([s for s in samples if s.label != "van" or s in keep], 0.2, 3)
w = dict(zip(split.classes, class_weights(split), strict=True))
assert w["van"] > w["sedan"] > 0 # the rare class weighs more
assert w["van"] / w["sedan"] < 4 # but not the full inverse ratio (damped)
assert abs(sum(w.values()) / len(w) - 1.0) < 1e-9
def test_metrics_and_report() -> None:
classes = ("sedan", "suv")
m = compute_metrics(classes, [0, 0, 1, 1], [0, 1, 1, 1], camera=["car"] * 4)
assert m.accuracy == 0.75
assert m.per_class["sedan"].recall == 0.5 and m.per_class["suv"].precision == 2 / 3
assert m.confusion == [[1, 1], [0, 2]]
assert m.camera_agreement == 0.0
text = render_report(
version="v1",
trained_at="t",
mode="features",
backbone="resnet18",
epochs=3,
classes=classes,
train_counts={"sedan": 10, "suv": 8},
val_counts={"sedan": 2, "suv": 2},
dropped={"truck": 2},
missing_files=1,
weights=[0.9, 1.1],
metrics=m,
min_accuracy=0.85,
written=False,
)
assert "MODEL NOT WRITTEN" in text and "| **sedan** | 1 | 1 |" in text and "truck (2)" in text
def test_preprocess_contract(tmp_path: Path, collector_dir: Path) -> None:
samples, _ = load_labelled(collector_dir)
x = load_input(samples[0].path, 32)
assert x.shape == (3, 32, 32) and x.dtype.name == "float32" and 0 <= x.min() and x.max() <= 255
assert x[0].mean() > x[2].mean() # a sedan crop is red: RGB order, not BGR
assert load_input(tmp_path / "nope.jpg", 32) is None
side = Sidecar(version="v1", classes=["sedan", "suv"])
side.write(tmp_path / "s.json")
back = Sidecar.read(tmp_path / "s.json")
assert back == side and back.crop_margin == CROP_MARGIN == 0.08 and back.normalization == "in-graph"
def test_inspect_prints_the_run_shape(collector_dir: Path, capsys) -> None: # type: ignore[no-untyped-def]
assert main(["inspect", "--data", str(collector_dir)]) == 0
out = json.loads(capsys.readouterr().out)
assert out["ready"] is True and out["run"]["classes"] == ["sedan", "suv", "van"]
assert out["run"]["dropped"] == {"truck": 5} and out["missingCrops"] == 1
assert main(["inspect", "--data", str(collector_dir), "--min-per-class", "100"]) == 2
+111
View File
@@ -0,0 +1,111 @@
"""The job API: readiness, one job at a time, subprocess jobs with persisted logs, versions."""
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
from trainer.server import Handler, Jobs, readiness, versions, wait_idle
@pytest.fixture
def api(collector_dir: Path, tmp_path: Path): # type: ignore[no-untyped-def]
out = tmp_path / "out"
Handler.jobs = Jobs(collector_dir, out, "https://example.invalid/pkg", "tok")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
base = f"http://127.0.0.1:{httpd.server_address[1]}"
def call(method: str, path: str, body: dict | None = None): # type: ignore[no-untyped-def]
req = urllib.request.Request(base + path, method=method)
data = None
if body is not None:
data = json.dumps(body).encode()
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, data=data, timeout=10) as r:
raw = r.read()
return r.status, (
json.loads(raw) if r.headers.get_content_type() == "application/json" else raw.decode()
)
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"{}")
yield call, out
httpd.shutdown()
httpd.server_close()
def test_readiness_and_empty_versions(api) -> None: # type: ignore[no-untyped-def]
call, _ = api
code, r = call("GET", "/readiness")
assert code == 200 and r["ready"] is True and r["run"]["classes"] == ["sedan", "suv", "van"]
assert r["defaults"]["minAccuracy"] == 0.85 and "finetune" in r["modes"]
assert call("GET", "/versions") == (200, {"versions": []})
assert call("GET", "/health")[1]["busy"] is False
assert readiness(Path("/nonexistent"))["ready"] is False
def test_evaluate_job_runs_as_a_subprocess_and_is_recorded(api) -> None: # type: ignore[no-untyped-def]
call, out = api
code, job = call("POST", "/jobs", {"kind": "evaluate", "version": "nope"})
assert code == 202 and job["status"] == "running" and job["kind"] == "evaluate"
wait_idle(Handler.jobs)
code, j = call("GET", f"/jobs/{job['id']}")
assert code == 200 and j["status"] == "failed" and j["exitCode"] == 1
assert "evaluate --data" in j["log"] and "nope" in j["log"]
assert (out / "jobs" / f"{job['id']}.json").is_file() and (out / "jobs" / f"{job['id']}.log").is_file()
code, lst = call("GET", "/jobs")
assert code == 200 and lst["jobs"][0]["id"] == job["id"] and lst["current"] is None
def test_bad_requests(api) -> None: # type: ignore[no-untyped-def]
call, _ = api
assert call("POST", "/jobs", {"kind": "nuke"})[0] == 400
assert call("POST", "/jobs", {"kind": "train", "mode": "magic"})[0] == 400
assert call("POST", "/jobs", {"kind": "evaluate", "version": "../etc"})[0] == 400
assert call("POST", "/jobs", {"kind": "publish", "version": "v1", "url": "ftp://x"})[0] == 400
assert call("GET", "/versions/../x/report")[0] == 400
assert call("GET", "/versions/v9/report")[0] == 404
assert call("GET", "/jobs/nope")[0] == 404
assert call("GET", "/nothing")[0] == 404
def test_train_job_then_versions_and_report(api) -> None: # type: ignore[no-untyped-def]
pytest.importorskip("torch")
call, out = api
body = {"kind": "train", "mode": "features", "minAccuracy": 0.0, "epochs": 100, "version": "vapi"}
# The test-only flags are not offered by the API; inject them via the CLI args the runner builds.
orig = Jobs._argv
def patched(self, kind, a): # type: ignore[no-untyped-def]
argv = orig(self, kind, a)
return argv + ["--no-pretrained", "--input-size", "64", "--no-cache"] if kind == "train" else argv
Jobs._argv = patched # type: ignore[method-assign]
try:
code, job = call("POST", "/jobs", body)
assert code == 202
assert call("POST", "/jobs", {"kind": "evaluate", "version": "vapi"})[0] == 409 # one at a time
wait_idle(Handler.jobs, 120)
finally:
Jobs._argv = orig # type: ignore[method-assign]
code, j = call("GET", f"/jobs/{job['id']}")
assert j["status"] == "done" and "MODEL WRITTEN" in j["log"]
code, v = call("GET", "/versions")
assert code == 200 and v["versions"][0]["version"] == "vapi" and v["versions"][0]["written"] is True
assert v["versions"][0]["classes"] == ["sedan", "suv", "van"] and v["versions"][0]["accuracy"] >= 0.9
code, report = call("GET", "/versions/vapi/report")
assert code == 200 and report.startswith("# Body-type classifier vapi")
assert versions(out)[0]["floor"] == 0.0
# evaluate on the written model now succeeds
code, job2 = call("POST", "/jobs", {"kind": "evaluate", "version": "vapi"})
wait_idle(Handler.jobs)
assert call("GET", f"/jobs/{job2['id']}")[1]["status"] == "done"
+155
View File
@@ -0,0 +1,155 @@
"""The training job end to end on the synthetic volume — needs the `train` extra (torch);
skipped where it is not installed (CI syncs without it, like the vision service)."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
torch = pytest.importorskip("torch")
from trainer.cli import main # noqa: E402
from trainer.infer import OnnxClassifier # noqa: E402
from trainer.preprocess import Sidecar # noqa: E402
COMMON = ["--no-pretrained", "--input-size", "64", "--no-cache", "--seed", "3"]
def test_features_run_writes_model_sidecar_report_and_evaluates(
collector_dir: Path, tmp_path: Path, capsys
) -> None: # type: ignore[no-untyped-def]
out = tmp_path / "out"
rc = main(
[
"train",
"--data",
str(collector_dir),
"--out",
str(out),
"--version",
"vtest",
"--mode",
"features",
"--epochs",
"150",
"--min-accuracy",
"0.0",
*COMMON,
]
)
assert rc == 0
d = out / "vtest"
assert {p.name for p in d.iterdir()} == {"bodytype.onnx", "bodytype.json", "report.md", "metrics.json"}
side = Sidecar.read(d / "bodytype.json")
assert side.classes == ["sedan", "suv", "van"] and side.input_size == 64 and side.mode == "features"
assert side.labels == {"train": 96, "val": 24} and side.metrics["floor"] == 0.0
metrics = json.loads((d / "metrics.json").read_text())
assert metrics["n"] == 24 and metrics["onnx_agreement"] == 1.0
# Colour-coded classes: even a random backbone's pooled features separate them.
assert metrics["accuracy"] >= 0.9
report = (d / "report.md").read_text()
assert (
"MODEL WRITTEN" in report
and "truck (5)" in report
and "crop is missing on disk (skipped): 1" in report
)
# The exported graph takes raw 0–255 RGB and answers by itself.
clf = OnnxClassifier(d / "bodytype.onnx")
probs, kept = clf.predict_files(
[s for s in sorted((collector_dir / "crops" / "booth-2").glob("*.jpg"))][:6]
)
assert probs.shape == (6, 3) and kept == [0, 1, 2, 3, 4, 5]
assert np.allclose(probs.sum(axis=1), 1.0, atol=1e-4)
# evaluate: labels reviewed after training (none — the fixture's reviews predate it) and the
# unlabelled pile (6 entry samples).
capsys.readouterr()
assert main(["evaluate", "--data", str(collector_dir), "--model", str(d / "bodytype.onnx")]) == 0
res = json.loads(capsys.readouterr().out)
assert res["model"] == "vtest" and res["reviewedSince"] is None
assert res["unlabelled"]["n"] == 6 and sum(res["unlabelled"]["predicted"].values()) == 6
assert (
main(
[
"evaluate",
"--data",
str(collector_dir),
"--model",
str(d / "bodytype.onnx"),
"--since",
"2026-09-01T00:00:00Z",
]
)
== 0
)
res2 = json.loads(capsys.readouterr().out)
assert res2["reviewedSince"]["n"] == 125 - 5 # trucks are not a class the model knows
def test_below_the_floor_writes_the_report_but_no_model(collector_dir: Path, tmp_path: Path) -> None:
out = tmp_path / "out"
rc = main(
[
"train",
"--data",
str(collector_dir),
"--out",
str(out),
"--version",
"vlow",
"--mode",
"features",
"--epochs",
"5",
"--min-accuracy",
"1.01",
*COMMON,
]
)
assert rc == 3
d = out / "vlow"
assert {p.name for p in d.iterdir()} == {"report.md", "metrics.json"}
assert "MODEL NOT WRITTEN" in (d / "report.md").read_text()
def test_not_enough_labels_is_exit_2(collector_dir: Path, tmp_path: Path) -> None:
out = tmp_path / "out"
rc = main(["train", "--data", str(collector_dir), "--out", str(out), "--min-per-class", "100", *COMMON])
assert rc == 2
assert not out.exists()
def test_finetune_runs_and_uses_the_feature_cache(collector_dir: Path, tmp_path: Path) -> None:
out = tmp_path / "out"
args = [
"train",
"--data",
str(collector_dir),
"--out",
str(out),
"--mode",
"finetune",
"--backbone",
"mobilenet_v3_small",
"--epochs",
"1",
"--batch",
"16",
"--min-accuracy",
"0.0",
"--no-pretrained",
"--input-size",
"64",
"--seed",
"3",
]
assert main([*args, "--version", "vft"]) == 0
cache = out / "cache" / "features-mobilenet_v3_small-64.npz"
assert cache.exists()
z = np.load(cache)
assert len(z["ids"]) == 96 and z["feats"].shape == (96, 576)
assert Sidecar.read(out / "vft" / "bodytype.json").mode == "finetune"
+7
View File
@@ -0,0 +1,7 @@
"""parking-trainer — the phase-B body-type classifier job.
Reads the wash collector's SQLite + crops straight off its volume, splits by TIME, trains a
small classifier on a pretrained backbone, and writes the ONNX model + sidecar + report —
or refuses to write the model when validation is below the owner's floor.
See wiki/decisions/bodytype-classifier-training.md.
"""

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