150 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

server 80/80; build+lint green.

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

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

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

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

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

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

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

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

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

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

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

server 75/75 green (8 suites).

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

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

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

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

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

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

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

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

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

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

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

server 22/22, shared 87/87 green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Build+lint 12/12 (i18n parity).

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

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

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

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

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

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

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

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

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

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

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

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

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

Build+lint 12/12 (i18n parity).

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

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

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

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

Build+lint 12/12 (i18n parity).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Wiki: tariff, log.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Build + lint green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Web build green; login renders on the new palette.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified on hardware (Hikvision 10.0.10.121): healthCheck ready,
captureSnapshot returns a valid JPEG.
2026-06-15 16:17:49 +02:00
305 changed files with 48912 additions and 925 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"hooks": {
"PreToolUse": []
}
}
+44
View File
@@ -0,0 +1,44 @@
# Build context hygiene for the server + vision images (context = repo root).
# Keep the context small and NEVER bake build artifacts, secrets, or the live DB.
# Node / build outputs (rebuilt inside the image)
**/node_modules/
**/dist/
**/.turbo/
**/*.tsbuildinfo
.turbo/
# Python (vision) — rebuilt by uv inside the image
**/.venv/
**/__pycache__/
**/.mypy_cache/
**/.pytest_cache/
**/.ruff_cache/
# Secrets + local env (the image gets config via runtime env, never baked)
**/.env
**/.env.local
# NEVER bake the live signed-ledger DB (or any of its WAL/SHM/backup variants) into an
# image — it lives on a mounted volume. Match the base file AND every -wal/-shm/.bak-*
# sibling (deploy copies the package dir's files, ignoring .gitignore).
**/*.sqlite
**/*.sqlite-*
**/parking.sqlite*
# Desktop app is built by its own tag-only release.yml, not these images
apps/desktop/
# VCS, logs, caches, editor cruft
.git/
.github/
*.log
**/.DS_Store
.vscode/
.idea/
# Wiki raw sources / large docs (not needed to build)
wiki/raw/
# Plans / scratch
.planning/
+134
View File
@@ -0,0 +1,134 @@
name: Build desktop
# Build the Tauri desktop installers (.deb + .AppImage) on every push to dev/main and
# upload them as workflow ARTIFACTS — a downloadable, per-commit build for testing the
# native shell. This is NOT a release: it's unsigned (no updater key) and creates no Gitea
# Release. Signed, versioned releases stay on release.yml (tag v* → .deb/.rpm/.AppImage +
# latest.json for the auto-updater). See wiki/decisions/desktop-shell-tauri.md.
on:
push:
branches: [dev, main]
paths:
- 'apps/desktop/**'
- 'apps/web/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/build-desktop.yml'
workflow_dispatch:
jobs:
desktop:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install Tauri system deps
# Same set release.yml uses (verified): WebKitGTK 4.1 + libsoup-3 + the GTK/
# appindicator/rsvg stack + AppImage tooling (patchelf, file).
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
file \
build-essential \
curl \
wget
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/desktop/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build desktop bundle (.deb + .AppImage)
# Unsigned — no TAURI_SIGNING_* here (this is a test artifact, not an updater
# release). The config sets createUpdaterArtifacts:true (release.yml signs them),
# which makes tauri DEMAND the signing key and fail without it — so override it to
# false for this build via --config (a JSON patch merged over tauri.conf.json).
# --bundles restricts to the two installers we ship; tauri builds the web SPA
# first (beforeBuildCommand), so the desktop UI matches.
run: >
pnpm --filter @parking/desktop bundle
--bundles deb,appimage
--config '{"bundle":{"createUpdaterArtifacts":false}}'
- name: Collect installers
id: collect
# Copy out the two installers under SPACE-FREE names (tauri names them
# "Parking System_0.0.0_amd64.deb" — spaces break asset URLs). Short SHA in the
# name so a downloaded file is traceable to its commit.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
mkdir -p dist
deb=$(find "$BUNDLE/deb" -name '*.deb' | head -1)
app=$(find "$BUNDLE/appimage" -name '*.AppImage' | head -1)
cp "$deb" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.deb"
cp "$app" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.AppImage"
echo "Artifacts:"; ls -la dist/
- name: Publish to a rolling per-branch pre-release
# actions/upload-artifact's backend isn't reliable on this Gitea runner, so we
# publish to a Gitea RELEASE via the API instead (the proven pattern from
# release.yml — built-in token, plain curl). One ROLLING pre-release per branch
# (tag desktop-<branch>): delete + recreate each push so it always holds the
# latest dev/main installer. This is NOT the signed updater release (release.yml,
# tag v*) — it's a prerelease, unsigned, with no latest.json.
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}
REPO: ${{ github.repository }}
TAG: desktop-${{ github.ref_name }}
run: |
set -e
auth="Authorization: token ${TOKEN}"
# Drop any existing rolling release for this branch (ignore if absent) so its
# tag + stale assets don't pile up; recreate it fresh below.
OLD=$(curl -sS -H "$auth" "${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$OLD" ]; then
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/releases/${OLD}" || true
# Also delete the tag itself so the recreate points at this commit.
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/git/refs/tags/${TAG}" || true
fi
REL=$(curl -sS -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"Desktop build (${GITHUB_REF_NAME})\",\"body\":\"Unsigned per-commit desktop installers from ${GITHUB_REF_NAME} @ ${GITHUB_SHA}. Rolling — overwritten each push. Not an updater release.\",\"draft\":false,\"prerelease\":true}" \
"${API}/repos/${REPO}/releases")
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
echo "release id: ${REL_ID}"
for f in dist/*; do
name=$(basename "$f")
echo "uploading ${name}"
curl -sS -X POST -H "$auth" -H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
+122
View File
@@ -0,0 +1,122 @@
name: Build & push images
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, main→:main).
# Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). Mirrors the
# house pattern (cf. trm/processor build.yml). See wiki/decisions/container-deployment.md.
on:
push:
branches: [dev, main]
paths:
- 'apps/server/**'
- 'apps/web/**'
- 'apps/vision/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'docker-compose*.yml'
- '.dockerignore'
- '.gitea/workflows/build-images.yml'
workflow_dispatch:
env:
REGISTRY: git.infra.msai.al/mca/parking_solution
jobs:
images:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up uv (for @parking/vision checks)
# Install uv via its official standalone script rather than a third-party action —
# the Gitea runner can't reliably resolve astral-sh/setup-uv. uv provisions the
# pinned Python (apps/vision/.python-version) itself. Add it to PATH for later steps.
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync vision deps
working-directory: apps/vision
run: uv sync --frozen
# Don't publish a broken image — run the same checks as ci.yml first.
- name: Build + lint + test (Turbo)
run: pnpm turbo run build lint test
- name: Compute tags
id: meta
# BRANCH = the pushed branch (dev|main); SHA = short commit. Two tags per image:
# the moving branch tag + an immutable branch-SHA tag.
run: |
BRANCH="${GITHUB_REF_NAME}"
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.infra.msai.al
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build & push SERVER (API + SPA)
uses: docker/build-push-action@v5
with:
context: .
file: apps/server/Dockerfile
push: true
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 VISION (ANPR)
uses: docker/build-push-action@v5
with:
context: apps/vision
file: apps/vision/Dockerfile
push: true
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
# Optional: trigger a Komodo stack redeploy (cf. trm/processor). Enable by setting the
# KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired.
- name: Trigger Komodo redeploy
if: success() && vars.KOMODO_ENABLED == 'true'
env:
URL: ${{ secrets.KOMODO_STACK_WEBHOOK_URL }}
SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}
run: |
body="{\"ref\":\"refs/heads/${GITHUB_REF_NAME}\"}"
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$sig" \
-d "$body" \
"$URL"
+57
View File
@@ -0,0 +1,57 @@
name: CI
# Lint/typecheck/test the whole Turborepo on every push/PR to dev. Mirrors the
# house pattern (cf. trm/processor): setup-node + corepack pnpm + frozen install.
# No Docker, no signing — pure checks. The desktop bundle is a separate, tag-only
# pipeline (see release.yml).
on:
push:
branches: [dev]
pull_request:
branches: [dev, main]
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
# Pin to the repo's packageManager version (pnpm 10), not latest.
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up uv (Python toolchain for @parking/vision)
# The vision service is a Python package wired into the Turbo graph via a
# package.json shim; its lint/typecheck/test scripts shell to `uv run …`. CI
# has no Python by default, so `uv run` would fail with "uv: not found" and
# break the whole Turbo run. Install uv via its official standalone script
# (the Gitea runner can't reliably resolve astral-sh/setup-uv); uv provisions the
# pinned Python (.python-version) itself. See wiki/decisions/vision-service-packaging.md.
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync vision deps
# Light deps + the dev group (ruff/mypy/pytest) only — NOT the optional `alpr`
# extra (heavy onnx/model stack), which isn't needed to lint/typecheck/test.
working-directory: apps/vision
run: uv sync --frozen
- name: Build + lint (Turbo)
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
# key fails the build), AND the vision service's ruff lint via uv.
run: pnpm turbo run build lint
- name: Test
run: pnpm turbo run test
+149
View File
@@ -0,0 +1,149 @@
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.
#
# 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.
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install Tauri system deps
# ubuntu-latest runner has no GUI/webkit libs by default. These are the
# exact deps a Tauri v2 Linux build needs (verified locally): WebKitGTK
# 4.1 + libsoup-3 + the GTK/appindicator/rsvg stack + AppImage tooling.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
file \
build-essential \
curl \
wget
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/desktop/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build + sign desktop bundle
env:
# Updater signing key (Gitea repo/org secrets). Without these the
# bundle is unsigned and the updater would reject it.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm --filter @parking/desktop bundle
- name: Collect artifacts
id: collect
# Gather the installers + their .sig into a flat dist/ for upload.
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/ \;
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.
env:
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
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}"
}
}
}
JSON
echo "latest.json:"; cat dist/latest.json
- name: Create release + upload assets (Gitea API)
# Uses the built-in token; no marketplace release action required. Creates
# the release for this tag (idempotent-ish: ignores "already exists") and
# uploads every file in dist/ as an asset.
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -e
# Create the release (capture id; tolerate an existing one).
REL=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${API}/repos/${REPO}/releases" || true)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
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)
fi
echo "release id: ${REL_ID}"
for f in dist/*; do
name=$(basename "$f")
echo "uploading ${name}"
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
+7
View File
@@ -11,6 +11,8 @@ dist/
.env .env
.env.* .env.*
!.env.example !.env.example
# Committed (non-secret): the desktop/prod build's backend origin — see apps/web/.env.production
!.env.production
# Editor/OS # Editor/OS
.DS_Store .DS_Store
@@ -20,3 +22,8 @@ dist/
/*.png /*.png
# Vendor device SDKs (reference only — protocol captured in wiki, not committed) # Vendor device SDKs (reference only — protocol captured in wiki, not committed)
/dingtian/ /dingtian/
/QRCode_sdk*/
# Graphify knowledge-graph output (dev tool; generated, not committed)
graphify-out/
parking.sqlite*.bak-*
+2 -1
View File
@@ -17,7 +17,8 @@ parking-system/
├── turbo.json ├── turbo.json
├── apps/ ├── apps/
│ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA │ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA
│ └── web/ # React + Vite SPA (operator UI) │ ├── web/ # React + Vite SPA (operator UI)
│ └── vision/ # Python/FastAPI ANPR service (planned; separate process, Turbo shim — see wiki/decisions/vision-service-packaging.md)
├── packages/ ├── packages/
│ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target) │ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target)
│ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay) │ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay)
+13
View File
@@ -0,0 +1,13 @@
# Booth reverse proxy. `:80` matches ANY hostname/IP, so the booth is reachable as
# http://<booth-ip>/, http://localhost/, or http://parksystems.msai.al/ (the name pointed
# at the booth's IP via hosts/DNS on-site) — with no domain baked into any image. The SPA
# uses a relative /api base, so everything (HTTP + the /api/ws WebSocket, which Caddy
# upgrades automatically) just flows through to the server container.
#
# TLS later: replace `:80` with the real hostname (e.g. `parksystems.msai.al`), uncomment
# Caddy's :443 in docker-compose.prod.yml, and Caddy auto-provisions HTTPS. For a private
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
:80 {
encode gzip
reverse_proxy server:3000
}
+10
View File
@@ -0,0 +1,10 @@
# Desktop (Tauri) build — the @parking/web SPA needs to know where Fastify is.
#
# In a BROWSER (dev via the Vite proxy, or prod where Fastify serves the SPA),
# leave VITE_API_BASE UNSET — requests stay relative/same-origin.
#
# For the DESKTOP build, the bundled SPA loads from tauri://localhost and has no
# proxy, so point it at the appliance's Fastify origin. This is read at WEB build
# time, so export it before `pnpm --filter @parking/desktop build` (or put it in
# apps/web/.env.production).
VITE_API_BASE=http://127.0.0.1:3000
+3
View File
@@ -0,0 +1,3 @@
# Rust / Tauri build artifacts
src-tauri/target/
src-tauri/gen/
+42
View File
@@ -0,0 +1,42 @@
# @parking/desktop — Tauri v2 kiosk shell
A **thin native desktop window** over the `@parking/web` SPA. It contains **no UI and no business
logic** of its own: the window renders the *same* web app the browser does, so the desktop and the
browser stay identical and never drift. Device/auth/ledger logic stays in `@parking/server`. See
`wiki/decisions/desktop-shell-tauri.md`.
## How the "same look & functionality" guarantee works
| | Source of the UI |
| --- | --- |
| **Dev** (`tauri dev`) | the window loads `http://localhost:5173` — the **`@parking/web` Vite dev server**. Edit a component in `apps/web` → HMR updates the desktop window live. |
| **Prod** (`tauri build`) | the window bundles `apps/web`'s built `dist/`. `beforeBuildCommand` rebuilds the SPA first. |
There is only one UI codebase (`apps/web`); this package just wraps it.
## Backend connection
The SPA talks to Fastify over HTTP/WS. In a browser that's same-origin (relative `/api`). In the
desktop build the bundled assets load from `tauri://localhost`, so set **`VITE_API_BASE`** (read at
web build time — see `.env.example`) to the appliance's Fastify origin, e.g.
`http://127.0.0.1:3000`. The CSP `connect-src` in `tauri.conf.json` is already allowed for that
origin, and the backend must include the Tauri origin in `WS_ALLOWED_ORIGINS` for the live feed.
## Commands
```bash
pnpm --filter @parking/desktop dev # native window over the web dev server (HMR)
pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app (.deb/.rpm/.AppImage)
```
> `build` is a **no-op** in this package so `turbo run build` stays fast — the real desktop bundle
> (compiles Rust, minutes long) is the explicit `bundle` script above.
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
window needs a display (WSLg or an X server).
## 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.
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@parking/desktop",
"version": "0.0.0",
"private": true,
"//": "Tauri v2 desktop shell — a THIN native window over the @parking/web SPA. No business logic lives here (device/auth/ledger stay in @parking/server); see wiki/decisions/desktop-shell-tauri.md. Dev loads the web dev server (HMR); build bundles the web app's dist/, so the desktop UI and the browser UI are the SAME codebase and never drift.",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "echo 'no-op in the Turbo graph — the real desktop bundle is a deliberate `pnpm --filter @parking/desktop bundle` (compiles Rust + packages installers, minutes long)'",
"bundle": "tauri build",
"tauri": "tauri",
"lint": "echo 'no JS lint (Tauri shell; Rust checked via cargo)'"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.1"
},
"dependencies": {
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1"
}
}
+4899
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "parking-desktop"
version = "0.0.0"
description = "Parking System — desktop kiosk shell"
edition = "2021"
rust-version = "1.77"
# Thin Tauri v2 shell. Deliberately holds NO business logic — it loads the
# @parking/web SPA and lets it talk to the local Fastify server. Device/auth/
# ledger stay server-side. See wiki/decisions/desktop-shell-tauri.md.
[lib]
name = "parking_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde_json = "1"
# Auto-update: prompt the operator, download a signed update, relaunch.
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
[features]
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Minimal capability set for the kiosk shell. The window only needs to render the SPA; it is granted NOTHING that touches the filesystem, shell, or devices — those stay server-side. Add a named permission here only when a concrete need arises (deny-by-default). See wiki/decisions/desktop-shell-tauri.md.",
"windows": ["main"],
"permissions": [
"core:default",
"updater:default",
"process:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+21
View File
@@ -0,0 +1,21 @@
// Parking System desktop shell — entry point.
//
// Intentionally minimal: build the default Tauri app and run it. The window
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
// the backend over HTTP to 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).
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// Auto-update: the JS side (apps/web) checks on launch, prompts the
// operator, and installs + relaunches on confirm. These plugins expose
// the update check/install and the relaunch to that flow. The updater
// endpoint + signing pubkey live in tauri.conf.json.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.run(tauri::generate_context!())
.expect("error while running the Parking System desktop shell");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
parking_desktop_lib::run()
}
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System",
"version": "0.0.0",
"identifier": "com.parking.desktop",
"build": {
"devUrl": "http://localhost:5173",
"frontendDist": "../../web/dist",
"beforeDevCommand": "pnpm --filter @parking/web dev",
"beforeBuildCommand": "pnpm --filter @parking/web build"
},
"app": {
"windows": [
{
"label": "main",
"title": "Parking System",
"width": 1280,
"height": 800,
"minWidth": 1024,
"minHeight": 640,
"resizable": true,
"maximized": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"//": "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.",
"endpoints": [
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"//": "Tauri shell as a first-class Turbo node. build outputs [] so `turbo run build` doesn't try to cache/compile the Rust bundle on every pass (a real desktop bundle is a deliberate `pnpm --filter @parking/desktop build`).",
"tasks": {
"build": {
"outputs": []
}
}
}
+46 -1
View File
@@ -8,13 +8,58 @@
# Generate one with: openssl rand -hex 32 # Generate one with: openssl rand -hex 32
JWT_SECRET= JWT_SECRET=
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
# Generate with: openssl rand -hex 32
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
# dev, but set a dedicated key before production. Events store the key that signed
# them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY=
# Optional ---------------------------------------------------------------- # Optional ----------------------------------------------------------------
# PORT=3000 # PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
# LOG_LEVEL=info # LOG_LEVEL=info
# DATABASE_URL=./parking.sqlite # DATABASE_URL=./parking.sqlite
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only) #
# Auth-cookie Secure flag. FAIL-SAFE: cookies are Secure (HTTPS-only) BY DEFAULT —
# you only ever opt OUT, never in. Set COOKIE_SECURE=0 for a plain-HTTP deployment
# (e.g. the LAN appliance serving the SPA same-origin over http, where a Secure
# cookie would never be sent and would lock operators out). Local dev over
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
# COOKIE_SECURE=0
# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged
# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted
# items forever (manual purge only). See wiki/concepts/soft-delete.md.
# RECYCLE_BIN_RETENTION_DAYS=30
# First admin (seed once): pnpm --filter @parking/server seed-admin # First admin (seed once): pnpm --filter @parking/server seed-admin
# ADMIN_USER=admin # ADMIN_USER=admin
# ADMIN_PASS= # ADMIN_PASS=
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
# The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
# http://tauri.localhost), which is NOT same-origin with the backend — add both
# so the desktop app's live feed connects. See apps/desktop.
# To open the dev SPA from another LAN device (phone over wifi), Vite must bind
# 0.0.0.0 (vite.config.ts) AND the host's LAN origin must be listed here, e.g.
# http://10.0.10.203:5173 — the WS handshake's Origin is that LAN address.
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
# Vision / ANPR (optional) -------------------------------------------------
# OFF by default. The Node SERVER's view of the vision microservice (apps/vision),
# which runs as a separate process with its OWN apps/vision/.env. Both sides share the
# VISION_ prefix but are different processes — keep the two .env files separate.
# See wiki/entities/opencv-anpr-service.md "Configuration".
# ANPR rides the entry/exit snapshot (button / QR / RFID triggers it) — no polling.
# VISION_ENABLED=1 # master switch — nothing runs without it
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
# VISION_MIN_CONFIDENCE=0.5 # advisory confidence floor; keep in sync with the service
#
# ANPR subscriber-entry bridge (anpr-entry.ts): a subscriber's plate, read off a lane
# camera's vehicle detection, admits them through the gated SubscriptionFlow. Opt-in per
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
+78
View File
@@ -0,0 +1,78 @@
# syntax=docker/dockerfile:1.7
# Parking SERVER image: Fastify API + the bundled React SPA (one container serves both —
# offline-first single appliance). Build CONTEXT is the REPO ROOT (it's a pnpm/turbo
# monorepo). better-sqlite3 is a native module → build stage needs node-gyp toolchain,
# runtime needs libstdc++. Mirrors the house multi-stage pattern (cf. trm/processor).
# See wiki/decisions/container-deployment.md.
# ---- deps: cache-friendly pnpm fetch (only manifests change the layer) ----
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
# Workspace manifests + lock first, so the fetch layer caches across source edits.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/
COPY apps/vision/package.json apps/vision/
COPY packages/db/package.json packages/db/
COPY packages/devices/package.json packages/devices/
COPY packages/shared/package.json packages/shared/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm fetch
# ---- build: install (offline from the fetched store) + turbo build everything ----
FROM deps AS build
ENV CI=true
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --offline
# Force the SPA to use a SAME-ORIGIN (relative) API base for THIS image. Vite auto-loads
# apps/web/.env.production, which sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
# DESKTOP build — but here Fastify serves the SPA same-origin, so an absolute base would
# make the browser hit 127.0.0.1:3000 cross-origin and fail CORS. `.env.production.local`
# has higher precedence than `.env.production`, so this empties it for the server image only.
RUN echo 'VITE_API_BASE=' > apps/web/.env.production.local
# Builds shared/db/devices, the server dist, AND the web SPA dist (apps/web/dist).
RUN pnpm turbo run build --filter=@parking/server --filter=@parking/web
# `pnpm deploy` produces a SELF-CONTAINED prod bundle for the server in /deploy: a hoisted
# node_modules with only @parking/server's prod deps (incl. the workspace packages' built
# dist + their native deps like better-sqlite3 — properly linked, unlike `prune` at root).
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter=@parking/server --legacy deploy --prod /deploy
# The server's own dist + scripts (deploy copies the package's package.json + files, but we
# copy dist explicitly so the layout under /deploy is predictable). The web SPA + db
# migrations are copied in the runtime stage from their build locations.
# ---- runtime: slim, non-root ----
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
RUN addgroup -S app && adduser -S -G app app
# The self-contained deploy bundle: dist/ + a hoisted node_modules carrying the server's
# prod deps AND the workspace packages (@parking/db|devices|shared) with their built dist,
# the drizzle migrations, and the native better-sqlite3 binding. Single COPY — no scattered
# package dirs, no root node_modules.
COPY --from=build --chown=app:app /deploy ./
# The built SPA — served by Fastify static at WEB_DIST_DIR. (Not part of the server's deploy
# bundle, so copied from the web build output.)
COPY --from=build --chown=app:app /app/apps/web/dist ./web/dist
# DB lives on a mounted volume (never in the image). Default points at /data.
ENV DATABASE_URL=/data/parking.sqlite
ENV WEB_DIST_DIR=/app/web/dist
ENV HOST=0.0.0.0
ENV PORT=3000
RUN mkdir -p /data && chown app:app /data
VOLUME ["/data"]
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- "http://localhost:${PORT:-3000}/health" >/dev/null 2>&1 || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Container entrypoint for the parking server. Applies DB migrations against the mounted
# volume (DATABASE_URL), optionally seeds the first admin, then execs the server. Idempotent:
# the runtime migrator (drizzle-orm migrator, no drizzle-kit) only applies pending migrations,
# so a restart is a no-op. See packages/db/scripts/migrate-runtime.mjs.
set -e
echo "[entrypoint] DATABASE_URL=${DATABASE_URL}"
# Apply migrations against the mounted DB file (creates it + the schema on first boot).
# The migrator ships inside the @parking/db package in the deploy bundle's node_modules.
node node_modules/@parking/db/scripts/migrate-runtime.mjs
# Optional first-boot admin seed: set SEED_ADMIN=1 plus ADMIN_USER + ADMIN_PASS (the seed
# script PROMPTS when these are unset, which would hang a container — so require ADMIN_PASS).
# The seed is idempotent: it won't overwrite an existing user unless FORCE=1.
if [ "${SEED_ADMIN}" = "1" ]; then
if [ -z "${ADMIN_PASS}" ]; then
echo "[entrypoint] SEED_ADMIN=1 but ADMIN_PASS is unset — skipping seed (would hang on prompt)"
else
echo "[entrypoint] seeding admin (${ADMIN_USER:-admin})"
node scripts/seed-admin.mjs || echo "[entrypoint] seed-admin skipped/failed (non-fatal)"
fi
fi
echo "[entrypoint] starting server"
exec "$@"
+5 -2
View File
@@ -9,13 +9,15 @@
"start": "node --env-file-if-exists=.env dist/index.js", "start": "node --env-file-if-exists=.env dist/index.js",
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs", "seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@fastify/jwt": "10.1.0", "@fastify/jwt": "10.1.0",
"@fastify/static": "9.1.3", "@fastify/static": "9.1.3",
"@fastify/websocket": "^11.2.0",
"@parking/db": "workspace:*", "@parking/db": "workspace:*",
"@parking/devices": "workspace:*", "@parking/devices": "workspace:*",
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
@@ -27,6 +29,7 @@
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/node": "25.9.3", "@types/node": "25.9.3",
"tsx": "4.22.4", "tsx": "4.22.4",
"typescript": "6.0.3" "typescript": "6.0.3",
"vitest": "^4.1.9"
} }
} }
+2 -2
View File
@@ -62,14 +62,14 @@ if (existing && process.env.FORCE !== "1") {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
if (existing) { if (existing) {
await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id)); await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id));
console.log(`reset password for admin "${username}"`); console.log(`reset password for admin "${username}"`);
} else { } else {
await db.insert(users).values({ await db.insert(users).values({
id: randomUUID(), id: randomUUID(),
username, username,
passwordHash, passwordHash,
role: "admin", roleId: "admin",
}); });
console.log(`created admin "${username}"`); console.log(`created admin "${username}"`);
} }
+191
View File
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
import type { VisionClient, VisionResult } from "./vision-client.js";
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
// own logic only. See anpr-entry.ts.
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
// (no registry, no network). The factory returns a fresh shot each call.
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
vi.mock("./snapshot.js", () => ({
buildCamera: () => ({ captureSnapshot }),
}));
// Import AFTER the mock is registered.
const { AnprBridge } = await import("./anpr-entry.js");
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
captureSnapshot.mockClear();
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
delete process.env.ANPR_DEBOUNCE_MS;
});
afterEach(() => {
vi.restoreAllMocks();
});
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
function seedCamera(opts: { anpr?: boolean } = {}): string {
const controllerId = randomUUID();
db.insert(devices).values({
id: controllerId,
category: "access",
driverId: "dingtian",
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
enabled: true,
}).run();
const camId = randomUUID();
db.insert(devices).values({
id: camId,
category: "camera",
driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
enabled: true,
}).run();
return camId;
}
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
const enabled = opts.enabled ?? true;
const result: VisionResult | null =
opts.plate == null
? null
: {
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
plates: [],
lowConfidence: false,
modelVersion: "test",
tookMs: 1,
};
return {
enabled,
analyze: vi.fn(async () => (enabled ? result : null)),
} as unknown as VisionClient;
}
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
}
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
/** Capture read events emitted during `fn` (async). */
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
const got: DeviceReadEvent[] = [];
const off = deviceEvents.onRead((e) => got.push(e));
try {
await fn();
} finally {
off();
}
return got;
}
describe("AnprBridge", () => {
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
const cam = seedCamera({ anpr: false });
const vision = fakeVision({ plate: "AA111BB" });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
expect(vision.analyze).not.toHaveBeenCalled();
expect(captureSnapshot).not.toHaveBeenCalled();
});
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
});
it("ignores a plate below the entry confidence floor", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
expect(skips).toHaveLength(1);
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
});
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
await bridge.onVehicleDetected(cam);
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
});
expect(reads).toHaveLength(1);
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
});
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
const cam = seedCamera({ anpr: true });
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
await disabled.onVehicleDetected(cam);
await noPlate.onVehicleDetected(cam);
});
expect(reads).toEqual([]);
});
it("never throws on an unknown device id", async () => {
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
});
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
const cam = seedCamera({ anpr: true });
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]);
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
expect(captureSnapshot).not.toHaveBeenCalled();
expect(vision.analyze).not.toHaveBeenCalled();
});
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
const cam = seedCamera({ anpr: true });
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
import { directionOf, type FlowDirection } from "./device-resolve.js";
import { buildCamera } from "./snapshot.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import type { VisionClient } from "./vision-client.js";
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
//
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
//
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
// barrier open stay inside the existing flow. A spoofed printed plate is just another
// credential through the same gate.
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
// transient plate-as-ticket exit flow.
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
// never throws into the push handler, never awaited on the camera's 200 response.
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
interface CameraConfig {
readonly anpr?: boolean;
readonly [k: string]: unknown;
}
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
function entryMinConfidence(): number {
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
}
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
function debounceMs(): number {
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
}
export class AnprBridge {
readonly #db: Db;
readonly #vision: VisionClient | null;
readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger;
readonly #entryMinConfidence: number;
readonly #debounceMs: number;
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
readonly #lastFire = new Map<string, number>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
this.#vision = vision;
this.#subscription = subscription;
this.#logger = logger;
this.#entryMinConfidence = entryMinConfidence();
this.#debounceMs = debounceMs();
}
/**
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
*/
async onVehicleDetected(deviceId: string): Promise<void> {
try {
if (!this.#vision?.enabled) return; // no recognizer configured
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (site && site.anprEntryEnabled === false) return;
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row || !row.enabled || row.category !== "camera") return;
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
// snapshot + analyze every second.
if (this.#debounced(deviceId)) return;
this.#stamp(deviceId);
const camera = buildCamera(row);
if (!camera) {
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
return;
}
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
// the gated flow infers the verb from the camera's bound relay direction).
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
const shot = await camera.captureSnapshot({ direction });
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate) return; // nothing read
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
// object with its confidence even when its own lowConfidence flag is set).
if (result.plate.confidence < this.#entryMinConfidence) {
this.#logger.info(
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
);
return;
}
const plate = result.plate.text.trim().toUpperCase();
if (!plate) return;
const e: DeviceReadEvent = {
driverId: row.driverId,
deviceId,
value: plate,
kind: "plate",
at: new Date().toISOString(),
};
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
const match = this.#subscription.match(e);
if (!match) {
this.#recordSkip(deviceId, plate, result.plate.confidence);
return;
}
// Plate-level debounce — belt-and-suspenders against a gap that slips the
// camera-level gate re-emitting the SAME plate.
const plateKey = `${deviceId}:${plate}`;
if (this.#debounced(plateKey)) return;
this.#stamp(plateKey);
this.#logger.info(
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
);
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
} catch (err) {
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
}
}
#debounced(key: string): boolean {
const last = this.#lastFire.get(key);
return last != null && Date.now() - last < this.#debounceMs;
}
#stamp(key: string): void {
this.#lastFire.set(key, Date.now());
}
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
#recordSkip(deviceId: string, plate: string, confidence: number): void {
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId,
category: "camera",
kind: "anpr-skip",
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
}
}
}
// DeviceRow is re-exported for the test's seed typing convenience.
export type { DeviceRow };
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { secureCookies } from "./auth.js";
// The auth/CSRF cookies' Secure flag must be FAIL-SAFE: Secure by default, dropped only
// on a deliberate opt-out. The old behaviour (Secure iff NODE_ENV==="production") leaked
// cookies over plain HTTP on an appliance that forgot to set NODE_ENV — this pins the
// corrected matrix.
let savedCookieSecure: string | undefined;
let savedNodeEnv: string | undefined;
beforeEach(() => {
savedCookieSecure = process.env.COOKIE_SECURE;
savedNodeEnv = process.env.NODE_ENV;
delete process.env.COOKIE_SECURE;
delete process.env.NODE_ENV;
});
afterEach(() => {
restore("COOKIE_SECURE", savedCookieSecure);
restore("NODE_ENV", savedNodeEnv);
});
function restore(key: string, val: string | undefined) {
if (val === undefined) delete process.env[key];
else process.env[key] = val;
}
describe("secureCookies — fail-safe Secure flag", () => {
it("defaults to Secure when nothing is set (the appliance-forgot-NODE_ENV case)", () => {
expect(secureCookies()).toBe(true);
});
it("stays Secure in production", () => {
process.env.NODE_ENV = "production";
expect(secureCookies()).toBe(true);
});
it("drops Secure only for an explicit local-dev NODE_ENV", () => {
process.env.NODE_ENV = "development";
expect(secureCookies()).toBe(false);
});
it("COOKIE_SECURE override wins: falsey values opt OUT", () => {
for (const v of ["0", "false", "no", "off", "FALSE", " Off "]) {
process.env.COOKIE_SECURE = v;
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(false);
}
});
it("COOKIE_SECURE override wins: any other value opts IN (even in dev)", () => {
process.env.NODE_ENV = "development";
for (const v of ["1", "true", "yes", "on", ""]) {
process.env.COOKIE_SECURE = v;
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(true);
}
});
});
+114 -16
View File
@@ -1,16 +1,22 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify"; import type { FastifyReply, FastifyRequest } from "fastify";
import type { Role } from "@parking/shared"; import { eq, rolePermissions, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
// Local JWT auth helpers — fully local, no external identity provider // Local JWT auth helpers — fully local, no external identity provider
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it); // (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
// a separate readable CSRF cookie + matching header defends mutations // a separate readable CSRF cookie + matching header defends mutations
// (double-submit). See wiki/entities/local-jwt-auth.md. // (double-submit). See wiki/entities/local-jwt-auth.md.
//
// Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each
// guarded route resolves that role's PERMISSION SET (cached in memory) and checks
// the permission it requires. Editing a role takes effect on the next request —
// no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS.
declare module "@fastify/jwt" { declare module "@fastify/jwt" {
interface FastifyJWT { interface FastifyJWT {
payload: { sub: string; username: string; role: Role; csrf: string }; payload: { sub: string; username: string; roleId: string; csrf: string };
user: { sub: string; username: string; role: Role; csrf: string }; user: { sub: string; username: string; roleId: string; csrf: string };
} }
} }
@@ -18,9 +24,15 @@ export const TOKEN_COOKIE = "parking_token";
export const CSRF_COOKIE = "parking_csrf"; export const CSRF_COOKIE = "parking_csrf";
export const CSRF_HEADER = "x-csrf-token"; export const CSRF_HEADER = "x-csrf-token";
/** Token lifetime, also used as the cookie maxAge. */ // Session lifetime: the JWT has NO expiry — a login is valid until explicit
export const TOKEN_TTL = "8h"; // logout. Booth reality breaks any fixed clock (relief late/absent, forced double
export const TOKEN_TTL_SECONDS = 8 * 60 * 60; // shifts), and a shift is a separate explicit boundary, not the token's lifetime.
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
//
// The cookie still needs a maxAge so it survives a browser restart (a session
// cookie would log out an active operator on browser close — the opposite of
// "until logout"). Use a long fixed window; the server clears it on logout.
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
/** /**
* Resolve the JWT signing secret, refusing to start without a strong one. * Resolve the JWT signing secret, refusing to start without a strong one.
@@ -38,9 +50,28 @@ export function requireJwtSecret(): string {
return secret; return secret;
} }
/** Cookies are secure in production; relaxed for local http dev. */ /**
function secureCookies(): boolean { * Whether to set the `Secure` flag on the auth/CSRF cookies. FAIL-SAFE: default is
return process.env.NODE_ENV === "production"; * `true` (Secure) — a misconfigured/forgotten env can only ever make cookies MORE
* restrictive, never silently drop the flag.
*
* The previous gate keyed off `NODE_ENV === "production"`, which meant an appliance
* deployed without that var leaked cookies over plain HTTP. Now `Secure` is the
* default and is dropped ONLY for an explicit, deliberate opt-out — `COOKIE_SECURE`
* set to a falsey value (`0/false/no/off`), or the legacy `NODE_ENV !== production`
* signal kept as a fallback so existing dev setups still work over http://localhost.
*
* The parking appliance often serves the SPA same-origin over the LAN with no TLS;
* THAT box sets `COOKIE_SECURE=0` on purpose (a Secure cookie would never be sent
* over its http origin and would lock operators out). Everything else stays secure.
*/
export function secureCookies(): boolean {
const override = process.env.COOKIE_SECURE;
if (override !== undefined) {
return !/^(0|false|no|off)$/i.test(override.trim());
}
// No explicit override: secure unless this is an obvious local-dev run.
return process.env.NODE_ENV !== "development";
} }
export function newCsrfToken(): string { export function newCsrfToken(): string {
@@ -55,7 +86,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict", sameSite: "strict",
secure, secure,
path: "/", path: "/",
maxAge: TOKEN_TTL_SECONDS, maxAge: COOKIE_MAX_AGE_SECONDS,
}); });
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit). // Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
reply.setCookie(CSRF_COOKIE, csrf, { reply.setCookie(CSRF_COOKIE, csrf, {
@@ -63,7 +94,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict", sameSite: "strict",
secure, secure,
path: "/", path: "/",
maxAge: TOKEN_TTL_SECONDS, maxAge: COOKIE_MAX_AGE_SECONDS,
}); });
} }
@@ -90,17 +121,84 @@ function assertCsrf(req: FastifyRequest): void {
} }
} }
// --- Permission resolution + cache -------------------------------------------
// A role's permission set is read from `role_permissions` and cached in memory.
// SQLite is single-writer/single-process here, so a module-level Map is a correct
// cache: every role / role-permission mutation calls bumpPermsCache() to clear it,
// and the next request re-reads. The built-in `admin` role always resolves to the
// FULL permission set in code (never trusts the DB rows for it), so administration
// can't be accidentally narrowed.
const ADMIN_PERMS: ReadonlySet<Permission> = new Set(PERMISSIONS);
const permsCache = new Map<string, ReadonlySet<Permission>>();
// The DB handle the permission resolver reads from. Set ONCE at startup via
// initAuth() so route guards don't each have to thread `db` (several route
// modules only receive a monitor/service, not the db). Single-process server.
let authDb: Db | null = null;
/** Wire the permission resolver to the app's DB. Call once in buildServer(). */
export function initAuth(db: Db): void {
authDb = db;
permsCache.clear();
}
/** Clear the permission cache. Call after ANY write to roles / role_permissions
* (or a user's roleId) so the change takes effect on the next request. */
export function bumpPermsCache(): void {
permsCache.clear();
}
/** The permission set for a role id, cached. `admin` is always the full set. */
export function permissionsFor(roleId: string): ReadonlySet<Permission> {
if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS;
const hit = permsCache.get(roleId);
if (hit) return hit;
if (!authDb) throw new Error("auth not initialised (call initAuth)");
const rows = authDb
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, roleId))
.all();
const set = new Set(rows.map((r) => r.permission as Permission));
permsCache.set(roleId, set);
return set;
}
/** True if the role grants every listed permission. */
export function roleHasPermissions(
roleId: string,
required: readonly Permission[],
): boolean {
const granted = permissionsFor(roleId);
return required.every((p) => granted.has(p));
}
/** /**
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces * preHandler permission guard. Verifies the JWT (from the HttpOnly cookie),
* CSRF on mutations, then checks the role. Authorization is a simple per-route * enforces CSRF on mutations, then requires the user's role to grant ALL of the
* role check — no Casbin/RBAC engine needed at this scale. * listed permissions. Authorization is a per-route permission check against the
* dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale.
*/ */
export function requireRole(...allowed: Role[]) { export function requirePermission(...required: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => { return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify(); // reads the token cookie (configured in server.ts) await req.jwtVerify(); // reads the token cookie (configured in server.ts)
assertCsrf(req); assertCsrf(req);
if (!req.user || !allowed.includes(req.user.role)) { if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
}; };
} }
/**
* preHandler that requires a valid signed-in session but NO specific permission —
* for "about me" routes (/me, change own language) every authenticated user may
* call regardless of role. Still enforces CSRF on mutations.
*/
export async function requireAuth(
req: FastifyRequest,
_reply: FastifyReply,
): Promise<void> {
await req.jwtVerify();
assertCsrf(req);
}
+184
View File
@@ -0,0 +1,184 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import {
printWithFailover,
registry,
type PrinterDevice,
type PrinterInstance,
type ReceiptData,
type TicketHeader,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js";
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
// from the exit, the customer pays at the booth and walks a printed voucher to the
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
//
// This mirrors the entry flow's printer selection + header build, but prints on the
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
/** Park identity for the voucher header, from site_config (all fields optional). */
function ticketHeader(db: Db): TicketHeader | undefined {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
/** Build live printer instances for failover selection (entry direction covers the
* booth-receipt role too — the booth printer is configured on the entry side). */
function loadPrinters(db: Db): PrinterInstance[] {
const rows = devicesByDirection(db, "printer", "entry");
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** The receipt figures for a paid session, folded from the SIGNED ledger
* (authoritative). Null if there's no entry or no payment for this id — the
* caller should have validated paid + open before printing. */
function receiptFigures(
db: Db,
ticketId: string,
): Omit<ReceiptData, "voucher" | "header"> | null {
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, ticketId))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
let payment: (typeof rows)[number] | undefined;
for (const r of rows) if (r.type === "payment") payment = r;
if (!payment) return null;
const p = (payment.payload ?? {}) as {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
graceExitMin?: number;
};
return {
ticketId,
enteredAt: entry.occurredAt,
paidAt: payment.occurredAt,
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
currency: p.currency ?? "ALL",
tender: p.tender === "card" ? "card" : "cash",
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
};
}
/**
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
* to the entry dispenser). The receipt is the customer's transparency record:
* entry time, payment time, duration, amount + tender — folded from the signed
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
* walk-back grace, so the one slip both proves payment AND self-exits at a
* distant exit reader (this replaces the old barcode-only voucher). In standalone
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
* at the exit. Returns the id of the printer that printed it.
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
*/
export async function printPaymentReceipt(
db: Db,
ticketId: string,
opts: { voucher: boolean },
logger: FastifyBaseLogger,
): Promise<string> {
const figures = receiptFigures(db, ticketId);
if (!figures) {
throw new Error(`no paid session to receipt for ${ticketId}`);
}
const printers = loadPrinters(db);
const data: ReceiptData = {
...figures,
voucher: opts.voucher,
header: ticketHeader(db),
};
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReceipt(data),
);
logger.info(
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
);
return printedBy;
}
/**
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
* a scannable QR of the credential code + holder/validity, so the operator can hand
* it to the customer. Used on subscription creation and on a "reprint" action.
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
*/
export async function printSubscriptionCard(
db: Db,
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const data = {
code: card.code,
holderName: card.holderName ?? null,
validFrom: card.validFrom ?? null,
validTo: card.validTo ?? null,
header: ticketHeader(db),
};
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printSubscriptionCard(data),
);
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
/**
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
* the total is computed at the booth on settlement (early-entry AND any late-exit time
* combined). It just gives the subscriber paper proof that a fee is pending against this
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
* the caller swallows failures so a missing printer never blocks the barrier.
*/
export async function printWindowChargeNotice(
db: Db,
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printWindowChargeNotice({
occurrenceId: notice.occurrenceId,
holderName: notice.holderName ?? null,
at: notice.at,
edge: notice.edge,
windowOpensMin: notice.windowOpensMin ?? null,
header: ticketHeader(db),
}),
);
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
return printedBy;
}
+89
View File
@@ -0,0 +1,89 @@
// Credential capture ("enroll a card"): lets an operator present a physical RFID
// card/chip (or a QR) to ONE chosen reader and have its value captured for a
// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the
// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is
// never affected and keeps serving the live entry/exit flow.
//
// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next
// read from that armed reader is captured (NOT dispatched to the access flow — the
// barrier must not open for a card being enrolled) and capture auto-disarms. The
// booth form polls result() until the value appears (or it times out / is cancelled).
//
// In-memory + single-site single-writer (one booth) → no DB, no cross-process
// concerns. See wiki/entities/subscription.md.
const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000);
export type CaptureState =
| { status: "idle" }
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
| { status: "expired"; deviceId: string };
export class CredentialCapture {
#armedDeviceId: string | null = null;
#expiresAt = 0;
#captured: { deviceId: string; value: string; capturedAt: number } | null = null;
#lastExpiredDeviceId: string | null = null;
/** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any
* prior arming (only one capture at a time). Clears a stale captured/expired
* result so the form starts fresh. */
arm(deviceId: string): { expiresAt: number } {
this.#armedDeviceId = deviceId;
this.#expiresAt = Date.now() + CAPTURE_TTL_MS;
this.#captured = null;
this.#lastExpiredDeviceId = null;
return { expiresAt: this.#expiresAt };
}
/** Cancel any pending arming (operator closed the form / clicked cancel). */
cancel(): void {
this.#armedDeviceId = null;
this.#expiresAt = 0;
}
/**
* Called by the reader route on EVERY read. If this reader is the armed one (and
* not expired), capture the value, disarm, and return true → the caller must NOT
* dispatch this read to the access flow. Otherwise false → dispatch normally.
*/
tryConsume(deviceId: string, value: string): boolean {
if (this.#armedDeviceId == null) return false;
if (Date.now() > this.#expiresAt) {
// Window lapsed before a card was presented — disarm, mark expired.
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return false;
}
if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader
if (!value) return false;
this.#captured = { deviceId, value, capturedAt: Date.now() };
this.#armedDeviceId = null; // single-shot
this.#expiresAt = 0;
return true;
}
/** Current state for the booth form's poll. Lazily transitions armed→expired. */
state(): CaptureState {
if (this.#captured) return { status: "captured", ...this.#captured };
if (this.#armedDeviceId != null) {
if (Date.now() > this.#expiresAt) {
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return { status: "expired", deviceId: this.#lastExpiredDeviceId };
}
return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt };
}
if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId };
return { status: "idle" };
}
/** Clear a consumed/expired result once the form has read it. */
clear(): void {
this.#captured = null;
this.#lastExpiredDeviceId = null;
}
}
+110 -3
View File
@@ -1,5 +1,6 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices"; import type { PrinterStatus } from "@parking/devices";
import type { LedgerEventRow } from "@parking/db";
// Internal event bus for device-originated events (button presses, etc.). // Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry // Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -8,22 +9,83 @@ import type { PrinterStatus } from "@parking/devices";
export interface DeviceInputEvent { export interface DeviceInputEvent {
readonly driverId: string; // e.g. "dingtian" readonly driverId: string; // e.g. "dingtian"
readonly deviceId: string; // which configured device (lane_devices id) readonly deviceId: string; // which configured device (devices id)
readonly input: number; // 1-based input/channel readonly input: number; // 1-based input/channel
readonly edge: "on" | "off"; // active / inactive readonly edge: "on" | "off"; // active / inactive
readonly at: string; // ISO-8601 (server receive time) readonly at: string; // ISO-8601 (server receive time)
readonly source: "push" | "poll"; readonly source: "push" | "poll";
} }
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
// mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent {
readonly driverId: string;
readonly deviceId: string; // devices id of the reader/scanner/camera
readonly value: string; // the ticket id / plate / card number
readonly kind: "ticket" | "plate" | "qr" | "card";
readonly at: string; // ISO-8601
}
/**
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
*/
export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
readonly accepted: boolean;
/** Which way it went, when known (subscription/exit infer this). */
readonly direction?: "entry" | "exit";
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
readonly reason?: string;
}
/** A printer's status as tracked by the live monitor (status + identity). */ /** A printer's status as tracked by the live monitor (status + identity). */
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // lane_devices id readonly deviceId: string; // devices id
readonly lane: number;
readonly driverId: string; readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt readonly role?: string; // entry-dispenser | booth-receipt
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
/**
* The unified live status of ANY configured device — what the booth footer shows.
* Every enabled device is polled: printers via their rich `readStatus()`
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
* reachability probe. `state` is the common traffic-light; `detail` carries the
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
* and wiki/concepts/device-status-monitoring.md.
*/
export interface DeviceStatusEvent {
readonly deviceId: string; // devices id
readonly driverId: string;
readonly category: "access" | "reader" | "camera" | "printer" | "vision";
/**
* The device's ROLE descriptor for the footer label — NOT the vendor. A
* direction-style token the client localises and pairs with the category, so the
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
* - undetermined: null (chip shows the category alone)
*/
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
readonly state: "ready" | "degraded" | "offline";
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
}
/** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free"
* the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a
* gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle
* `active` event; it auto-clears to "free" after a timeout (this camera class sends
* no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */
export interface LaneStatusEvent {
readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity)
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
}
class DeviceEventBus extends EventEmitter { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -33,6 +95,15 @@ class DeviceEventBus extends EventEmitter {
return () => this.off("input", cb); return () => this.off("input", cb);
} }
/** A credential read (ticket scan, plate, card). */
emitRead(event: DeviceReadEvent): void {
this.emit("read", event);
}
onRead(cb: (event: DeviceReadEvent) => void): () => void {
this.on("read", cb);
return () => this.off("read", cb);
}
/** Emitted by the printer monitor whenever a printer's status CHANGES. */ /** Emitted by the printer monitor whenever a printer's status CHANGES. */
emitPrinterStatus(event: PrinterStatusEvent): void { emitPrinterStatus(event: PrinterStatusEvent): void {
this.emit("printer-status", event); this.emit("printer-status", event);
@@ -41,6 +112,42 @@ class DeviceEventBus extends EventEmitter {
this.on("printer-status", cb); this.on("printer-status", cb);
return () => this.off("printer-status", cb); return () => this.off("printer-status", cb);
} }
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
* (all categories — relays, readers, cameras, printers). Drives the booth
* device-status footer over the WS. */
emitDeviceStatus(event: DeviceStatusEvent): void {
this.emit("device-status", event);
}
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
this.on("device-status", cb);
return () => this.off("device-status", cb);
}
/**
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
* payment, void, …). The payload is the persisted row — business facts only, no
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
* This is a read-side notification ONLY: it never feeds back into append/sign/
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
*/
emitLedger(event: LedgerEventRow): void {
this.emit("ledger", event);
}
onLedger(cb: (event: LedgerEventRow) => void): () => void {
this.on("ledger", cb);
return () => this.off("ledger", cb);
}
/** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle
* detection). Drives the booth's barrier lights. Advisory only. */
emitLaneStatus(event: LaneStatusEvent): void {
this.emit("lane-status", event);
}
onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void {
this.on("lane-status", cb);
return () => this.off("lane-status", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+192
View File
@@ -0,0 +1,192 @@
import type { FastifyBaseLogger } from "fastify";
import { devices, type Db, type DeviceRow } from "@parking/db";
import { isMonitorable, registry } 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";
/** Synthetic device id for the vision service in the status footer (it's a service,
* not a device row, but shares the footer's traffic-light + WS plumbing). */
const VISION_STATUS_ID = "vision-service";
// Unified live DEVICE monitor — the source for the booth's device-status footer.
// Every enabled, configured device is probed on an interval, regardless of
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
// generic healthCheck() reachability probe every Device implements. The result is
// flattened to a common traffic-light (ready | degraded | offline) + a detail
// string, cached per device id, and emitted on the bus ONLY when it changes.
//
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
// and read-only — polling a device never drives a relay or mutates the ledger.
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
/**
* 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)
*/
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) {
case "reader":
case "camera": {
const d = directionOf(db, row); // entry | exit | both
return d;
}
case "access": {
const dirs = new Set(relaysOf(row).map((r) => r.direction));
if (dirs.size === 0) return null;
if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both
return only ?? null;
}
case "printer": {
const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane";
return null;
}
default:
return null;
}
}
export class DeviceMonitor {
readonly #db: Db;
readonly #log: FastifyBaseLogger;
readonly #pollMs: number;
/** Latest unified status per device id. */
readonly #latest = new Map<string, DeviceStatusEvent>();
#timer: ReturnType<typeof setInterval> | null = null;
#ticking = false;
/** Optional: the vision service client. When present + enabled, the monitor probes
* its /health each tick and shows it as a "vision" chip in the footer. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#pollMs = pollMs;
this.#vision = vision;
}
/** Begin polling. Idempotent. */
start(): void {
if (this.#timer) return;
void this.#tick(); // immediate first pass so the footer fills without a wait
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
this.#timer.unref?.();
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
}
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
/** Current snapshot for the API / a freshly-connected WS client. */
snapshot(): DeviceStatusEvent[] {
return [...this.#latest.values()];
}
async #tick(): Promise<void> {
if (this.#ticking) return; // never overlap polls
this.#ticking = true;
try {
// Re-read the device set each tick so a newly-assigned/removed device is
// picked up without a restart.
const rows = await this.#db.select().from(devices).all();
const enabled = rows.filter((r) => r.enabled);
const present = new Set(enabled.map((r) => r.id));
// The vision service is a pseudo-device — keep it in the present set when enabled
// so the cleanup below doesn't evict it.
if (this.#vision?.enabled) present.add(VISION_STATUS_ID);
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
for (const id of [...this.#latest.keys()]) {
if (!present.has(id)) this.#latest.delete(id);
}
await Promise.all([...enabled.map((r) => this.#poll(r)), this.#pollVision()]);
} catch (err) {
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
} finally {
this.#ticking = false;
}
}
async #poll(row: DeviceRow): Promise<void> {
const cfg = (row.config ?? {}) as Record<string, unknown>;
const base = {
deviceId: row.id,
driverId: row.driverId,
category: row.category,
roleKind: roleKindOf(this.#db, row),
};
let next: DeviceStatusEvent;
const driver = registry.get(row.driverId);
if (!driver) {
// Configured against a driver that's no longer registered — surface it,
// don't silently hide it.
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
} else {
try {
const device = driver.create(cfg as never);
// Printers expose richer paper/cover/cutter status; everything else uses
// the generic reachability probe. Both flatten to the same traffic-light.
if (isMonitorable(device)) {
const s = await device.readStatus();
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
} else {
const h = await device.healthCheck();
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
}
} catch (err) {
// A probe that throws (build error, timeout) reads as offline — never crash
// the tick, and fail toward "there's a problem" rather than false-healthy.
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
}
}
this.#publish(row.id, next);
}
/** Probe the vision service /health and publish it as a "vision" footer chip. Skipped
* entirely when no client is wired or it's disabled (no chip then). */
async #pollVision(): Promise<void> {
if (!this.#vision?.enabled) return;
const h = await this.#vision.health();
const state: DeviceStatusEvent["state"] = h.ok && h.ready ? "ready" : h.ready ? "degraded" : "offline";
this.#publish(VISION_STATUS_ID, {
deviceId: VISION_STATUS_ID,
driverId: "vision",
category: "vision",
roleKind: null,
state,
detail: h.ready ? h.recognizer : (h.detail ?? "not ready"),
checkedAt: new Date().toISOString(),
});
}
/** Cache + emit a status, but only when it CHANGED (state or detail). */
#publish(id: string, next: DeviceStatusEvent): void {
const prev = this.#latest.get(id);
this.#latest.set(id, next);
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
this.#log.info(
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
);
deviceEvents.emitDeviceStatus(next);
}
}
}
+201
View File
@@ -0,0 +1,201 @@
import { and, eq, devices, type Db, type DeviceRow } from "@parking/db";
// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one
// pool with a flexible set of entry/exit points. Direction lives on each RELAY
// inside an access controller, and readers/cameras BIND to a (controller, relay).
// See wiki/concepts/entry-exit-points.md.
/** A flow direction. "both" = one relay/barrier serving entry AND exit. */
export type Direction = "entry" | "exit" | "both";
/** A concrete flow a credential/button drives (never "both"). */
export type FlowDirection = "entry" | "exit";
/** One relay on an access controller: which barrier it opens, in which direction,
* and (optionally) the input terminals its entry button + presence loop are wired to. */
export interface RelaySpec {
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
readonly relay: number;
readonly direction: Direction;
/** 1-based input terminal of the entry button that fires this relay (transient
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
readonly button?: number;
/**
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
* Two modes, chosen by what barrier feedback exists at this lane:
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
* 1-based input terminal of an induction loop / barrier presence signal on THIS
* controller. A press prints only while a car is present, and no second ticket
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
* makes one-car-one-ticket physical.
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
*/
readonly presenceInput?: number;
readonly entryCooldownSec?: number;
}
/** Access controller config (the `relays[]` map + connection fields). */
interface AccessConfig {
readonly relays?: RelaySpec[];
readonly [k: string]: unknown;
}
/** Reader/camera config: optional binding to a controller relay. */
interface BoundConfig {
/** The access `devices.id` this reader/camera sits at. */
readonly controllerId?: string;
/** The relay on that controller it opens. */
readonly relay?: number;
/** Fallback direction when not bound to a relay. */
readonly direction?: Direction;
readonly [k: string]: unknown;
}
/** A resolved barrier: the controller row + the specific relay to pulse. Carries the
* transient-entry anti-double-press config (presence loop / cooldown) when resolved
* from a button press, so the entry flow can enforce one-car-one-ticket. */
export interface ResolvedRelay {
readonly controller: DeviceRow;
readonly relay: number;
readonly direction: Direction;
/** 1-based presence-loop input gating this relay's entry (when wired). */
readonly presenceInput?: number;
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
readonly entryCooldownSec?: number;
}
/** All enabled access controller rows. */
function accessRows(db: Db): DeviceRow[] {
return db
.select()
.from(devices)
.where(eq(devices.category, "access"))
.all()
.filter((r) => r.enabled);
}
/** The relay specs declared on an access controller (defaults to none). */
export function relaysOf(row: DeviceRow): RelaySpec[] {
const cfg = row.config as AccessConfig;
return Array.isArray(cfg.relays) ? cfg.relays : [];
}
/**
* Resolve a button press to the relay it fires: the access controller with this
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
*/
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const spec = relaysOf(row).find((r) => r.button === terminal);
if (!spec) return null;
if (spec.direction !== "entry" && spec.direction !== "both") return null;
return {
controller: row,
relay: spec.relay,
direction: spec.direction,
presenceInput: spec.presenceInput,
entryCooldownSec: spec.entryCooldownSec,
};
}
/**
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
*/
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
if (!spec) return null;
if (spec.direction !== "entry" && spec.direction !== "both") return null;
return { controller: row, relay: spec.relay, direction: spec.direction };
}
/**
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
* spec. Fallback (unbound): the device's config.direction + the first relay site-
* wide matching that direction — keeps the single-barrier case trivial. Null if
* nothing resolves (no barrier to open).
*/
export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null {
const cfg = deviceRow.config as BoundConfig;
// Bound: follow controllerId + relay to the exact barrier.
if (cfg.controllerId && typeof cfg.relay === "number") {
const controller = db
.select()
.from(devices)
.where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access")))
.get();
if (controller && controller.enabled) {
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
}
return null;
}
// Unbound: fall back to the device's declared direction + first matching relay.
const want = cfg.direction;
if (want === "entry" || want === "exit" || want === "both") {
return firstRelayByDirection(db, want === "both" ? "entry" : want);
}
return null;
}
/**
* The first relay site-wide serving a direction ("both" relays match either).
* Used as the unbound fallback and where a flow only needs "an exit barrier".
*/
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
for (const controller of accessRows(db)) {
const spec = relaysOf(controller).find(
(r) => r.direction === direction || r.direction === "both",
);
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
}
return null;
}
/** Enabled devices of a category whose direction matches `want` (or is "both").
* Direction is inherited from each device's bound relay, else its config fallback.
* Used for snapshots: every entry/exit camera fires on an entry/exit. */
export function devicesByDirection(
db: Db,
category: DeviceRow["category"],
want: FlowDirection,
): DeviceRow[] {
return db
.select()
.from(devices)
.where(eq(devices.category, category))
.all()
.filter((r) => {
if (!r.enabled) return false;
const d = directionOf(db, r);
return d === want || d === "both";
});
}
/** The direction a reader/camera operates in (inherited from its bound relay, or
* its config fallback). "both" when undetermined → the flow infers. */
export function directionOf(db: Db, deviceRow: DeviceRow): Direction {
const resolved = relayForDevice(db, deviceRow);
if (resolved) return resolved.direction;
const cfg = deviceRow.config as BoundConfig;
return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both";
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { validateTicketCode } from "./entry-flow.js";
// validateTicketCode is the manual-entry typo guard: an all-digit code whose last digit
// is the Luhn check of the rest. The booth uses it to reject a mistyped ticket up front
// (instead of a confusing "session not found"). The capacity-gate / print-hold / sign-
// before-open paths of EntryFlow need device fakes and are exercised in the device +
// route phases; here we pin the pure, exported checksum contract.
describe("validateTicketCode (Luhn)", () => {
it("accepts a well-formed 11-digit id", () => {
// 10-digit body + its Luhn check digit. 0000000000 → check digit 0.
expect(validateTicketCode("00000000000")).toBe(true);
});
it("rejects a single-digit typo", () => {
expect(validateTicketCode("00000000000")).toBe(true);
expect(validateTicketCode("00000000010")).toBe(false); // flipped a digit, checksum now wrong
});
it("rejects non-digit and out-of-length strings", () => {
expect(validateTicketCode("abc")).toBe(false);
expect(validateTicketCode("123")).toBe(false); // too short
expect(validateTicketCode("123456789012345")).toBe(false); // too long
expect(validateTicketCode("")).toBe(false);
});
it("round-trips a generated body+check (Luhn is self-consistent)", () => {
// Construct a valid code: pick a body, compute its check the same way the issuer does.
const body = "4992739871";
// brute the check digit 0..9 — exactly one makes a valid code.
const valid = Array.from({ length: 10 }, (_, d) => body + d).filter(validateTicketCode);
expect(valid).toHaveLength(1);
});
it("accepts a legacy 13-digit id shape", () => {
// 12-digit body 000000000000 → check 0; the validator is length-agnostic in 10..14.
expect(validateTicketCode("0000000000000")).toBe(true);
});
});
+426
View File
@@ -0,0 +1,426 @@
import { randomInt, randomUUID } from "node:crypto";
import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
import {
NoPrinterAvailableError,
printWithFailover,
registry,
type AccessControlDevice,
type PrinterDevice,
type PrinterInstance,
type TicketData,
type TicketHeader,
} from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
// → open the barrier. The button is wired into an access controller's input; the
// admin maps that input terminal to a relay (config.relays[].button), so a press
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
//
// Two invariants from the threat model + safety analysis:
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
// signal (wiki/concepts/append-only-event-chain.md).
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
// unprinted) and leave the barrier closed; the operator handles the held car.
// Crucially, NO vehicle_entry is written in that case — we never record an
// "entered" event for a car that didn't get in (decision 2026-06-15).
//
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
// (fail) sign anomaly, stop.
//
// ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held
// or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry
// (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The
// guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what
// barrier feedback exists at the lane:
// - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A
// press prints only while a car is present, and NO second ticket issues until the
// loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's
// input edges to track presence + "armed" per relay.
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
// See wiki/concepts/entry-double-press.md.
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
interface RelayGuardState {
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
lastTicketAt: number;
/** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */
present: boolean;
/** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket
* prints; re-armed when the loop CLEARS (the car drove through). */
armed: boolean;
}
export class EntryFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
/** Guard against double-fire from the same physical press (on edge only). */
readonly #inFlight = new Set<string>();
/** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */
readonly #guard = new Map<string, RelayGuardState>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/** Handle a device input edge. Two kinds of edge matter to this flow:
* (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay
* anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update
* presence state so the guard knows when a car arrives/leaves. The same physical
* input is never both, so we resolve each independently. */
async onInput(e: DeviceInputEvent): Promise<void> {
// Presence-loop edge (both directions matter): keep the per-relay state current.
const presence = relayForPresence(this.#db, e.deviceId, e.input);
if (presence) {
this.#onPresenceEdge(presence, e.edge);
return; // a loop input is not a button — nothing else to do
}
if (e.edge !== "on") return; // for buttons, the release edge is just telemetry
// The firing device must be an access controller, and the pressed input terminal
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
const resolved = relayForButton(this.#db, e.deviceId, e.input);
if (!resolved) return;
// ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown)
const suppressed = this.#suppressReason(resolved);
if (suppressed) {
this.#recordSuppressedPress(e, resolved, suppressed);
this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`);
return;
}
const key = `${e.deviceId}:${e.input}`;
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
this.#inFlight.add(key);
try {
await this.#runEntry(resolved);
} catch (err) {
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
} finally {
this.#inFlight.delete(key);
}
}
/** Stable per-relay key for the guard map. */
#relayKey(r: ResolvedRelay): string {
return `${r.controller.id}:${r.relay}`;
}
/** Lazily get (or create) the guard state for a relay. New relays start ARMED and
* with no car present, so the first press on a fresh lane works immediately. */
#guardState(r: ResolvedRelay): RelayGuardState {
const key = this.#relayKey(r);
let s = this.#guard.get(key);
if (!s) {
s = { lastTicketAt: 0, present: false, armed: true };
this.#guard.set(key, s);
}
return s;
}
/** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing;
* the car LEAVING the loop (after its entry) re-arms for the NEXT car. */
#onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void {
const s = this.#guardState(r);
if (edge === "on") {
s.present = true; // a vehicle is at the barrier
} else {
// Loop cleared: the car drove through (or backed off). Re-arm for the next car —
// this is the gate that makes a *new* car necessary before another ticket.
s.present = false;
s.armed = true;
}
}
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
* 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. */
#suppressReason(r: ResolvedRelay): string | null {
const s = this.#guardState(r);
if (typeof r.presenceInput === "number") {
// 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;
}
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
const elapsed = Date.now() - s.lastTicketAt;
if (elapsed < r.entryCooldownSec * 1000) {
const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000);
return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`;
}
}
return null;
}
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: e.deviceId,
category: "access",
kind: "input",
detail: {
driverId: e.driverId,
input: e.input,
edge: e.edge,
entrySuppressed: true,
relay: r.relay,
reason,
},
occurredAt: e.at,
})
.run();
} catch (err) {
this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`);
}
}
async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
// they aren't locked out. "Full" is a soft policy seam for valet over-
// capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db);
if (occ.full) {
// No ticket id exists for a refused entry, so mint a synthetic ref to key the
// anomaly + its evidence snapshot together. The operator wants the photo of WHO
// was turned away (a fraud/dispute signal), so we still fire the entry camera.
const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
await this.#log.append({
type: "anomaly",
identity: refusedRef,
payload: {
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
entryRefused: true,
full: true,
},
});
this.#fireSnapshot("entry", refusedRef);
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
return;
}
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
const printers = this.#loadPrinters();
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket),
);
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
// ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm +
// stamp the cooldown so a repeat press (held button / mashing) issues no second
// ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode
// re-allows after entryCooldownSec. Done on the print success, NOT the open.
const guard = this.#guardState(resolved);
guard.lastTicketAt = Date.now();
guard.armed = false;
} catch (err) {
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
// failed attempt is in the tamper-evident record for the operator.
const reason =
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
await this.#log.append({
type: "anomaly",
identity: ticketId,
payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
});
// Capture who is held at the barrier (evidence for the operator handling the car).
this.#fireSnapshot("entry", ticketId);
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return;
}
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
// `category` is FROZEN here (in the signed payload) so the tariff prices and
// later reprices the same way at exit. Today every transient takes the SITE
// default category (operator policy, site_config.default_vehicle_category;
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
// is the future seam — source it from `resolved` then. A V1/no-category tariff
// ignores it; only V2 category cards consult it.
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const category =
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
? cfg.defaultVehicleCategory
: DEFAULT_VEHICLE_CATEGORY;
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: "ticket",
identity: ticketId,
payload: { sessionRef: ticketId, ticketPrinted: true, category },
occurredAt: issuedAt,
});
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
// a camera failure must not delay or block the already-open barrier).
this.#fireSnapshot("entry", ticketId);
// 4. Update the session projection cache (rebuildable from the ledger; this is
// just a fast read-model, never the source of truth).
try {
this.#db
.insert(sessions)
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
.run();
} catch (err) {
// Cache miss is non-fatal — the ledger is authoritative and the projection
// can be rebuilt. Log it; don't fail the (already-open) entry.
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
}
}
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
* held car is exactly when the operator wants the photo. */
#fireSnapshot(direction: "entry", identity: string): void {
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
);
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
/** Build live ENTRY printer instances (for failover selection). */
#loadPrinters(): PrinterInstance[] {
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** Park identity for the ticket header, from site_config (all fields optional;
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
#ticketHeader(): TicketHeader | undefined {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
}
/**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
*
* Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles.
*
* Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
* Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
* The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of
* failing as "session not found".
*/
function newTicketId(): string {
let body = "";
for (let i = 0; i < 10; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body);
}
/** The Luhn (mod-10) check digit for an all-digit string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Walk right-to-left; the check digit sits at position 0 from the right, so the
// last body digit is an "even" position that gets doubled.
let double = true;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return String((10 - (sum % 10)) % 10);
}
/**
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
* Lets a manual-entry path (operator types the code off the ticket when readers are
* down) reject a typo up front. A scanned/looked-up id that predates this format
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
* never reject an id that already exists in the ledger. See ticket-encoding.md.
*/
export function validateTicketCode(code: string): boolean {
// Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
// Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
// (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
if (!/^\d{10,14}$/.test(code)) return false;
const body = code.slice(0, -1);
return luhnCheckDigit(body) === code[code.length - 1];
}
+88
View File
@@ -0,0 +1,88 @@
import { eq, subscriptions, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
// We resolve them when serializing an event for the API / WS feed — never on the
// signed record itself.
//
// Today: a subscription occurrence's identity is an opaque `SUBSESS-…` key. The human
// who matters is the subscription HOLDER, whose name lives on the subscriptions row
// (mutable master data — NOT signed into the event). We resolve payload.permitId →
// holder_name so the feed reads "Aqif Kopertoni" rather than "SUBSESS-08cd1c52e219".
/** Fallback label when a subscription has no holder name (or was deleted). Matches the
* i18n key `booth.subscriberFallback`; kept here in English for the API/log layer. */
const SUBSCRIBER_FALLBACK = "Subscriber";
/** Tiny holder-name cache. Single-writer SQLite; a subscription rename is rare and the
* feed is not security-sensitive, so a short-lived cache is plenty. Invalidate by
* process lifetime — restart picks up renames; for live correctness the lookup is
* cheap enough that we just read per miss. */
const holderCache = new Map<string, string | null>();
/** Resolve a subscription id to its holder name (or null), memoized. */
function holderName(db: Db, permitId: string): string | null {
if (holderCache.has(permitId)) return holderCache.get(permitId) ?? null;
const row = db
.select({ holderName: subscriptions.holderName })
.from(subscriptions)
.where(eq(subscriptions.id, permitId))
.get();
const name = row?.holderName?.trim() || null;
holderCache.set(permitId, name);
return name;
}
/** Drop a cached holder name (call after a subscription create/update/delete). */
export function invalidateHolder(permitId: string): void {
holderCache.delete(permitId);
}
/** Clear the whole holder cache (call on bulk subscription changes). */
export function clearHolderCache(): void {
holderCache.clear();
}
/**
* Attach read-time display fields to a raw ledger row before it goes to a client:
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
* - `plate` for an entry/exit event whose session has an advisory ANPR read.
* Idempotent and cheap; events without either pass through unchanged. Used by the WS
* feed (per event). For the bulk feed page prefer `enrichEvents` (one plate scan).
*/
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
let out: T = event;
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if ((event.type === "vehicle_entry" || event.type === "vehicle_exit") && event.identity) {
const p = plateForIdentity(db, event.identity);
if (p) out = { ...out, plate: p.plate };
}
return out;
}
/**
* Bulk variant for the feed page: enriches a list of events with subscriber labels AND
* plates using a SINGLE device_events scan for all the plates (instead of one per row).
* Order preserved.
*/
export function enrichEvents<T extends LedgerEvent>(db: Db, events: T[]): T[] {
// Collect identities of entry/exit events to resolve their plates in one scan.
const wanted = new Set<string>();
for (const e of events) {
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) wanted.add(e.identity);
}
const plates = wanted.size ? platesForIdentities(db, wanted) : new Map();
return events.map((e) => {
let out: T = e;
const permitId = e.payload && typeof e.payload.permitId === "string" ? e.payload.permitId : null;
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) {
const p = plates.get(e.identity);
if (p) out = { ...out, plate: p.plate };
}
return out;
});
}
+129
View File
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { EventLog, canonicalize, hashEvent } from "./event-log.js";
import { SoftwareSigner, buildVerifier } from "./signer.js";
// The append-only, hash-chained, signed event log is THE anti-fraud primitive
// (threat model: the operator at the booth). These tests pin every integrity rule:
// monotonic index, prevHash linkage, payload-in-signature, and that verifyChain()
// catches each class of tamper (content edit, reorder, deletion gap, forged sig,
// missing key). No live DB is touched — a fresh in-memory SQLite per test.
const SECRET = "test-event-signing-key-0123456789";
let db: Db;
let close: () => void;
let log: EventLog;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
});
afterEach(() => close());
describe("EventLog.append — chain construction", () => {
it("assigns a monotonic index starting at 1", async () => {
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
expect(a.index).toBe(1);
expect(b.index).toBe(2);
});
it("genesis event has a null prevHash; the next chains to it", async () => {
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
expect(a.prevHash).toBeNull();
expect(b.prevHash).toBe(hashEvent(canonicalize(a)));
});
it("signs each row under the active keyId", async () => {
const row = await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 100 } });
expect(row.keyId).toBe("sw-hmac-v2");
expect(new SoftwareSigner(SECRET).verify(canonicalize(row), row.signature)).toBe(true);
});
it("serializes concurrent appends without index collisions", async () => {
const rows = await Promise.all(
Array.from({ length: 25 }, (_, i) => log.append({ type: "vehicle_entry", identity: `T${i}` })),
);
const indices = rows.map((r) => r.index).sort((a, b) => a - b);
expect(indices).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
});
});
describe("EventLog.verifyChain — integrity", () => {
async function seed() {
await log.append({ type: "vehicle_entry", identity: "T1", direction: "entry" });
await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 200, tariffVersionId: "tv1" } });
await log.append({ type: "vehicle_exit", identity: "T1", direction: "exit" });
}
it("accepts an untampered chain", async () => {
await seed();
expect(log.verifyChain()).toEqual({ ok: true });
});
it("accepts an empty chain", () => {
expect(log.verifyChain()).toEqual({ ok: true });
});
it("detects a tampered payload (the money amount)", async () => {
await seed();
// Rewrite the payment amount directly in the DB — exactly the booth-operator
// fraud the signed payload defends against.
db.update(ledgerEvents).set({ payload: { amountMinor: 1, tariffVersionId: "tv1" } }).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.index).toBe(2);
expect(r.reason).toMatch(/signature invalid/);
}
});
it("detects a deleted row as an index gap", async () => {
await seed();
db.delete(ledgerEvents).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/index gap/);
});
it("detects a broken prevHash link (reordering / re-chaining)", async () => {
await seed();
db.update(ledgerEvents).set({ prevHash: "0".repeat(64) }).where(eq(ledgerEvents.index, 3)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.index).toBe(3);
expect(r.reason).toMatch(/prevHash/);
}
});
it("detects an event signed under a key that is no longer configured", async () => {
await seed();
// Re-sign row 2 under an unknown keyId — buildVerifier can't resolve it.
db.update(ledgerEvents).set({ keyId: "atecc608-slot9" }).where(eq(ledgerEvents.index, 2)).run();
const r = log.verifyChain();
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/no signer for keyId/);
});
});
describe("canonicalize — byte-stability", () => {
it("is independent of payload key order (sorted recursively)", () => {
const base = { index: 1, type: "payment", direction: null, source: null, identity: "T1", occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
const a = canonicalize({ ...base, payload: { amountMinor: 100, tariffVersionId: "tv1" } });
const b = canonicalize({ ...base, payload: { tariffVersionId: "tv1", amountMinor: 100 } });
expect(a).toBe(b);
});
it("changes when any signed field changes", () => {
const base = { index: 1, type: "payment" as const, direction: null, source: null, identity: "T1", payload: { amountMinor: 100 }, occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, payload: { amountMinor: 101 } }));
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, identity: "T2" }));
});
});
+85 -19
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { desc, events, type Db, type EventRow } from "@parking/db"; import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared"; import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
// The append-only, hash-chained, signed event log — the system's core anti-fraud // The append-only, hash-chained, signed event log — the system's core anti-fraud
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device // primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
@@ -16,11 +16,12 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
// so we guard it with an in-process async lock as well. // so we guard it with an in-process async lock as well.
export interface AppendInput { export interface AppendInput {
readonly type: ParkingEventType; readonly type: LedgerEventType;
readonly lane: number;
readonly direction?: Direction | null; readonly direction?: Direction | null;
readonly source?: IdentitySource | null; readonly source?: IdentitySource | null;
readonly identity?: string | null; readonly identity?: string | null;
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
readonly payload?: LedgerPayload | null;
/** Event time (ISO-8601). Defaults to now. */ /** Event time (ISO-8601). Defaults to now. */
readonly occurredAt?: string; readonly occurredAt?: string;
} }
@@ -36,9 +37,9 @@ export function canonicalize(e: {
index: number; index: number;
type: string; type: string;
direction: string | null; direction: string | null;
lane: number;
source: string | null; source: string | null;
identity: string | null; identity: string | null;
payload: Record<string, unknown> | null;
occurredAt: string; occurredAt: string;
prevHash: string | null; prevHash: string | null;
}): string { }): string {
@@ -46,57 +47,107 @@ export function canonicalize(e: {
e.index, e.index,
e.type, e.type,
e.direction ?? null, e.direction ?? null,
e.lane,
e.source ?? null, e.source ?? null,
e.identity ?? null, e.identity ?? null,
// Payload is part of the signed form so business data is tamper-evident.
// Serialize with sorted keys for byte-stability (object key order must not
// change a signature). null when the event type carries no payload.
canonicalPayload(e.payload),
e.occurredAt, e.occurredAt,
e.prevHash ?? null, e.prevHash ?? null,
]); ]);
} }
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
if (p == null) return null;
const sort = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sort);
if (v && typeof v === "object") {
return Object.keys(v as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((o, k) => {
o[k] = sort((v as Record<string, unknown>)[k]);
return o;
}, {});
}
return v;
};
return sort(p);
}
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */ /** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
export function hashEvent(canonical: string): string { export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex"); return createHash("sha256").update(canonical, "utf8").digest("hex");
} }
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
* Returns undefined when the key that signed an event is not available. */
export type SignerResolver = (keyId: string) => Signer | undefined;
export class EventLog { export class EventLog {
readonly #db: Db; readonly #db: Db;
readonly #signer: Signer; readonly #signer: Signer;
/** Picks the verifying signer per event keyId; lets a chain span key rotations
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
* callers that don't pass one (single-key chains, tests). */
readonly #resolveVerifier: SignerResolver;
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
* to fan the event out to live booth clients (WS). It is best-effort and must
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
readonly #onAppended?: (row: LedgerEventRow) => void;
/** Serialize appends: each waits for the previous to finish. */ /** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve(); #tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) { constructor(
db: Db,
signer: Signer,
resolveVerifier?: SignerResolver,
onAppended?: (row: LedgerEventRow) => void,
) {
this.#db = db; this.#db = db;
this.#signer = signer; this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
this.#onAppended = onAppended;
} }
/** Append one event to the chain. Returns the persisted row. Serialized. */ /** Append one event to the chain. Returns the persisted row. Serialized. */
append(input: AppendInput): Promise<EventRow> { append(input: AppendInput): Promise<LedgerEventRow> {
const run = this.#tail.then(() => this.#appendNow(input)); const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock). // Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined); this.#tail = run.catch(() => undefined);
return run; // Read-side notification, AFTER the row is durably written. Wrapped so a
// failing sink can never reject the append or break the chain lock above.
return run.then((row) => {
try {
this.#onAppended?.(row);
} catch {
// best-effort fan-out only — swallow.
}
return row;
});
} }
#appendNow(input: AppendInput): EventRow { #appendNow(input: AppendInput): LedgerEventRow {
const prev = this.#db const prev = this.#db
.select() .select()
.from(events) .from(ledgerEvents)
.orderBy(desc(events.index)) .orderBy(desc(ledgerEvents.index))
.limit(1) .limit(1)
.get(); .get();
const index = (prev?.index ?? 0) + 1; const index = (prev?.index ?? 0) + 1;
const prevHash = prev ? hashEvent(canonicalize(prev)) : null; const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
const occurredAt = input.occurredAt ?? new Date().toISOString(); const occurredAt = input.occurredAt ?? new Date().toISOString();
const payload = input.payload ?? null;
const canonical = canonicalize({ const canonical = canonicalize({
index, index,
type: input.type, type: input.type,
direction: input.direction ?? null, direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload,
occurredAt, occurredAt,
prevHash, prevHash,
}); });
@@ -106,26 +157,33 @@ export class EventLog {
index, index,
type: input.type, type: input.type,
direction: input.direction ?? null, direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload,
occurredAt, occurredAt,
prevHash, prevHash,
signature: this.#signer.sign(canonical), signature: this.#signer.sign(canonical),
keyId: this.#signer.keyId,
}; };
this.#db.insert(events).values(row).run(); this.#db.insert(ledgerEvents).values(row).run();
return row as EventRow; return row as LedgerEventRow;
} }
/** /**
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the * Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an * first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted * integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature. * row (index gap), a forged/invalid signature, and an event signed under a key
* that is no longer configured.
*
* Each row is verified against the signer for ITS OWN `keyId`, not the current
* append signer — so a chain that spans a key rotation (e.g. early events under
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
* verifies end to end. See signer.buildVerifier.
*/ */
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(events).orderBy(events.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
let expectedIndex = 1; let expectedIndex = 1;
let prevHash: string | null = null; let prevHash: string | null = null;
for (const row of rows) { for (const row of rows) {
@@ -135,8 +193,16 @@ export class EventLog {
if ((row.prevHash ?? null) !== prevHash) { if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" }; return { ok: false, index: row.index, reason: "prevHash does not match chain" };
} }
const verifier = this.#resolveVerifier(row.keyId);
if (!verifier) {
return {
ok: false,
index: row.index,
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
};
}
const canonical = canonicalize(row); const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) { if (!verifier.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" }; return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
} }
prevHash = hashEvent(canonical); prevHash = hashEvent(canonical);
+118
View File
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
// The exit flow is the anti-fraud GATE: no car leaves without a covering payment within
// the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has
// no bypass. With no relay configured a clean exit returns { opened:false } — we assert
// the DECISION (refuse vs. sign the exit), not the hardware open.
let db: Db;
let close: () => void;
let log: EventLog;
let exit: ExitFlow;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
exit = new ExitFlow(db, log, silentLogger());
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
}
function exitsSigned(identity: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
}
describe("exitForBooth — refusal gates", () => {
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
const r = await exit.exitForBooth("ghost");
expect(r).toMatchObject({ ok: false, status: "no_session" });
const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all();
expect(anomalies).toHaveLength(1);
expect(exitsSigned("ghost")).toHaveLength(0);
});
it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const r = await exit.exitForBooth("T1");
expect(r).toMatchObject({ ok: false, status: "unpaid" });
expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave
});
it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(200));
// A payment made 60 min ago → its 15-min walk-back grace lapsed long ago.
await log.append({
type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60),
payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 },
});
const r = await exit.exitForBooth("T1");
expect(r).toMatchObject({ ok: false, status: "grace_expired" });
expect(exitsSigned("T1")).toHaveLength(0);
});
});
describe("exitForBooth — valid exit signs the vehicle_exit", () => {
it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
await pay.pay("T1", "cash"); // fresh payment → within grace
const r = await exit.exitForBooth("T1");
expect(r.ok).toBe(true);
if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests
expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain
expect(log.verifyChain()).toEqual({ ok: true });
});
// NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential
// path), not exitForBooth — the booth's transient exit has no subscription bypass and
// applies the same paid/grace gate to any identity it's handed. Asserting that here so
// the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a
// subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead.
it("does NOT give the booth transient-exit path a subscription bypass", async () => {
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
const r = await exit.exitForBooth("SUBSESS-1");
expect(r).toMatchObject({ ok: false, status: "unpaid" });
expect(exitsSigned("SUBSESS-1")).toHaveLength(0);
});
it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => {
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
const r = await exit.reopenBarrier("SUBSESS-1", "op1");
expect(r.ok).toBe(true);
expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence
});
});
describe("reopenBarrier — no unpaid re-open", () => {
it("refuses to re-open an unpaid transient session", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const r = await exit.reopenBarrier("T1", "op1");
expect(r.ok).toBe(false);
expect(exitsSigned("T1")).toHaveLength(0);
});
it("re-opening a paid OPEN session also closes it (signs the exit)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
await pay.pay("T1", "cash");
const r = await exit.reopenBarrier("T1", "op1");
expect(r.ok).toBe(true);
// The open session is closed by the human-intervention exit so it leaves the list.
expect(exitsSigned("T1")).toHaveLength(1);
});
});
+496
View File
@@ -0,0 +1,496 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
// the session → validate it is PAID and within the walk-back grace → sign a
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
//
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
// projection cache: find the open vehicle_entry for this identity, then a covering
// payment within grace. The cache is updated after, for fast reads.
//
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
// is sent back to the pay station, the rejection is logged.
//
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
// passable once the pay-station + `payment` events land.
interface SessionView {
readonly identity: string;
readonly enteredAt: string;
readonly open: boolean; // no vehicle_exit yet
readonly paidAt: string | null; // latest payment time, if any
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
* exit / re-open without a `payment`. */
readonly subscription: boolean;
readonly graceExitMin: number | null; // from the payment's tariff context, if known
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
// active tariff resolves (then we fall back to the normal paid check).
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
}
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
* intervention was recorded (signed anomaly) but the relay did not fire. */
export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
export class ExitFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the exit image. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/**
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
* relay site-wide, pulses it, and fires the exit snapshot.
*
* Returns a discriminated result so the route can react precisely:
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
* — nothing is signed beyond the existing anomaly; the operator takes payment.
* - { ok: true, opened: true } on a clean exit.
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
const key = `booth:${id}`;
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
this.#inFlight.add(key);
try {
const view = this.#sessionFor(id);
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
// path) so a booth attempt on a bad ticket is auditable.
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
}
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
const freeGrace = view.paidAt == null && view.freeGrace != null;
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!freeGrace && (!paid || !withinGrace)) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
await this.#log.append({
type: "payment",
identity: id,
payload: {
sessionRef: id,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
}
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
// to let the car out has been made and validated. Then attempt the open.
await this.#signExit(id);
if (!resolved) {
await this.#openFailedAnomaly(id, "no exit relay configured");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
await this.#openFailedAnomaly(id, "exit controller would not build");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
this.#fireExitSnapshot(id);
this.#closeSessionCache(id);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/**
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
* opens the barrier with a signed trace.
*
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
* the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
*
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
* second exit (that would double-count occupancy): anomaly only, as before.
* See wiki/concepts/booth-exit-flow.md.
*/
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
const id = identity.trim();
if (!id) return { ok: false, reason: "ticket id required" };
const view = this.#sessionFor(id);
if (!view) return { ok: false, reason: "no session for ticket" };
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
// the operator must assist when the exit reader / card fails) OR a transient whose
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
// authorize a free open: a car that paid once and then sat inside past grace owes a
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
// path. So we mirror the exit flow's grace check here (not just in the UI): an
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!view.subscription && (!paid || !withinGrace)) {
return {
ok: false,
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
};
}
const key = `reopen:${id}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
this.#inFlight.add(key);
try {
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
...reasonPayload("exit.manualOpen"),
source: "booth",
barrierReopen: true,
...(operator ? { operator } : {}),
},
});
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
// session leaves the active list + occupancy settles. Skip when already exited
// (no double-count). Recorded as a human-intervention exit for the audit trail.
if (view.open) {
await this.#signExit(id, "manual");
this.#closeSessionCache(id);
this.#fireExitSnapshot(id);
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
}
if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher from the reader's binding, which has ruled out a subscription match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#runExit(resolved, e);
} catch (err) {
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const view = this.#sessionFor(e.value);
// No matching open session — unknown/duplicate ticket. Reject + log.
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { ...rp, exitRefused: true },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused: no open session for ${e.value}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
// its "an exit is covered by a payment" invariant, then fall through to open.
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
if (view.paidAt == null && view.freeGrace) {
await this.#log.append({
type: "payment",
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
identity: e.value,
payload: {
sessionRef: e.value,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
this.#logger.info(`exit free within entry-grace (${e.value})`);
return this.#signExitAndOpen(resolved, e);
}
// PAID + within walk-back grace?
const paid = view.paidAt != null;
const withinGrace =
paid &&
view.graceExitMin != null &&
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!paid || !withinGrace) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { ...rp, exitRefused: true, sessionRef: e.value },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
// Valid (a real payment within walk-back grace): sign + open.
return this.#signExitAndOpen(resolved, e);
}
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
* Shared by the paid-exit and free-entry-grace paths. The caller has already
* established the session is allowed out (and, for grace, minted the $0 payment). */
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
this.#fireExitSnapshot(e.value);
this.#closeSessionCache(e.value);
return { accepted: true, direction: "exit" };
}
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
* or "manual" (a human-intervention barrier re-open that closes an open session —
* see reopenBarrier). */
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity,
payload: {
sessionRef: identity,
...(source === "manual" ? reasonPayload("exit.manualOpen") : {}),
},
});
}
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
#fireExitSnapshot(identity: string): void {
void snapshotAsync({
db: this.#db,
direction: "exit",
identity,
logger: this.#logger,
vision: this.#vision,
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
}
/** Update the (rebuildable) session projection cache to closed. */
#closeSessionCache(identity: string): void {
try {
this.#db
.update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "closed" })
.where(eq(sessions.id, identity))
.run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
}
}
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
* The payment + exit STAND; this tells the operator to open manually. */
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity,
payload: { ...reasonPayload("exit.open.failed"), detail, source: "booth", exitOpenFailed: true },
});
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
}
/** Fold the signed ledger into a session view for one identity (authoritative). */
#sessionFor(identity: string): SessionView | null {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
if (rows.length === 0) return null;
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
// A `void` (cancelled ticket) closes the session like an exit, so a voided ticket
// presented at exit reads as "already closed" — never re-opens. See void-flow.ts.
const exited = rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
// Free entry-grace: if the tariff prices entry→now at 0 (a quick in-and-out),
// the exit may open at the gate. Resolve against the tariff in force at entry,
// same as the pay station. Null when no payment is needed yet and no tariff
// resolves — then exit falls back to the normal paid check.
let freeGrace: SessionView["freeGrace"] = null;
if (!exited && paidAt == null) {
const tv = this.#tariffVersionFor(entry.occurredAt);
if (tv) {
const structure = tv.structure as unknown as TariffStructure;
// Same frozen-at-entry category the pay station uses, so the free-grace
// check agrees with the booth quote for V2 category tariffs.
const category = (entry.payload as { category?: string } | null)?.category;
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
if (fee === 0) {
freeGrace = {
tariffVersionId: tv.id,
currency: tv.currency,
graceExitMin: structure.gracePeriodExitMin,
};
}
}
}
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
return {
identity,
enteredAt: entry.occurredAt,
open: !exited,
paidAt,
subscription,
graceExitMin,
freeGrace,
};
}
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
-30
View File
@@ -1,30 +0,0 @@
import { laneDevices, type Db } from "@parking/db";
// Resolves a device instance id (lane_devices.id) to its lane number.
//
// Device pushes/events carry the `lane_devices` id (which device fired), not a
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
// rebuilt from the DB at startup and refreshed whenever assignments change
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
// every input event, so a cached map beats a per-event DB lookup.
export class LaneMap {
readonly #db: Db;
#byDeviceId = new Map<string, number>();
constructor(db: Db) {
this.#db = db;
}
/** (Re)load the id->lane map from the lane_devices table. */
refresh(): void {
const rows = this.#db.select().from(laneDevices).all();
const next = new Map<string, number>();
for (const r of rows) next.set(r.id, r.lane);
this.#byDeviceId = next;
}
/** Lane for a device instance id, or null if the device isn't known. */
laneFor(deviceId: string): number | null {
return this.#byDeviceId.get(deviceId) ?? null;
}
}
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LaneStatus } from "./lane-status.js";
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
import { silentLogger } from "./test-helpers.js";
// LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears
// after a timeout (this camera class sends no leave signal). Advisory; emits a
// lane-status change only when the busy/free state actually flips.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
/** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay
* whose direction we want, so directionOf resolves from the real bound relay. */
function seedCamera(direction: "entry" | "exit" | "both"): string {
const controllerId = randomUUID();
db.insert(devices).values({
id: controllerId,
category: "access",
driverId: "dingtian",
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
{ relay: 3, direction: "both" },
],
},
enabled: true,
}).run();
const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3;
const camId = randomUUID();
db.insert(devices).values({
id: camId,
category: "camera",
driverId: "hikvision",
config: { host: "10.0.0.9", controllerId, relay },
enabled: true,
}).run();
return camId;
}
/** Capture lane-status events emitted during `fn`. */
function captureEmits(fn: () => void): LaneStatusEvent[] {
const got: LaneStatusEvent[] = [];
const off = deviceEvents.onLaneStatus((e) => got.push(e));
try {
fn();
} finally {
off();
}
return got;
}
describe("LaneStatus", () => {
it("marks the camera's bound lane busy on a vehicle detection, free until then", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
const emits = captureEmits(() => lane.vehicleDetected(cam));
expect(lane.snapshot()).toEqual({ entry: true, exit: false });
expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip
});
it("auto-clears to free after the TTL (no leave signal from the camera)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot().entry).toBe(true);
const emits = captureEmits(() => vi.advanceTimersByTime(90_001));
expect(lane.snapshot().entry).toBe(false);
expect(emits).toEqual([{ entry: false, exit: false }]);
});
it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
// Re-fire just before the TTL — should NOT clear, and should push the clear out.
vi.advanceTimersByTime(80_000);
lane.vehicleDetected(cam);
vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect
expect(lane.snapshot().entry).toBe(true);
// Now let it lapse fully.
vi.advanceTimersByTime(90_001);
expect(lane.snapshot().entry).toBe(false);
});
it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => {
const cam = seedCamera("entry");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam); // flip -> emits
const emits = captureEmits(() => {
lane.vehicleDetected(cam); // already busy -> no emit
lane.vehicleDetected(cam);
});
expect(emits).toEqual([]);
});
it("a 'both'-direction camera marks BOTH lanes busy", () => {
const cam = seedCamera("both");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot()).toEqual({ entry: true, exit: true });
});
it("exit camera marks only the exit lane", () => {
const cam = seedCamera("exit");
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected(cam);
expect(lane.snapshot()).toEqual({ entry: false, exit: true });
});
it("ignores an unknown device id", () => {
const lane = new LaneStatus(db, silentLogger(), 90_000);
lane.vehicleDetected("nope");
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
});
});
+103
View File
@@ -0,0 +1,103 @@
import { eq, devices, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
import { directionOf } from "./device-resolve.js";
// Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection
// is a hint the booth shows as barrier lights; it never gates a ticket or opens a
// barrier (see wiki/entities/lpr-camera.md, the advisory-only rule).
//
// A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and
// (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so
// "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone
// (each refreshing the timer); once the car leaves, the actives stop and the lane
// flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes.
/** How long after the last vehicle detection a lane stays "busy" before clearing.
* Must exceed the camera's `active` re-fire interval so a still-present car keeps the
* lane busy. MEASURED on the test unit (controlled in/out test): the re-fire rate is
* MOVEMENT-driven, not a fixed rate — ~1-3s apart while the car moves, but stretching
* to ~15-25s when it sits MOTIONLESS in the zone. So the TTL must clear the still-car
* gap (~25s) or a parked car flickers free. The camera has ~no dwell lag (it goes
* silent within a second of the car leaving), so 30s clears promptly after departure
* while keeping a motionless car solidly busy. Override with LANE_BUSY_TTL_MS. */
export function busyTtlMs(): number {
const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
export class LaneStatus {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #ttlMs: number;
#entry = false;
#exit = false;
#entryTimer: ReturnType<typeof setTimeout> | null = null;
#exitTimer: ReturnType<typeof setTimeout> | null = null;
constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) {
this.#db = db;
this.#logger = logger;
this.#ttlMs = ttlMs;
}
/** Current snapshot (for the WS hello). */
snapshot(): LaneStatusEvent {
return { entry: this.#entry, exit: this.#exit };
}
/**
* A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction
* and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera
* or a non-vehicle caller is the caller's concern — this only handles a confirmed
* vehicle detection. Emits a lane-status change only when the state actually flips.
*/
vehicleDetected(deviceId: string): void {
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row) return;
const dir = directionOf(this.#db, row);
if (dir === "entry" || dir === "both") this.#mark("entry");
if (dir === "exit" || dir === "both") this.#mark("exit");
}
#mark(lane: "entry" | "exit"): void {
const was = lane === "entry" ? this.#entry : this.#exit;
if (lane === "entry") this.#entry = true;
else this.#exit = true;
// (Re)arm the auto-clear — each detection pushes the free-flip further out.
const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer;
if (existing) clearTimeout(existing);
const timer = setTimeout(() => this.#clear(lane), this.#ttlMs);
timer.unref?.(); // never hold the process open
if (lane === "entry") this.#entryTimer = timer;
else this.#exitTimer = timer;
if (!was) {
this.#logger.info(`lane-status: ${lane} -> busy`);
this.#emit();
}
}
#clear(lane: "entry" | "exit"): void {
if (lane === "entry") {
this.#entry = false;
this.#entryTimer = null;
} else {
this.#exit = false;
this.#exitTimer = null;
}
this.#logger.info(`lane-status: ${lane} -> free`);
this.#emit();
}
#emit(): void {
deviceEvents.emitLaneStatus(this.snapshot());
}
/** Clear timers on shutdown. */
stop(): void {
if (this.#entryTimer) clearTimeout(this.#entryTimer);
if (this.#exitTimer) clearTimeout(this.#exitTimer);
}
}
+236
View File
@@ -0,0 +1,236 @@
import { randomUUID } from "node:crypto";
import { and, appLogs, desc, eq, sql, type Db } from "@parking/db";
import {
LOG_LEVEL_ORDER,
type AppLogRecord,
type ClientLogInput,
type LogLevel,
type LogSource,
} from "@parking/shared";
// Application/diagnostic LOG SINK — the host-side store behind the third log stream
// (app_logs), distinct from the signed ledger and device telemetry. It persists:
// - BACKEND warn/error/fatal, fed by a pino stream (see pinoDbStream) so any
// app.log.warn/error lands in the DB without changing call sites.
// - FRONTEND errors POSTed to /api/logs (failed requests, uncaught errors).
// Everything here is UNSIGNED + prunable. Pruned by age AND a row cap so an offline
// appliance with finite disk can't be filled by a log storm. See
// wiki/concepts/app-logs.md, decisions/event-streams-split.md.
/** Only warn and above are persisted from the backend (info/debug stay stdout-only). */
const BACKEND_PERSIST_MIN: LogLevel = "warn";
/** Defensive caps so one runaway log can't bloat a row (chars). */
const MAX_MESSAGE = 4_000;
const MAX_STACK = 16_000;
const MAX_CONTEXT_JSON = 16_000;
export interface LogRetention {
/** Delete logs older than this many days. */
readonly maxAgeDays: number;
/** Hard cap on total rows — the oldest beyond this are pruned. */
readonly maxRows: number;
}
export const DEFAULT_RETENTION: LogRetention = {
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
};
function clamp(s: string | null | undefined, max: number): string | null {
if (s == null) return null;
return s.length > max ? s.slice(0, max) : s;
}
/** Serialize context to JSON, bounded — never throw on a circular/huge object. */
function safeContext(ctx: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (ctx == null) return null;
try {
const json = JSON.stringify(ctx);
if (json.length <= MAX_CONTEXT_JSON) return ctx;
return { _truncated: true, preview: json.slice(0, MAX_CONTEXT_JSON) };
} catch {
return { _unserializable: true };
}
}
export class LogService {
readonly #db: Db;
readonly #retention: LogRetention;
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
#writing = false;
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
this.#db = db;
this.#retention = retention;
}
/** Low-level insert. Best-effort: a logging failure must never break a request or
* recurse (a DB error here would otherwise log → insert → error → log …). */
#insert(row: {
level: LogLevel;
source: LogSource;
message: string;
context?: Record<string, unknown> | null;
httpStatus?: number | null;
path?: string | null;
stack?: string | null;
userId?: string | null;
userAgent?: string | null;
createdAt?: string;
}): void {
if (this.#writing) return;
this.#writing = true;
try {
this.#db
.insert(appLogs)
.values({
id: randomUUID(),
level: row.level,
source: row.source,
message: clamp(row.message, MAX_MESSAGE) ?? "",
context: safeContext(row.context),
httpStatus: row.httpStatus ?? null,
path: clamp(row.path, 512),
stack: clamp(row.stack, MAX_STACK),
userId: row.userId ?? null,
userAgent: clamp(row.userAgent, 512),
createdAt: row.createdAt ?? new Date().toISOString(),
})
.run();
} catch {
// Swallow — diagnostics must never take down the path they observe. (Can't log
// it; that's the recursion we're guarding against.)
} finally {
this.#writing = false;
}
}
/** Persist a BACKEND log line (called by the pino stream). Below warn is dropped. */
recordBackend(level: LogLevel, message: string, context?: Record<string, unknown> | null): void {
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
this.#insert({ level, source: "backend", message, context });
}
/** Persist a FRONTEND-reported log (from POST /api/logs). The server stamps the
* user + receive time; the client supplies level/message/context. */
recordClient(
input: ClientLogInput,
meta: { userId?: string | null; userAgent?: string | null },
): void {
this.#insert({
level: input.level,
source: "frontend",
message: input.message,
context: input.context ?? null,
httpStatus: input.httpStatus ?? null,
path: input.path ?? null,
stack: input.stack ?? null,
userId: meta.userId ?? null,
userAgent: meta.userAgent ?? null,
// Keep the client's capture time in context for ordering; createdAt is server time.
createdAt: new Date().toISOString(),
});
}
/** Read recent logs, newest first, with optional level/source/since filters. */
query(opts: {
limit: number;
level?: LogLevel;
source?: LogSource;
since?: string;
}): AppLogRecord[] {
const conds = [];
if (opts.level) conds.push(eq(appLogs.level, opts.level));
if (opts.source) conds.push(eq(appLogs.source, opts.source));
if (opts.since) conds.push(sql`${appLogs.createdAt} >= ${opts.since}`);
const rows = this.#db
.select()
.from(appLogs)
.where(conds.length ? and(...conds) : undefined)
.orderBy(desc(appLogs.createdAt))
.limit(opts.limit)
.all();
return rows as unknown as AppLogRecord[];
}
/** Prune by age then by row cap. Returns how many rows were deleted. Safe to call
* on a timer; cheap (indexed on created_at). */
prune(): number {
let deleted = 0;
try {
const cutoff = new Date(Date.now() - this.#retention.maxAgeDays * 86_400_000).toISOString();
const byAge = this.#db.delete(appLogs).where(sql`${appLogs.createdAt} < ${cutoff}`).run();
deleted += byAge.changes ?? 0;
// Row cap: keep the newest maxRows, delete the rest. One subquery — find the
// created_at boundary of the keep-window, delete older.
const total = this.#db.select({ c: sql<number>`count(*)` }).from(appLogs).get();
const count = total?.c ?? 0;
if (count > this.#retention.maxRows) {
const boundary = this.#db
.select({ createdAt: appLogs.createdAt })
.from(appLogs)
.orderBy(desc(appLogs.createdAt))
.limit(1)
.offset(this.#retention.maxRows - 1)
.get();
if (boundary) {
const byCap = this.#db
.delete(appLogs)
.where(sql`${appLogs.createdAt} < ${boundary.createdAt}`)
.run();
deleted += byCap.changes ?? 0;
}
}
} catch {
// best-effort
}
return deleted;
}
}
/**
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
* Pino writes one JSON object per line to this stream; we parse, 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.
*/
export function pinoDbStream(
service: LogService,
tee: NodeJS.WritableStream,
): { write: (line: string) => void } {
const NUM_TO_LEVEL: Record<number, LogLevel> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
};
return {
write(line: string): void {
// Always tee to the original destination first (don't lose stdout logging).
try {
tee.write(line);
} catch {
/* ignore */
}
try {
const obj = JSON.parse(line) as {
level?: number;
msg?: string;
err?: { stack?: string; message?: string };
[k: string]: unknown;
};
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
// Strip pino's noisy standard fields from the persisted context.
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
service.recordBackend(level, typeof msg === "string" ? msg : "", rest);
} catch {
// A non-JSON line (shouldn't happen with pino) — ignore for persistence.
}
},
};
}
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
import { getOccupancy, occupancyCount, reservedSubscriberSpots } from "./occupancy.js";
// Occupancy is a FOLD over the signed ledger, never a stored counter. These tests
// pin: the entries-minus-exits count, the capacity/full gate, and the reserved-
// subscriber-spots model (its trickiest invariant — never double-count a parked
// subscriber, and never gate the subscriber's own entry).
let db: Db;
let close: () => void;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
});
afterEach(() => close());
// Insert a ledger row directly (these fns read raw rows; signing is event-log's job).
let idx = 0;
function entry(identity: string, payload?: Record<string, unknown>) {
idx += 1;
db.insert(ledgerEvents).values({
id: `e${idx}`, index: idx, type: "vehicle_entry", direction: "entry",
identity, payload: payload ?? null, occurredAt: new Date().toISOString(),
signature: "x", keyId: "test",
}).run();
}
function exit(identity: string) {
idx += 1;
db.insert(ledgerEvents).values({
id: `e${idx}`, index: idx, type: "vehicle_exit", direction: "exit",
identity, payload: null, occurredAt: new Date().toISOString(),
signature: "x", keyId: "test",
}).run();
}
function voidEvt(identity: string) {
idx += 1;
db.insert(ledgerEvents).values({
id: `e${idx}`, index: idx, type: "void",
identity, payload: { sessionRef: identity, voidReason: "misprint" }, occurredAt: new Date().toISOString(),
signature: "x", keyId: "test",
}).run();
}
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
}
describe("occupancyCount", () => {
beforeEach(() => { idx = 0; });
it("is 0 with no events", () => {
expect(occupancyCount(db)).toBe(0);
});
it("counts open sessions (entries minus matching exits)", () => {
entry("A"); entry("B"); entry("C");
exit("B");
expect(occupancyCount(db)).toBe(2);
});
it("a re-entry after exit counts again", () => {
entry("A"); exit("A"); entry("A");
expect(occupancyCount(db)).toBe(1);
});
it("a voided (cancelled) entry does NOT count inside", () => {
entry("A"); entry("B");
voidEvt("B"); // B's ticket was a misprint — cancelled
expect(occupancyCount(db)).toBe(1);
});
});
describe("getOccupancy — capacity + full gate", () => {
beforeEach(() => { idx = 0; });
it("uncapped: never full, free/effectiveFree null", () => {
setSite({ capacity: null });
entry("A");
const o = getOccupancy(db);
expect(o.full).toBe(false);
expect(o.free).toBeNull();
expect(o.effectiveFree).toBeNull();
});
it("capped: full when count reaches capacity", () => {
setSite({ capacity: 2 });
entry("A");
expect(getOccupancy(db).full).toBe(false);
entry("B");
const o = getOccupancy(db);
expect(o.full).toBe(true);
expect(o.free).toBe(0);
});
});
describe("reservedSubscriberSpots", () => {
beforeEach(() => { idx = 0; });
function addSub(id: string, opts: Partial<typeof subscriptions.$inferInsert> = {}) {
db.insert(subscriptions).values({ id, status: "active", quantity: 1, period: "month", ...opts }).run();
}
it("is 0 when the toggle is off (default)", () => {
setSite({ capacity: 10, reserveSubscriberSpots: false });
addSub("s1", { quantity: 2 });
expect(reservedSubscriberSpots(db)).toBe(0);
});
it("holds quantity spots for an active, not-parked subscription", () => {
setSite({ capacity: 10, reserveSubscriberSpots: true });
addSub("s1", { quantity: 2 });
expect(reservedSubscriberSpots(db)).toBe(2);
});
it("does NOT double-count a subscriber already parked (holds only the rest)", () => {
setSite({ capacity: 10, reserveSubscriberSpots: true });
addSub("s1", { quantity: 2 });
// One of the family's two cars is inside (occurrence entry carries permitId = sub id).
entry("SUBSESS-1", { permitId: "s1" });
expect(reservedSubscriberSpots(db)).toBe(1); // 2 quantity − 1 inside
});
it("ignores suspended/revoked and out-of-window subscriptions", () => {
setSite({ capacity: 10, reserveSubscriberSpots: true });
addSub("active", { quantity: 1 });
addSub("suspended", { quantity: 5, status: "suspended" });
addSub("expired", { quantity: 5, validTo: "2000-01-01T00:00:00.000Z" });
expect(reservedSubscriberSpots(db)).toBe(1);
});
});
describe("getOccupancy — reserved tightens the transient gate", () => {
beforeEach(() => { idx = 0; });
it("transient sees full once count + reserved ≥ capacity", () => {
setSite({ capacity: 3, reserveSubscriberSpots: true });
db.insert(subscriptions).values({ id: "s1", status: "active", quantity: 2, period: "month" }).run();
entry("A"); // 1 inside + 2 reserved = 3 ≥ capacity 3
const o = getOccupancy(db);
expect(o.reserved).toBe(2);
expect(o.effectiveFree).toBe(0);
expect(o.full).toBe(true);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
// with no matching vehicle_exit. Never a hand-maintained counter (which is
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
export interface Occupancy {
/** Cars currently inside (open sessions). */
readonly count: number;
/** Spots HELD for active subscribers who are NOT currently parked (when the
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
* `quantity` spots minus however many of its cars are already inside. */
readonly reserved: number;
/** Admin-set nominal capacity, or null = no limit. */
readonly capacity: number | null;
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
readonly free: number | null;
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
readonly effectiveFree: number | null;
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
* (always false when uncapped). Subscribers are never gated by this. */
readonly full: boolean;
}
/** Count cars inside: entries minus exits, per identity, over the ledger. */
export function occupancyCount(db: Db): number {
const rows = db
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
.from(ledgerEvents)
.all();
const balance = new Map<string, number>();
for (const r of rows) {
// A `void` (cancelled ticket) closes the session like an exit — the car never entered
// (misprint), so it must not count inside. See void-flow.ts.
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
else if (r.type === "vehicle_exit" || r.type === "void")
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
}
let open = 0;
for (const v of balance.values()) if (v > 0) open += 1;
return open;
}
/** Admin-set capacity (null = uncapped). */
export function siteCapacity(db: Db): number | null {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return row?.capacity ?? null;
}
/**
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
* subscription already inside (so we never double-count a parked subscriber). This is
* what makes a transient see "full" sooner while the subscriber's spot is held.
*/
export function reservedSubscriberSpots(db: Db): number {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!cfg?.reserveSubscriberSpots) return 0;
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
const insidePerSub = new Map<string, number>();
const net = new Map<string, number>(); // occurrence identity → entries−exits
const subOf = new Map<string, string>(); // occurrence identity → subscription id
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const pl = (r.payload ?? {}) as { permitId?: string };
if (pl.permitId == null) continue; // transient
net.set(id, (net.get(id) ?? 0) + 1);
subOf.set(id, pl.permitId);
} else if (r.type === "vehicle_exit" || r.type === "void") {
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
}
}
for (const [id, n] of net) if (n > 0) {
const sub = subOf.get(id)!;
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
}
const now = new Date().toISOString();
const subs = db.select().from(subscriptions).all();
let reserved = 0;
for (const s of subs) {
const active =
s.status === "active" &&
(s.validFrom == null || now >= s.validFrom) &&
(s.validTo == null || now <= s.validTo);
if (!active) continue;
const qty = s.quantity ?? 1;
const inside = insidePerSub.get(s.id) ?? 0;
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
}
return reserved;
}
export function getOccupancy(db: Db): Occupancy {
const count = occupancyCount(db);
const capacity = siteCapacity(db);
const reserved = reservedSubscriberSpots(db);
return {
count,
reserved,
capacity,
free: capacity == null ? null : capacity - count,
effectiveFree: capacity == null ? null : capacity - count - reserved,
// A transient is refused once physical cars + held subscriber spots reach capacity.
full: capacity != null && count + reserved >= capacity,
};
}
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { PayStation, NoOpenSessionError, NoTariffError } from "./pay-station.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
// The pay station prices an open session against the tariff frozen at entry and writes
// a SIGNED payment event (never a mutable "paid" flag). These tests pin the quote math,
// the signed-payment side effect, the no-session / no-tariff errors, and the lookup
// view the booth modal reads.
let db: Db;
let close: () => void;
let log: EventLog;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
}
describe("PayStation.quote", () => {
it("throws NoOpenSessionError for an unknown ticket", () => {
seedTariff(db);
expect(() => pay.quote("nope")).toThrow(NoOpenSessionError);
});
it("throws NoTariffError when no site tariff is configured", async () => {
await enter("T1", minutesAgo(120));
expect(() => pay.quote("T1")).toThrow(NoTariffError);
});
it("prices a stay against the frozen tariff (90min → 2 increments at 100/h = 200)", async () => {
// 90 min rounds UP to a 2nd 60-min increment; well clear of the boundary so a few
// ms of test runtime can't tip it into a 3rd increment.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
await enter("T1", minutesAgo(90));
const q = pay.quote("T1");
expect(q.amountMinor).toBe(20000);
expect(q.currency).toBe("ALL");
expect(q.overstay).toBe(false);
});
it("prices 0 within the entry grace (quick in-and-out)", async () => {
seedTariff(db, { gracePeriodEntryMin: 10 });
await enter("T1", minutesAgo(5));
expect(pay.quote("T1").amountMinor).toBe(0);
});
});
describe("PayStation.pay — signed payment side effect", () => {
it("appends a signed payment event carrying amount, currency, tender, grace", async () => {
const { currency } = seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
const res = await pay.pay("T1", "cash");
expect(res.amountMinor).toBe(20000);
expect(res.currency).toBe(currency);
const payments = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all();
expect(payments).toHaveLength(1);
const pl = payments[0].payload as Record<string, unknown>;
expect(pl.amountMinor).toBe(20000);
expect(pl.tender).toBe("cash");
expect(pl.graceExitMin).toBe(15);
// It must be a real signed chain event.
expect(log.verifyChain()).toEqual({ ok: true });
});
it("honours an operator override amount (lost ticket / dispute)", async () => {
seedTariff(db);
await enter("T1", minutesAgo(120));
const res = await pay.pay("T1", "card", 99900);
expect(res.amountMinor).toBe(99900);
const pl = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all()[0].payload as Record<string, unknown>;
expect(pl.amountMinor).toBe(99900);
expect(pl.reason).toBe("operator-set amount");
});
});
describe("PayStation.lookup — booth modal view", () => {
it("reports not-found for an unknown ticket", () => {
const v = pay.lookup("ghost");
expect(v.found).toBe(false);
expect(v.open).toBe(false);
});
it("shows an open unpaid transient with the amount owed", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const v = pay.lookup("T1");
expect(v.found).toBe(true);
expect(v.open).toBe(true);
expect(v.paidAt).toBeNull();
expect(v.amountMinor).toBe(20000);
expect(v.subscription).toBe(false);
});
it("after payment shows paid + within grace, amount cleared", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(120));
await pay.pay("T1", "cash");
const v = pay.lookup("T1");
expect(v.paidAt).not.toBeNull();
expect(v.withinGrace).toBe(true);
expect(v.overstay).toBe(false);
});
it("flags a subscription occurrence (prepaid — never a transient charge)", async () => {
seedTariff(db);
await enter("SUBSESS-1", minutesAgo(120), { permit: true, permitId: "sub-1" });
const v = pay.lookup("SUBSESS-1");
expect(v.subscription).toBe(true);
expect(v.subscriptionId).toBe("sub-1");
expect(v.amountMinor).toBeNull(); // no timeframes → nothing owed
});
});
describe("PayStation.activeSessions", () => {
it("lists open sessions newest-first and omits exited-past-grace", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("OLD", minutesAgo(200));
await enter("NEW", minutesAgo(30));
const list = pay.activeSessions();
expect(list.map((s) => s.identity)).toEqual(["NEW", "OLD"]);
expect(list.every((s) => s.open)).toBe(true);
});
});
+553
View File
@@ -0,0 +1,553 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender } 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";
// 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:
// 1. quote(identity) → look up the open session, price it against the tariff in
// force at entry, return the amount due (no side effect).
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
// flow can validate paid + within walk-back grace). Payment is a signed ledger
// event, never a mutable "paid" flag — an operator can't forge or delete it.
// See wiki/concepts/tariff.md, parking-session.md.
export class NoOpenSessionError extends Error {
constructor(identity: string) {
super(`no open session for ${identity}`);
this.name = "NoOpenSessionError";
}
}
export class NoTariffError extends Error {
constructor() {
super("no active tariff configured");
this.name = "NoTariffError";
}
}
export interface Quote {
readonly identity: string;
/** Vehicle entry time (the session's original entry; for display/audit). */
readonly enteredAt: string;
/** Start of the period being billed RIGHT NOW. For a first payment this is the
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
* re-parked / a new period began) it is the moment that grace expired: the overstay
* 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]. */
readonly amountMinor: number;
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean;
readonly currency: string;
readonly tariffVersionId: string;
readonly graceExitMin: number;
}
/** One row in the booth Active Sessions list. A session is "active" while it is
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
* paid/exited car is presumed possibly-still-present until grace expires. The
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
export interface ActiveSession {
readonly identity: string;
readonly source: string | null;
readonly enteredAt: string;
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
readonly exitedAt: string | null;
readonly open: boolean;
readonly paidAt: string | null;
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
readonly amountMinor: number | null;
readonly currency: string | null;
readonly withinGrace: boolean;
readonly graceExpiresAt: string | null;
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
* See wiki/concepts/booth-exit-flow.md. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
readonly subscription: boolean;
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
readonly subscriptionId: string | null;
/** The subscriber's holder name (for a friendly label instead of the raw key). */
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for
* at-a-glance identification. Null when no plate was read. Never an access decision. */
readonly plate: string | null;
}
/** Booth session view: everything the pay/exit modal needs in one read. */
export interface SessionLookup {
readonly identity: string;
readonly found: boolean;
/** Open = entered, no exit yet. */
readonly open: boolean;
readonly enteredAt: string | null;
readonly exitedAt: string | null;
/** Latest payment time, if paid. */
readonly paidAt: string | null;
/** Amount owed right now (the quote). Null when no session / no active tariff. */
readonly amountMinor: number | null;
readonly currency: string | null;
/** True when paid AND still within the walk-back grace window. */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
readonly subscription: boolean;
readonly subscriptionId: string | null;
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */
readonly plate: string | null;
}
export class PayStation {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** 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
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
* reflects the reality and re-accrues the fee. No side effect. */
quote(identity: string): Quote {
const entry = this.#openEntry(identity);
if (!entry) throw new NoOpenSessionError(identity);
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
// for an overstay period — the customer keeps the rate card they entered under.
const tv = this.#tariffVersionFor(entry.occurredAt);
if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure;
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
// both read it from there, so a V2 category tariff yields the same amount at the
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
const category = (entry.payload as { category?: string } | null)?.category;
// 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.
const last = this.#lastPayment(identity);
const p = priceSession(
entry.occurredAt,
new Date().toISOString(),
structure,
last ? [last] : [],
category,
);
return {
identity,
enteredAt: entry.occurredAt,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
overstay: p.overstay,
currency: tv.currency,
tariffVersionId: tv.id,
graceExitMin: structure.gracePeriodExitMin,
};
}
/** The latest signed `payment` for this session (time + the grace window it granted),
* or null if never paid. Folds the append-only ledger. */
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
const rows = this.#db
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
let last: { paidAt: string; graceExitMin: number | null } | null = null;
for (const r of rows) {
if (r.type !== "payment") continue;
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
}
return last;
}
/**
* Take payment for a session and append the signed `payment` event. Re-quotes at
* the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
* `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) —
* recorded as the charged amount.
*/
async pay(
identity: string,
tender: Tender,
overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> {
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
// to the occurrence so the exit gate (#windowOwed − payments) clears.
const subWindow = this.#payableSubscriptionWindow(identity);
if (subWindow) {
const amountMinor = overrideMinor ?? subWindow.dueMinor;
await this.#log.append({
type: "payment",
source: "manual",
identity,
payload: {
sessionRef: identity,
amountMinor,
currency: subWindow.currency ?? undefined,
tender,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
},
});
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
return { amountMinor, currency: subWindow.currency ?? "" };
}
const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({
type: "payment",
source: "manual",
identity,
payload: {
sessionRef: identity,
amountMinor,
currency: q.currency,
tender,
tariffVersionId: q.tariffVersionId,
// The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
},
});
// Update the projection cache (rebuildable; not the source of truth).
try {
this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run();
} catch (err) {
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
}
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
return { amountMinor, currency: q.currency };
}
/**
* One-read session view for the booth pay/exit modal: entry/exit times, paid
* state, amount owed now, and walk-back-grace status. Read-only — folds the
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
* rather than throwing, so the modal can still show the session.
*/
lookup(identity: string): SessionLookup {
const id = identity.trim();
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
// A `void` (cancelled ticket) closes the session like an exit — a voided ticket is no
// longer open and can't be paid/exited. See void-flow.ts.
const exitRow = rows.find((r) => r.type === "vehicle_exit" || r.type === "void");
const open = !exitRow;
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
const graceExpiresAt =
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
// on the entry payload + a live late-exit charge), which the booth must take so the
// exit gate clears. See wiki/entities/subscription.md.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
} else if (open && isSubscription) {
const w = this.#subscriptionWindowDue(id, subscriptionId);
if (w && w.dueMinor > 0) {
amountMinor = w.dueMinor;
currency = w.currency;
}
}
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
return {
identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
};
}
/**
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
* until grace expires). One ledger scan, grouped by identity (cheaper than N
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
* (authoritative — not the sessions projection cache, which can drift).
* See wiki/concepts/booth-exit-flow.md.
*/
activeSessions(): ActiveSession[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Group the relevant events per identity in one pass.
type Acc = {
enteredAt?: string;
source: string | null;
exitedAt?: string;
paidAt?: string;
graceExitMin?: number;
subscriptionId?: string | null;
};
const byId = new Map<string, Acc>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const a = byId.get(id) ?? { source: r.source ?? null };
a.enteredAt = r.occurredAt;
a.source = r.source ?? a.source;
// Subscription occurrence? The entry payload carries permit:true + permitId
// (the on-chain field). Mark it so the booth never tries to charge it.
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
byId.set(id, a);
} else if (r.type === "vehicle_exit" || r.type === "void") {
// A `void` closes the session like an exit — drop it from the active list.
const a = byId.get(id);
if (a) a.exitedAt = r.occurredAt;
} else if (r.type === "payment") {
const a = byId.get(id);
if (a) {
a.paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
}
}
}
// Resolve advisory plates for all candidate identities in ONE device_events scan
// (cheaper than one lookup per row).
const plates = platesForIdentities(this.#db, byId.keys());
const now = Date.now();
const out: ActiveSession[] = [];
for (const [identity, a] of byId) {
if (!a.enteredAt) continue; // no entry → not a real session
const open = a.exitedAt == null;
const graceExpiresAt =
a.paidAt && a.graceExitMin != null
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
: null;
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
const isSubscription = a.subscriptionId !== undefined;
// ACTIVE membership:
// - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
// age these out (a silent display filter); now we KEEP them and flag `overstay`
// so the operator reconciles via a top-up. The signed log is untouched, and the
// barrier never opens for free on these. See booth-exit-flow.md.
if (!open && !withinGrace) continue;
const overstay =
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
// period's top-up). A subscription is prepaid — never quote/charge it.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription && (a.paidAt == null || overstay)) {
try {
const q = this.quote(identity);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null */
}
}
out.push({
identity,
source: a.source,
enteredAt: a.enteredAt,
exitedAt: a.exitedAt ?? null,
open,
paidAt: a.paidAt ?? null,
amountMinor,
currency,
withinGrace,
graceExpiresAt,
overstay,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
plate: plates.get(identity)?.plate ?? null,
});
}
// Newest entry first.
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
return out;
}
/**
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
* now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE
* stay `[entry, now]` (one computation — covers early entry AND late exit without
* double-counting), minus whatever they've already paid against the occurrence. null
* when the plan has no timeframes / nothing is owed. Single source of truth shared with
* the exit gate so the booth quote and the gate agree.
*/
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
if (!subscriptionId) return null;
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
if (!sub) return null;
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
const entryRow = rows.find((r) => r.type === "vehicle_entry");
if (!entryRow) return null;
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
if (!owed) return null;
let paid = 0;
for (const r of rows) {
if (r.type !== "payment") continue;
const pl = (r.payload ?? {}) as { amountMinor?: number };
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
}
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
* the due amount + currency + the tariff version that priced the late-exit charge (for
* the payment payload), or null when it's transient / nothing owed. */
#payableSubscriptionWindow(
identity: string,
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (ep.permit !== true && ep.permitId == null) return null; // transient
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
if (!due || due.dueMinor <= 0) return null;
// Tariff version for the payment payload = the one that priced the stay (resolved at
// entry inside windowOwedBetween).
const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString());
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null };
}
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
#planVersionOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.planVersionId ?? null;
}
/** The subscriber's holder name for a subscription id (for a friendly UI label),
* or null. Best-effort: a deleted subscription just yields null. */
#holderOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
try {
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.holderName ?? null;
} catch {
return null;
}
}
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
#openEntry(identity: string) {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
return entry;
}
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
// 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
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
//
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
// session); within a direction, the newest read wins. Returns the plate text only —
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
/** The best advisory plate observed for a session, for display. */
export interface PlateView {
readonly plate: string;
readonly confidence: number | null;
readonly direction: "entry" | "exit" | null;
}
interface ReadDetail {
identity?: string;
plate?: string;
confidence?: number;
direction?: string;
}
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
export function plateForIdentity(db: Db, identity: string): PlateView | 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();
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
}
/** Resolve plates for MANY identities in one device_events scan (used by the active-
* sessions list and the feed page, which each carry tens–hundreds of rows). */
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
const want = new Set(identities);
const out = new Map<string, PlateView>();
if (want.size === 0) return out;
// Newest first so the first acceptable read per (identity,direction) is the freshest.
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const byId = new Map<string, ReadDetail[]>();
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
let list = byId.get(d.identity);
if (!list) byId.set(d.identity, (list = []));
list.push(d);
}
for (const [id, reads] of byId) {
const best = pickBest(reads, id);
if (best) out.set(id, best);
}
return out;
}
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
* exit read; otherwise the first (newest) acceptable read wins. */
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
let fallback: ReadDetail | null = null;
for (const d of reads) {
if (d.identity !== identity || !d.plate) continue;
if (d.direction === "entry") return toView(d);
if (!fallback) fallback = d;
}
return fallback ? toView(fallback) : null;
}
function toView(d: ReadDetail): PlateView {
return {
plate: d.plate!.trim().toUpperCase(),
confidence: typeof d.confidence === "number" ? d.confidence : null,
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
};
}
+4 -5
View File
@@ -1,5 +1,5 @@
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { eq, laneDevices, type Db } from "@parking/db"; import { eq, devices, type Db } from "@parking/db";
import { import {
isMonitorable, isMonitorable,
registry, registry,
@@ -66,8 +66,8 @@ export class PrinterMonitor {
async refreshDevices(): Promise<void> { async refreshDevices(): Promise<void> {
const rows = await this.#db const rows = await this.#db
.select() .select()
.from(laneDevices) .from(devices)
.where(eq(laneDevices.category, "printer")) .where(eq(devices.category, "printer"))
.all(); .all();
const seen = new Set<string>(); const seen = new Set<string>();
@@ -89,7 +89,6 @@ export class PrinterMonitor {
build: () => driver.create(cfg as never), build: () => driver.create(cfg as never),
meta: { meta: {
deviceId: row.id, deviceId: row.id,
lane: row.lane,
driverId: row.driverId, driverId: row.driverId,
role: typeof cfg.role === "string" ? cfg.role : undefined, role: typeof cfg.role === "string" ? cfg.role : undefined,
}, },
@@ -139,7 +138,7 @@ export class PrinterMonitor {
if (!prev || statusChanged(prev.status, status)) { if (!prev || statusChanged(prev.status, status)) {
this.#log.info( this.#log.info(
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`, `printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
); );
deviceEvents.emitPrinterStatus(event); deviceEvents.emitPrinterStatus(event);
} }
+56
View File
@@ -0,0 +1,56 @@
import { devices, 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 type { SubscriptionFlow } from "./subscription-flow.js";
import { relayForDevice } from "./device-resolve.js";
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
// credential is (decision 2026-06-15):
// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
//
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
// resolves to exactly the barrier it sits at, and the direction is inherited from
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
// 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).
export class ReadDispatcher {
readonly #db: Db;
readonly #exit: ExitFlow;
readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger;
constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
this.#exit = exit;
this.#subscription = subscription;
this.#logger = logger;
}
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
if (!reader || !reader.enabled) {
return { accepted: false, reason: "read from unknown/disabled device" };
}
const resolved = relayForDevice(this.#db, reader);
if (!resolved) {
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
}
const sub = this.#subscription.match(e);
if (sub) {
return this.#subscription.run(resolved, e, sub);
}
// 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.
if (resolved.direction === "entry") {
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
}
return this.#exit.handleAt(resolved, e);
}
}
+189
View File
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it } from "vitest";
import { randomUUID } from "node:crypto";
import {
eq,
isNull,
roles,
rolePermissions,
subscriptionCredentials,
subscriptionPlans,
subscriptions,
tariffs,
users,
type Db,
} from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import {
listRecycleBin,
purge,
restore,
restoreBlockedReason,
softDelete,
sweepExpired,
} from "./recycle-bin.js";
// Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists
// soft-deleted items across kinds, restore brings them back, purge does the real
// DELETE (+ children), a restore that would collide with a live row is blocked, and the
// retention sweep purges only items past the window.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
});
function seedUser(username: string): string {
const id = randomUUID();
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run();
return id;
}
function seedRole(name: string): string {
const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run();
db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run();
return id;
}
function seedSubscription(holder: string): string {
const id = randomUUID();
db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run();
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run();
return id;
}
function seedPlan(planId: string, versions = 2): void {
for (let i = 0; i < versions; i++) {
db.insert(subscriptionPlans).values({
id: randomUUID(),
planId,
name: planId,
period: "month",
pricePerPeriodMinor: 100000,
currency: "ALL",
effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`,
}).run();
}
}
describe("softDelete + restore + purge", () => {
it("stamps the row instead of removing it, and hides it from a live query", () => {
const id = seedUser("alice");
expect(softDelete(db, "user", id, "admin-1")).toBe(true);
const row = db.select().from(users).where(eq(users.id, id)).get();
expect(row).toBeDefined(); // still there
expect(row?.deletedAt).toBeTruthy();
expect(row?.deletedBy).toBe("admin-1");
// A live-only query no longer sees it.
expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0);
});
it("soft-deleting an already-deleted row is a no-op (returns false)", () => {
const id = seedUser("bob");
expect(softDelete(db, "user", id, "a")).toBe(true);
expect(softDelete(db, "user", id, "a")).toBe(false);
});
it("restore clears the stamps and brings the row back to the live set", () => {
const id = seedRole("valet");
softDelete(db, "role", id, "a");
expect(restore(db, "role", id)).toBe(true);
const row = db.select().from(roles).where(eq(roles.id, id)).get();
expect(row?.deletedAt).toBeNull();
expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id);
});
it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => {
const id = seedSubscription("carlos");
// Cannot purge while live (purge only touches soft-deleted rows).
expect(purge(db, "subscription", id)).toBe(false);
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined();
softDelete(db, "subscription", id, "a");
expect(purge(db, "subscription", id)).toBe(true);
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined();
// Children gone too.
expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0);
});
});
describe("versioned plans", () => {
it("soft-deletes / restores / purges ALL versions of a planId together", () => {
seedPlan("hotel-daily", 3);
expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true);
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0);
// The bin lists the plan as ONE item, not three.
const planItems = listRecycleBin(db).filter((i) => i.kind === "plan");
expect(planItems).toHaveLength(1);
expect(planItems[0]?.id).toBe("hotel-daily");
expect(restore(db, "plan", "hotel-daily")).toBe(true);
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3);
softDelete(db, "plan", "hotel-daily", "a");
expect(purge(db, "plan", "hotel-daily")).toBe(true);
expect(db.select().from(subscriptionPlans).all()).toHaveLength(0);
});
});
describe("listRecycleBin", () => {
it("collects soft-deleted items across every kind, newest-deleted first", () => {
const u = seedUser("dora");
const r = seedRole("guard");
const t = randomUUID();
db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run();
softDelete(db, "user", u, "a");
softDelete(db, "role", r, "a");
softDelete(db, "tariff", t, "a");
const items = listRecycleBin(db);
expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]);
// Each carries a human label + the deletedAt stamp.
expect(items.find((i) => i.kind === "user")?.label).toBe("dora");
expect(items.every((i) => i.deletedAt)).toBe(true);
});
});
describe("restoreBlockedReason", () => {
// NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live
// duplicate can't even be INSERTed while the deleted one exists (the create route
// returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a
// belt-and-suspenders guard at restore time; verify it returns null in the normal
// case (nothing colliding) so a clean restore is never wrongly blocked.
it("does not block a normal restore (no live collision)", () => {
const u = seedUser("eve");
softDelete(db, "user", u, "a");
expect(restoreBlockedReason(db, "user", u)).toBeNull();
const r = seedRole("cleaner");
softDelete(db, "role", r, "a");
expect(restoreBlockedReason(db, "role", r)).toBeNull();
});
});
describe("sweepExpired (retention)", () => {
it("purges items deleted longer than the window ago, keeps recent ones", () => {
const old = seedUser("old");
const fresh = seedUser("fresh");
softDelete(db, "user", old, "a");
softDelete(db, "user", fresh, "a");
// Backdate `old`'s deletion to 40 days ago.
const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString();
db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run();
const purged = sweepExpired(db, 30);
expect(purged.user).toBe(1);
expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined();
expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined();
});
it("days <= 0 disables the sweep (keep forever)", () => {
const id = seedUser("keeper");
softDelete(db, "user", id, "a");
db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run();
const purged = sweepExpired(db, 0);
expect(purged.user).toBe(0);
expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined();
});
});
+206
View File
@@ -0,0 +1,206 @@
import {
and,
eq,
isNotNull,
isNull,
lte,
rolePermissions,
roles,
subscriptionCredentials,
subscriptionPlans,
subscriptionPlates,
subscriptions,
tariffs,
users,
type Db,
} from "@parking/db";
// Soft delete + recycle bin. Accidental hard-deletes of master data (a user, role,
// subscription, plan, tariff) used to be unrecoverable. Now a DELETE STAMPS the row
// (`deleted_at` = now, `deleted_by` = admin) instead of removing it; it disappears from
// every catalog (the list queries filter `deleted_at IS NULL`) but survives in the
// recycle bin, where an admin can RESTORE it (clear the stamps) or PURGE it (the real
// DELETE). A retention sweep auto-purges items deleted longer than the window ago.
//
// Scope: only the MUTABLE master-data tables below. The signed, append-only ledger is
// NOT here — it has no delete path by design. See wiki/concepts/soft-delete.md.
/** The soft-deletable resource kinds, as they appear in the recycle-bin API. */
export type ResourceKind = "user" | "role" | "subscription" | "plan" | "tariff";
export const RESOURCE_KINDS: ResourceKind[] = ["user", "role", "subscription", "plan", "tariff"];
/** Default retention window before a soft-deleted item is auto-purged (days). Override
* with RECYCLE_BIN_RETENTION_DAYS. 0/negative disables the sweep (keep forever). */
export function retentionDays(): number {
const raw = Number(process.env.RECYCLE_BIN_RETENTION_DAYS ?? 30);
return Number.isFinite(raw) ? raw : 30;
}
/** A row surfaced in the recycle bin (normalised across resource kinds). */
export interface RecycleBinItem {
readonly kind: ResourceKind;
/** The id used to restore/purge. For a versioned PLAN this is the stable planId. */
readonly id: string;
/** Human label for the list (username, role/plan/tariff name, subscriber holder). */
readonly label: string;
readonly deletedAt: string;
readonly deletedBy: string | null;
}
const NOW = () => new Date().toISOString();
// --- Per-resource helpers ----------------------------------------------------
// Subscriptions/users/roles/tariffs are 1 row per id. PLANS are versioned (N rows per
// plan_id) — stamp/clear/delete ALL versions of the plan_id together.
/** Soft-delete a row by id. Returns false if no live row matched (404). PLAN uses planId. */
export function softDelete(db: Db, kind: ResourceKind, id: string, byUserId: string): boolean {
const stamp = { deletedAt: NOW(), deletedBy: byUserId };
switch (kind) {
case "user":
return db.update(users).set(stamp).where(and(eq(users.id, id), isNull(users.deletedAt))).run().changes > 0;
case "role":
return db.update(roles).set(stamp).where(and(eq(roles.id, id), isNull(roles.deletedAt))).run().changes > 0;
case "subscription":
return db.update(subscriptions).set(stamp).where(and(eq(subscriptions.id, id), isNull(subscriptions.deletedAt))).run().changes > 0;
case "plan":
return db.update(subscriptionPlans).set(stamp).where(and(eq(subscriptionPlans.planId, id), isNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.update(tariffs).set(stamp).where(and(eq(tariffs.id, id), isNull(tariffs.deletedAt))).run().changes > 0;
}
}
/** Restore a soft-deleted row (clear the stamps). Returns false if nothing was restored. */
export function restore(db: Db, kind: ResourceKind, id: string): boolean {
const clear = { deletedAt: null, deletedBy: null };
switch (kind) {
case "user":
return db.update(users).set(clear).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
case "role":
return db.update(roles).set(clear).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
case "subscription":
return db.update(subscriptions).set(clear).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
case "plan":
return db.update(subscriptionPlans).set(clear).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.update(tariffs).set(clear).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
}
}
/** True if restoring would collide with a LIVE row (e.g. a user with the same username
* was re-created after the delete). The caller turns this into a 409 so the admin
* understands why restore is blocked. */
export function restoreBlockedReason(db: Db, kind: ResourceKind, id: string): string | null {
if (kind === "user") {
const row = db.select().from(users).where(eq(users.id, id)).get();
if (row && db.select().from(users).where(and(eq(users.username, row.username), isNull(users.deletedAt))).get()) {
return `a live user named "${row.username}" already exists`;
}
} else if (kind === "role") {
const row = db.select().from(roles).where(eq(roles.id, id)).get();
if (row && db.select().from(roles).where(and(eq(roles.name, row.name), isNull(roles.deletedAt))).get()) {
return `a live role named "${row.name}" already exists`;
}
}
return null;
}
// --- Restore ordering note --------------------------------------------------
// A restored USER points at a roleId; if that role is itself deleted, the user reappears
// with a dangling role. We don't auto-cascade (keep it predictable); the bin lists both
// and the admin restores the role too. The role guard already resolves a missing role to
// an empty permission set (safe-by-default), so a dangling role never escalates.
/** Hard-delete (purge) a soft-deleted row + its children. The real DELETE. Returns false
* if no soft-deleted row matched (so you can't purge a live row through this path). */
export function purge(db: Db, kind: ResourceKind, id: string): boolean {
switch (kind) {
case "user":
return db.delete(users).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
case "role": {
// Children (role_permissions) only matter once the role row is gone; purge both.
const ok = db.delete(roles).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
if (ok) deleteRolePermissions(db, id);
return ok;
}
case "subscription": {
const ok = db.delete(subscriptions).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
if (ok) deleteSubscriptionChildren(db, id);
return ok;
}
case "plan":
return db.delete(subscriptionPlans).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
case "tariff":
return db.delete(tariffs).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
}
}
// Child cleanup on purge (role_permissions / subscription credentials + plates).
function deleteRolePermissions(db: Db, roleId: string): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
}
function deleteSubscriptionChildren(db: Db, id: string): void {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
}
// --- Listing the bin --------------------------------------------------------
/** All soft-deleted items across every resource kind, newest-deleted first. */
export function listRecycleBin(db: Db): RecycleBinItem[] {
const items: RecycleBinItem[] = [];
for (const r of db.select().from(users).where(isNotNull(users.deletedAt)).all()) {
items.push({ kind: "user", id: r.id, label: r.fullName || r.username, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(roles).where(isNotNull(roles.deletedAt)).all()) {
items.push({ kind: "role", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(subscriptions).where(isNotNull(subscriptions.deletedAt)).all()) {
items.push({ kind: "subscription", id: r.id, label: r.holderName || r.id, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
// Plans are versioned: collapse to one item per plan_id (the latest version's name).
const planSeen = new Set<string>();
const planRows = db.select().from(subscriptionPlans).where(isNotNull(subscriptionPlans.deletedAt)).all();
planRows.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom));
for (const r of planRows) {
if (planSeen.has(r.planId)) continue;
planSeen.add(r.planId);
items.push({ kind: "plan", id: r.planId, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
for (const r of db.select().from(tariffs).where(isNotNull(tariffs.deletedAt)).all()) {
items.push({ kind: "tariff", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
}
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
}
// --- Retention sweep --------------------------------------------------------
/** Purge every soft-deleted row deleted more than `retentionDays()` ago. Returns the
* count purged per kind. Safe to call repeatedly (idempotent). */
export function sweepExpired(db: Db, days = retentionDays()): Record<ResourceKind, number> {
const out: Record<ResourceKind, number> = { user: 0, role: 0, subscription: 0, plan: 0, tariff: 0 };
if (!Number.isFinite(days) || days <= 0) return out; // keep-forever
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
// Collect ids first so children purge through the same path as a manual purge.
for (const r of db.select().from(users).where(and(isNotNull(users.deletedAt), lte(users.deletedAt, cutoff))).all()) {
if (purge(db, "user", r.id)) out.user++;
}
for (const r of db.select().from(roles).where(and(isNotNull(roles.deletedAt), lte(roles.deletedAt, cutoff))).all()) {
if (purge(db, "role", r.id)) out.role++;
}
for (const r of db.select().from(subscriptions).where(and(isNotNull(subscriptions.deletedAt), lte(subscriptions.deletedAt, cutoff))).all()) {
if (purge(db, "subscription", r.id)) out.subscription++;
}
const planIds = new Set(
db.select().from(subscriptionPlans).where(and(isNotNull(subscriptionPlans.deletedAt), lte(subscriptionPlans.deletedAt, cutoff))).all().map((r) => r.planId),
);
for (const planId of planIds) if (purge(db, "plan", planId)) out.plan++;
for (const r of db.select().from(tariffs).where(and(isNotNull(tariffs.deletedAt), lte(tariffs.deletedAt, cutoff))).all()) {
if (purge(db, "tariff", r.id)) out.tariff++;
}
return out;
}
+183
View File
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it } from "vitest";
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { randomUUID } from "node:crypto";
import { makeLog } from "./test-helpers.js";
import { reportSummary } from "./reports.js";
import type { EventLog } from "./event-log.js";
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
// summed straight from the signed ledger (entry/exit counts + payment money, split the
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
// stats from the closed-sessions cache and subscription counts as of the range end.
let db: Db;
let log: EventLog;
beforeEach(() => {
({ db } = createTestDb());
log = makeLog(db);
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
});
/** ISO at a UTC instant, for deterministic bucket assertions. */
function at(iso: string): string {
return new Date(iso).toISOString();
}
async function entry(occurredAt: string): Promise<void> {
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
}
async function exit(occurredAt: string): Promise<void> {
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
}
async function payment(
occurredAt: string,
amountMinor: number,
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
): Promise<void> {
await log.append({
type: "payment",
occurredAt,
payload: {
amountMinor,
currency: "ALL",
tender: opts.tender ?? "cash",
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
},
});
}
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
describe("reportSummary — ledger-first totals", () => {
it("counts entries and exits from the signed ledger", async () => {
await entry(at("2026-06-10T08:00:00Z"));
await entry(at("2026-06-10T09:00:00Z"));
await exit(at("2026-06-10T18:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.entries).toBe(2);
expect(r.totals.exits).toBe(1);
});
it("excludes events outside [from, to)", async () => {
await entry(at("2026-05-31T23:00:00Z")); // before
await entry(at("2026-06-15T10:00:00Z")); // inside
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.entries).toBe(1);
});
it("sums payment money and splits cash vs card", async () => {
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.payments).toBe(2);
expect(r.totals.revenueMinor).toBe(25000);
expect(r.totals.cashMinor).toBe(20000);
expect(r.totals.cardMinor).toBe(5000);
});
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.ticketMinor).toBe(10000);
expect(r.totals.subscriptionSalesMinor).toBe(30000);
expect(r.totals.subscriptionWindowMinor).toBe(1500);
// The three add up to the gross revenue.
expect(r.totals.revenueMinor).toBe(41500);
});
it("picks up the currency from a payment in range", async () => {
await payment(at("2026-06-12T10:00:00Z"), 10000);
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.currency).toBe("ALL");
});
});
describe("reportSummary — time bucketing (site timezone)", () => {
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
await entry(at("2026-06-15T23:30:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const point = r.series.find((p) => p.entries > 0);
expect(point?.bucket).toBe("2026-06-16");
});
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
await entry(at("2026-06-10T08:00:00Z"));
await payment(at("2026-06-10T09:00:00Z"), 7000);
await entry(at("2026-06-12T08:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const labels = r.series.map((p) => p.bucket);
expect(labels).toEqual([...labels].sort());
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
expect(d10?.entries).toBe(1);
expect(d10?.revenueMinor).toBe(7000);
});
it("entriesByHour is a 24-slot local-hour histogram", async () => {
// 06:00Z = 08:00 local (summer) → hour slot 8.
await entry(at("2026-06-10T06:00:00Z"));
await entry(at("2026-06-11T06:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.entriesByHour).toHaveLength(24);
expect(r.entriesByHour[8]).toBe(2);
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
});
});
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
// 60-min and 120-min stays → avg 90, median 90.
db.insert(sessions).values({
id: "s1",
identity: "t1",
enteredAt: at("2026-06-10T08:00:00Z"),
exitedAt: at("2026-06-10T09:00:00Z"),
state: "closed",
}).run();
db.insert(sessions).values({
id: "s2",
identity: "t2",
enteredAt: at("2026-06-10T08:00:00Z"),
exitedAt: at("2026-06-10T10:00:00Z"),
state: "closed",
}).run();
// An OPEN session (no exit) must not count.
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.closedSessions).toBe(2);
expect(r.totals.totalParkedMinutes).toBe(180);
expect(r.totals.avgParkedMinutes).toBe(90);
expect(r.totals.medianParkedMinutes).toBe(90);
});
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
// active + valid window covering `to`, quantity 2.
db.insert(subscriptions).values({
id: "a", status: "active", quantity: 2,
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
}).run();
// active but EXPIRED before `to` → not currently valid.
db.insert(subscriptions).values({
id: "b", status: "active", quantity: 1,
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
}).run();
// suspended.
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.subscriptions.active).toBe(2);
expect(r.subscriptions.suspended).toBe(1);
expect(r.subscriptions.revoked).toBe(0);
expect(r.subscriptions.currentlyValid).toBe(1);
expect(r.subscriptions.coveredCars).toBe(2);
});
});
+288
View File
@@ -0,0 +1,288 @@
import {
and,
asc,
desc,
eq,
gte,
lte,
ledgerEvents,
sessions,
subscriptions,
tariffVersions,
tariffs,
type Db,
} from "@parking/db";
import { siteTz } from "./subscription-window.js";
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
// admin sees on the Reports page are summed from the SIGNED, hash-chained
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
// reconciles against — so a chart total always ties out to the drawer. Only the
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
//
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
// floats (money is integer minor units throughout).
export type Bucket = "hour" | "day" | "month";
export interface ReportQuery {
/** Inclusive lower bound (ISO instant). */
readonly from: string;
/** Exclusive upper bound (ISO instant). */
readonly to: string;
/** Time grain for the series. Default "day". */
readonly bucket: Bucket;
}
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
* for a day, "2026-06-22 14" for an hour). */
export interface SeriesPoint {
readonly bucket: string;
readonly entries: number;
readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number;
/** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number;
}
export interface ReportTotals {
readonly entries: number;
readonly exits: number;
readonly payments: number;
readonly revenueMinor: number;
readonly cashMinor: number;
readonly cardMinor: number;
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
readonly ticketMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
readonly closedSessions: number;
readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number;
readonly medianParkedMinutes: number;
}
export interface SubscriptionStats {
readonly active: number;
readonly suspended: number;
readonly revoked: number;
/** Active subscriptions whose window covers `to` (the report's "now"). */
readonly currentlyValid: number;
/** Cars covered by currently-valid subscriptions (Σ quantity). */
readonly coveredCars: number;
}
export interface ReportSummary {
readonly from: string;
readonly to: string;
readonly bucket: Bucket;
readonly tz: string;
readonly currency: string | null;
readonly totals: ReportTotals;
readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[];
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 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),
};
}
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
const p = localParts(iso, tz);
const mo = String(p.mo).padStart(2, "0");
const d = String(p.d).padStart(2, "0");
const h = String(p.h).padStart(2, "0");
if (bucket === "month") return `${p.y}-${mo}`;
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
return `${p.y}-${mo}-${d}`;
}
interface PaymentPayload {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean;
}
function median(sorted: number[]): number {
if (sorted.length === 0) return 0;
const mid = Math.floor(sorted.length / 2);
const hi = sorted[mid] ?? 0;
if (sorted.length % 2) return hi;
const lo = sorted[mid - 1] ?? 0;
return Math.round((lo + hi) / 2);
}
/**
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
* and money are summed from the signed ledger; duration stats from the closed sessions
* in range; subscription counts from the subscriptions table as of `to`.
*/
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const tz = siteTz(db);
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
const rows = db
.select()
.from(ledgerEvents)
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
.orderBy(asc(ledgerEvents.index))
.all();
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
// the currency they were taken in, so take it from a payment in range (then fall back
// to the active tariff version). Reports never mix currencies (single-currency site).
let currency: string | null = null;
const seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0);
const totals = {
entries: 0,
exits: 0,
payments: 0,
revenueMinor: 0,
cashMinor: 0,
cardMinor: 0,
ticketMinor: 0,
subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0,
};
function point(label: string): SeriesPoint {
let p = seriesMap.get(label);
if (!p) {
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
seriesMap.set(label, p);
}
return p;
}
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
// entry is normally in the same window; this skips it when both are in range.)
const voided = new Set<string>();
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
for (const row of rows) {
const label = bucketLabel(row.occurredAt, tz, q.bucket);
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
if (row.type === "vehicle_entry") {
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;
} else if (row.type === "vehicle_exit") {
totals.exits++;
p.exits++;
} else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (!currency && typeof pl.currency === "string") currency = pl.currency;
totals.payments++;
totals.revenueMinor += amt;
p.payments++;
p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt;
else totals.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;
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
else totals.ticketMinor += amt;
}
}
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
// 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) {
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (tariff) {
const tv = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.get();
currency = tv?.currency ?? null;
}
}
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
const closed = db
.select()
.from(sessions)
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
.all();
const durations: number[] = [];
for (const s of closed) {
if (!s.enteredAt || !s.exitedAt) continue;
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
durations.push(mins);
}
durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
// --- 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 };
for (const s of subs) {
if (s.status === "active") subStats.active++;
else if (s.status === "suspended") subStats.suspended++;
else if (s.status === "revoked") subStats.revoked++;
const validNow =
s.status === "active" &&
(!s.validFrom || s.validFrom <= q.to) &&
(!s.validTo || s.validTo >= q.to);
if (validNow) {
subStats.currentlyValid++;
subStats.coveredCars += s.quantity ?? 1;
}
}
return {
from: q.from,
to: q.to,
bucket: q.bucket,
tz,
currency,
totals: {
...totals,
closedSessions: durations.length,
totalParkedMinutes,
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
medianParkedMinutes: median(durations),
},
series,
entriesByHour,
subscriptions: subStats,
};
}
+176 -14
View File
@@ -1,11 +1,11 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, users, type Db } from "@parking/db"; import { eq, roles, users, type Db } from "@parking/db";
import { import {
TOKEN_TTL,
clearAuthCookies, clearAuthCookies,
newCsrfToken, newCsrfToken,
requireRole, permissionsFor,
requireAuth,
setAuthCookies, setAuthCookies,
} from "../auth.js"; } from "../auth.js";
@@ -17,6 +17,75 @@ interface LoginBody {
password: string; password: string;
} }
const LANGS = ["sq", "en"] as const;
type Lang = (typeof LANGS)[number];
interface LanguageBody {
language: Lang;
}
const THEMES = ["dark", "light"] as const;
type Theme = (typeof THEMES)[number];
interface ThemeBody {
theme: Theme;
}
// Self-service profile: a signed-in user edits their OWN display name + email. This is
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
// other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md.
interface ProfileBody {
fullName?: string | null;
email?: string | null;
}
// Self-service password change: the user proves they hold the CURRENT password before
// setting a new one — unlike the admin reset (users.ts), which sets it outright. This is
// why it lives here and not behind a permission: it's account-self-care, not admin power.
interface PasswordBody {
currentPassword: string;
newPassword: string;
}
const MIN_PASSWORD = 8;
/** Trim a self-service profile string; "" (or whitespace) → null (clear the field).
* Returns undefined for an absent key so an update only touches what was sent. */
function cleanProfileField(v: string | null | undefined): string | null | undefined {
if (v === undefined) return undefined;
const trimmed = typeof v === "string" ? v.trim() : "";
return trimmed === "" ? null : trimmed;
}
/** 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. */
function sessionView(
db: Db,
user: {
id: string;
username: string;
roleId: string;
language: string;
theme: string;
fullName?: string | null;
email?: string | null;
},
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)];
return {
id: user.id,
username: user.username,
roleId: user.roleId,
roleName: role?.name ?? user.roleId,
permissions,
language: user.language,
theme: user.theme,
fullName: user.fullName ?? null,
email: user.email ?? null,
};
}
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => { app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
const { username, password } = req.body ?? {}; const { username, password } = req.body ?? {};
@@ -29,17 +98,26 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Always run a bcrypt compare to avoid leaking which usernames exist (timing). // Always run a bcrypt compare to avoid leaking which usernames exist (timing).
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv"; const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const ok = await bcrypt.compare(password, hash); const ok = await bcrypt.compare(password, hash);
if (!user || !ok) { // A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no
// distinct error so a deleted account isn't enumerable.
if (!user || !ok || user.deletedAt) {
return reply.code(401).send({ error: "invalid credentials" }); return reply.code(401).send({ error: "invalid credentials" });
} }
const csrf = newCsrfToken(); const csrf = newCsrfToken();
const token = await reply.jwtSign( // No expiresIn: the token is valid until explicit logout (see auth.ts). The
{ sub: user.id, username: user.username, role: user.role, csrf }, // token carries roleId (not the permission list) — perms resolve per-request,
{ expiresIn: TOKEN_TTL }, // so a role edit applies immediately with no re-login.
); const token = await reply.jwtSign({
sub: user.id,
username: user.username,
roleId: user.roleId,
csrf,
});
setAuthCookies(reply, token, csrf); setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role }; // `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);
}); });
app.post("/api/auth/logout", async (_req, reply) => { app.post("/api/auth/logout", async (_req, reply) => {
@@ -47,13 +125,97 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
return { ok: true }; return { ok: true };
}); });
// Who am I — used by the SPA to bootstrap session state on load. // Who am I — used by the SPA to bootstrap session state on load. Reads the live
// `language` preference from the DB (not the token).
app.get( app.get(
"/api/auth/me", "/api/auth/me",
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") }, { preHandler: requireAuth },
async (req) => { async (req, reply) => {
const { sub, username, role } = req.user; const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
return { id: sub, username, role }; if (!row) {
// The user was deleted while their cookie was still valid — clear it.
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
return sessionView(db, row);
},
);
// Change MY own UI language preference (any signed-in user). Persisted to the
// users row so it's restored on the next login, from any booth. See i18n.md.
app.put<{ Body: LanguageBody }>(
"/api/auth/language",
{ preHandler: requireAuth },
async (req, reply) => {
const language = req.body?.language;
if (!language || !LANGS.includes(language)) {
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
}
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
return { language };
},
);
// Change MY own UI theme preference (any signed-in user). Persisted to the users
// row like `language`, so it's restored on the next login from any booth.
app.put<{ Body: ThemeBody }>(
"/api/auth/theme",
{ preHandler: requireAuth },
async (req, reply) => {
const theme = req.body?.theme;
if (!theme || !THEMES.includes(theme)) {
return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` });
}
await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run();
return { theme };
},
);
// Edit MY own display name / email (any signed-in user; no permission needed — it only
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
app.put<{ Body: ProfileBody }>(
"/api/auth/profile",
{ preHandler: requireAuth },
async (req, reply) => {
const fullName = cleanProfileField(req.body?.fullName);
const email = cleanProfileField(req.body?.email);
const patch: Record<string, string | null> = {};
if (fullName !== undefined) patch.fullName = fullName;
if (email !== undefined) patch.email = email;
if (Object.keys(patch).length === 0) {
return reply.code(400).send({ error: "nothing to update" });
}
await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run();
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) return reply.code(401).send({ error: "session no longer valid" });
return sessionView(db, row);
},
);
// Change MY own password — must prove the CURRENT one first (defends against a walked-up,
// already-logged-in booth: a passerby can't silently re-key the account). New password
// >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw.
app.put<{ Body: PasswordBody }>(
"/api/auth/password",
{ preHandler: requireAuth },
async (req, reply) => {
const currentPassword = req.body?.currentPassword ?? "";
const newPassword = req.body?.newPassword ?? "";
if (newPassword.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) {
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
const ok = await bcrypt.compare(currentPassword, row.passwordHash);
if (!ok) {
return reply.code(403).send({ error: "current password is incorrect" });
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run();
return { ok: true };
}, },
); );
} }
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from "fastify";
import { requirePermission } from "../auth.js";
import type { DeviceMonitor } from "../device-monitor.js";
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
// configured devices (relays/readers/cameras via healthCheck, printers via their
// rich readStatus) in the background; this exposes its cache. Live updates ride the
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
// fallback. Any authenticated role may read (operational, not a setup action).
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
export async function deviceStatusRoutes(
app: FastifyInstance,
monitor: DeviceMonitor,
): Promise<void> {
const guard = requirePermission("device:read");
app.get("/api/devices/status", { preHandler: guard }, async () => ({
devices: monitor.snapshot(),
}));
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq, laneDevices, type Db } from "@parking/db"; import { eq, devices, type Db } from "@parking/db";
import { deviceEvents } from "../device-events.js"; import { deviceEvents } from "../device-events.js";
import { verifyDigest } from "../digest-auth.js"; import { verifyDigest } from "../digest-auth.js";
@@ -36,7 +36,7 @@ export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void>
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => { const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
const { deviceId, n, edge } = req.params; const { deviceId, n, edge } = req.params;
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get(); const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
const cfg = row?.config as DingtianDeviceConfig | undefined; const cfg = row?.config as DingtianDeviceConfig | undefined;
// Unknown device / not a dingtian / no push creds / wrong source IP → 404. // Unknown device / not a dingtian / no push creds / wrong source IP → 404.
+31 -8
View File
@@ -1,6 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, events, type Db } from "@parking/db"; import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
import { requireRole } from "../auth.js"; import type { LedgerEvent } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { enrichEvents } from "../event-enrich.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
// Read access to the append-only signed event log. NO write/update/delete routes // Read access to the append-only signed event log. NO write/update/delete routes
@@ -13,17 +15,38 @@ export async function eventRoutes(
db: Db, db: Db,
eventLog: EventLog, eventLog: EventLog,
): Promise<void> { ): Promise<void> {
// Any authenticated role may read the log (it's the audit trail). // Reading the log (the audit trail).
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requirePermission("event:read");
// Recent events, newest first. `limit` caps the page (default 100, max 1000). // Recent events, newest first. `limit` caps the page (default 100, max 1000).
app.get<{ Querystring: { limit?: string } }>( // 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 } }>(
"/api/events", "/api/events",
{ preHandler: guard }, { preHandler: guard },
async (req) => { async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all(); const since = (req.query.since ?? "").trim();
return { events: rows }; const until = (req.query.until ?? "").trim();
const bounds = [
since ? gte(ledgerEvents.occurredAt, since) : undefined,
until ? lte(ledgerEvents.occurredAt, until) : undefined,
].filter(Boolean);
const rows = db
.select()
.from(ledgerEvents)
.where(bounds.length ? and(...bounds) : undefined)
.orderBy(desc(ledgerEvents.index))
.limit(limit)
.all();
// Attach read-time display fields (subscriber name, advisory plate) without
// touching the signed record. One plate scan for the whole page (enrichEvents).
// The cast bridges the Drizzle row to the shared LedgerEvent.
const events = enrichEvents(db, rows as unknown as LedgerEvent[]);
return { events };
}, },
); );
@@ -32,7 +55,7 @@ export async function eventRoutes(
// reconciliation job / "is the log intact?" check calls. // reconciliation job / "is the log intact?" check calls.
app.get( app.get(
"/api/events/verify", "/api/events/verify",
{ preHandler: requireRole("admin") }, { preHandler: requirePermission("event:read") },
async () => eventLog.verifyChain(), async () => eventLog.verifyChain(),
); );
} }
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Fastify, { type FastifyInstance as RawFastify } from "fastify";
import { createTestDb } from "@parking/db/testing";
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { hikvisionAlarmRoutes } from "./hikvision-alarm.js";
import type { AnprBridge } from "../anpr-entry.js";
import { seedUser, login } from "../test-helpers.js";
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
// detection POST from the camera's configured IP is accepted, summarized (eventType /
// target / plate pulled out of the XML), and recorded verbatim as a kind:"alarm"
// device_event — while a wrong source IP or a push-disabled device is refused.
const CAM_IP = "10.0.10.121";
const CAM_ID = "cam-1";
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();
});
function seedHikCamera(cfg: Record<string, unknown> = {}) {
db.insert(devices).values({
id: CAM_ID,
category: "camera",
driverId: "hikvision",
config: { host: CAM_IP, alarmPushEnabled: true, ...cfg },
enabled: true,
}).run();
}
/** A representative Hikvision smart-event POST body (vehicle target). The real firmware
* payload may differ; the endpoint stores it verbatim regardless — this asserts the
* best-effort summary extraction over a plausible shape. */
const VEHICLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<EventNotificationAlert version="2.0" xmlns="http://www.hikvision.com/ver20/XMLSchema">
<ipAddress>10.0.10.121</ipAddress>
<channelID>1</channelID>
<dateTime>2026-06-22T10:15:30+02:00</dateTime>
<eventType>fielddetection</eventType>
<eventState>active</eventState>
<DetectionRegionList>
<DetectionRegionEntry><detectionTarget>vehicle</detectionTarget></DetectionRegionEntry>
</DetectionRegionList>
</EventNotificationAlert>`;
function alarmEvents(): { detail: Record<string, unknown> }[] {
return db
.select()
.from(deviceEventsTable)
.where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm")))
.all() as { detail: Record<string, unknown> }[];
}
/** Every recorded push for a device — accepted (kind:"alarm") AND rejected
* (kind:"alarm-rejected"). */
function allRecorded(deviceId: string): { kind: string; detail: Record<string, unknown> }[] {
return db
.select()
.from(deviceEventsTable)
.where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"])))
.all() as { kind: string; detail: Record<string, unknown> }[];
}
describe("Hikvision Alarm Server push", () => {
it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
const events = alarmEvents();
expect(events).toHaveLength(1);
const d = events[0]!.detail;
expect(d.source).toBe("hikvision-alarm-server");
expect(d.eventType).toBe("fielddetection");
expect(d.target).toBe("vehicle");
expect(d.ip).toBe(CAM_IP);
// The raw body is kept verbatim for inspection.
expect(String(d.rawHead)).toContain("EventNotificationAlert");
});
it("accepts the legacy string \"true\" for alarmPushEnabled (setup form quirk)", async () => {
// The setup checkbox historically saved a STRING "true" instead of a boolean; the
// guard must coerce it, not silently reject a feature the admin enabled.
seedHikCamera({ alarmPushEnabled: "true" });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()).toHaveLength(1);
});
it("pulls a plate out of an ANPR-style payload when present", async () => {
seedHikCamera();
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
<ANPR><plateNumber>AA123BB</plateNumber></ANPR></EventNotificationAlert>`;
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: anpr,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()[0]!.detail.plate).toBe("AA123BB");
});
it("accepts an unknown/JSON content-type as raw bytes (discovery-first)", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/octet-stream" },
payload: Buffer.from('{"eventType":"vehicleDetection"}'),
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection");
});
it("accepts a push from ANY source IP when skipSourceIpCheck is set (WSL rewrites it)", async () => {
// WSL mirrored mode rewrites the inbound source to the host's own IP, so the camera's
// real IP never survives and a strict check rejects every push. With the opt-out, a
// push from the 'wrong' IP is accepted.
seedHikCamera({ skipSourceIpCheck: true });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: "10.0.10.203", // the rewritten host IP, NOT the camera's
});
expect(res.statusCode).toBe(200);
expect(alarmEvents()).toHaveLength(1);
expect(alarmEvents()[0]!.detail.target).toBe("vehicle");
});
it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => {
seedHikCamera();
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: "10.0.10.200", // not the camera
});
expect(res.statusCode).toBe(404);
// No ACCEPTED alarm...
expect(alarmEvents()).toHaveLength(0);
// ...but the rejection IS recorded (with the reason), so "nothing arrived" is never
// ambiguous — you can see it came in and why it was refused.
const recorded = allRecorded(CAM_ID);
expect(recorded).toHaveLength(1);
expect(recorded[0]!.kind).toBe("alarm-rejected");
expect(String(recorded[0]!.detail.reason)).toMatch(/source IP/i);
});
it("rejects when alarm push is disabled on the device", async () => {
seedHikCamera({ alarmPushEnabled: false });
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(404);
});
it("rejects an unknown device id", async () => {
const res = await app.inject({
method: "POST",
url: `/api/devices/hikvision/nope/event`,
headers: { "content-type": "application/xml" },
payload: VEHICLE_XML,
remoteAddress: CAM_IP,
});
expect(res.statusCode).toBe(404);
expect(res.json().reason).toMatch(/unknown device/i);
});
it("GET /api/devices/hikvision/alarms lists accepted AND rejected pushes, newest first", async () => {
seedHikCamera();
// One accepted (right IP) + one rejected (wrong IP).
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: CAM_IP });
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: "10.0.10.200" });
const { username, password } = await seedUser(db, { username: "admin1", roleId: "admin" });
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms", headers: { cookie } });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.count).toBe(2);
// Both accepted and rejected appear, with the accepted/reason flags.
expect(body.alarms.some((a: { accepted: boolean }) => a.accepted === true)).toBe(true);
const rejected = body.alarms.find((a: { accepted: boolean }) => a.accepted === false);
expect(rejected.reason).toMatch(/source IP/i);
});
it("the alarms read endpoint is gated (device:read) — 401 without a session", async () => {
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms" });
expect(res.statusCode).toBe(401);
});
});
// The ANPR bridge is handed each vehicle detection (fire-and-forget). We register the
// routes on a bare instance with a SPY bridge to assert exactly when it's invoked —
// only on a vehicle target that isn't `inactive`. (The bridge's own logic is covered in
// anpr-entry.test.ts.)
describe("Hikvision Alarm Server → ANPR bridge wiring", () => {
let rawApp: RawFastify;
let rawDb: Db;
let rawClose: () => void;
let onVehicleDetected: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const t = createTestDb();
rawDb = t.db;
rawClose = t.close;
onVehicleDetected = vi.fn(async () => {});
const bridge = { onVehicleDetected } as unknown as AnprBridge;
rawApp = Fastify();
await hikvisionAlarmRoutes(rawApp, rawDb, undefined, bridge);
await rawApp.ready();
rawDb.insert(devices).values({
id: CAM_ID,
category: "camera",
driverId: "hikvision",
config: { host: CAM_IP, alarmPushEnabled: true },
enabled: true,
}).run();
});
afterEach(async () => {
await rawApp.close();
rawClose();
});
async function post(payload: string) {
return rawApp.inject({
method: "POST",
url: `/api/devices/hikvision/${CAM_ID}/event`,
headers: { "content-type": "application/xml" },
payload,
remoteAddress: CAM_IP,
});
}
it("hands a vehicle (active) detection to the bridge", async () => {
const res = await post(VEHICLE_XML);
expect(res.statusCode).toBe(200);
expect(onVehicleDetected).toHaveBeenCalledTimes(1);
expect(onVehicleDetected).toHaveBeenCalledWith(CAM_ID);
});
it("does NOT call the bridge for a human target", async () => {
const human = VEHICLE_XML.replace("vehicle", "human");
await post(human);
expect(onVehicleDetected).not.toHaveBeenCalled();
});
it("does NOT call the bridge on an `inactive` (leave) vehicle event", async () => {
const leave = VEHICLE_XML.replace("<eventState>active</eventState>", "<eventState>inactive</eventState>");
await post(leave);
expect(onVehicleDetected).not.toHaveBeenCalled();
});
});
+280
View File
@@ -0,0 +1,280 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { deviceEvents } from "../device-events.js";
import { requirePermission } from "../auth.js";
import { verifyDigest } from "../digest-auth.js";
import type { LaneStatus } from "../lane-status.js";
import type { AnprBridge } from "../anpr-entry.js";
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
// Settings → Alarm Server) HTTP-POST an EventNotificationAlert to a URL we host every
// time the chosen target is detected. This is the same machine-call pattern as the
// Dingtian Input Link push (routes/devices.ts): source-IP guarded, NOT behind the SPA
// cookie/CSRF.
//
// DISCOVERY-FIRST. Hik's push format varies by model/firmware (event XML, or multipart
// with an attached JPEG, or — on some ANPR units — an <ANPR>/<plateNumber> block). So
// this endpoint is deliberately PERMISSIVE: it accepts ANY content-type as raw bytes,
// records the verbatim body as a `kind:"alarm"` device_event, and best-effort extracts a
// summary (eventType / target / plate). The goal of this first cut is to SEE exactly what
// a given camera sends — inspect via GET /api/events or the logs — before we wire it into
// the read bus / a snapshot trigger. It never opens a barrier (a plate read is advisory,
// never the sole reason; see wiki/concepts/append-only-event-chain.md).
//
// See wiki/entities/lpr-camera.md, wiki/concepts/device-input-flow.md.
interface HikDeviceConfig {
host?: string;
alarmPushEnabled?: boolean | string | number;
pushUser?: string;
pushPassword?: string;
/** Skip the source-IP guard for this device's pushes. The source IP is the primary
* LAN guard, but it's UNRELIABLE in some environments — notably WSL mirrored mode,
* which rewrites an inbound packet's source to the host's OWN address, so the camera's
* real IP never survives and a strict check rejects every push. When pushUser/
* pushPassword (Digest) are set, that auth is the real guard and source-IP adds little;
* this flag lets a deployment opt out. The signed ledger remains the anti-fraud truth. */
skipSourceIpCheck?: boolean | string | number;
}
/** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the
* setup form, which has historically stored a checkbox as the STRING "true" (a form-
* serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the
* rest. Being lenient here means a stray "true" never silently disables a real feature. */
function isOn(v: unknown): boolean {
if (v === true) return true;
if (typeof v === "number") return v === 1;
if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim());
return false;
}
/** A best-effort summary pulled out of the raw push body (XML or JSON), for the device
* event detail + the log line. Absent fields just mean "not found in this firmware's
* payload" — the raw body is always stored so nothing is lost. */
interface AlarmSummary {
eventType?: string;
/** `active` (target entered the region) | `inactive` (target left). The edge that
* drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */
eventState?: string;
target?: string;
plate?: string;
dateTime?: string;
channelId?: string;
}
function clientIp(req: FastifyRequest): string {
return req.ip.replace(/^::ffff:/, "");
}
/** First capture group of `re` in `s`, trimmed, or undefined. */
function pick(s: string, re: RegExp): string | undefined {
const m = re.exec(s);
return m?.[1]?.trim() || undefined;
}
/**
* Best-effort summary extraction. Hikvision event XML uses tags like <eventType>,
* <dateTime>, <channelID>; smart/ANPR events add target/plate tags whose exact names
* vary by firmware (<detectionTarget>, <targetType>, <plateNumber>, <licensePlate>).
* We probe several spellings; whatever doesn't match is simply absent. JSON bodies are
* scanned for the same keys.
*/
function summarize(body: string): AlarmSummary {
return {
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
eventState: pick(body, /<eventState>([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i),
target:
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i),
plate:
pick(body, /<(?:plateNumber|licensePlate|plateNo)>([^<]+)<\//i) ??
pick(body, /"(?:plateNumber|licensePlate|plateNo)"\s*:\s*"([^"]+)"/i),
dateTime: pick(body, /<dateTime>([^<]+)<\/dateTime>/i),
channelId: pick(body, /<channelID>([^<]+)<\/channelID>/i) ?? pick(body, /<channelId>([^<]+)<\/channelId>/i),
};
}
export async function hikvisionAlarmRoutes(
app: FastifyInstance,
db: Db,
laneStatus?: LaneStatus,
anprBridge?: AnprBridge,
): Promise<void> {
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
// wildcard parser; a 10 MB cap covers an event + an attached frame.
app.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, (_req, body, done) => {
done(null, body);
});
/** Record EVERY push (accepted or rejected) as a device_event so the read endpoint /
* DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a
* warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
function record(args: {
deviceId: string;
method: string;
accepted: boolean;
reason?: string;
ip: string;
contentType: string;
raw: Buffer;
summary: AlarmSummary;
}): void {
try {
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: args.deviceId,
category: "camera",
kind: args.accepted ? "alarm" : "alarm-rejected",
detail: {
source: "hikvision-alarm-server",
accepted: args.accepted,
method: args.method,
...(args.reason ? { reason: args.reason } : {}),
ip: args.ip,
contentType: args.contentType,
bytes: args.raw.length,
...args.summary,
// Readable head verbatim (the XML part); truncated to keep the row small.
rawHead: args.raw.toString("utf8").slice(0, 8000),
},
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
}
}
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
const { deviceId } = req.params;
const method = req.method;
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
const cfg = row?.config as HikDeviceConfig | undefined;
const ip = clientIp(req);
const contentType = String(req.headers["content-type"] ?? "");
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
const summary = summarize(raw.toString("utf8"));
// Log EVERY hit immediately (method + ip + size), before any guard — so even a probe
// that gets rejected is visible in the dev log the instant it arrives.
app.log.info(`[hik-alarm:${deviceId}] HIT ${method} from ${ip} (${contentType || "no-ct"} ${raw.length}B)`);
// Guard: must be a known hikvision device with alarm-push enabled, posting from its
// configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian).
// On rejection we STILL record it (with the precise reason) so a push that reached us
// never silently disappears — that's what makes "is it coming?" answerable.
// The source-IP check is skipped when the device opts out (skipSourceIpCheck) — needed
// where the network rewrites the inbound source IP (e.g. WSL mirrored mode rewrites it
// to the host's own address), so a strict match can never pass. Digest auth (when set)
// and the signed ledger remain the real guards. See HikDeviceConfig.skipSourceIpCheck.
const skipIp = isOn(cfg?.skipSourceIpCheck);
let reason: string | null = null;
if (!row || !cfg) reason = "unknown device id";
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
else if (!isOn(cfg.alarmPushEnabled)) reason = "alarm push not enabled on this device (tick it in Setup)";
else if (!cfg.host) reason = "device has no host IP configured";
else if (!skipIp && ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host} (set skipSourceIpCheck if the network rewrites it, e.g. WSL)`;
if (reason) {
app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`);
record({ deviceId, method, accepted: false, reason, ip, contentType, raw, summary });
return reply.code(404).send({ error: "not found", reason });
}
// Optional Digest auth — only when the admin configured push creds (some firmware
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
if (cfg!.pushUser && cfg!.pushPassword) {
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: cfg!.pushPassword })) {
record({ deviceId, method, accepted: false, reason: "digest auth failed/challenge", ip, contentType, raw, summary });
return; // 401 challenge already sent
}
}
// Loud log so the operator can SEE the payload during testing.
app.log.info(
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
`event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
);
record({ deviceId, method, accepted: true, ip, contentType, raw, summary });
// Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory,
// for the booth barrier lights). Only on a vehicle target that's `active` — an
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
const isVehicleActive =
(summary.target ?? "").toLowerCase() === "vehicle" &&
(summary.eventState ?? "active").toLowerCase() !== "inactive";
if (laneStatus && isVehicleActive) {
laneStatus.vehicleDetected(deviceId);
}
// ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr),
// pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read
// onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit +
// barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get
// a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts.
if (anprBridge && isVehicleActive) {
void anprBridge.onVehicleDetected(deviceId);
}
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
// entry/exit) is the deliberate next step once we know the real payload.
deviceEvents.emitInput({ driverId: "hikvision", deviceId, input: 0, edge: "on", at: new Date().toISOString(), source: "push" });
// 200 so the camera considers the alarm delivered and doesn't retry-storm.
return reply.code(200).send({ ok: true });
};
// Listen for EVERY method on the event path. The camera (and its "Test" button) may
// probe with GET/HEAD/OPTIONS/PUT, not just POST — and a method we don't register gets
// Fastify's generic 404, which the camera reads as "service available" while our
// handler never runs (so nothing is recorded). Registering all methods means ANYTHING
// that hits this URL reaches `handle` and is captured (the method is logged + stored),
// so we can finally SEE exactly what the camera sends. See wiki/entities/lpr-camera.md.
// (HEAD is auto-added by Fastify alongside GET — don't register it explicitly.)
for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
}
// Read endpoint: the recent alarm pushes (accepted AND rejected), newest first — so you
// can SEE in the browser whether events are arriving and why any were refused, instead
// of grepping the dev log or querying SQLite. Gated device:read (admin device view).
app.get<{ Querystring: { limit?: string } }>(
"/api/devices/hikvision/alarms",
{ preHandler: requirePermission("device:read") },
async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
const rows = db
.select()
.from(deviceEventsTable)
.where(inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))
.orderBy(desc(deviceEventsTable.occurredAt))
.limit(limit)
.all();
const alarms = rows.map((r) => {
const d = (r.detail ?? {}) as Record<string, unknown>;
return {
at: r.occurredAt,
deviceId: r.deviceId,
accepted: d.accepted === true,
method: (d.method as string) ?? null,
reason: (d.reason as string) ?? null,
ip: (d.ip as string) ?? null,
contentType: (d.contentType as string) ?? null,
bytes: (d.bytes as number) ?? 0,
eventType: (d.eventType as string) ?? null,
eventState: (d.eventState as string) ?? null,
target: (d.target as string) ?? null,
plate: (d.plate as string) ?? null,
rawHead: (d.rawHead as string) ?? null,
};
});
return { count: alarms.length, alarms };
},
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { FastifyInstance } from "fastify";
import type { AppLogRecord, ClientLogInput, LogLevel } from "@parking/shared";
import { requireAuth, requirePermission } from "../auth.js";
import type { LogService } from "../log-service.js";
// Application/diagnostic logs (app_logs) — see wiki/concepts/app-logs.md. Two ends:
// - POST /api/logs : the FRONTEND ships its errors here (failed requests, uncaught
// exceptions). Any signed-in user may write (it's their own
// browser's diagnostics); CSRF still applies (mutation).
// - GET /api/logs : read the store — gated by `log:read` (admin/diagnostic role).
// Writes go through the shared LogService (bounded, best-effort, reentrancy-guarded);
// the DB sink for BACKEND warn+ is wired at the pino stream, not here.
const LEVELS: ReadonlySet<string> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
/** Cap a single ingest batch so a misbehaving client can't flood the store. */
const MAX_BATCH = 50;
function isValidEntry(e: unknown): e is ClientLogInput {
if (!e || typeof e !== "object") return false;
const o = e as Record<string, unknown>;
return typeof o.message === "string" && typeof o.level === "string" && LEVELS.has(o.level);
}
export async function logRoutes(app: FastifyInstance, logService: LogService): Promise<void> {
// INGEST — accept one entry or a small batch ({ entries: [...] }). Returns 204.
// Deliberately tolerant: it never 4xx's on a malformed entry (a client erroring
// while reporting an error shouldn't get a second error) — invalid items are skipped.
app.post<{ Body: ClientLogInput | { entries?: unknown[] } }>(
"/api/logs",
{ preHandler: requireAuth },
async (req, reply) => {
const body = req.body as ClientLogInput | { entries?: unknown[] };
const raw = Array.isArray((body as { entries?: unknown[] }).entries)
? (body as { entries: unknown[] }).entries
: [body];
const userId = req.user?.sub ?? null;
const userAgent = req.headers["user-agent"] ?? null;
for (const entry of raw.slice(0, MAX_BATCH)) {
if (!isValidEntry(entry)) continue;
logService.recordClient(entry, { userId, userAgent });
}
reply.code(204).send();
},
);
// READ — newest first, with optional level/source/since filters + a limit. The
// booth Logs viewer calls this. Gated by log:read.
app.get<{ Querystring: { limit?: string; level?: string; source?: string; since?: string } }>(
"/api/logs",
{ preHandler: requirePermission("log:read") },
async (req): Promise<{ logs: AppLogRecord[] }> => {
const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 2000);
const level = (req.query.level ?? "").trim();
const source = (req.query.source ?? "").trim();
const since = (req.query.since ?? "").trim();
const logs = logService.query({
limit,
level: LEVELS.has(level) ? (level as LogLevel) : undefined,
source: source === "frontend" || source === "backend" ? source : undefined,
since: since || undefined,
});
return { logs };
},
);
}
+256
View File
@@ -0,0 +1,256 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requirePermission } from "../auth.js";
import {
NoOpenSessionError,
NoTariffError,
type PayStation,
} from "../pay-station.js";
import type { ExitFlow } from "../exit-flow.js";
import type { VoidFlow } from "../void-flow.js";
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
import { printPaymentReceipt } from "../booth-print.js";
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
// when the booth is at/near the exit — open the barrier. The payment becomes a
// signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
// P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
interface QuoteQuery {
identity: string;
}
interface PayBody {
identity: string;
tender: "cash" | "card";
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
overrideMinor?: number;
}
interface ExitBody {
identity: string;
}
interface VoucherBody {
identity: string;
}
interface ReceiptBody {
identity: string;
}
interface VoidBody {
identity: string;
reason: string;
}
export async function payRoutes(
app: FastifyInstance,
db: Db,
payStation: PayStation,
exitFlow: ExitFlow,
shift: ShiftService,
voidFlow: VoidFlow,
): Promise<void> {
// Reads (lookup, active sessions, quote) need session/payment read; the booth
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
// single guard covers the whole booth flow — anyone who takes payment also reads
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
const guard = requirePermission("payment:create");
const readGuard = requirePermission("session:read");
const voidGuard = requirePermission("event:void");
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
// re-open is processed, so every taking is attributed to a shift (one operator's
// accountability period). Read-only lookups (session/active/quote) stay ungated so
// the modal can still DISPLAY the session and prompt the operator to open a shift.
// Returns 409 { error, code: "no_shift" } so the UI can show the "open a shift"
// prompt rather than a generic failure. See wiki/concepts/shift.md.
const requireShift = async (
_req: import("fastify").FastifyRequest,
reply: import("fastify").FastifyReply,
) => {
try {
shift.requireOpenShift();
} catch (err) {
if (err instanceof NoShiftOpenError) {
return reply.code(409).send({ error: err.message, code: "no_shift" });
}
throw err;
}
};
// Active sessions for the booth list: still-open OR exited-but-within-grace
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
app.get("/api/sessions/active", { preHandler: readGuard }, async () => ({
sessions: payStation.activeSessions(),
}));
// Session lookup for the booth pay/exit modal: entry/exit times, paid state,
// amount owed now, walk-back-grace status. Read-only (no side effect).
app.get<{ Params: { identity: string } }>(
"/api/session/:identity",
{ preHandler: readGuard },
async (req, reply) => {
const identity = (req.params.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
return payStation.lookup(identity);
},
);
// Booth-driven exit: validate (paid + grace, or free entry-grace) THEN sign
// vehicle_exit + open the barrier. Maps the discriminated result to HTTP:
// - validation reject → 409 with a reason (operator takes payment first),
// - exit signed but barrier didn't open → 200 { opened:false } (payment stands;
// operator opens manually; an anomaly is already signed),
// - clean exit → 200 { opened:true }.
app.post<{ Body: ExitBody }>(
"/api/exit",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const res = await exitFlow.exitForBooth(identity);
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
return reply.code(200).send(res);
},
);
// Human-intervention barrier re-open for an ACTIVE (paid) session — damaged
// ticket / dead scanner / phantom re-close. Re-pulses the exit relay + signs an
// anomaly (attributed); NEVER a second vehicle_exit. Refused without a payment
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ExitBody }>(
"/api/barrier/reopen",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const operator = req.user?.username;
const res = await exitFlow.reopenBarrier(identity, operator);
if (!res.ok) return reply.code(409).send({ error: res.reason });
return reply.code(200).send(res);
},
);
// Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event
// referencing the entry, with the operator + a REQUIRED reason — the entry itself is
// never edited/deleted (append-only). The session projection folds the void to CLOSED,
// so the voided car stops counting inside and can't be paid/exited. Opens NO barrier
// (the misprinted ticket's car never entered). Gated on event:void + an open shift
// (the booth accountability period). Refusals (subscription / already exited / already
// voided / already paid) → 409. See void-flow.ts, wiki/concepts/append-only-event-chain.md.
app.post<{ Body: VoidBody }>(
"/api/tickets/void",
{ preHandler: [voidGuard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
const reason = (req.body?.reason ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
if (!reason) return reply.code(400).send({ error: "a cancellation reason is required" });
const operator = req.user?.username ?? "unknown";
const res = await voidFlow.voidTicket({ identity, reason, operator });
if (!res.ok) return reply.code(409).send({ error: res.reason });
return reply.code(201).send(res);
},
);
// Quote: what does this session owe right now? (No side effect.)
app.get<{ Querystring: QuoteQuery }>(
"/api/pay/quote",
{ preHandler: readGuard },
async (req, reply) => {
const identity = (req.query.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
try {
return payStation.quote(identity);
} catch (err) {
return mapError(reply, err);
}
},
);
// Pay: take payment and append the signed `payment` event.
app.post<{ Body: PayBody }>(
"/api/pay",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const { identity, tender, overrideMinor } = req.body ?? {};
if (!identity || (tender !== "cash" && tender !== "card")) {
return reply.code(400).send({ error: "identity and tender (cash|card) required" });
}
if (overrideMinor != null && (!Number.isInteger(overrideMinor) || overrideMinor < 0)) {
return reply.code(400).send({ error: "overrideMinor must be a non-negative integer (minor units)" });
}
try {
const res = await payStation.pay(identity, tender, overrideMinor);
return reply.code(201).send(res);
} catch (err) {
return mapError(reply, err);
}
},
);
// Print an exit voucher (the paid ticket id reprinted as a barcode) on the booth
// printer. Used when the booth is far from the exit — the customer self-scans the
// voucher at the exit reader, which runs the normal validated exit. Requires the
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
app.post<{ Body: VoucherBody }>(
"/api/voucher",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found || !view.open) {
return reply.code(404).send({ error: "no open session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" });
}
try {
const printedBy = await printPaymentReceipt(db, identity, { voucher: true }, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Print a standalone PAYMENT RECEIPT (transparency: entry/paid/duration/amount,
// no barcode) on the booth printer. Used (a) auto, right after a payment when no
// voucher is issued, and (b) on-demand "reprint" if the slip jammed. Requires the
// session to be PAID. See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ReceiptBody }>(
"/api/receipt",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found) {
return reply.code(404).send({ error: "no session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — nothing to receipt" });
}
try {
const printedBy = await printPaymentReceipt(db, identity, { voucher: false }, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
}
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
if (err instanceof NoOpenSessionError) return reply.code(404).send({ error: err.message });
if (err instanceof NoTariffError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
import { deviceEvents } from "../device-events.js"; import { deviceEvents } from "../device-events.js";
import type { PrinterMonitor } from "../printer-monitor.js"; import type { PrinterMonitor } from "../printer-monitor.js";
@@ -12,7 +12,7 @@ export async function printerRoutes(
app: FastifyInstance, app: FastifyInstance,
monitor: PrinterMonitor, monitor: PrinterMonitor,
): Promise<void> { ): Promise<void> {
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requirePermission("device:read");
// Current status of every monitored printer (cached — no device round-trip). // Current status of every monitored printer (cached — no device round-trip).
app.get("/api/printers/status", { preHandler: guard }, async () => ({ app.get("/api/printers/status", { preHandler: guard }, async () => ({
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { eq, users, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// Self-service profile (routes/auth.ts): /api/auth/profile + /api/auth/password. These act
// ONLY on the signed-in user, need NO `user:*` permission (any role), and the password change
// must prove the current password. Distinct from admin user-management (routes/users.ts).
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();
});
describe("PUT /api/auth/profile (self-service)", () => {
it("a permission-less user can edit their OWN name + email", async () => {
// 'viewer' role with NO user:* permission — profile is not gated on it.
const { username, password } = await seedUser(db, {
username: "cashier", roleId: "viewer", permissions: [],
});
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: "Mon Kukaleshi", email: "mon@example.com" },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.fullName).toBe("Mon Kukaleshi");
expect(body.email).toBe("mon@example.com");
// Persisted to the caller's own row.
const row = db.select().from(users).where(eq(users.username, "cashier")).get();
expect(row?.fullName).toBe("Mon Kukaleshi");
expect(row?.email).toBe("mon@example.com");
});
it('clears a field when sent ""', async () => {
const { username, password } = await seedUser(db, { username: "u2", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
// First set a name…
await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: "Old Name" },
});
// …then clear it with whitespace (→ null).
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: " " },
});
expect(res.statusCode).toBe(200);
expect(res.json().fullName).toBeNull();
});
it("rejects an empty patch (nothing to update)", async () => {
const { username, password } = await seedUser(db, { username: "u3", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it("requires a session (401 without a token)", async () => {
const res = await app.inject({ method: "PUT", url: "/api/auth/profile", payload: { fullName: "x" } });
expect(res.statusCode).toBe(401);
});
});
describe("PUT /api/auth/password (self-service)", () => {
it("changes the password when the current one is correct, and the new one then logs in", async () => {
const { username, password } = await seedUser(db, { username: "p1", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: password, newPassword: "brand-new-pw-123" },
});
expect(res.statusCode).toBe(200);
// Old password no longer works; new one does.
const oldTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
expect(oldTry.statusCode).toBe(401);
const newTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "brand-new-pw-123" } });
expect(newTry.statusCode).toBe(200);
});
it("refuses when the current password is wrong (403) and leaves the password unchanged", async () => {
const { username, password } = await seedUser(db, { username: "p2", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: "not-it", newPassword: "brand-new-pw-123" },
});
expect(res.statusCode).toBe(403);
// Original password still works.
const still = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
expect(still.statusCode).toBe(200);
});
it("rejects a too-short new password (400)", async () => {
const { username, password } = await seedUser(db, { username: "p3", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: password, newPassword: "short" },
});
expect(res.statusCode).toBe(400);
});
});
+128
View File
@@ -0,0 +1,128 @@
import type { FastifyInstance } from "fastify";
import { eq, devices, type Db } from "@parking/db";
import type { DeviceReadEvent } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.js";
import type { CredentialCapture } from "../credential-capture.js";
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
//
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
// reply status: 1 = valid (beep 2×) / 0 = invalid (beep 1×)
// reply output: 0 = Access, 1 = WG26, 2 = WG34 (line driven on a valid read)
// reply time: UTC — syncs the device clock
//
// The "server language" set on the device only selects this URL path; we accept the
// SDK default path. No auth on the device side (it can't); the reader sits on the
// device subnet (network-isolation) and the signed ledger is the real guarantee.
interface ReaderQuery {
cardid?: string;
mjihao?: string; // device id
cjihao?: string; // device serial
status?: string; // 2 chars: high valid/invalid, low 1=in/0=out
time?: string;
}
export async function qrReaderRoutes(
app: FastifyInstance,
db: Db,
dispatcher: ReadDispatcher,
capture: CredentialCapture,
): Promise<void> {
// Resolve the lane_devices row whose config.serial matches the reader's reported
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
// reader is assigned for that serial. (Small device set → scan in JS.)
const readerRowIdForSerial = (serial: string): string | null => {
if (!serial) return null;
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial);
return match?.id ?? null;
};
// No auth: the reader is a machine on the isolated device subnet and offers no
// auth on its side. Public route, like the Dingtian input push.
const handler = async (req: { query: ReaderQuery }, reply: import("fastify").FastifyReply) => {
const q = req.query;
// The reader sends `Connection: keep-alive` but only ACTS on our verdict (beep,
// drive output) once the socket CLOSES — every vendor demo replies
// `Connection: close` and shuts the socket. Without it the reader waits out a
// ~10 s keep-alive timeout before beeping. So force-close the connection.
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
reply.header("connection", "close");
const cardid = (q.cardid ?? "").trim();
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
const serial = (q.cjihao ?? "").trim();
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
// resolves the lane from that row). If unassigned, deviceId stays the serial so
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
const matchedRowId = readerRowIdForSerial(serial);
const deviceId = matchedRowId ?? serial;
let accepted = false;
if (cardid) {
// 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}`);
accepted = true; // beep "ok" so the operator knows the card was read
} else {
const read: DeviceReadEvent = {
driverId: "gee-qr-reader",
deviceId,
value: cardid,
kind: "qr",
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).
app.log.info(
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
`card=${cardid} 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}`);
}
}
}
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
// output 0 = Access (drive the reader's access line on a valid read).
return {
data: [
{
cardid,
cjihao: q.cjihao ?? 0,
mjihao,
status: accepted ? 1 : 0,
time: String(Math.floor(Date.now() / 1000)),
output: 0,
},
],
code: 0,
message: "",
};
};
// The reader's "server language" setting (JSP/PHP/C#/ASP/CGI) selects the URL
// EXTENSION it GETs — verified on hardware: a JSP-configured unit posts
// /qa/mcardsea.jsp. Register every extension so the endpoint works whatever the
// device is set to; accept POST too in case a variant differs.
for (const ext of ["php", "jsp", "asp", "aspx", "cgi"]) {
const path = `/qa/mcardsea.${ext}`;
app.get<{ Querystring: ReaderQuery }>(path, handler);
app.post<{ Querystring: ReaderQuery }>(path, handler);
}
}
@@ -0,0 +1,114 @@
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";
// HTTP integration for soft delete + recycle bin: an admin DELETE soft-deletes (the user
// leaves the list, can't log in), the bin lists it, restore brings it back, and a deleted
// user can log in again. Drives the REAL app over a fresh in-memory DB via app.inject.
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();
});
/** Log in an admin and return the auth headers for mutations. */
async function asAdmin() {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
return { cookie, csrf };
}
describe("soft delete via the resource DELETE route", () => {
it("DELETE /api/users/:id soft-deletes: user leaves the list and can't log in, but is restorable", async () => {
const { cookie, csrf } = await asAdmin();
// Create a victim user to delete.
await seedUser(db, { username: "victim", password: "victim-pass-123", roleId: "admin" });
const victim = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string; id: string }) => u.username === "victim");
expect(victim).toBeDefined();
// Delete (soft).
const del = await app.inject({
method: "DELETE", url: `/api/users/${victim.id}`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(del.statusCode).toBeLessThan(300);
// Gone from the live list.
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json();
expect(list.users.some((u: { username: string }) => u.username === "victim")).toBe(false);
// Can't log in.
const relogin = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
expect(relogin.statusCode).toBe(401);
// Shows in the recycle bin.
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
expect(bin.items.some((i: { kind: string; label: string }) => i.kind === "user" && i.label === "victim")).toBe(true);
// Restore → reappears + can log in.
const restore = await app.inject({
method: "POST", url: `/api/recycle-bin/user/${victim.id}/restore`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(restore.statusCode).toBeLessThan(300);
const relogin2 = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
expect(relogin2.statusCode).toBe(200);
});
it("purge permanently removes a soft-deleted user", async () => {
const { cookie, csrf } = await asAdmin();
await seedUser(db, { username: "gone", password: "gone-pass-1234", roleId: "admin" });
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string }) => u.username === "gone").id;
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
const purge = await app.inject({
method: "DELETE", url: `/api/recycle-bin/user/${id}`,
headers: { cookie, "x-csrf-token": csrf },
});
expect(purge.statusCode).toBe(204);
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
expect(bin.items.some((i: { label: string }) => i.label === "gone")).toBe(false);
});
it("the recycle bin is gated — a user without recyclebin:read is 403", async () => {
const { username, password } = await seedUser(db, {
username: "plain", roleId: "plain", permissions: ["user:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } });
expect(res.statusCode).toBe(403);
});
it("recreating a user with a soft-deleted user's username gives a clear 409", async () => {
const { cookie, csrf } = await asAdmin();
await seedUser(db, { username: "dup", password: "dup-pass-12345", roleId: "admin" });
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
.users.find((u: { username: string }) => u.username === "dup").id;
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
const create = await app.inject({
method: "POST", url: "/api/users",
headers: { cookie, "x-csrf-token": csrf },
payload: { username: "dup", password: "new-pass-12345", roleId: "admin" },
});
expect(create.statusCode).toBe(409);
expect(create.json().error).toMatch(/recycle bin/i);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { requirePermission, bumpPermsCache } from "../auth.js";
import {
listRecycleBin,
purge,
restore,
restoreBlockedReason,
retentionDays,
RESOURCE_KINDS,
type ResourceKind,
} from "../recycle-bin.js";
// Recycle bin API — view / restore / purge soft-deleted master data. The actual
// soft-delete STAMP happens in each resource's own DELETE route (users/roles/
// subscriptions/plans/tariffs); this is the way back. Admin-grade (recyclebin:*).
// See recycle-bin.ts, wiki/concepts/soft-delete.md.
function isKind(s: string): s is ResourceKind {
return (RESOURCE_KINDS as string[]).includes(s);
}
export async function recycleBinRoutes(app: FastifyInstance, db: Db): Promise<void> {
// List everything in the bin (+ the retention window so the UI can warn how long
// items survive before auto-purge).
app.get(
"/api/recycle-bin",
{ preHandler: requirePermission("recyclebin:read") },
async () => ({ items: listRecycleBin(db), retentionDays: retentionDays() }),
);
// Restore a soft-deleted item (clear the stamps → it reappears in its catalog).
// Blocked with a 409 when a live row would collide (e.g. the username was reused).
app.post<{ Params: { kind: string; id: string } }>(
"/api/recycle-bin/:kind/:id/restore",
{ preHandler: requirePermission("recyclebin:update") },
async (req, reply) => {
const { kind, id } = req.params;
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
const blocked = restoreBlockedReason(db, kind, id);
if (blocked) return reply.code(409).send({ error: `cannot restore: ${blocked}` });
const ok = restore(db, kind, id);
if (!ok) return reply.code(404).send({ error: "no deleted item to restore" });
// A restored role/user changes the authz picture — drop the permission cache.
if (kind === "role" || kind === "user") bumpPermsCache();
app.log.info(`recycle-bin: restored ${kind} ${id}`);
return { kind, id, restored: true };
},
);
// Purge (permanently delete) a soft-deleted item + its children. Irreversible.
app.delete<{ Params: { kind: string; id: string } }>(
"/api/recycle-bin/:kind/:id",
{ preHandler: requirePermission("recyclebin:delete") },
async (req, reply) => {
const { kind, id } = req.params;
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
const ok = purge(db, kind, id);
if (!ok) return reply.code(404).send({ error: "no deleted item to purge" });
if (kind === "role" || kind === "user") bumpPermsCache();
app.log.warn(`recycle-bin: PURGED ${kind} ${id} (permanent)`);
return reply.code(204).send();
},
);
}
+65
View File
@@ -0,0 +1,65 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import { reportSummary, type Bucket } from "../reports.js";
// Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions
// cache for durations); no writes, no new event types. Gated on `report:read` — the
// same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md.
const BUCKETS: Bucket[] = ["hour", "day", "month"];
/** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */
function parseQuery(q: { from?: string; to?: string; bucket?: string }): {
from: string;
to: string;
bucket: Bucket;
} {
const now = Date.now();
const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString();
const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString();
const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day";
// Guard the inversion (from after to) — swap rather than return an empty report.
return from <= to ? { from, to, bucket } : { from: to, to: from, bucket };
}
function isFiniteIso(s: string | undefined): boolean {
return !!s && Number.isFinite(Date.parse(s));
}
export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void> {
const guard = requirePermission("report:read");
// The whole dashboard in one call: totals, the time series, peak-hour histogram, and
// subscription stats — aggregated server-side so the SPA just renders. Bucketed in the
// site timezone. See reports.ts.
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
"/api/reports/summary",
{ preHandler: guard },
async (req) => reportSummary(db, parseQuery(req.query)),
);
// The same series as CSV (one row per bucket) for spreadsheet / accountant export.
// Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike
// the JSON which stays in minor units. text/csv with a download filename.
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
"/api/reports/summary.csv",
{ preHandler: guard },
async (req, reply) => {
const summary = reportSummary(db, parseQuery(req.query));
const lines = [
"bucket,entries,exits,payments,revenue",
...summary.series.map((p) =>
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
),
];
reply
.header("content-type", "text/csv; charset=utf-8")
.header(
"content-disposition",
`attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`,
)
.send(lines.join("\n") + "\n");
},
);
}
+171
View File
@@ -0,0 +1,171 @@
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 { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
// role. The built-in `admin` role (id ADMIN_ROLE_ID) is PROTECTED — it can't be
// edited or deleted and always resolves to every permission in code. Every write
// here bumps the in-memory permission cache so changes take effect on the next
// request. See @parking/shared PERMISSIONS and ../auth.ts.
//
// PRIVILEGE-ESCALATION GUARD: `role:update`/`role:create` must NOT let a caller
// grant a permission they don't themselves hold — otherwise a non-admin with
// `role:*` could edit their own role to add (say) `tariff:update`, or mint a role
// 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.
interface RoleBody {
name: string;
permissions: string[];
}
interface UpdateBody {
name?: string;
permissions?: string[];
}
const VALID = new Set<string>(PERMISSIONS);
/** Validate + dedupe a requested permission list against the code-defined grid. */
function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | { ok: false; bad: string } {
if (!Array.isArray(input)) return { ok: false, bad: "permissions must be an array" };
const out = new Set<Permission>();
for (const p of input) {
if (typeof p !== "string" || !VALID.has(p)) return { ok: false, bad: `unknown permission: ${String(p)}` };
out.add(p as Permission);
}
return { ok: true, perms: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update");
const deleteGuard = requirePermission("role:delete");
/** A role + its permission list + how many users hold it. */
function roleView(roleId: string) {
const role = db.select().from(roles).where(eq(roles.id, roleId)).get();
if (!role) return null;
const perms = db
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, roleId))
.all()
.map((r) => r.permission);
const userCount = db.select().from(users).where(and(eq(users.roleId, roleId), isNull(users.deletedAt))).all().length;
// The admin role always reports the full grid (it's enforced in code).
return {
id: role.id,
name: role.name,
builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
userCount,
};
}
/** 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();
for (const p of perms) {
db.insert(rolePermissions).values({ roleId, permission: p }).run();
}
}
// The full permission grid (for the role-composer checkbox UI) + every LIVE role.
// Soft-deleted roles live in the recycle bin, not here.
app.get("/api/roles", { preHandler: readGuard }, async () => {
const all = db.select().from(roles).where(isNull(roles.deletedAt)).all();
return {
catalog: PERMISSIONS,
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
};
});
/** Reject any permission in `perms` the caller does not themselves hold — so a
* non-admin can't grant privileges beyond their own. Returns the offending
* permission, or null if all are within the caller's set. (Admin holds the full
* set, so it never trips.) */
function escalates(callerRoleId: string, perms: Permission[]): Permission | null {
const held = permissionsFor(callerRoleId);
return perms.find((p) => !held.has(p)) ?? null;
}
// Create a composable role from a name + a permission set.
app.post<{ Body: RoleBody }>("/api/roles", { preHandler: createGuard }, async (req, reply) => {
const name = (req.body?.name ?? "").trim();
if (!name) return reply.code(400).send({ error: "name required" });
if (db.select().from(roles).where(eq(roles.name, name)).get()) {
return reply.code(409).send({ error: "a role with that name already exists" });
}
const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.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);
bumpPermsCache();
return reply.code(201).send(roleView(id));
});
// Edit a role's name and/or permission set. The built-in admin role is locked.
app.put<{ Params: { id: string }; Body: UpdateBody }>(
"/api/roles/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const role = db.select().from(roles).where(eq(roles.id, id)).get();
if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
}
if (req.body?.name != null) {
const name = req.body.name.trim();
if (!name) return reply.code(400).send({ error: "name cannot be empty" });
const clash = db.select().from(roles).where(eq(roles.name, name)).get();
if (clash && clash.id !== id) return reply.code(409).send({ error: "a role with that name already exists" });
db.update(roles).set({ name }).where(eq(roles.id, id)).run();
}
if (req.body?.permissions != null) {
const cleaned = cleanPermissions(req.body.permissions);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.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}` });
setPermissions(id, cleaned.perms);
}
bumpPermsCache();
return roleView(id);
},
);
// Delete a role — SOFT (recycle bin). Refused if built-in or any LIVE user still holds
// it. The row is stamped deleted (recoverable), not removed; its permission rows are
// KEPT so a restore brings the role back intact. Restore/purge from the recycle bin.
app.delete<{ Params: { id: string } }>(
"/api/roles/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const role = db.select().from(roles).where(and(eq(roles.id, id), isNull(roles.deletedAt))).get();
if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
}
// Only LIVE holders block deletion (a soft-deleted user's role assignment is moot).
const holders = db.select().from(users).where(and(eq(users.roleId, id), isNull(users.deletedAt))).all().length;
if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
}
softDelete(db, "role", id, req.user.sub);
bumpPermsCache();
return { ok: true };
},
);
}
+103
View File
@@ -0,0 +1,103 @@
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";
// HTTP integration: boot the REAL Fastify app over a fresh in-memory DB (no listen —
// app.inject drives it) and exercise the auth + RBAC guards end to end. The point is the
// security seam: no token → 401, wrong permission → 403, CSRF required on mutations, and
// a correctly-scoped user passes. (vitest.config sets JWT_SECRET/EVENT_SIGNING_KEY.)
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();
});
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" });
});
it("login with bad credentials is rejected", async () => {
await seedUser(db, { username: "alice", password: "right-password" });
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "wrong" } });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
it("login with good credentials sets auth + csrf cookies", async () => {
await seedUser(db, { username: "alice", password: "right-password" });
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "right-password" } });
expect(res.statusCode).toBe(200);
const names = res.cookies.map((c) => c.name);
expect(names).toContain("parking_token");
expect(names).toContain("parking_csrf");
});
});
describe("auth guard — no token", () => {
it("GET /api/occupancy without a session is 401", async () => {
const res = await app.inject({ method: "GET", url: "/api/occupancy" });
expect(res.statusCode).toBe(401);
});
});
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, {
username: "viewer", roleId: "viewer", permissions: ["site:read"],
});
const { cookie, csrf } = await login(app, username, password);
// GET allowed (site:read).
const get = await app.inject({ method: "GET", url: "/api/occupancy", headers: { cookie } });
expect(get.statusCode).toBe(200);
// PUT requires site:update — which this role lacks → 403 (with valid CSRF, so the
// 403 is the PERMISSION check, not CSRF).
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { capacity: 50 },
});
expect(put.statusCode).toBe(403);
});
it("an admin user passes the same PUT", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { capacity: 50 },
});
expect(put.statusCode).toBeLessThan(300);
});
});
describe("CSRF double-submit on mutations", () => {
it("a mutation with the auth cookie but NO csrf header is 403", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie } = await login(app, username, password);
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie }, // csrf header deliberately omitted
payload: { capacity: 50 },
});
expect(put.statusCode).toBe(403);
});
});
+267 -107
View File
@@ -1,27 +1,33 @@
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, laneDevices, setupState, type Db } from "@parking/db"; import { eq, devices, setupState, type Db } from "@parking/db";
import { import {
hasPreconditions, hasPreconditions,
hasPushConfig, hasPushConfig,
isCamera,
isDiscoverable, isDiscoverable,
isHardenable, isHardenable,
registerBuiltinDrivers, registerBuiltinDrivers,
registry, registry,
setDeviceLogSink, setDeviceLogSink,
type CameraDevice,
type DeviceCategory, type DeviceCategory,
type DeviceConfig,
} from "@parking/devices"; } from "@parking/devices";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
import type { VisionClient } from "../vision-client.js";
// First-run setup API. The admin reads the driver catalog and assigns devices // First-run setup API. The admin reads the driver catalog and assigns devices
// per lane. See wiki/concepts/first-run-setup.md. // per lane. See wiki/concepts/first-run-setup.md.
interface AssignBody { interface AssignBody {
lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
config: Record<string, string | number | boolean>; // Driver config (opaque JSON, validated by the driver). Carries the model's
// direction/binding: access → config.relays=[{relay,direction,button?}];
// reader/camera → config.controllerId + config.relay. See entry-exit-points.md.
config: DeviceConfig;
/** Optional: the backend IP the device should push to (overrides auto-pick; /** Optional: the backend IP the device should push to (overrides auto-pick;
* matters on multi-NIC hosts). */ * matters on multi-NIC hosts). */
backendIp?: string; backendIp?: string;
@@ -48,25 +54,147 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
/** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome =
| { config: Record<string, unknown>; warnings: string[] }
| { error: { code: number; message: string } };
/**
* Validate + configure a device, returning the config to persist. Runs the same
* pipeline for both create and edit: validate the driver config, fix
* preconditions, harden (relay password + protocol lockdown), and set up input
* push (Digest creds + push URLs). Each step is a device write (the device
* reboots on apply). The caller owns the DB row; this never touches the DB.
*
* `id` is the assignment id (stable across an edit) — it's baked into the push
* URL, so editing in place keeps the device pushing to the same path.
* `existingConfig` carries forward secrets the client never sees on edit
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
*/
async function configureDevice(
app: FastifyInstance,
args: {
id: string;
driverId: string;
config: DeviceConfig;
backendIp?: string;
existingConfig?: Record<string, unknown>;
},
): Promise<ConfigureOutcome> {
const { id, driverId, config, backendIp, existingConfig } = args;
// Start from any machine-only secrets already on the row (push/relay passwords
// are redacted out of the client's copy, so an edit would otherwise drop them),
// then layer the submitted config on top.
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return { error: { code: 400, message: (err as Error).message } };
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return {
error: {
code: 502,
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
},
};
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return {
error: {
code: 400,
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
},
};
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
}
return { config: fullConfig, warnings: hardenWarnings };
}
export async function setupRoutes( export async function setupRoutes(
app: FastifyInstance, app: FastifyInstance,
db: Db, db: Db,
// Called after the set of assignments changes (assign/unassign) so the caller vision?: VisionClient | null,
// can refresh anything derived from it — e.g. the device id->lane map.
onAssignmentsChanged: () => void = () => {},
): Promise<void> { ): Promise<void> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
// Setup endpoints require an admin (cookie-based JWT — see ../auth.ts). // Device setup is site administration — it changes which hardware the site runs
const adminGuard = requireRole("admin"); // and how readers bind to relays. Gated on site:update. See ../auth.ts.
const adminGuard = requirePermission("site:update");
// Catalog of selectable drivers per category (no secrets — schema only). // Catalog of selectable drivers per category (no secrets — schema only).
// `discoverable` flags drivers that can scan the LAN. // `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
// drivers that push to the backend (and thus need a backend IP at assign time).
app.get("/api/setup/catalog", async () => { app.get("/api/setup/catalog", async () => {
const catalog = registry.catalog(); const catalog = registry.catalog();
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id); const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
return { ...catalog, discoverable }; const pushCapable = registry.pushCapable();
return { ...catalog, discoverable, pushCapable };
}); });
// Scan the LAN for devices a driver can discover (UDP broadcast, etc). // Scan the LAN for devices a driver can discover (UDP broadcast, etc).
@@ -108,7 +236,7 @@ export async function setupRoutes(
{ preHandler: adminGuard }, { preHandler: adminGuard },
async () => { async () => {
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get(); const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
const rows = await db.select().from(laneDevices).all(); const rows = await db.select().from(devices).all();
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) })); const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
return { completedAt: state?.completedAt ?? null, assignments }; return { completedAt: state?.completedAt ?? null, assignments };
}, },
@@ -140,6 +268,72 @@ export async function setupRoutes(
}, },
); );
// Test ANPR end-to-end on a camera config WITHOUT saving: capture a live snapshot
// off the camera and run it through the vision (ANPR) service, reporting whether a
// plate was extracted, the read, and how long it took. Lets the admin verify the
// camera→vision pipeline before committing the camera's `anpr` opt-in. Advisory +
// fail-soft, exactly like the runtime path (snapshot.ts): a vision failure is a
// reported "no plate", never a 500. See wiki/entities/opencv-anpr-service.md.
app.post<{ Body: TestBody }>(
"/api/setup/test-anpr",
{ preHandler: adminGuard },
async (req, reply) => {
const { driverId, config } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
if (driver.category !== "camera") {
return reply.code(400).send({ error: `driver ${driverId} is not a camera` });
}
if (!vision?.enabled) {
// The vision service is off (VISION_ENABLED unset) — there's nothing to test
// against. Report it cleanly so the UI can say "enable vision first".
return reply.send({ ok: false, reason: "vision-disabled" });
}
let device;
try {
device = registry.create(driverId, config);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!isCamera(device)) {
return reply.code(400).send({ error: `driver ${driverId} cannot capture snapshots` });
}
// 1) Grab a frame off the camera. A camera/network failure here is the failure
// we're testing for — report it, don't 500.
const startedAt = Date.now();
let shot: Awaited<ReturnType<CameraDevice["captureSnapshot"]>>;
try {
shot = await device.captureSnapshot({ direction: "entry" });
} catch (err) {
return reply.send({
ok: false,
reason: "snapshot-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
// 2) Run the same advisory analyze the runtime path uses. `analyze` is fail-soft
// (null on any error/timeout) and applies the confidence floor.
const result = await vision.analyze(shot.bytes, shot.contentType);
const tookMs = Date.now() - startedAt;
if (!result || !result.plate) {
return reply.send({ ok: false, reason: "no-plate", tookMs });
}
return reply.send({
ok: true,
plate: result.plate.text.trim().toUpperCase(),
confidence: result.plate.confidence,
region: result.plate.region ?? null,
lowConfidence: result.lowConfidence,
modelVersion: result.modelVersion,
tookMs,
});
},
);
// Candidate backend IPs the device can push to, for a given device host. The // 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 // 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. // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
@@ -152,117 +346,84 @@ export async function setupRoutes(
}, },
); );
// Assign a device to a lane. Validates the chosen driver + config, configures // Assign a device. Validates the chosen driver + config, configures the device
// the device (fix preconditions + set up Digest-authenticated input push — no // (fix preconditions + set up Digest-authenticated input push — no manual device-
// manual device-web-UI step by the admin), then persists. Fails the save if // web-UI step by the admin), then persists. Fails the save if the device can't be
// the device can't be configured. See wiki/concepts/device-input-flow.md. // configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
app.post<{ Body: AssignBody }>( app.post<{ Body: AssignBody }>(
"/api/setup/assign", "/api/setup/assign",
{ preHandler: adminGuard }, { preHandler: adminGuard },
async (req, reply) => { async (req, reply) => {
const { lane, category, driverId, config, backendIp } = req.body; const { category, driverId, config, backendIp } = req.body;
const driver = registry.get(driverId); const driver = registry.get(driverId);
if (!driver || driver.category !== category) { if (!driver || driver.category !== category) {
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` }); return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
} }
const id = randomUUID(); const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...config }; const outcome = await configureDevice(app, { id, driverId, config, backendIp });
// The web password the admin typed is a DESIRED value, not a stored fact: if ("error" in outcome) {
// it's passed to the driver (via create(config) below) as the rotation return reply.code(outcome.error.code).send({ error: outcome.error.message });
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
});
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return reply
.code(502)
.send({ error: `device configuration failed: ${(err as Error).message}` });
} }
const row = { const row = {
id, id,
lane,
category, category,
driverId, driverId,
config: fullConfig, config: outcome.config,
enabled: true, enabled: true,
}; };
await db.insert(laneDevices).values(row); await db.insert(devices).values(row);
onAssignmentsChanged(); // refresh derived state (device->lane map)
// Don't echo device secrets back (push Digest password, web-UI login, …). // Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({ return reply.code(201).send({
...row, ...row,
config: redactSecrets(fullConfig), config: redactSecrets(outcome.config),
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}), ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
// Edit an assigned device in place. Same configure pipeline as assign, but it
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
// since the id is baked into the device's input-push URL
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
// break push until reconfigured; PATCH re-runs harden/push against the same id.
// The category and driver are fixed at create time (an edit can't change what
// KIND of device a slot is); only config changes. Admin-only.
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(devices)
.where(eq(devices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
const { config, backendIp } = req.body;
const outcome = await configureDevice(app, {
id: existing.id,
driverId: existing.driverId,
config,
backendIp,
// Carry forward machine-only secrets the client never received, so an
// edit that omits them doesn't blank out push/relay passwords.
existingConfig: existing.config,
});
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
return reply.code(200).send({
id: existing.id,
category: existing.category,
driverId: existing.driverId,
config: redactSecrets(outcome.config),
enabled: existing.enabled,
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
}); });
}, },
); );
@@ -283,13 +444,12 @@ export async function setupRoutes(
async (req, reply) => { async (req, reply) => {
const existing = await db const existing = await db
.select() .select()
.from(laneDevices) .from(devices)
.where(eq(laneDevices.id, req.params.id)) .where(eq(devices.id, req.params.id))
.get(); .get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" }); if (!existing) return reply.code(404).send({ error: "no such device assignment" });
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id)); await db.delete(devices).where(eq(devices.id, req.params.id));
onAssignmentsChanged(); // refresh derived state (device->lane map) app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
return reply.code(204).send(); return reply.code(204).send();
}, },
); );
+151
View File
@@ -0,0 +1,151 @@
import bcrypt from "bcrypt";
import { eq, users, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js";
import {
InvalidCashMovementError,
NoOpenShiftError,
ShiftAlreadyOpenError,
type ShiftService,
} from "../shift-service.js";
interface CashVoucherBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
* cash_out = Mandat Pagese (pay-OUT). */
type: "cash_in" | "cash_out";
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
amountMinor: number;
reason?: string;
currency?: string;
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
authorizedBy: string;
/** That admin's password — re-entered to sign off on the drawer movement. */
authorizerPassword: string;
}
interface ShiftsQuery {
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
operator?: string;
/** ISO window over shift START time. */
from?: string;
to?: 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.
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): 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();
const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance();
return {
operator: me,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me,
drawerMinor: drawer.balanceMinor,
currency: drawer.currency,
};
});
// 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();
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.
// - `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) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
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" };
});
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
// (`shift:cash`) who re-enters their password. This keeps the float control —
// an operator cannot move the float alone — while letting them raise the slip.
// See wiki/concepts/shift.md.
app.post<{ Body: CashVoucherBody }>(
"/api/cash-voucher",
{ preHandler: guard },
async (req, reply) => {
const b = req.body ?? ({} as CashVoucherBody);
if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
}
const authName = (b.authorizedBy ?? "").trim();
if (!authName || !b.authorizerPassword) {
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
}
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
// Always run a bcrypt compare (constant-time wrt whether the user exists).
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
if (!authUser || !passwordOk || !isAdminGrade) {
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
}
try {
return await shift.recordVoucher({
type: b.type,
operator: req.user.username, // who RAISED it
authorizedBy: authUser.username, // who signed off (canonical case)
amountMinor: b.amountMinor,
reason: b.reason ?? "",
currency: b.currency,
});
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
try {
return await shift.open(req.user.username);
} 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) => {
try {
return await shift.close(req.user.username);
} catch (err) {
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
});
}
+134
View File
@@ -0,0 +1,134 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import { getOccupancy } from "../occupancy.js";
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
const TEXT_FIELDS = [
"parkName",
"operatorName",
"nius",
"address",
"phone",
"email",
// IANA timezone for tariff wall-clock windows (copied into each published version).
"timezone",
// Default vehicle/customer category frozen onto each transient entry.
"defaultVehicleCategory",
] as const;
type TextField = (typeof TEXT_FIELDS)[number];
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Nominal capacity; null = no limit. */
capacity?: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault?: boolean;
/** Site default monthly subscription price in minor units (pre-fills the form). */
subscriptionMonthlyPriceMinor?: number | null;
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
* parked — so transients see "full" sooner and the subscriber's spot is held. */
reserveSubscriberSpots?: boolean;
/** 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;
}
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
* + every metadata field. */
type SiteConfig = {
capacity: number | null;
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean;
anprEntryEnabled: boolean;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = {
capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
anprEntryEnabled: row?.anprEntryEnabled ?? true,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
}
/** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null {
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
}
export async function siteRoutes(app: FastifyInstance, db: Db): 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));
// 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();
return toSiteConfig(row);
});
// Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
// (only the fields PRESENT in the body are updated; absent fields are untouched).
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const body = req.body ?? ({} as SiteConfigBody);
const patch: Partial<typeof siteConfig.$inferInsert> = {};
if ("capacity" in body) {
const c = body.capacity;
if (c != null && (!Number.isInteger(c) || c < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
}
patch.capacity = c ?? null;
}
if ("exitVoucherDefault" in body) {
if (typeof body.exitVoucherDefault !== "boolean") {
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
}
patch.exitVoucherDefault = body.exitVoucherDefault;
}
if ("subscriptionMonthlyPriceMinor" in body) {
const p = body.subscriptionMonthlyPriceMinor;
if (p != null && (!Number.isInteger(p) || p < 0)) {
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
}
patch.subscriptionMonthlyPriceMinor = p ?? null;
}
if ("reserveSubscriberSpots" in body) {
if (typeof body.reserveSubscriberSpots !== "boolean") {
return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" });
}
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
}
if ("anprEntryEnabled" in body) {
if (typeof body.anprEntryEnabled !== "boolean") {
return reply.code(400).send({ error: "anprEntryEnabled must be a boolean" });
}
patch.anprEntryEnabled = body.anprEntryEnabled;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString();
if (existing) {
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
}
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
});
}
+124
View File
@@ -0,0 +1,124 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
// tied to a signed vehicle_entry/exit by `identity`; the operator reviews them
// next to the event. Read-only — images are written only by the flows (snapshot.ts),
// never via the API.
export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void> {
const guard = requirePermission("session:read");
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
// first — lets the UI show "entry/exit image" links beside an event. We also return
// FAILED capture attempts (from snapshot telemetry) so the operator can tell a
// camera that was offline from a direction that simply has no camera — otherwise a
// missing shot is a silent gap. See snapshot.ts (recordFailure).
app.get<{ Params: { identity: string } }>(
"/api/snapshots/by-identity/:identity",
{ preHandler: guard },
async (req) => {
const identity = req.params.identity;
const rows = db
.select({
id: snapshots.id,
direction: snapshots.direction,
deviceId: snapshots.deviceId,
identity: snapshots.identity,
contentType: snapshots.contentType,
capturedAt: snapshots.capturedAt,
})
.from(snapshots)
.where(eq(snapshots.identity, identity))
.orderBy(desc(snapshots.capturedAt))
.all();
// Failed attempts: kind="snapshot" telemetry whose detail.identity matches and
// detail.ok === false. There may be both a failure and (on a retry) a success
// for the same direction; we keep only failures with NO successful shot in the
// same direction, so a recovered capture doesn't show a stale warning.
const haveDir = new Set<string | null>(rows.map((r) => r.direction));
const telemetry = db
.select({ detail: deviceEvents.detail, deviceId: deviceEvents.deviceId, occurredAt: deviceEvents.occurredAt })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "snapshot")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const failures: {
direction: "entry" | "exit" | null;
deviceId: string;
error: string;
occurredAt: string;
}[] = [];
const seenFailDir = new Set<string>();
for (const row of telemetry) {
const d = (row.detail ?? {}) as { identity?: string; ok?: boolean; error?: string; direction?: string };
if (d.identity !== identity || d.ok !== false) continue;
const dir = d.direction === "entry" || d.direction === "exit" ? d.direction : null;
const dirKey = dir ?? "both";
if (haveDir.has(dir) || seenFailDir.has(dirKey)) continue; // a success exists, or already shown
seenFailDir.add(dirKey);
failures.push({
direction: dir,
deviceId: row.deviceId ?? "",
error: d.error ?? "capture failed",
occurredAt: row.occurredAt ?? "",
});
}
// Recognized PLATES for this session: kind="read" telemetry from the ANPR-on-
// snapshot path (snapshot.ts → recognizePlate). Advisory — a record of the plate
// observed for the session, shown beside the image. Newest first.
const plateRows = db
.select({ detail: deviceEvents.detail, occurredAt: deviceEvents.occurredAt })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const plates: {
plate: string;
confidence: number | null;
region: string | null;
direction: "entry" | "exit" | null;
snapshotId: string | null;
at: string;
}[] = [];
for (const row of plateRows) {
const d = (row.detail ?? {}) as {
identity?: string;
plate?: string;
confidence?: number;
region?: string | null;
direction?: string;
snapshotId?: string;
};
if (d.identity !== identity || !d.plate) continue;
plates.push({
plate: d.plate,
confidence: typeof d.confidence === "number" ? d.confidence : null,
region: d.region ?? null,
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
snapshotId: d.snapshotId ?? null,
at: row.occurredAt ?? "",
});
}
return { snapshots: rows, failures, plates };
},
);
// Stream one snapshot's image bytes by id. Returns the stored content type.
app.get<{ Params: { id: string } }>(
"/api/snapshots/:id",
{ preHandler: guard },
async (req, reply) => {
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
if (!row) return reply.code(404).send({ error: "no such snapshot" });
reply.header("content-type", row.contentType);
reply.header("cache-control", "private, max-age=31536000, immutable");
return reply.send(row.bytes);
},
);
}
@@ -0,0 +1,190 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db";
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { siteTz } from "../subscription-window.js";
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
// from (so they never type a price). Mirrors the tariff composer: plans are
// EFFECTIVE-DATED IMMUTABLE VERSIONS keyed by a stable `planId`; editing a plan
// PUBLISHES A NEW VERSION (new row, new effectiveFrom), never mutates an old one, so
// a past sale reprices identically against its recorded planVersionId. Retire =
// active=0 (soft, keeps history). Admin-only (`subscription:plan`); selling stays
// operator-grade (`subscription:create`). See wiki/entities/subscription.md.
interface PlanBody {
/** Stable identity across versions (e.g. "hotel-daily"). New on create; reused to
* publish a new version of an existing plan. Slugified server-side. */
planId?: string;
name?: string;
period?: SubscriptionPeriod;
pricePerPeriodMinor?: number;
currency?: string;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
/** Allowed-time windows (tariff bridge); null/omitted = 24/7. */
timeframes?: PlanTimeframes | null;
}
/** Validate the optional timeframes blob (minutes-of-day 0–1439, days 0–6, sane grace). */
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
if (tf == null) return null;
const okMin = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439;
if (!okMin(tf.fromMin) || !okMin(tf.toMin)) return "window times must be minutes-of-day (0–1439)";
if (tf.days != null && (!Array.isArray(tf.days) || tf.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) {
return "days must be integers 0–6 (0=Sun..6=Sat)";
}
if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0";
return null;
}
/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */
function slugify(s: string): string {
return s
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("subscription:read");
const planGuard = requirePermission("subscription:plan");
function validate(b: PlanBody): string[] {
const errs: string[] = [];
if (!b.name?.trim()) errs.push("name is required");
if (!b.period || !SUBSCRIPTION_PERIODS.includes(b.period)) {
errs.push(`period must be one of: ${SUBSCRIPTION_PERIODS.join(", ")}`);
}
if (!Number.isInteger(b.pricePerPeriodMinor) || (b.pricePerPeriodMinor ?? 0) <= 0) {
errs.push("pricePerPeriodMinor must be a positive integer (minor units)");
}
if (!b.currency?.trim()) errs.push("currency is required");
if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
}
const tfErr = validTimeframes(b.timeframes);
if (tfErr) errs.push(tfErr);
return errs;
}
// List plans. ?all=1 → every version (history); default → the CURRENT sellable plan
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
// need the current list; the admin catalog screen asks for ?all=1.
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
// Exclude soft-deleted plan versions — those live in the recycle bin. (A plan is
// versioned; a soft-delete stamps every version row of the planId.)
const rows = db
.select()
.from(subscriptionPlans)
.where(isNull(subscriptionPlans.deletedAt))
.orderBy(desc(subscriptionPlans.effectiveFrom))
.all();
if (req.query?.all) return { plans: rows };
const now = new Date().toISOString();
// Newest-effective active version wins per planId.
const current = new Map<string, (typeof rows)[number]>();
for (const r of rows) {
if (!r.active || r.effectiveFrom > now) continue;
if (!current.has(r.planId)) current.set(r.planId, r); // rows are newest-first
}
return { plans: [...current.values()] };
});
// Publish a plan version (create a plan, or a new version of an existing planId).
app.post<{ Body: PlanBody }>("/api/subscription-plans", { preHandler: planGuard }, async (req, reply) => {
const b = req.body ?? ({} as PlanBody);
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid plan", problems });
const planId = (b.planId?.trim() ? slugify(b.planId) : slugify(b.name!)) || randomUUID();
const now = new Date().toISOString();
const effectiveFrom = b.effectiveFrom?.trim() || now;
// Backdating would retroactively reprice — refuse (mirrors tariff publish).
if (Date.parse(effectiveFrom) < Date.parse(now) - 60_000) {
return reply.code(400).send({
error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales",
});
}
// Stamp the site tz into the timeframes so the windows evaluate in the site's
// wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz).
const timeframes =
b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null;
const row = {
id: randomUUID(),
planId,
name: b.name!.trim(),
period: b.period!,
pricePerPeriodMinor: b.pricePerPeriodMinor!,
currency: b.currency!.trim(),
effectiveFrom,
timeframes,
active: true,
createdBy: req.user?.username ?? null,
};
db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run();
return reply.code(201).send(row);
});
// Retire a plan (soft): mark every version of this planId inactive so it's no longer
// sellable. History (and past sales' planVersionId) is preserved. Reactivate to revive.
app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/retire",
{ preHandler: planGuard },
async (req) => {
db.update(subscriptionPlans)
.set({ active: false })
.where(eq(subscriptionPlans.planId, req.params.planId))
.run();
return { planId: req.params.planId, retired: true };
},
);
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
// latest-effective version becomes "in force" again. (The inverse of retire.)
app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/reactivate",
{ preHandler: planGuard },
async (req) => {
db.update(subscriptionPlans)
.set({ active: true })
.where(eq(subscriptionPlans.planId, req.params.planId))
.run();
return { planId: req.params.planId, reactivated: true };
},
);
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
// version). A referenced plan version MUST survive: a subscription's planVersionId is
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
// when in use (the admin should retire instead). SOFT delete (recycle bin): stamps all
// versions of the planId; a restore brings the plan back; purge does the real removal.
app.delete<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId",
{ preHandler: planGuard },
async (req, reply) => {
// Only LIVE subscriptions block deletion (a soft-deleted subscriber's planId ref is
// itself in the bin; if it's restored later, the plan can be restored too).
const refs = db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.planId, req.params.planId), isNull(subscriptions.deletedAt)))
.all();
if (refs.length > 0) {
return reply.code(409).send({
error: "plan is in use and cannot be deleted",
code: "plan_in_use",
subscribers: refs.length,
});
}
const ok = softDelete(db, "plan", req.params.planId, req.user.sub);
if (!ok) return reply.code(404).send({ error: "plan not found" });
return { planId: req.params.planId, deleted: true };
},
);
}
+552
View File
@@ -0,0 +1,552 @@
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 { requirePermission, roleHasPermissions } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js";
import type { EventLog } from "../event-log.js";
import type { ShiftService } from "../shift-service.js";
import { directionOf } from "../device-resolve.js";
import { priceSubscriptionSpan, resolvePlanVersion } from "../subscription-pricing.js";
// Subscription admin CRUD. A subscription is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
// them as one unit (create/update replace the child sets; delete removes all).
//
// Pricing & THE SALE. priceMinor + period ("monthly") + currency record the recurring
// plan (e.g. 10,000 ALL / month). When a subscription is SOLD (created with a price),
// the operator collects real money — so we append a SIGNED `payment` ledger event for
// the amount actually taken (priceMinor × months for a multi-month prepay), with the
// tender the operator chose. That is the ONLY accountability mechanism: without it the
// sale leaves no trace in the live feed, the drawer, or the shift Z-report, and the
// operator could pocket the cash untraceably (the exact booth-operator-as-adversary
// gap this system exists to close). The `subscriptions` row is mutable master data and
// is NOT the financial record; the signed payment event is. See wiki/concepts/shift.md.
interface Credential {
kind: "rf" | "qr";
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
value?: string;
}
interface SubscriptionBody {
holderName?: string;
contact?: string;
/** PRICED SALE: the plan the operator selected. The price is LOOKED UP from the
* plan version (periods × per-period price) — the operator never types an amount.
* Omit for a free/comp subscription (no plan, no charge). */
planId?: string | null;
/** Coverage window. For a priced sale: `validFrom` defaults to now, `validTo` is
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */
validFrom?: string | null;
validTo?: string | null;
/** How many cars this subscription covers (a family pays once for N cars). Sale =
* plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */
quantity?: number | null;
/** Car-count binding: cars inside at once. Default = quantity; null = unbound. */
maxConcurrent?: number | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
tender?: Tender;
/** UPDATE-only CORRECTION: move this sub to a different VERSION of its SAME plan (e.g.
* an admin published v2 with different timeframes and wants an existing subscriber on
* it, or back on v1). Must be a version of the sub's existing planId; price/currency/
* period stay FROZEN (not a re-sale — only the access rules change going forward).
* Gated on `subscription:plan` (plan-management, stronger than subscription:update);
* ignored from a non-privileged caller. See wiki/entities/subscription.md. */
planVersionId?: string;
}
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
interface QuoteBody {
planId?: string;
validFrom?: string;
validTo?: string;
quantity?: number;
}
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
function newQrCode(): string {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const bytes = randomBytes(15);
let out = "";
for (const b of bytes) out += alphabet[b % 32];
return `SUB-${out}`;
}
export async function subscriptionRoutes(
app: FastifyInstance,
db: Db,
capture: CredentialCapture,
eventLog: EventLog,
shift: ShiftService,
): Promise<void> {
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
const readGuard = requirePermission("subscription:read");
const createGuard = requirePermission("subscription:create");
const updateGuard = requirePermission("subscription:update");
const deleteGuard = requirePermission("subscription:delete");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: SubscriptionBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
// PRICED SALE: a plan is selected → the span must be valid and price > 0. The
// amount is derived from the plan (operator never types it), so there's no
// priceMinor to validate.
if (b.planId != null && b.planId.trim()) {
const from = b.validFrom?.trim() || new Date().toISOString();
const to = b.validTo?.trim();
if (!to) {
errs.push("validTo (end date) is required when selling a plan");
} else if (Number.isNaN(Date.parse(to)) || Number.isNaN(Date.parse(from))) {
errs.push("validFrom/validTo must be valid ISO-8601 dates");
} else if (Date.parse(to) <= Date.parse(from)) {
errs.push("validTo must be after validFrom");
} else {
// Resolve the plan version at the SALE instant (now) — the customer buys today's
// published plan/price. (validFrom is the coverage start, which may be midnight
// today and predate a plan published this afternoon.)
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) errs.push("no active plan found for the selected planId");
}
}
if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) {
errs.push("quantity must be a positive integer (cars covered)");
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
if (b.tender != null && b.tender !== "cash" && b.tender !== "card") {
errs.push("tender must be cash|card");
}
for (const c of b.credentials ?? []) {
if (c.kind !== "rf" && c.kind !== "qr") {
errs.push("each credential needs kind (rf|qr)");
break;
}
// RF must carry the physical card id; QR may be blank (server auto-generates).
if (c.kind === "rf" && !c.value?.trim()) {
errs.push("an RF credential needs a non-empty value (the card/tag id)");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
if (!sub) return null;
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
return {
...sub,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
/** Is this credential value already used by ANY subscription? (Global uniqueness —
* a value is the lane identity, so it must resolve to one subscription.) */
function valueTaken(value: string): boolean {
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
}
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
function mintQrCode(): string {
for (let i = 0; i < 5; i += 1) {
const code = newQrCode();
if (!valueTaken(code)) return code;
}
throw new Error("could not mint a unique QR code");
}
// Replace a subscription's child rows (credentials + plates) from the body. QR
// credentials with no value are SERVER-GENERATED here (the customer never picks the
// code). The generated value is returned via loadAggregate so the UI can print it.
function writeChildren(id: string, b: SubscriptionBody) {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
for (const c of b.credentials ?? []) {
const supplied = c.value?.trim();
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
// QR being preserved on edit).
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
if (!value) continue; // guarded by validate(); defensive
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
}
}
/** Resolve the coverage end: an explicit validTo (the span end the operator picked).
* Falls back to the existing value on an update that doesn't touch it. */
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
if (b.validTo !== undefined) return b.validTo ?? null;
return fallback;
}
/** Resolve + price a priced sale: returns the plan version, the effective span, the
* quantity (cars covered), and the server-computed quote with the amount already
* MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
* sub (no planId). validate() guards the happy path. */
function priceSale(
b: SubscriptionBody,
): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null {
if (!b.planId?.trim() || !b.validTo?.trim()) return null;
const validFrom = b.validFrom?.trim() || new Date().toISOString();
const validTo = b.validTo.trim();
// Plan version is resolved at the SALE instant (now), not validFrom (which is the
// coverage start and may predate a plan published later today).
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) return null;
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
const base = priceSubscriptionSpan(plan, validFrom, validTo);
// Price ×N: the whole sale covers N cars on one subscription.
const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity };
return { plan, validFrom, validTo, quantity, quote };
}
// List all LIVE subscriptions (with their credentials + plates). Soft-deleted ones
// live in the recycle bin, not here.
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
});
// --- Credential capture ("enroll a card") -------------------------------
// The operator picks a reader and presents an RFID card to it; the next read on
// that reader is captured for the form instead of opening a barrier. The OTHER
// reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts.
// The readers the operator can capture on (entry/exit by their bound relay).
app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => {
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
return {
readers: rows
.filter((r) => r.enabled)
.map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })),
};
});
// Arm capture on a reader (by devices.id). Operator-or-admin (booth action).
app.post<{ Body: { deviceId?: string } }>(
"/api/subscriptions/capture/arm",
{ preHandler: readGuard },
async (req, reply) => {
const deviceId = (req.body?.deviceId ?? "").trim();
if (!deviceId) return reply.code(400).send({ error: "deviceId required" });
const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!reader || reader.category !== "reader" || !reader.enabled) {
return reply.code(404).send({ error: "no such enabled reader" });
}
return capture.arm(deviceId);
},
);
// Poll the capture state (idle | armed | captured | expired). The form polls this
// and, on "captured", reads `value` into the credential field then clears it.
app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state());
// Operator cancelled / closed the form — disarm and clear any result.
app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => {
capture.cancel();
capture.clear();
return { ok: true };
});
// Create a subscription.
// Price a span against a plan WITHOUT writing anything — the live quote the sell form
// shows ("3 nights · 2,400 ALL"). Server-computed so the operator can't fudge it.
app.post<{ Body: QuoteBody }>("/api/subscriptions/quote", { preHandler: readGuard }, async (req, reply) => {
const b = req.body ?? {};
if (!b.planId?.trim()) return reply.code(400).send({ error: "planId is required" });
const validFrom = b.validFrom?.trim() || new Date().toISOString();
const validTo = b.validTo?.trim();
if (!validTo) return reply.code(400).send({ error: "validTo is required" });
if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validTo))) {
return reply.code(400).send({ error: "validFrom/validTo must be valid ISO-8601 dates" });
}
if (Date.parse(validTo) <= Date.parse(validFrom)) {
return reply.code(400).send({ error: "validTo must be after validFrom" });
}
// Resolve at the sale instant (now), not validFrom — see priceSale.
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" });
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
const base = priceSubscriptionSpan(plan, validFrom, validTo);
// Echo the ×quantity total so the form previews the family's combined price.
return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan };
});
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
const id = randomUUID();
// Price is LOOKED UP from the chosen plan (periods × per-period price) — never typed
// by the operator. A comp sub (no plan) carries no price. Persist the plan + version
// so the sale reprices identically later.
const priced = priceSale(b);
db.insert(subscriptions)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: priced ? priced.quote.amountMinor : null,
period: priced ? priced.plan.period : "month",
currency: priced ? priced.quote.currency : null,
planId: priced ? priced.plan.planId : null,
planVersionId: priced ? priced.plan.id : null,
quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
// maxConcurrent defaults to the quantity (the family's N cars can all be inside),
// unless the operator set it explicitly (null = unbound).
maxConcurrent:
b.maxConcurrent !== undefined
? b.maxConcurrent
: priced
? priced.quantity
: (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
validTo: priced ? priced.validTo : resolveValidTo(b, null),
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
const sub = loadAggregate(id);
// THE SALE: a priced subscription means the operator collected money. Append a
// SIGNED `payment` event so the takings show up in the live feed, the drawer, and
// the shift Z-report — never an untraceable cash grab. Best-effort wrt the response,
// but the append is the whole point, so a failure is logged loudly.
const sale = await recordSale(id, priced, b.tender, req.user?.username ?? "?");
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
// a print failure NEVER fails the create (the subscription + its code are saved);
// the response carries { printed, printError } so the UI can warn + offer reprint.
const printResult = await tryPrintCard(sub);
return reply.code(201).send({ ...sub, ...sale, ...printResult });
});
/**
* Append the SIGNED `payment` ledger event for a subscription sale, so the money is
* accounted for exactly like a parking payment (live feed + drawer + Z-report). The
* amount comes from the PLAN quote (periods × per-period price) — never an
* operator-typed number. No plan → no sale → nothing appended (free/comp). The event
* carries `subscriptionSale: true` + the subscription id + the plan version so the
* feed/audit can label it and the price is reproducible. We do NOT hard-require an
* open shift (a subscription can be sold outside the booth money path), but the
* operator IS recorded and the payment folds into whichever shift window contains its
* timestamp — so it can never be silently pocketed. Returns { sale } or {}.
*/
async function recordSale(
id: string,
priced: ReturnType<typeof priceSale>,
tenderIn: Tender | undefined,
operator: string,
): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; periods: number; inShift: boolean } }> {
if (!priced || priced.quote.amountMinor <= 0) return {}; // free/comp — nothing collected
const { plan, quote } = priced;
const amountMinor = quote.amountMinor;
const tender: Tender = tenderIn ?? "cash";
const currency = quote.currency;
const inShift = shift.currentOpenShift() != null;
try {
await eventLog.append({
type: "payment",
source: "manual",
// Key the payment to the subscription so the feed can resolve the holder label
// and the audit can trace WHICH subscription was sold.
identity: id,
payload: {
sessionRef: id,
amountMinor,
currency,
tender,
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
// and reproducible repricing.
subscriptionSale: true,
permitId: id,
planId: plan.planId,
planVersionId: plan.id,
periods: quote.periods,
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
},
});
app.log.info(
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` +
(inShift ? "" : " [no open shift]"),
);
} catch (err) {
// A failed append is serious — the money would be untraceable. Surface it.
app.log.error(`subscription-sale payment append FAILED for ${id}: ${(err as Error).message}`);
return {};
}
return { sale: { amountMinor, currency, tender, periods: quote.periods, inShift } };
}
/** The first QR credential's code for a subscription aggregate, or null. */
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
const cred = sub?.credentials.find((c) => c.kind === "qr");
return cred?.value ?? null;
}
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
* (never throws). No QR credential → nothing to print (printed:false, no error). */
async function tryPrintCard(
sub: ReturnType<typeof loadAggregate>,
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
const code = qrCodeOf(sub);
if (!sub || !code) return { printed: false };
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return { printed: true, printedBy };
} catch (err) {
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
return { printed: false, printError };
}
}
// Update a subscription (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
"/api/subscriptions/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "subscription not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
// PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION
// of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this
// subscriber on it, or back on v1. Price/currency/period stay frozen (not a re-sale).
// Guarded HERE on `subscription:plan` (stronger than the route's subscription:update),
// so a plain operator's edit can't move a version; a non-privileged caller sending it
// is rejected rather than silently ignored.
let planVersionId = existing.planVersionId;
if (b.planVersionId !== undefined && b.planVersionId !== existing.planVersionId) {
if (!req.user || !roleHasPermissions(req.user.roleId, ["subscription:plan"])) {
return reply.code(403).send({ error: "changing the plan version requires the subscription:plan permission" });
}
const target = db
.select()
.from(subscriptionPlans)
.where(eq(subscriptionPlans.id, b.planVersionId))
.get();
if (!target) return reply.code(404).send({ error: "plan version not found" });
// Must be a version of the SAME plan — this field corrects the version, never the
// plan itself (a different plan = a different price basis = a re-sale).
if (target.planId !== existing.planId) {
return reply.code(400).send({
error: `plan version belongs to "${target.planId}", not this subscription's plan "${existing.planId}"`,
});
}
planVersionId = b.planVersionId;
req.log.info(
`subscription ${req.params.id} plan version ${existing.planVersionId} → ${b.planVersionId} (plan ${existing.planId}) by ${req.user.username ?? "?"}`,
);
}
// An update is otherwise a MASTER-DATA edit — it never re-sells or re-prices. Price,
// plan and currency are FROZEN as the original sale recorded them (a new price means a
// new sale = a new subscription). Editable here: holder/contact, car-count, the
// validity window, status, credentials/plates, and (privileged) the plan version.
db.update(subscriptions)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
validTo: resolveValidTo(b, existing.validTo),
status: b.status ?? existing.status,
planVersionId,
})
.where(eq(subscriptions.id, req.params.id))
.run();
writeChildren(req.params.id, b);
// The holder name may have changed — drop the feed-label cache for this sub.
invalidateHolder(req.params.id);
return loadAggregate(req.params.id);
},
);
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/print",
{ preHandler: readGuard },
async (req, reply) => {
const sub = loadAggregate(req.params.id);
if (!sub) return reply.code(404).send({ error: "subscription not found" });
const code = qrCodeOf(sub);
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Revoke (soft): the common case — keeps the subscription + its history, just bars
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
// DELETE only to fully remove one created in error.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/revoke",
{ preHandler: updateGuard },
async (req, reply) => {
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
return loadAggregate(req.params.id);
},
);
// Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
// are KEPT (stamped deleted) so a restore brings the subscriber back intact; it leaves
// the catalog and stops opening the barrier (the entry flow filters deleted). Past
// ledger events that reference it are untouched (append-only). Restore/purge from the
// recycle bin. (Distinct from /revoke, which BARS but keeps the subscriber visible.)
app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
if (!ok) return reply.code(404).send({ error: "subscription not found" });
invalidateHolder(req.params.id);
return reply.code(204).send();
},
);
}
+246
View File
@@ -0,0 +1,246 @@
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 {
computeFee,
isTariffV2,
priceSession,
validateTariffStructure,
type SessionPayment,
type TariffStructure,
} from "@parking/shared";
import { requirePermission } from "../auth.js";
/** Default site timezone for wall-clock tariff windows when none is configured. */
const DEFAULT_TZ = "Europe/Tirane";
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
// mutates one; a session reprices against the version in force at its entry, and
// the `payment` event records the tariffVersionId. "One active tariff per site" for
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
interface PublishBody {
currency: string;
structure: TariffStructure;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
}
const SITE_TARIFF_NAME = "Site tariff";
/** 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). */
interface SimulateBody {
enteredAt: string; // ISO-8601
asOf: string; // ISO-8601 (the "now"/exit instant being simulated)
payments?: SessionPayment[]; // hypothetical payment history (latest grants grace)
category?: string;
tariffVersionId?: string;
structure?: TariffStructure;
currency?: string;
}
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Reading the rate card (pay station / operator UI needs it).
const readGuard = requirePermission("tariff:read");
// Publishing a new version changes what customers are charged.
const writeGuard = requirePermission("tariff:update");
// The single site tariff row, created on first read/publish. A soft-deleted (recycle-
// bin) tariff is ignored here so a fresh one is created — the deleted one waits in the
// bin for restore/purge. (Tariffs have soft-delete support for completeness; today the
// site runs one tariff and there's no delete button — recovery is via the recycle bin.)
function ensureSiteTariff(): string {
const existing = db.select().from(tariffs).where(and(eq(tariffs.scope, "site"), isNull(tariffs.deletedAt))).get();
if (existing) return existing.id;
const id = randomUUID();
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
return id;
}
// Current state: the active (latest-effective, ≤ now) version + the full history.
app.get("/api/tariff", { preHandler: readGuard }, async () => {
const tariffId = ensureSiteTariff();
const versions = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
const now = new Date().toISOString();
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
return { tariffId, active, versions };
});
// Publish a new immutable version. Validates the structure first — a malformed
// rate card can never be published (the fee calc + the chain depend on it).
app.post<{ Body: PublishBody }>(
"/api/tariff/versions",
{ preHandler: writeGuard },
async (req, reply) => {
const { currency, structure, effectiveFrom } = 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 problems = validateTariffStructure(toStore);
if (problems.length) {
return reply.code(400).send({ error: "invalid tariff structure", problems });
}
// effectiveFrom must NOT be in the past. A version is selected by
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
// retroactively reprice already-entered sessions — exactly the immutability
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
// backdating: a new version applies only from publish (now) forward; a future
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
// absorbs client/server clock drift + request round-trip. Once a car has
// entered, no later publish can reprice it (no effectiveFrom can predate it).
const now = Date.now();
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
let effective = new Date().toISOString();
if (effectiveFrom != null) {
const t = Date.parse(effectiveFrom);
if (Number.isNaN(t)) {
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
}
if (t < now - SKEW_MS) {
return reply.code(400).send({
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
});
}
effective = new Date(t).toISOString();
}
const tariffId = ensureSiteTariff();
const id = randomUUID();
const row = {
id,
tariffId,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
};
db.insert(tariffVersions).values(row).run();
return reply.code(201).send(row);
},
);
// --- Tariff Lab (simulator) -------------------------------------------------
// Price a HYPOTHETICAL session at arbitrary times against any tariff version —
// pure, no ledger writes. Lets an admin test rates "in time" (overnight windows,
// daily caps, overstay) in seconds instead of waiting hours. Also used to quote a
// customer dispute on-site. tariff:read (admins always have it). See tariff.md.
app.post<{ Body: SimulateBody }>("/api/tariff/simulate", { preHandler: readGuard }, async (req, reply) => {
const b = req.body ?? ({} as SimulateBody);
if (!b.enteredAt || !b.asOf) {
return reply.code(400).send({ error: "enteredAt and asOf (ISO-8601) required" });
}
if (!(Date.parse(b.enteredAt) <= Date.parse(b.asOf))) {
return reply.code(400).send({ error: "asOf must be at or after enteredAt" });
}
// Resolve the structure: an explicit version id, or the active version, or an
// inline structure (preview unpublished edits). A version carries its currency.
let structure: TariffStructure | undefined = b.structure;
let currency = b.currency ?? null;
if (b.tariffVersionId) {
const v = db.select().from(tariffVersions).where(eq(tariffVersions.id, b.tariffVersionId)).get();
if (!v) return reply.code(404).send({ error: "tariff version not found" });
structure = v.structure as unknown as TariffStructure;
currency = v.currency;
} else if (!structure) {
const tariffId = ensureSiteTariff();
const nowIso = new Date().toISOString();
const active =
db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all()
.find((v) => v.effectiveFrom <= nowIso) ?? null;
if (!active) return reply.code(404).send({ error: "no active tariff to simulate against" });
structure = active.structure as unknown as TariffStructure;
currency = active.currency;
}
const problems = validateTariffStructure(structure);
if (problems.length) return reply.code(400).send({ error: "invalid tariff structure", problems });
const payments = Array.isArray(b.payments) ? b.payments : [];
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, 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);
const curve = SAMPLES_MIN.map((min) => ({
minutes: min,
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
}));
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
});
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
// admin can re-evaluate an actual ticket (e.g. an overstay) at any chosen `asOf`.
app.get<{ Params: { identity: string } }>(
"/api/tariff/simulate/session/:identity",
{ preHandler: readGuard },
async (req, reply) => {
const id = (req.params.identity ?? "").trim();
if (!id) return reply.code(400).send({ error: "identity required" });
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return reply.code(404).send({ error: "no session for identity" });
const payments: { paidAt: string; graceExitMin: number | null }[] = [];
for (const r of rows) {
if (r.type !== "payment") continue;
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
payments.push({ paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null });
}
const exit = rows.find((r) => r.type === "vehicle_exit");
const category = (entry.payload as { category?: string } | null)?.category ?? null;
return {
identity: id,
enteredAt: entry.occurredAt,
exitedAt: exit?.occurredAt ?? null,
payments,
category,
// The version frozen at entry — the rate card this session actually keeps.
tariffVersionId: tariffVersionIdFor(entry.occurredAt),
};
},
);
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
function tariffVersionIdFor(whenIso: string): string | null {
const tariffId = ensureSiteTariff();
const v =
db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all()
.find((row) => row.effectiveFrom <= whenIso) ?? null;
return v?.id ?? null;
}
}
+257
View File
@@ -0,0 +1,257 @@
import { randomUUID } from "node:crypto";
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 { softDelete } from "../recycle-bin.js";
// User management (admin). Users are created/edited at runtime here — the
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
// role (RBAC); the role resolves to a permission set at request time. Passwords
// are bcrypt-hashed (cost 12) and never returned. See @parking/shared PERMISSIONS.
//
// NO-LOCKOUT INVARIANT: the app refuses to delete, or move off the `admin` role,
// the LAST user still holding `admin`. Administration can therefore never be
// locked out of the appliance. See wiki/entities/local-jwt-auth.md.
//
// PRIVILEGE-ESCALATION GUARD: a non-admin caller with `user:*` must NOT be able to
// (a) ASSIGN a role whose permissions exceed their own (e.g. hand themselves or a
// peer the admin role, or any role broader than theirs), nor (b) MODIFY a user who
// already holds a role broader than the caller's (resetting an admin's password is
// account takeover; deleting an admin is sabotage). Both are blocked below by
// comparing permission SETS. An admin holds the full set, so it is unrestricted.
// Optional profile metadata accepted on create/update. All nullable; "" is treated
// as "clear" (→ null). Trimmed before persisting.
interface ProfileBody {
fullName?: string | null;
phone?: string | null;
email?: string | null;
address?: string | null;
}
interface CreateBody extends ProfileBody {
username: string;
password: string;
roleId: string;
}
interface UpdateBody extends ProfileBody {
username?: string;
roleId?: string;
}
interface PasswordBody {
password: string;
}
const MIN_PASSWORD = 8;
const PROFILE_FIELDS = ["fullName", "phone", "email", "address"] as const;
/** Pull the optional profile fields out of a body → a patch of trimmed values
* ("" → null). Absent keys are omitted (so an update only touches what's sent). */
function profilePatch(body: ProfileBody): Record<string, string | null> {
const out: Record<string, string | null> = {};
for (const k of PROFILE_FIELDS) {
const v = body[k];
if (v === undefined) continue;
const trimmed = typeof v === "string" ? v.trim() : "";
out[k] = trimmed === "" ? null : trimmed;
}
return out;
}
export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("user:read");
const createGuard = requirePermission("user:create");
const updateGuard = requirePermission("user:update");
const deleteGuard = requirePermission("user:delete");
/** Count LIVE users currently holding the protected admin role. A soft-deleted admin
* doesn't count — they can't log in — so the no-lockout check uses live admins only. */
function adminCount(): number {
return db.select().from(users).where(and(eq(users.roleId, ADMIN_ROLE_ID), isNull(users.deletedAt))).all().length;
}
/** True if removing/relocating `userId` from admin would leave zero admins. */
function isLastAdmin(userId: string): boolean {
const u = db.select().from(users).where(eq(users.id, userId)).get();
return u?.roleId === ADMIN_ROLE_ID && adminCount() <= 1;
}
/** A user row safe to return — never the password hash. */
function publicUser(u: {
id: string;
username: string;
roleId: string;
language: string;
createdAt: string;
fullName?: string | null;
phone?: string | null;
email?: string | null;
address?: string | null;
}) {
return {
id: u.id,
username: u.username,
roleId: u.roleId,
language: u.language,
createdAt: u.createdAt,
fullName: u.fullName ?? null,
phone: u.phone ?? null,
email: u.email ?? null,
address: u.address ?? null,
};
}
/** True if `targetRoleId` grants any permission the caller's role does NOT hold,
* i.e. assigning or touching it would let the caller act beyond their own
* privileges. (Admin holds the full set, so it never trips.) */
function exceedsCaller(callerRoleId: string, targetRoleId: string): boolean {
if (callerRoleId === targetRoleId) return false;
const held = permissionsFor(callerRoleId);
for (const p of permissionsFor(targetRoleId)) {
if (!held.has(p)) return true;
}
return false;
}
// List all LIVE users (no password hashes) + their role names for display. Soft-deleted
// users live in the recycle bin, not here.
app.get("/api/users", { preHandler: readGuard }, async () => {
const rows = db.select().from(users).where(isNull(users.deletedAt)).all();
const roleRows = db.select().from(roles).all();
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
return {
users: rows.map((u) => ({ ...publicUser(u), roleName: roleName.get(u.roleId) ?? u.roleId })),
};
});
// Create a user. Username unique; password >= 8 chars; roleId must exist.
app.post<{ Body: CreateBody }>("/api/users", { preHandler: createGuard }, async (req, reply) => {
const username = (req.body?.username ?? "").trim();
const password = req.body?.password ?? "";
const roleId = (req.body?.roleId ?? "").trim();
if (!username || !roleId) {
return reply.code(400).send({ error: "username and roleId required" });
}
if (password.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
return reply.code(400).send({ error: "unknown roleId" });
}
// No-escalation: can't create a user with a role broader than your own.
if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
}
const clash = db.select().from(users).where(eq(users.username, username)).get();
if (clash) {
// The username is UNIQUE across live AND soft-deleted rows. If a DELETED user holds
// it, point the admin at the recycle bin (restore or purge) rather than a bare 409.
return reply.code(409).send({
error: clash.deletedAt
? "username belongs to a deleted user — restore or purge it from the recycle bin first"
: "username already exists",
});
}
const id = randomUUID();
const passwordHash = await bcrypt.hash(password, 12);
db.insert(users).values({ id, username, passwordHash, roleId, ...profilePatch(req.body) }).run();
const created = db.select().from(users).where(eq(users.id, id)).get()!;
return reply.code(201).send(publicUser(created));
});
// Update a user's username and/or role. Guarded against orphaning admin.
app.put<{ Params: { id: string }; Body: UpdateBody }>(
"/api/users/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const existing = db.select().from(users).where(eq(users.id, id)).get();
if (!existing) return reply.code(404).send({ error: "user not found" });
// No-escalation: can't modify a user who already outranks you.
if (exceedsCaller(req.user.roleId, existing.roleId)) {
return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" });
}
const next: { username?: string; roleId?: string } & Record<string, string | null> = {
...profilePatch(req.body ?? {}),
};
if (req.body?.username != null) {
const username = req.body.username.trim();
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
const clash = db.select().from(users).where(eq(users.username, username)).get();
if (clash && clash.id !== id) return reply.code(409).send({ error: "username already exists" });
next.username = username;
}
if (req.body?.roleId != null) {
const roleId = req.body.roleId.trim();
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
return reply.code(400).send({ error: "unknown roleId" });
}
// No-escalation: can't promote a user into a role broader than your own.
if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
}
// No-lockout: don't move the last admin off the admin role.
if (roleId !== ADMIN_ROLE_ID && isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot change the role of the last admin" });
}
next.roleId = roleId;
}
if (Object.keys(next).length === 0) {
return reply.code(400).send({ error: "nothing to update" });
}
db.update(users).set(next).where(eq(users.id, id)).run();
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
},
);
// Reset a user's password (admin sets a new one; >= 8 chars).
app.put<{ Params: { id: string }; Body: PasswordBody }>(
"/api/users/:id/password",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const target = db.select().from(users).where(eq(users.id, id)).get();
if (!target) {
return reply.code(404).send({ error: "user not found" });
}
// No-escalation: can't reset the password of a user who outranks you
// (that would be account takeover of a more-privileged account).
if (exceedsCaller(req.user.roleId, target.roleId)) {
return reply.code(403).send({ error: "cannot reset the password of a user whose role exceeds your own" });
}
const password = req.body?.password ?? "";
if (password.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
const passwordHash = await bcrypt.hash(password, 12);
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
return { ok: true };
},
);
// Delete a user — SOFT (recycle bin). Refused if it's the last admin (no-lockout).
// The row is stamped deleted (recoverable), not removed; it vanishes from the list and
// can't log in. Restore/purge from the recycle bin. See recycle-bin.ts.
app.delete<{ Params: { id: string } }>(
"/api/users/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const target = db.select().from(users).where(and(eq(users.id, id), isNull(users.deletedAt))).get();
if (!target) {
return reply.code(404).send({ error: "user not found" });
}
// No-escalation: can't delete a user who outranks you.
if (exceedsCaller(req.user.roleId, target.roleId)) {
return reply.code(403).send({ error: "cannot delete a user whose role exceeds your own" });
}
if (isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot delete the last admin" });
}
softDelete(db, "user", id, req.user.sub);
return { ok: true };
},
);
}
+129
View File
@@ -0,0 +1,129 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js";
import type { LaneStatus } from "../lane-status.js";
import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
// server-pushed updates instead of polling: each signed ledger append (entry,
// exit, payment, void) is fanned out, and the recomputed occupancy rides along
// so the screen's count stays exact (occupancy is a fold over the same ledger,
// never a counter). Printer-status changes are forwarded too.
//
// Auth: the handshake is a normal GET through Fastify's lifecycle, so the same
// HttpOnly JWT cookie that guards the REST API guards this. We verify the JWT and
// role here. A browser's WebSocket constructor cannot set custom headers, so the
// CSRF double-submit header the REST mutations use is unavailable — which would
// leave the socket open to Cross-Site WebSocket Hijacking: a malicious page in the
// operator's browser could open ws://<booth>/api/ws, the browser would auto-attach
// the HttpOnly cookie, and the attacker would receive the live entry/exit/payment
// stream. The cookie alone is NOT a control here. So we replace the CSRF check with
// 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.
/** 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;
/**
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
* (comma-separated) for a booth UI served from a different origin. A missing or
* mismatched Origin is rejected — that is the anti-CSWSH control.
*/
function isAllowedOrigin(origin: string | undefined, host: string | undefined): boolean {
if (!origin) return false; // no Origin → not a same-origin browser request
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return false; // malformed Origin
}
if (host && originHost === host) return true; // same-origin (any scheme/port match via host)
const allow = (process.env.WS_ALLOWED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return allow.includes(origin);
}
type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent };
export async function wsRoutes(
app: FastifyInstance,
db: Db,
deviceMonitor: DeviceMonitor,
laneStatus: LaneStatus,
): Promise<void> {
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.
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 });
}
},
},
(socket) => {
const send = (msg: OutMsg) => {
// readyState 1 = OPEN; never throw out of an event-bus callback.
if (socket.readyState === 1) {
try {
socket.send(JSON.stringify(msg));
} catch {
/* drop on a broken socket */
}
}
};
// Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
// 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) => {
// 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) });
});
const offPrinter = deviceEvents.onPrinterStatus((event) => {
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 });
});
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes });
});
socket.on("close", () => {
offLedger();
offPrinter();
offDevice();
offLane();
});
},
);
}
+248 -44
View File
@@ -1,18 +1,50 @@
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt"; import jwt from "@fastify/jwt";
import websocket from "@fastify/websocket";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { createDb, type Db } from "@parking/db"; import { randomUUID } from "node:crypto";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
import { deviceEvents } from "./device-events.js"; import { deviceEvents } from "./device-events.js";
import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
import { LaneMap } from "./lane-map.js"; import { ExitFlow } from "./exit-flow.js";
import { VoidFlow } from "./void-flow.js";
import { PayStation } from "./pay-station.js";
import { SubscriptionFlow } from "./subscription-flow.js";
import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js";
import { CredentialCapture } from "./credential-capture.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js"; import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js";
import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
import { LaneStatus } from "./lane-status.js";
import { AnprBridge } from "./anpr-entry.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { reportRoutes } from "./routes/reports.js";
import { recycleBinRoutes } from "./routes/recycle-bin.js";
import { sweepExpired, retentionDays } from "./recycle-bin.js";
import { payRoutes } from "./routes/pay.js";
import { subscriptionRoutes } from "./routes/subscriptions.js";
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
import { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js";
import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
import { deviceStatusRoutes } from "./routes/device-status.js";
import { wsRoutes } from "./routes/ws.js";
import { registerSpa } from "./static-spa.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify // The backend is Fastify (Node). Hardware drivers live as isolated Fastify
// plugins emitting onto a shared internal event bus; auth is fully local // plugins emitting onto a shared internal event bus; auth is fully local
@@ -23,14 +55,30 @@ export interface BuildOptions {
} }
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> { export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
const app = Fastify({ // DB first — the logger's DB sink needs it before Fastify is constructed.
logger: { level: process.env.LOG_LEVEL ?? "info" },
});
const db = opts.db ?? createDb(); const db = opts.db ?? createDb();
// Application-log store: a pino stream tees warn+ lines into app_logs (and still
// writes them to stdout), so backend warnings/errors are queryable from the booth
// alongside frontend errors. See log-service.ts + wiki/concepts/app-logs.md.
const logService = new LogService(db);
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? "info",
stream: pinoDbStream(logService, process.stdout),
},
});
// Wire the RBAC permission resolver to this DB (route guards resolve a user's
// role → permission set through it). See auth.ts.
initAuth(db);
await app.register(cookie); await app.register(cookie);
// WebSocket support for the live booth feed (/api/ws). Registered before the
// routes so the `{ websocket: true }` route option is available.
await app.register(websocket);
// Local JWT signing with a local secret — no external identity provider. // Local JWT signing with a local secret — no external identity provider.
// Fail fast rather than fall back to a known default: a booth machine started // Fail fast rather than fall back to a known default: a booth machine started
// without a real secret would sign tokens anyone could forge (incl. an admin // without a real secret would sign tokens anyone could forge (incl. an admin
@@ -38,7 +86,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// The token is carried in an HttpOnly cookie (not the Authorization header). // The token is carried in an HttpOnly cookie (not the Authorization header).
await app.register(jwt, { await app.register(jwt, {
secret: requireJwtSecret(), secret: requireJwtSecret(),
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire // No expiry: a login is valid until explicit logout — a shift is a separate
// boundary, not the token lifetime (see auth.ts + wiki/concepts/shift.md).
cookie: { cookieName: TOKEN_COOKIE, signed: false }, cookie: { cookieName: TOKEN_COOKIE, signed: false },
}); });
@@ -47,21 +96,38 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie. // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db); await authRoutes(app, db);
// device id -> lane resolver. Built from lane_devices at startup and refreshed // RBAC administration: compose roles (role:*) + manage users (user:*). The
// by setupRoutes on assign/unassign, so device events can be stamped with the // built-in admin role is protected; the last admin can't be removed. See auth.ts.
// lane the device belongs to (events carry the device id, not a lane). await userRoutes(app, db);
const laneMap = new LaneMap(db); await roleRoutes(app, db);
laneMap.refresh();
// Device-agnostic setup: the admin selects devices per lane from the driver // Vision (ANPR) client — built early so the device monitor can include the vision
// catalog at first-run. See wiki/concepts/first-run-setup.md. // service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
await setupRoutes(app, db, () => laneMap.refresh()); // snapshot→analyze probe on an ANPR-enabled camera. Opt-in (VISION_ENABLED) +
// fail-soft; advisory only. See wiki/entities/opencv-anpr-service.md.
const visionClient = new VisionClient(app.log);
if (visionClient.enabled) app.log.info("vision client enabled");
// 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);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events), // Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from // guarded by source-IP allowlist + a shared-secret path token, both read from
// the device's lane_devices config (written on assign). // the device's lane_devices config (written on assign).
await deviceRoutes(app, db); await deviceRoutes(app, db);
// Lane busy/free tracker: a camera's vehicle detection marks its bound lane busy
// (advisory barrier lights on the booth); auto-clears on a timeout. See lane-status.ts.
const laneStatus = new LaneStatus(db, app.log);
app.addHook("onClose", async () => laneStatus.stop());
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
// flows are constructed — because the ANPR bridge they carry depends on the
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and // Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
// pushes changes to the booth UI. setupRoutes() has already registered the // pushes changes to the booth UI. setupRoutes() has already registered the
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md. // built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
@@ -70,39 +136,177 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => printerMonitor.start()); app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop()); app.addHook("onClose", async () => printerMonitor.stop());
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button // Unified device-status monitor: polls EVERY configured device (relays/readers/
// presses) into the hash-chained, signed `events` table — the anti-fraud audit // cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
// trail. The device is NOT trusted; the host record is the source of truth, and // /health, and feeds the booth's device-status footer over the WS. Read-only.
// a relay open with no matching signed event is itself the anomaly. We record // See wiki/concepts/device-status-monitoring.md.
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that const deviceMonitor = new DeviceMonitor(db, app.log, undefined, visionClient);
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md. await deviceStatusRoutes(app, deviceMonitor);
const eventLog = new EventLog(db, buildSigner(app.log)); 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),
);
await eventRoutes(app, db, eventLog); await eventRoutes(app, db, eventLog);
// Admin reporting: read-only charts/totals aggregated from the signed ledger
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
await reportRoutes(app, db);
// Recycle bin: view / restore / purge soft-deleted master data (users/roles/subs/
// plans/tariffs). Gated on recyclebin:*. See routes/recycle-bin.ts, recycle-bin.ts.
await recycleBinRoutes(app, db);
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor, laneStatus);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db);
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
// Subscribes to the SAME input bus as the telemetry writer below; the two are
// independent (telemetry always records; the entry flow acts only on an access
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
// The flows take the vision client so ANPR rides their entry/exit SNAPSHOT: a button
// press / QR / RFID triggers the open + snapshot, and the plate is recognized off that
// same image and recorded against the session (advisory; never changes the decision).
// No polling — recognition fires only on a real entry/exit. See snapshot.ts +
// wiki/entities/opencv-anpr-service.md.
const entryFlow = new EntryFlow(db, eventLog, app.log, visionClient);
const unsubscribeEntry = deviceEvents.onInput((e) => {
void entryFlow.onInput(e);
});
app.addHook("onClose", async () => unsubscribeEntry());
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
// parking-session.md.
const exitFlow = new ExitFlow(db, eventLog, app.log, visionClient);
const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log, visionClient);
const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
const unsubscribeRead = deviceEvents.onRead((e) => {
void readDispatcher.dispatch(e);
});
app.addHook("onClose", async () => unsubscribeRead());
// ANPR bridge: a subscriber's plate, read off the lane camera's vehicle detection,
// admits them through the SAME gated SubscriptionFlow a QR/card scan uses (it emits a
// plate read onto the bus, which the dispatcher above turns into a gated entry/exit).
// Advisory + fail-soft + subscriber-only — never the sole reason a barrier opens. Needs
// the subscriptionFlow constructed just above. See anpr-entry.ts.
const anprBridge = new AnprBridge(db, visionClient, subscriptionFlow, app.log);
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
// payload as a `kind:"alarm"` device_event, drives lane busy/free, AND hands a vehicle
// detection to the ANPR bridge above. See routes/hikvision-alarm.ts.
await hikvisionAlarmRoutes(app, db, laneStatus, anprBridge);
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
// CHOSEN reader to populate a subscription credential, without blocking the other
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
const credentialCapture = new CredentialCapture();
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
// on an armed reader for enrollment; otherwise the read routes through the
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
// (sum payments by tender, print the Z-report). Constructed before the pay routes
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
// Pay station (pay-on-foot): quote an open session against the active tariff +
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
const payStation = new PayStation(db, eventLog, app.log);
// Ticket-void (cancel a wrongly-printed ticket): appends a signed `void` referencing the
// entry; the session projection folds it closed. See void-flow.ts.
const voidFlow = new VoidFlow(db, eventLog, app.log);
await payRoutes(app, db, payStation, exitFlow, shiftService, voidFlow);
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
// the pay station prices against. See wiki/concepts/tariff.md.
await tariffRoutes(app, db);
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
// wiki/entities/subscription.md.
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
await subscriptionPlanRoutes(app, db);
// Shift open/close + drawer endpoints (shiftService constructed above).
await shiftRoutes(app, shiftService, db);
// 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);
// 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.
await logRoutes(app, logService);
// Periodic retention prune (age + row cap) so the log table stays bounded on the
// offline appliance. Runs hourly; unref'd so it never holds the process open.
const pruneTimer = setInterval(() => {
const n = logService.prune();
if (n > 0) app.log.debug(`pruned ${n} app_log rows`);
}, 60 * 60 * 1000);
pruneTimer.unref();
logService.prune(); // once at startup
app.addHook("onClose", async () => clearInterval(pruneTimer));
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
const binTimer = setInterval(() => {
const purged = sweepExpired(db);
const total = Object.values(purged).reduce((a, b) => a + b, 0);
if (total > 0) app.log.info(`recycle-bin: auto-purged ${total} expired item(s) ${JSON.stringify(purged)}`);
}, 6 * 60 * 60 * 1000);
binTimer.unref();
if (retentionDays() > 0) sweepExpired(db); // once at startup
app.addHook("onClose", async () => clearInterval(binTimer));
const unsubscribeInput = deviceEvents.onInput((e) => { const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't // Record every input edge as unsigned telemetry, keyed to the device that fired
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded // (provenance). No lane — the pool-of-spaces model has none. The entry flow
// faithfully (the chain is append-only) rather than silently dropped or // (above) independently decides whether this edge is an entry button.
// mis-stamped as lane 0, which is a real lane. try {
const lane = laneMap.laneFor(e.deviceId) ?? -1; db.insert(deviceEventsTable)
if (lane === -1) { .values({
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`); id: randomUUID(),
deviceId: e.deviceId,
category: "access",
kind: "input",
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
occurredAt: e.at,
})
.run();
} catch (err) {
app.log.error(`device-event insert failed: ${(err as Error).message}`);
} }
eventLog
.append({
type: "input_received",
lane,
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
// VEHICLE was identified. A raw input has none, so it stays null. The
// device provenance lives in `identity` instead.
source: null,
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
occurredAt: e.at,
})
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
}); });
app.addHook("onClose", async () => unsubscribeInput()); app.addHook("onClose", async () => unsubscribeInput());
// TODO: entry flow (input event → signed event → print → relay). // LAST: serve the built React SPA (apps/web/dist) when present — so one container
// serves the API + the operator UI (offline-first single appliance). No-op in dev (no
// build → the Vite dev server serves the UI). Registered after every API route and
// GET-only with /api + /health excluded, so it can never shadow the backend.
// See static-spa.ts + wiki/decisions/container-deployment.md.
await registerSpa(app);
return app; return app;
} }

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