26 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
82 changed files with 5095 additions and 111 deletions
+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"
+19 -2
View File
@@ -31,9 +31,26 @@ jobs:
- 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, and i18n catalog type-parity (a missing
# sq/en key fails the build). 14 tasks across the workspace.
# 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
+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
}
+15 -1
View File
@@ -28,6 +28,11 @@ EVENT_SIGNING_KEY=
# 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
# ADMIN_USER=admin
# ADMIN_PASS=
@@ -37,6 +42,9 @@ EVENT_SIGNING_KEY=
# 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) -------------------------------------------------
@@ -48,4 +56,10 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
# 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 # confidence floor; keep in sync with the service
# 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 "$@"
+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 };
+20
View File
@@ -76,6 +76,16 @@ export interface DeviceStatusEvent {
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 {
emitInput(event: DeviceInputEvent): void {
this.emit("input", event);
@@ -128,6 +138,16 @@ class DeviceEventBus extends EventEmitter {
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. */
+3 -1
View File
@@ -418,7 +418,9 @@ export class ExitFlow {
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const exited = rows.some((r) => r.type === "vehicle_exit");
// 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;
+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);
}
}
+14
View File
@@ -36,6 +36,14 @@ function exit(identity: string) {
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();
}
@@ -57,6 +65,12 @@ describe("occupancyCount", () => {
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", () => {
+5 -2
View File
@@ -30,8 +30,11 @@ export function occupancyCount(db: Db): number {
.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") 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;
@@ -68,7 +71,7 @@ export function reservedSubscriberSpots(db: Db): number {
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") {
} else if (r.type === "vehicle_exit" || r.type === "void") {
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
}
}
+5 -2
View File
@@ -282,7 +282,9 @@ export class PayStation {
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
const exitRow = rows.find((r) => r.type === "vehicle_exit");
// 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;
@@ -366,7 +368,8 @@ export class PayStation {
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") {
} 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") {
+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;
}
+7
View File
@@ -183,10 +183,17 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
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;
+80 -1
View File
@@ -29,6 +29,33 @@ 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. */
@@ -41,6 +68,7 @@ function sessionView(
language: string;
theme: string;
fullName?: string | null;
email?: string | null;
},
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
@@ -54,6 +82,7 @@ function sessionView(
language: user.language,
theme: user.theme,
fullName: user.fullName ?? null,
email: user.email ?? null,
};
}
@@ -69,7 +98,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
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" });
}
@@ -139,4 +170,52 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
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 };
},
);
}
@@ -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 };
},
);
}
+29
View File
@@ -8,6 +8,7 @@ import {
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";
@@ -36,6 +37,10 @@ interface VoucherBody {
interface ReceiptBody {
identity: string;
}
interface VoidBody {
identity: string;
reason: string;
}
export async function payRoutes(
app: FastifyInstance,
@@ -43,6 +48,7 @@ export async function payRoutes(
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
@@ -50,6 +56,7 @@ export async function payRoutes(
// 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
@@ -125,6 +132,28 @@ export async function payRoutes(
},
);
// 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",
+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);
});
});
@@ -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();
},
);
}
+13 -9
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
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
@@ -56,7 +57,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
.where(eq(rolePermissions.roleId, roleId))
.all()
.map((r) => r.permission);
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
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,
@@ -75,9 +76,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
}
// The full permission grid (for the role-composer checkbox UI) + every role.
// 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).all();
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),
@@ -143,23 +145,25 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Delete a role. Refused if it's built-in or any user still holds it.
// 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(eq(roles.id, id)).get();
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" });
}
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
// 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)` });
}
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
db.delete(roles).where(eq(roles.id, id)).run();
softDelete(db, "role", id, req.user.sub);
bumpPermsCache();
return { ok: true };
},
+11
View File
@@ -32,6 +32,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | 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
@@ -41,6 +44,7 @@ type SiteConfig = {
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean;
anprEntryEnabled: boolean;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -49,6 +53,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
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;
@@ -106,6 +111,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
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]);
}
+21 -5
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
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
@@ -75,7 +76,14 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
// 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) => {
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all();
// 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.
@@ -154,12 +162,19 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
// 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). Removes all versions of the planId.
// 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) => {
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
// 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",
@@ -167,7 +182,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
subscribers: refs.length,
});
}
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
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 };
},
);
+12 -9
View File
@@ -1,9 +1,10 @@
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
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";
@@ -226,9 +227,10 @@ export async function subscriptionRoutes(
return { plan, validFrom, validTo, quantity, quote };
}
// List all subscriptions (with their credentials + plates).
// 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).all();
const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
});
@@ -532,16 +534,17 @@ export async function subscriptionRoutes(
},
);
// Hard delete a subscription + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
// 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 r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
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();
},
+6 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
isTariffV2,
@@ -48,9 +48,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
// Publishing a new version changes what customers are charged.
const writeGuard = requirePermission("tariff:update");
// The single site tariff row, created on first read/publish.
// 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(eq(tariffs.scope, "site")).get();
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();
+22 -10
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, roles, users, type Db } from "@parking/db";
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
@@ -64,9 +65,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
const updateGuard = requirePermission("user:update");
const deleteGuard = requirePermission("user:delete");
/** Count users currently holding the protected admin role. */
/** 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(eq(users.roleId, ADMIN_ROLE_ID)).all().length;
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. */
@@ -112,9 +114,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return false;
}
// List all users (no password hashes) + their role names for display.
// 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).all();
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 {
@@ -140,8 +143,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
}
if (db.select().from(users).where(eq(users.username, username)).get()) {
return reply.code(409).send({ error: "username already exists" });
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);
@@ -221,13 +231,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Delete a user. Refused if it's the last admin (no-lockout).
// 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(eq(users.id, id)).get();
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" });
}
@@ -238,7 +250,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot delete the last admin" });
}
db.delete(users).where(eq(users.id, id)).run();
softDelete(db, "user", id, req.user.sub);
return { ok: true };
},
);
+17 -5
View File
@@ -2,9 +2,10 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { deviceEvents } from "../device-events.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
@@ -52,12 +53,18 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
}
type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
| { 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: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent };
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
export async function wsRoutes(
app: FastifyInstance,
db: Db,
deviceMonitor: DeviceMonitor,
laneStatus: LaneStatus,
): Promise<void> {
app.get(
"/api/ws",
{
@@ -89,7 +96,7 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
// 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() });
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.
@@ -106,11 +113,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
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();
});
},
);
+57 -2
View File
@@ -9,6 +9,7 @@ import { deviceEvents } from "./device-events.js";
import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.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";
@@ -24,8 +25,13 @@ 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 { 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 { 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";
@@ -38,6 +44,7 @@ import { printerRoutes } from "./routes/printers.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
// plugins emitting onto a shared internal event bus; auth is fully local
@@ -112,6 +119,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// the device's lane_devices config (written on assign).
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
// pushes changes to the booth UI. setupRoutes() has already registered the
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
@@ -146,9 +162,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// (+ 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);
await wsRoutes(app, db, deviceMonitor, laneStatus);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db);
@@ -180,6 +200,19 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
});
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.
@@ -200,7 +233,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// 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);
await payRoutes(app, db, payStation, exitFlow, shiftService);
// 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.
@@ -232,6 +268,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
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) => {
// Record every input edge as unsigned telemetry, keyed to the device that fired
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
@@ -253,5 +301,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
});
app.addHook("onClose", async () => unsubscribeInput());
// 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;
}
+3 -2
View File
@@ -141,8 +141,9 @@ async function recognizePlate(
}
}
/** Build a live camera adapter from a resolved devices row, or null. */
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
+56
View File
@@ -0,0 +1,56 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import fastifyStatic from "@fastify/static";
import type { FastifyInstance } from "fastify";
// Serve the built React SPA (apps/web/dist) from the Fastify server, so ONE container
// serves both the API and the operator UI — matching the offline-first single-appliance
// model (the booth has no separate web host). This is a NO-OP in dev (the Vite dev server
// serves the SPA on its own port and no dist exists), so it never changes local behavior.
//
// Registration order matters: this is registered LAST, after every API route, and its
// catch-all is GET-only and explicitly excludes /api, /health, and the WS path — so it
// can never shadow the backend. See wiki/decisions/container-deployment.md.
/** Where the built SPA lives. Override with WEB_DIST_DIR (the container sets it). Default
* resolves relative to this file's dist location: apps/server/dist → ../../web/dist, the
* layout the image lays down (/app/dist + /app/web/dist → ../web/dist from dist). */
function resolveWebDist(): string {
const fromEnv = process.env.WEB_DIST_DIR;
if (fromEnv) return resolve(fromEnv);
const here = dirname(fileURLToPath(import.meta.url));
// In the image the server runs from /app/dist and the SPA sits at /app/web/dist.
return resolve(here, "../web/dist");
}
/**
* Register SPA static serving if a build is present. Returns true when wired, false when
* skipped (dev / no build). Serves assets from the dist dir and falls back to index.html
* for any non-API GET so client-side routing (TanStack Router) works on deep links/reload.
*/
export async function registerSpa(app: FastifyInstance): Promise<boolean> {
const root = resolveWebDist();
const indexHtml = resolve(root, "index.html");
if (!existsSync(indexHtml)) {
app.log.info(`SPA static serving disabled (no build at ${root})`);
return false;
}
await app.register(fastifyStatic, { root, wildcard: false });
// SPA fallback: any GET that didn't match an API route or a real static file returns
// index.html (client routing). EXCLUDE the backend surfaces so a missing /api route
// still 404s as JSON rather than silently returning the HTML shell. WS upgrades and
// non-GET methods are never touched (this is a GET-only notFound handler path).
app.setNotFoundHandler((req, reply) => {
const url = req.raw.url ?? "/";
if (req.method !== "GET" || url.startsWith("/api") || url.startsWith("/health")) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
app.log.info(`SPA static serving enabled from ${root}`);
return true;
}
+4 -1
View File
@@ -108,7 +108,10 @@ export class SubscriptionFlow {
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
// A soft-deleted (recycle-bin) subscription must NOT open the barrier — treat it as
// gone. (Its credential rows are kept for restore, so the dispatcher can still match
// it; the gate is here.)
if (!sub || sub.deletedAt) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { VoidFlow } from "./void-flow.js";
import { PayStation } from "./pay-station.js";
import { occupancyCount } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff } from "./test-helpers.js";
// Cancel (void) a wrongly-printed ticket: a SIGNED `void` event that references the entry
// and folds the session CLOSED. The entry itself is never edited/deleted (append-only).
let db: Db;
let close: () => void;
let log: EventLog;
let voidFlow: VoidFlow;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
voidFlow = new VoidFlow(db, log, silentLogger());
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, payload: payload ?? null });
}
function voids(identity: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "void");
}
describe("VoidFlow.voidTicket", () => {
it("voids an open transient ticket: signs a void, closes the session, drops occupancy", async () => {
await enter("T1");
expect(occupancyCount(db)).toBe(1);
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(true);
const v = voids("T1");
expect(v).toHaveLength(1);
const pl = v[0]!.payload as Record<string, unknown>;
expect(pl.voidReason).toBe("misprint");
expect(pl.operator).toBe("alice");
expect(pl.voidedEntryRef).toBeDefined();
expect(pl.reasonCode).toBe("void.ticketCancelled");
// Folds: not inside, not an active session, no longer "open".
expect(occupancyCount(db)).toBe(1 - 1);
expect(pay.activeSessions().some((s) => s.identity === "T1")).toBe(false);
expect(pay.lookup("T1").open).toBe(false);
});
it("requires a reason", async () => {
await enter("T1");
const r = await voidFlow.voidTicket({ identity: "T1", reason: " ", operator: "alice" });
expect(r.ok).toBe(false);
expect(voids("T1")).toHaveLength(0);
});
it("refuses an unknown ticket", async () => {
const r = await voidFlow.voidTicket({ identity: "ghost", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/no such ticket/i);
});
it("refuses a second void (already cancelled)", async () => {
await enter("T1");
await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "again", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already cancelled/i);
expect(voids("T1")).toHaveLength(1);
});
it("refuses an already-exited session", async () => {
await enter("T1");
await log.append({ type: "vehicle_exit", direction: "exit", identity: "T1" });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already exited/i);
});
it("refuses a PAID ticket (refund is a separate action)", async () => {
await enter("T1");
await log.append({ type: "payment", identity: "T1", payload: { sessionRef: "T1", amountMinor: 100, currency: "ALL" } });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already paid/i);
});
it("refuses a subscription occurrence (closed via its own flow)", async () => {
await enter("SUBSESS-x", { permit: true, permitId: "sub-1" });
const r = await voidFlow.voidTicket({ identity: "SUBSESS-x", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/subscription/i);
});
it("keeps the signed chain verifiable after a void", async () => {
seedTariff(db);
await enter("T1");
await voidFlow.voidTicket({ identity: "T1", reason: "test", operator: "alice" });
// The void is the newest signed row; the chain is intact (verifier is exercised by
// the event-log on append — a broken chain would have thrown).
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
const last = rows[rows.length - 1]!;
expect(last.type).toBe("void");
expect(last.prevHash).toBeTruthy();
expect(last.signature).toBeTruthy();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { eq, ledgerEvents, sessions, type Db } from "@parking/db";
import { reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
// Cancel a wrongly-printed transient ticket by appending a SIGNED `void` event that
// references the entry. The signed ledger is append-only and hash-chained — the
// vehicle_entry is NEVER edited or deleted; the void is a new appended row that the
// session projection folds to CLOSE the session (so a voided car stops counting inside
// and can't be paid/exited). Fully traceable: the operator + a required reason are signed
// into the void payload. A misprinted ticket's car never entered, so voiding opens NO
// barrier. See wiki/concepts/append-only-event-chain.md, parking-session.md.
export interface VoidResult {
readonly ok: boolean;
/** English reason on refusal (localized client-side via the reasonCode it mirrors). */
readonly reason?: string;
/** The void event's identity on success (= the entry identity). */
readonly identity?: string;
}
export class VoidFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
/** Serialize concurrent voids of the SAME ticket (double-click / double-scan). */
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/**
* Void (cancel) a transient ticket. Guards, then appends a signed `void`. Refuses:
* unknown ticket, a subscription occurrence (use the subscription flow), an already-
* exited or already-voided session, or a session that has a payment (a paid ticket is a
* refund situation — out of scope). `reason` is REQUIRED (the route enforces non-empty).
*/
async voidTicket(args: { identity: string; reason: string; operator: string }): Promise<VoidResult> {
const identity = args.identity.trim();
const reason = args.reason.trim();
if (!identity) return { ok: false, reason: "missing ticket id" };
if (!reason) return { ok: false, reason: "a cancellation reason is required" };
if (this.#inFlight.has(identity)) return { ok: false, reason: "cancel already in flight" };
this.#inFlight.add(identity);
try {
return await this.#run(identity, reason, args.operator);
} catch (err) {
this.#logger.error(`void-flow failed (${identity}): ${(err as Error).message}`);
return { ok: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(identity);
}
}
async #run(identity: string, reason: string, operator: string): Promise<VoidResult> {
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 { ok: false, reason: "no such ticket (no entry for this id)" };
// Subscriptions are closed via their own flow — ticket-void would double-mean permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return { ok: false, reason: "this is a subscription occurrence — cancel it via the subscription, not a ticket void" };
}
if (rows.some((r) => r.type === "vehicle_exit")) {
return { ok: false, reason: "session already exited — nothing to cancel" };
}
if (rows.some((r) => r.type === "void")) {
return { ok: false, reason: "ticket already cancelled" };
}
// A paid ticket is a refund, not a misprint cancel — out of scope.
if (rows.some((r) => r.type === "payment")) {
return { ok: false, reason: "ticket already paid — a refund is a separate action, not a cancellation" };
}
await this.#log.append({
type: "void",
identity,
// `sessionRef` + `voidedEntryRef` tie the void to the entry; `voidReason` + `operator`
// make it traceable. The reasonCode localizes; the free-text reason is the operator's note.
payload: {
...reasonPayload("void.ticketCancelled", { reason }),
sessionRef: identity,
voidedEntryRef: entry.id,
voidReason: reason,
operator,
},
});
// Best-effort close the projection cache (the ledger fold is the truth either way).
try {
this.#db
.update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "voided" })
.where(eq(sessions.id, identity))
.run();
} catch (err) {
this.#logger.error(`void session-cache close failed for ${identity}: ${(err as Error).message}`);
}
this.#logger.info(`ticket ${identity} cancelled by ${operator}: ${reason}`);
// NO barrier action — the misprinted ticket's car never entered.
return { ok: true, identity };
}
}
+57
View File
@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1.7
# Parking VISION image: the Python/uv ANPR microservice. Build CONTEXT is apps/vision
# (self-contained Python package; no monorepo deps). Ships WITH the `alpr` extra (real
# fast-alpr/onnxruntime stack) but the engine is env-selected: VISION_RECOGNIZER=stub
# (default, boots anywhere) or fast_alpr (prod). See wiki/decisions/container-deployment.md,
# wiki/decisions/vision-service-packaging.md.
# uv-provided Python 3.12 (matches apps/vision/.python-version).
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
WORKDIR /app
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
# Manifests first so the heavy `uv sync` layer caches across source edits.
COPY pyproject.toml uv.lock .python-version ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --extra alpr
# ---- project source ----
COPY vision_service/ ./vision_service/
COPY README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --extra alpr
# Non-root runtime user, created BEFORE the model pre-warm so the weights cache lands in
# this user's HOME (~/.cache) — the SAME path the runtime reads. (fast-alpr's
# open-image-models caches under $HOME/.cache/open-image-models keyed to HOME, ignoring
# HF_HOME/XDG_CACHE_HOME — so the pre-warm MUST run as the runtime user, not root.)
RUN useradd --system --create-home --uid 999 vision \
&& chown -R vision:vision /app
USER vision
# Pre-warm the fast-alpr model weights INTO the image (as the vision user → /home/vision/
# .cache) so the prod recognizer is OFFLINE-first: ALPR() downloads weights on first
# construction, which would otherwise need network on the appliance's first scan. Best-effort
# — if the build host has no network this is skipped and weights fetch lazily at runtime.
# NB: NO --mount=type=cache here — a BuildKit cache mount at ~/.cache is NOT committed to the
# image layer, so the downloaded weights would vanish. They must write to the real layer.
RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
|| echo "[build] model pre-warm skipped (no network) — weights fetch at runtime"
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
ENV VISION_RECOGNIZER=stub \
VISION_HOST=0.0.0.0 \
VISION_PORT=8089
EXPOSE 8089
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
CMD ["uv", "run", "uvicorn", "vision_service.app:app", "--host", "0.0.0.0", "--port", "8089"]
+101 -17
View File
@@ -4,6 +4,7 @@ import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
boothExit,
can,
fetchSiteConfig,
lookupSession,
openShift,
@@ -11,8 +12,10 @@ import {
printReceipt,
printVoucher,
reopenBarrier,
voidTicket,
type SessionLookup,
} from "./api.js";
import { rootRoute } from "./router.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
@@ -52,6 +55,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
// first, then the modal reveals "Open barrier". This flips true once paid.
const [windowPaid, setWindowPaid] = useState(false);
// Cancel (void) a wrongly-printed ticket: a small reason prompt, then a signed void.
const { user } = rootRoute.useRouteContext();
const canVoid = can(user, "event:void");
const [voiding, setVoiding] = useState(false); // reason prompt revealed
const [voidReason, setVoidReason] = useState("");
const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle.
@@ -127,6 +135,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
}
}
// A wrongly-printed ticket is cancellable only while it's a TRANSIENT, UNPAID, OPEN
// session (a subscription is closed via its own flow; a paid ticket is a refund). The
// server enforces all of this too; the UI just hides the action when it can't apply.
const canCancel = !!(canVoid && shiftReady && s?.found && s.open && !isSubscription && !alreadyPaid);
async function handleVoidTicket() {
const reason = voidReason.trim();
if (!reason) return;
setError(null);
setPhase("finishing");
try {
await voidTicket(identity, reason);
setResult(t("pay.ticketCancelled"));
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleReprintReceipt() {
setReprinting(true);
setError(null);
@@ -365,6 +396,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</label>
)}
{/* Cancel-ticket reason prompt (revealed by the "Cancel ticket" button).
A few presets + free text; a reason is REQUIRED. Voiding appends a
signed `void` event — the entry is never edited. */}
{voiding && phase !== "done" && (
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
{t("pay.cancelTicketTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setVoidReason(t(`pay.cancelReason.${k}`))}
className={voidReason === t(`pay.cancelReason.${k}`) ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{t(`pay.cancelReason.${k}`)}
</button>
))}
</div>
<input
className="input mt-2 w-full"
value={voidReason}
onChange={(e) => setVoidReason(e.target.value)}
placeholder={t("pay.cancelReasonPlaceholder")}
/>
</div>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
@@ -439,27 +500,50 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{t("pay.assistOpenReveal")}
</button>
)
) : (
) : voiding ? (
// Cancel-ticket confirm (reason prompt is shown above).
<button
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="btn btn-go btn-lg"
onClick={handleVoidTicket}
disabled={!voidReason.trim() || phase === "finishing"}
className="btn btn-danger btn-lg"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
{phase === "finishing" ? t("pay.cancelling") : t("pay.confirmCancelTicket")}
</button>
) : (
<>
{/* Cancel a wrongly-printed ticket (transient, unpaid, open only;
gated on event:void). Reveals the reason prompt above. */}
{canCancel && (
<button
type="button"
onClick={() => setVoiding(true)}
className="btn btn-ghost btn-sm text-term-red"
>
{t("pay.cancelTicket")}
</button>
)}
<button
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="btn btn-go btn-lg"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
</>
)}
</>
)}
+46 -2
View File
@@ -112,6 +112,44 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
);
}
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
* from camera detection). Advisory only; it gates nothing. */
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
return (
<div
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
}`}
title={label}
>
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="5" y1="21" x2="5" y2="9" />
<line x1="5" y1="10" x2="21" y2="6" />
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
</svg>
<div className="leading-tight">
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
{busy ? "●" : "○"}
</div>
</div>
</div>
);
}
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
function LaneIndicators() {
const { t } = useTranslation();
const lanes = useLiveStore((s) => s.lanes);
return (
<div className="flex items-center gap-2">
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
</div>
);
}
export function BoothScreen() {
const { t } = useTranslation();
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
@@ -198,10 +236,16 @@ export function BoothScreen() {
return (
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
{/* Ticket input spans both columns at the top — the operator's primary action. */}
{/* Ticket input spans both columns at the top — the operator's primary action.
The lane barrier lights sit beside it (live vehicle-detection busy/free). */}
<div className="lg:col-span-2">
<Panel title={t("booth.processTicket")}>
<TicketInput onSubmit={setActiveTicket} />
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-[260px] flex-1">
<TicketInput onSubmit={setActiveTicket} />
</div>
<LaneIndicators />
</div>
</Panel>
</div>
+171
View File
@@ -0,0 +1,171 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { changeMyPassword, updateMyProfile, type SessionUser } from "./api.js";
// Self-service profile: the signed-in user edits their OWN display name + email and
// changes their OWN password (proving the current one). This is NOT the admin
// user-manager (UsersManager.tsx) — it never touches another account, username, or
// role, and needs no `user:*` permission. See routes/auth.ts (/api/auth/profile,
// /api/auth/password) and wiki/entities/local-jwt-auth.md.
const MIN_PASSWORD = 8;
export function Profile({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
const { t } = useTranslation();
// --- Account (name / email) ---
const [fullName, setFullName] = useState(user.fullName ?? "");
const [email, setEmail] = useState(user.email ?? "");
const [accountMsg, setAccountMsg] = useState<string | null>(null);
const [savingAccount, setSavingAccount] = useState(false);
async function saveAccount() {
setAccountMsg(null);
setSavingAccount(true);
try {
const next = await updateMyProfile({ fullName, email });
// Keep the router-context user in sync so the header reflects the change.
setUser(next);
setFullName(next.fullName ?? "");
setEmail(next.email ?? "");
setAccountMsg(t("profile.profileSaved"));
} catch (e) {
setAccountMsg((e as Error).message);
} finally {
setSavingAccount(false);
}
}
// --- Password ---
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [pwMsg, setPwMsg] = useState<string | null>(null);
const [savingPw, setSavingPw] = useState(false);
async function changePassword() {
setPwMsg(null);
if (next.length < MIN_PASSWORD) {
setPwMsg(t("profile.passwordTooShort", { min: MIN_PASSWORD }));
return;
}
if (next !== confirm) {
setPwMsg(t("profile.passwordsDontMatch"));
return;
}
setSavingPw(true);
try {
await changeMyPassword(current, next);
setCurrent("");
setNext("");
setConfirm("");
setPwMsg(t("profile.passwordChanged"));
} catch (e) {
setPwMsg((e as Error).message);
} finally {
setSavingPw(false);
}
}
return (
<div className="mx-auto flex max-w-xl flex-col gap-6">
<h1 className="text-lg text-term-text">{t("profile.title")}</h1>
{/* Account: display name + email (username + role are read-only — admin-managed). */}
<section className="card flex flex-col gap-3 p-4">
<h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.accountSection")}
</h2>
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
<div>
<span className="block">{t("profile.username")}</span>
<span className="text-sm text-term-text">{user.username}</span>
</div>
<div>
<span className="block">{t("profile.role")}</span>
<span className="text-sm text-term-text">{user.roleName}</span>
</div>
</div>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.fullName")}
<input
className="input"
value={fullName}
placeholder={t("profile.fullNamePh")}
onChange={(e) => setFullName(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.email")}
<input
className="input"
type="email"
value={email}
placeholder={t("profile.emailPh")}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
{t("profile.saveProfile")}
</button>
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
</div>
</section>
{/* Password: requires the current one (server enforces). */}
<section className="card flex flex-col gap-3 p-4">
<h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.passwordSection")}
</h2>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.currentPassword")}
<input
className="input"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.newPassword")}
<input
className="input"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.confirmPassword")}
<input
className="input"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</label>
<div className="flex items-center gap-3">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={changePassword}
disabled={savingPw || !current || !next || !confirm}
>
{t("profile.changePassword")}
</button>
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
</div>
</section>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
can,
fetchRecycleBin,
purgeRecycleItem,
restoreRecycleItem,
type RecycleBinItem,
type RecycleKind,
type SessionUser,
} from "./api.js";
import { qk } from "./lib/query.js";
import { formatRelativeDateTime } from "./lib/format.js";
import { Modal } from "./ui/Modal.js";
// Recycle bin — the way back from an accidental delete. Lists everything soft-deleted
// across users/roles/subscriptions/plans/tariffs; an admin can Restore (back to its
// catalog) or Purge (permanent). Items auto-purge after the retention window. Gated by
// recyclebin:* (read to view, update to restore, delete to purge). See
// apps/server/src/recycle-bin.ts, wiki/concepts/soft-delete.md.
const KIND_KEY: Record<RecycleKind, string> = {
user: "recycleBin.kind.user",
role: "recycleBin.kind.role",
subscription: "recycleBin.kind.subscription",
plan: "recycleBin.kind.plan",
tariff: "recycleBin.kind.tariff",
};
export function RecycleBin({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
const qc = useQueryClient();
const binQ = useQuery({ queryKey: qk.recycleBin, queryFn: fetchRecycleBin });
const canRestore = can(user, "recyclebin:update");
const canPurge = can(user, "recyclebin:delete");
const [error, setError] = useState<string | null>(null);
const [purging, setPurging] = useState<RecycleBinItem | null>(null);
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: qk.recycleBin });
// A restore/purge can change any catalog — refresh the ones a restore touches.
for (const key of [["users"], ["roles"], ["subscriptions"], ["subscription-plans"], ["tariff"]]) {
void qc.invalidateQueries({ queryKey: key });
}
};
const restoreM = useMutation({
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => restoreRecycleItem(kind, id),
onSuccess: invalidate,
onError,
});
const purgeM = useMutation({
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => purgeRecycleItem(kind, id),
onSuccess: () => {
setPurging(null);
invalidate();
},
onError: (e) => {
setPurging(null);
onError(e);
},
});
const items = binQ.data?.items ?? [];
const retentionDays = binQ.data?.retentionDays ?? 0;
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center gap-3">
<h1 className="text-base font-bold uppercase tracking-widest text-term-amber">
{t("recycleBin.title")}
</h1>
{retentionDays > 0 && (
<span className="text-[12px] text-term-muted">
{t("recycleBin.retentionNote", { days: retentionDays })}
</span>
)}
</div>
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{!binQ.isLoading && items.length === 0 ? (
<p className="rounded-term border border-term-border bg-term-panel p-6 text-center text-term-muted">
{t("recycleBin.empty")}
</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
<th className="py-1.5 text-right">{t("recycleBin.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
<td className="py-1.5 pr-3">
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
{t(KIND_KEY[it.kind])}
</span>
</td>
<td className="py-1.5 pr-3 text-term-text">{it.label}</td>
<td className="py-1.5 pr-3 text-term-muted">
{formatRelativeDateTime(it.deletedAt, t)}
</td>
<td className="py-1.5 text-right">
{canRestore && (
<button
type="button"
className="btn btn-sm"
disabled={restoreM.isPending}
onClick={() => {
setError(null);
restoreM.mutate({ kind: it.kind, id: it.id });
}}
>
{t("recycleBin.restore")}
</button>
)}
{canPurge && (
<button
type="button"
className="btn btn-sm btn-ghost ml-1 text-term-red"
onClick={() => {
setError(null);
setPurging(it);
}}
>
{t("recycleBin.purge")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{purging && (
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
<p className="text-[13px] text-term-text">
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
</p>
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
{t("common.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={purgeM.isPending}
onClick={() => purgeM.mutate({ kind: purging.kind, id: purging.id })}
>
{t("recycleBin.purge")}
</button>
</div>
</Modal>
)}
</div>
);
}
+38 -8
View File
@@ -331,11 +331,13 @@ function DeviceForm({
// Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
const [config, setConfig] = useState<Record<string, string | number>>(() => {
// Booleans are kept as real booleans (a checkbox field) — older saved configs may
// have stored a boolean as the string "true"/"false"; normalize those on load.
const [config, setConfig] = useState<Record<string, string | number | boolean>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number> = {};
const out: Record<string, string | number | boolean> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number") out[k] = v;
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v;
}
return out;
});
@@ -415,9 +417,16 @@ function DeviceForm({
}
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
function mergedScalarConfig(): Record<string, string | number> {
const out: Record<string, string | number> = {};
function mergedScalarConfig(): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {};
for (const f of selected?.configFields ?? []) {
// Boolean (checkbox) fields persist a REAL boolean — always (so toggling one OFF
// on an edit actually writes false), defaulting to the field default or false.
if (f.type === "boolean") {
const cur = config[f.key];
out[f.key] = typeof cur === "boolean" ? cur : Boolean(cur ?? f.default ?? false);
continue;
}
const v = config[f.key] ?? (f.default as string | number | undefined);
if (v !== undefined && v !== "") out[f.key] = v;
}
@@ -560,7 +569,27 @@ function DeviceForm({
</div>
)}
{selected.configFields.map((f) => (
{selected.configFields.map((f) =>
f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below.
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<input
type="checkbox"
className="mt-0.5"
checked={Boolean(config[f.key] ?? f.default ?? false)}
onChange={(e) => {
const v = e.target.checked;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
<span>
<span className="font-semibold text-term-text">{f.label}</span>
{f.help && <span className="hint mt-0.5 block">{f.help}</span>}
</span>
</label>
) : (
<div key={f.key} className="field my-2 max-w-sm">
<label className="label">
{f.label}
@@ -586,7 +615,7 @@ function DeviceForm({
<input
className="input"
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
@@ -596,7 +625,8 @@ function DeviceForm({
/>
)}
</div>
))}
),
)}
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
+15
View File
@@ -26,6 +26,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [reserveSubs, setReserveSubs] = useState(false);
const [anprEntry, setAnprEntry] = useState(true);
const [msg, setMsg] = useState<string | null>(null);
function reload() {
@@ -38,6 +39,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault);
setReserveSubs(c.reserveSubscriberSpots);
setAnprEntry(c.anprEntryEnabled);
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
@@ -52,6 +54,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault,
reserveSubscriberSpots: reserveSubs,
anprEntryEnabled: anprEntry,
};
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
@@ -112,6 +115,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.reserveSubsHint")}</span>
</span>
</label>
<label className="flex items-start gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
checked={anprEntry}
onChange={(e) => setAnprEntry(e.target.checked)}
/>
<span>
{t("site.anprEntry")}
<span className="hint block">{t("site.anprEntryHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
+67
View File
@@ -75,6 +75,8 @@ export interface SessionUser {
theme: Theme;
/** Optional display name (profile metadata); null if unset. */
fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */
email: string | null;
}
/** Does this session grant the permission? Central authz check for the SPA. */
@@ -103,6 +105,29 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
}
/** Edit MY own profile (display name / email). Returns the refreshed session.
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
export function updateMyProfile(patch: {
fullName?: string | null;
email?: string | null;
}): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/profile", {
method: "PUT",
body: JSON.stringify(patch),
});
}
/** Change MY own password — proves the current one first (server enforces). */
export function changeMyPassword(
currentPassword: string,
newPassword: string,
): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/password", {
method: "PUT",
body: JSON.stringify({ currentPassword, newPassword }),
});
}
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
@@ -368,6 +393,39 @@ export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): st
return apiUrl(`/api/reports/summary.csv?${qs}`);
}
// --- Recycle bin (soft-deleted master data) ------------------------------
export type RecycleKind = "user" | "role" | "subscription" | "plan" | "tariff";
export interface RecycleBinItem {
kind: RecycleKind;
id: string;
label: string;
deletedAt: string;
deletedBy: string | null;
}
export interface RecycleBin {
items: RecycleBinItem[];
retentionDays: number;
}
/** Everything currently in the recycle bin + the retention window (days). */
export function fetchRecycleBin(): Promise<RecycleBin> {
return apiFetch<RecycleBin>("/api/recycle-bin");
}
/** Restore a soft-deleted item (back to its catalog). 409 if a live row would collide. */
export function restoreRecycleItem(kind: RecycleKind, id: string): Promise<{ restored: boolean }> {
return apiFetch<{ restored: boolean }>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}/restore`, {
method: "POST",
});
}
/** Permanently purge a soft-deleted item. Irreversible. */
export function purgeRecycleItem(kind: RecycleKind, id: string): Promise<void> {
return apiFetch<void>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export interface BackendIpCandidate {
ip: string;
iface: string;
@@ -910,6 +968,8 @@ export interface SiteConfig {
subscriptionMonthlyPriceMinor: number | null;
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
reserveSubscriberSpots: boolean;
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
anprEntryEnabled: boolean;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
@@ -1050,6 +1110,13 @@ export function paySession(
});
}
/** Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event with
* the operator + a required reason; the entry itself is never edited (append-only).
* Refuses a subscription / already-exited / already-voided / paid ticket (409). */
export function voidTicket(identity: string, reason: string): Promise<{ ok: boolean; identity?: string }> {
return apiFetch("/api/tickets/void", { method: "POST", body: JSON.stringify({ identity, reason }) });
}
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
* open (payment stands; operator opens manually). */
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
+58
View File
@@ -56,7 +56,29 @@ export const en: Catalog = {
roles: "Roles",
shifts: "Shifts",
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
profile: "Profile",
},
profile: {
title: "My profile",
accountSection: "Account",
fullName: "Full name",
fullNamePh: "First and last name",
email: "Email",
emailPh: "you@example.com",
username: "Username",
role: "Role",
saveProfile: "Save profile",
profileSaved: "Profile saved.",
passwordSection: "Change password",
currentPassword: "Current password",
newPassword: "New password",
confirmPassword: "Confirm password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordsDontMatch: "Passwords don't match.",
passwordTooShort: "Password must be at least {{min}} characters.",
},
status: {
live: "LIVE",
@@ -94,6 +116,8 @@ export const en: Catalog = {
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
laneEntry: "Entry",
laneExit: "Exit",
open: "Open",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
@@ -153,6 +177,7 @@ export const en: Catalog = {
evtCashIn: "PAY-IN",
evtCashOut: "PAY-OUT",
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
// live-feed event detail line + classification badges (computed from payload)
evtNoReason: "no reason recorded",
badgeEntryRefused: "entry refused",
@@ -221,6 +246,7 @@ export const en: Catalog = {
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
},
tariff: {
title: "Tariff",
@@ -529,6 +555,8 @@ export const en: Catalog = {
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
reserveSubs: "Reserve subscriber spots",
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
anprEntry: "Auto-open for subscriber plates (ANPR)",
anprEntryHint: "When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
@@ -712,6 +740,24 @@ export const en: Catalog = {
subCars: "Cars covered",
},
},
recycleBin: {
title: "Recycle bin",
retentionNote: "Deleted items are kept for {{days}} days, then permanently removed.",
empty: "Nothing deleted. Items you delete appear here, recoverable until they expire.",
col: { type: "Type", item: "Item", deleted: "Deleted", actions: "" },
kind: {
user: "User",
role: "Role",
subscription: "Subscription",
plan: "Plan",
tariff: "Tariff",
},
restore: "Restore",
purge: "Purge",
purgeConfirmTitle: "Purge permanently?",
purgeConfirmBody: "Permanently delete “{{label}}”? It cannot be restored after this.",
purgeIrreversible: "This is irreversible.",
},
logs: {
title: "System logs",
refresh: "Refresh",
@@ -776,6 +822,18 @@ export const en: Catalog = {
receiptReprinted: "Receipt reprinted on {{printer}}.",
reprintReceipt: "Reprint receipt",
reprinting: "printing…",
cancelTicket: "Cancel ticket",
cancelTicketTitle: "Cancel this ticket",
cancelTicketHint: "Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.",
cancelReason: {
misprint: "Misprint",
test: "Test",
wrongVehicle: "Wrong vehicle",
},
cancelReasonPlaceholder: "Reason for cancelling (required)…",
confirmCancelTicket: "Confirm cancellation",
cancelling: "cancelling…",
ticketCancelled: "Ticket cancelled.",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
snapEntry: "entry",
+58
View File
@@ -58,7 +58,29 @@ export const sq = {
roles: "Rolet",
shifts: "Turnet",
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget",
profile: "Profili",
},
profile: {
title: "Profili im",
accountSection: "Llogaria",
fullName: "Emri i plotë",
fullNamePh: "Emri dhe mbiemri",
email: "Email",
emailPh: "ti@shembull.com",
username: "Përdoruesi",
role: "Roli",
saveProfile: "Ruaj profilin",
profileSaved: "Profili u ruajt.",
passwordSection: "Ndrysho fjalëkalimin",
currentPassword: "Fjalëkalimi aktual",
newPassword: "Fjalëkalimi i ri",
confirmPassword: "Konfirmo fjalëkalimin",
changePassword: "Ndrysho fjalëkalimin",
passwordChanged: "Fjalëkalimi u ndryshua.",
passwordsDontMatch: "Fjalëkalimet nuk përputhen.",
passwordTooShort: "Fjalëkalimi duhet të jetë të paktën {{min}} karaktere.",
},
status: {
live: "LIVE",
@@ -96,6 +118,8 @@ export const sq = {
booth: {
processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
laneEntry: "Hyrje",
laneExit: "Dalje",
open: "Hap",
occupancy: "Prania",
occUnavailable: "zënia e padisponueshme",
@@ -157,6 +181,7 @@ export const sq = {
evtCashIn: "ARKËTIM",
evtCashOut: "PAGESË",
evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
evtNoReason: "pa arsye të regjistruar",
badgeEntryRefused: "hyrje e refuzuar",
@@ -224,6 +249,7 @@ export const sq = {
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
},
tariff: {
title: "Tarifa",
@@ -540,6 +566,8 @@ export const sq = {
printExitHint: "(klienti skanon biletën në dalje)",
reserveSubs: "Rezervo vendet e abonentëve",
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
anprEntry: "Hapje automatike për targat e abonentëve (ANPR)",
anprEntryHint: "Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",
@@ -726,6 +754,24 @@ export const sq = {
subCars: "Makina të mbuluara",
},
},
recycleBin: {
title: "Koshi",
retentionNote: "Artikujt e fshirë mbahen për {{days}} ditë, pastaj hiqen përgjithmonë.",
empty: "Asgjë e fshirë. Artikujt që fshini shfaqen këtu, të rikuperueshëm derisa të skadojnë.",
col: { type: "Lloji", item: "Artikulli", deleted: "Fshirë", actions: "" },
kind: {
user: "Përdorues",
role: "Rol",
subscription: "Abonim",
plan: "Plan",
tariff: "Tarifë",
},
restore: "Rikthe",
purge: "Fshi përfundimisht",
purgeConfirmTitle: "Të fshihet përfundimisht?",
purgeConfirmBody: "Të fshihet përgjithmonë “{{label}}”? Nuk mund të rikthehet pas kësaj.",
purgeIrreversible: "Ky veprim është i pakthyeshëm.",
},
logs: {
title: "Loget e sistemit",
refresh: "Rifresko",
@@ -790,6 +836,18 @@ export const sq = {
receiptReprinted: "Fatura u riprintua në {{printer}}.",
reprintReceipt: "Riprinto faturën",
reprinting: "duke printuar…",
cancelTicket: "Anulo biletën",
cancelTicketTitle: "Anulo këtë biletë",
cancelTicketHint: "Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.",
cancelReason: {
misprint: "Printim i gabuar",
test: "Test",
wrongVehicle: "Automjet i gabuar",
},
cancelReasonPlaceholder: "Arsyeja e anulimit (e detyrueshme)…",
confirmCancelTicket: "Konfirmo anulimin",
cancelling: "duke anuluar…",
ticketCancelled: "Bileta u anulua.",
// snapshots
noSnapshots: "asnjë foto",
loadingSnapshots: "duke ngarkuar fotot…",
+13 -1
View File
@@ -10,6 +10,12 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
export type WsStatus = "connecting" | "open" | "closed";
/** Per-lane busy/free from camera vehicle detection (advisory barrier lights). */
export interface LaneStatus {
entry: boolean; // true = busy
exit: boolean; // true = busy
}
/** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200;
@@ -23,6 +29,8 @@ interface LiveState {
/** Live device status keyed by device id (for the footer): set from the WS
* hello snapshot, then upserted per device on each device-status push. */
devices: Record<string, DeviceStatus>;
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
lanes: LaneStatus | null;
setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void;
@@ -30,6 +38,8 @@ interface LiveState {
setDevices: (list: DeviceStatus[]) => void;
/** Upsert one device's status (a device-status push). */
upsertDevice: (d: DeviceStatus) => void;
/** Set lane busy/free (WS hello + each lane-status push). */
setLanes: (l: LaneStatus) => void;
reset: () => void;
}
@@ -45,6 +55,7 @@ export const useLiveStore = create<LiveState>((set) => ({
occupancy: null,
feed: [],
devices: {},
lanes: null,
setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) =>
@@ -54,5 +65,6 @@ export const useLiveStore = create<LiveState>((set) => ({
})),
setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
setLanes: (lanes) => set({ lanes }),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
}));
+1
View File
@@ -29,4 +29,5 @@ export const qk = {
deviceStatus: ["device-status"] as const,
report: (from: string, to: string, bucket: string) =>
["report", from, to, bucket] as const,
recycleBin: ["recycle-bin"] as const,
} as const;
+8 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js";
import { useLiveStore, type LaneStatus } from "./live-store.js";
import { wsUrl } from "./origin.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
@@ -14,15 +14,16 @@ import { wsUrl } from "./origin.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus };
| { kind: "device-status"; event: DeviceStatus }
| { kind: "lane-status"; lanes: LaneStatus };
export function useLiveFeed(): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
@@ -54,8 +55,11 @@ export function useLiveFeed(): void {
setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices);
if (msg.lanes) setLanes(msg.lanes);
} else if (msg.kind === "device-status") {
upsertDevice(msg.event);
} else if (msg.kind === "lane-status") {
setLanes(msg.lanes);
} else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy);
pushEvent(msg.event);
+42 -4
View File
@@ -30,6 +30,8 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js";
import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
// initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
@@ -44,7 +46,9 @@ export interface RouterContext {
setUser: (u: SessionUser | null) => void;
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
// Exported so a deep component (e.g. the booth pay modal) can read the signed-in user
// from route context without prop-threading through every layer.
export const rootRoute = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
@@ -88,6 +92,7 @@ function SetupLayout() {
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
</nav>
<Outlet />
@@ -387,6 +392,7 @@ function RootLayout() {
show("site:read") ||
show("user:read") ||
show("role:read") ||
show("recyclebin:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
@@ -394,9 +400,15 @@ function RootLayout() {
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.roleName}
</span>
{user && (
<Link
to="/profile"
title={t("nav.profile")}
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
>
{user.username} · {user.roleName}
</Link>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
@@ -513,6 +525,7 @@ const SETUP_TABS: { to: string; perm: Permission }[] = [
{ to: "/setup/site", perm: "site:read" },
{ to: "/setup/users", perm: "user:read" },
{ to: "/setup/roles", perm: "role:read" },
{ to: "/setup/recycle-bin", perm: "recyclebin:read" },
{ to: "/shifts", perm: "shift:read" },
{ to: "/setup/logs", perm: "log:read" },
];
@@ -610,6 +623,17 @@ const rolesRoute = createRoute({
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
// Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read.
const recycleBinRoute = createRoute({
getParentRoute: () => setupRoute,
path: "recycle-bin",
beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context),
component: function RecycleBinRoute() {
const { user } = rootRoute.useRouteContext();
return <RecycleBin user={user} />;
},
});
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
const logsRoute = createRoute({
getParentRoute: () => setupRoute,
@@ -618,10 +642,23 @@ const logsRoute = createRoute({
component: LogsViewer,
});
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
const profileRoute = createRoute({
getParentRoute: () => rootRoute,
path: "profile",
component: function ProfileRoute() {
const { user, setUser } = rootRoute.useRouteContext();
if (!user) return null;
return <Profile user={user} setUser={setUser} />;
},
});
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
...legacyRedirects,
profileRoute,
shiftRoute,
reportsRoute,
subscriptionsRoute.addChildren([
@@ -635,6 +672,7 @@ const routeTree = rootRoute.addChildren([
siteRoute,
usersRoute,
rolesRoute,
recycleBinRoute,
logsRoute,
]),
]);
+33 -9
View File
@@ -26,6 +26,27 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
/**
* A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is
* `anomaly` for both (immutable history), but a refused exit / refused subscription /
* refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed
* session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the
* flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an
* amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies
* (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change.
*/
export function isRefusedWarning(e: LedgerEvent): boolean {
if (e.type !== "anomaly") return false;
const p = e.payload;
return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused));
}
/** The label key + colour to render for an event, applying the refused-warning split. */
export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } {
if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" };
return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
}
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
function hhmmss(iso: string): string {
const d = new Date(iso);
@@ -79,9 +100,12 @@ export function displayIdentity(e: LedgerEvent): string {
* its own row, indented under the identity column. */
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
const isAnomaly = e.type === "anomaly";
const style = eventStyleFor(e);
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
// A refused-action event is a benign WARNING (amber), distinct from a genuine red
// anomaly. Only true anomalies get the red row tint + the "no reason" fallback.
const refusedWarning = isRefusedWarning(e);
const isAnomaly = e.type === "anomaly" && !refusedWarning;
const p = e.payload;
const reason = renderReason(p, t);
const amount = paymentSummary(p);
@@ -95,11 +119,11 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
type="button"
onClick={() => onOpen(e)}
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
isAnomaly ? "bg-term-red/5" : ""
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
}`}
>
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-term-text">{displayIdentity(e)}</span>
{e.plate && (
@@ -152,12 +176,12 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
* this only DISPLAYS the signed record. */
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
const style = eventStyleFor(e);
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
const p = e.payload;
const reason = renderReason(p, t);
const badges = eventBadges(p);
const isAnomaly = e.type === "anomaly";
const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e);
// Pretty money for any minor-unit amount in the payload.
const money =
@@ -177,7 +201,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
<div className="flex flex-col gap-3">
{/* Headline: the type + localized reason, prominent for anomalies. */}
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
{(reason || money) && (
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
{reason ?? money}
+6
View File
@@ -9,6 +9,12 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
// Bind all interfaces so the dev SPA is reachable from other LAN devices
// (phone over wifi, etc.) at http://<host-lan-ip>:5173 — not just localhost.
// NB: loading from a non-localhost origin means the booth WebSocket (/api/ws)
// sends Origin: http://<host-lan-ip>:5173, which the backend's WS_ALLOWED_ORIGINS
// must include or the live feed is rejected. See apps/server/.env(.example).
host: "0.0.0.0",
proxy: {
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
+29
View File
@@ -0,0 +1,29 @@
# DEV override: build the images locally from the Dockerfiles, expose both ports, run the
# stub recognizer (no model load), and verbose logging. Use with the base file:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
services:
server:
build:
context: .
dockerfile: apps/server/Dockerfile
environment:
LOG_LEVEL: debug
# Dev convenience: seed an admin on first boot (set ADMIN_PASS to enable).
SEED_ADMIN: ${SEED_ADMIN:-0}
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASS: ${ADMIN_PASS:-}
# 32+ chars and must NOT contain dev-only/insecure/change-me (auth.ts rejects those).
# This is a fixed LOCAL-DEV value only; prod injects a real `openssl rand -hex 32`.
JWT_SECRET: ${JWT_SECRET:-localdevsecret0123456789abcdef0123}
ports:
- "3000:3000"
vision:
build:
context: apps/vision
dockerfile: Dockerfile
environment:
VISION_RECOGNIZER: stub
ports:
- "8089:8089"
+59
View File
@@ -0,0 +1,59 @@
# PROD override: pull pinned registry images (no local build), restart always, real
# recognizer, and a CADDY reverse proxy in front so operators reach the booth on a clean
# port-80 URL (no :3000) — and a path to real TLS later. Server + vision stay INTERNAL
# (only Caddy publishes a port). Use with the base file and pin TAG to the branch you deploy:
# REGISTRY=git.infra.msai.al/mca/parking_solution TAG=main \
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# See wiki/decisions/container-deployment.md.
services:
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
# site's real hostname later enables automatic HTTPS. The booth is reached at
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
proxy:
image: caddy:2-alpine
restart: always
ports:
- "80:80"
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- server
networks:
- parking
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
server:
restart: always
# No published port — only the proxy reaches the server, over the private network.
expose:
- "3000"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
vision:
restart: always
# The real ANPR engine. The image baked the model weights at build (offline-first).
environment:
VISION_RECOGNIZER: fast_alpr
# No published ports — vision is reached only by the server over the private network.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
caddy-data:
caddy-config:
+53
View File
@@ -0,0 +1,53 @@
# Base stack: the parking SERVER (API + SPA) + the VISION (ANPR) service. Branch-aware via
# ${REGISTRY}/${TAG} — a deploy on `dev` pulls :dev, on `main` pulls :main. Use an env
# override file for the environment: docker-compose.dev.yml (build locally, stub recognizer)
# or docker-compose.prod.yml (pull pinned images, fast_alpr). See
# wiki/decisions/container-deployment.md.
#
# local dev : docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
# prod : REGISTRY=… TAG=main docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services:
server:
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-server:${TAG:-dev}
restart: unless-stopped
environment:
DATABASE_URL: /data/parking.sqlite
# Reach the vision service over the private compose network by service name.
VISION_URL: http://vision:8089
VISION_ENABLED: ${VISION_ENABLED:-1}
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
# Dedicated ledger-signing key. Falls back to JWT_SECRET (with a warning) if empty;
# set a distinct one in prod. See apps/server/.env.example + local-jwt-auth.
EVENT_SIGNING_KEY: ${EVENT_SIGNING_KEY:-}
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
COOKIE_SECURE: ${COOKIE_SECURE:-0}
# The booth WS live feed checks the browser Origin — must list the address operators
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
volumes:
- parking-data:/data
depends_on:
vision:
condition: service_started
networks:
- parking
vision:
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-vision:${TAG:-dev}
restart: unless-stopped
environment:
# Engine: stub (no models) by default; prod override sets fast_alpr.
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub}
networks:
- parking
volumes:
parking-data:
networks:
parking:
driver: bridge
+19
View File
@@ -0,0 +1,19 @@
-- Soft delete (recycle bin) for accidental hard-deletes of master data. Adds a nullable
-- `deleted_at` (ISO-8601; null = live) + `deleted_by` (the admin user id) to the mutable
-- master-data tables. A DELETE now stamps these instead of removing the row; restore
-- clears them; an admin purge (or the retention sweep) does the real DELETE. The signed
-- append-only ledger is NOT touched — it has no delete path and is out of scope here.
--
-- All additive ALTER ADD COLUMN — backward-compatible (existing rows: deleted_at null =
-- live). SQLite ADD COLUMN is in-place. Subscription PLANS are versioned (many rows per
-- plan_id); a soft-delete stamps every version row of that plan_id together.
ALTER TABLE `users` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `users` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `roles` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `roles` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `subscription_plans` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `subscription_plans` ADD `deleted_by` text;--> statement-breakpoint
ALTER TABLE `tariffs` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `tariffs` ADD `deleted_by` text;
@@ -0,0 +1,5 @@
-- Site master switch for the ANPR subscriber-entry bridge (anpr-entry.ts). Additive
-- ALTER ADD COLUMN — backward-compatible. Default 1 (ON) so existing installs keep the
-- now-live auto-open-for-subscriber-plates behaviour after upgrade. The toggle gates ONLY
-- the barrier-driving bridge; advisory snapshot-ANPR + lane busy/free are unaffected.
ALTER TABLE `site_config` ADD `anpr_entry_enabled` integer DEFAULT 1 NOT NULL;
+14
View File
@@ -85,6 +85,20 @@
"when": 1781885400000,
"tag": "0011_subscription_plan_v2",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1781885500000,
"tag": "0012_soft_delete",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1781885600000,
"tag": "0013_anpr_entry_toggle",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -25,7 +25,8 @@
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate"
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate",
"db:migrate:runtime": "node scripts/migrate-runtime.mjs"
},
"dependencies": {
"@parking/shared": "workspace:*",
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
// Apply Drizzle migrations against the DATABASE_URL sqlite file using the runtime
// migrator (drizzle-orm/better-sqlite3/migrator) — NOT drizzle-kit. This lets the
// container run migrations on boot with only runtime deps installed (drizzle-kit is a
// devDep, pruned out of the production image). Same migration set + folder the test
// helper uses (packages/db/src/testing.ts), so the schema matches production exactly.
//
// Usage: DATABASE_URL=/data/parking.sqlite node scripts/migrate-runtime.mjs
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { mkdirSync } from "node:fs";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
const url = process.env.DATABASE_URL;
if (!url) {
console.error("[migrate] DATABASE_URL is required");
process.exit(1);
}
// Migrations folder ships beside this package (packages/db/drizzle); from scripts/ that's ../drizzle.
const migrationsFolder = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
// Ensure the DB's parent dir exists (a fresh mounted volume may be empty).
try {
mkdirSync(dirname(resolve(url)), { recursive: true });
} catch {
/* dir already exists (or url has no dir) — fine */
}
const sqlite = new Database(url);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
const db = drizzle(sqlite);
console.log(`[migrate] applying migrations from ${migrationsFolder} → ${url}`);
migrate(db, { migrationsFolder });
sqlite.close();
console.log("[migrate] done");
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package).
export { eq, and, asc, desc, gte, lte, sql } from "drizzle-orm";
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
/**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+36
View File
@@ -29,6 +29,11 @@ export const roles = sqliteTable("roles", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin): ISO instant the row was deleted, null = live; the admin
// user id who deleted it. A DELETE stamps these; restore clears them; purge/retention
// does the real row removal. See wiki/concepts/soft-delete.md.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
/** The role→permission grid. One row per granted `resource:action` permission.
@@ -78,6 +83,11 @@ export const users = sqliteTable("users", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. NB: `username` stays UNIQUE across
// live AND deleted rows, so creating a new user reusing a deleted user's name is
// blocked until that row is restored or purged (the route returns a clear 409).
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// --- The signed business ledger (formerly `events`) ----------------------
@@ -226,6 +236,16 @@ export const siteConfig = sqliteTable("site_config", {
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
.notNull()
.default(false),
/** Site master switch for the ANPR subscriber-entry BRIDGE (anpr-entry.ts): when ON
* (default), a subscriber's plate read off a lane camera's vehicle detection opens the
* barrier through the normal gated subscription flow. When OFF, the bridge emits no read
* (subscribers fall back to their card/QR). This gates ONLY the barrier-driving bridge —
* advisory snapshot-ANPR recording and lane busy/free are unaffected. Read LIVE per event
* so toggling takes effect with no restart. Default ON because the feature is already
* live. Stored 0/1. See wiki/concepts/lane-presence-and-anpr-entry.md. */
anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" })
.notNull()
.default(true),
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
* each published tariff version's structure.tz so the windows are frozen/immutable
@@ -258,6 +278,10 @@ export const tariffs = sqliteTable("tariffs", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. Stamps the rate-card row; its
// immutable tariff_versions are kept (referenced for repricing) and ride along.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const tariffVersions = sqliteTable("tariff_versions", {
@@ -314,6 +338,12 @@ export const subscriptionPlans = sqliteTable("subscription_plans", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. A plan is VERSIONED (many rows per
// plan_id); a soft-delete stamps every version row of the plan_id together, and the bin
// shows/restores the plan as one item. Distinct from `active=0` (retire = unsellable
// but kept in the catalog); deletedAt removes it from the catalog entirely.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const subscriptions = sqliteTable("subscriptions", {
@@ -348,6 +378,12 @@ export const subscriptions = sqliteTable("subscriptions", {
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. Distinct from `status: "revoked"`
// (a domain state that BARS the subscriber but keeps it visible); deletedAt removes it
// from the catalog entirely, recoverable from the bin. Child credential/plate rows are
// kept and restored with it.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
+44 -2
View File
@@ -95,13 +95,55 @@ const channelField: ConfigField = {
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
// Alarm Server) HTTP-POSTs an EventNotificationAlert to a URL we host every time the
// chosen target is detected — same shape as the Dingtian Input Link push. When enabled,
// the admin points the camera's Alarm Server at /api/devices/hikvision/:deviceId/event
// and we record what it sends. See routes/hikvision-alarm.ts, wiki/entities/lpr-camera.md.
const alarmPushFields: ConfigField[] = [
{
key: "alarmPushEnabled",
label: "Alarm Server push (Event → vehicle)",
type: "boolean",
required: false,
default: false,
help: "The camera POSTs each detected event to us (set its Alarm Settings → Alarm Server to this backend). No polling.",
},
{
key: "pushUser",
label: "Alarm push username (optional)",
type: "string",
required: false,
help: "Only if the camera's Alarm Server is set to authenticate (HTTP Digest). Leave blank to accept by source-IP only.",
},
{
key: "pushPassword",
label: "Alarm push password (optional)",
type: "secret",
required: false,
help: "Paired with the username above for Digest auth on the push. Leave blank for source-IP-only.",
},
{
key: "skipSourceIpCheck",
label: "Don't verify push source IP",
type: "boolean",
required: false,
default: false,
help: "Accept pushes regardless of the source IP. Needed when the network rewrites the inbound source address (e.g. WSL mirrored mode reports the host's own IP, not the camera's), which would otherwise reject every push. Leave OFF on a normal LAN.",
},
];
export const hikvisionDriver: CameraDriver = {
id: "hikvision",
category: "camera",
label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI (HTTP Digest).",
description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
transports: ["tcp-ip"],
configFields: cameraConfigFields,
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
// so it may need the backend push IP at assign time (like the Dingtian).
pushesToBackend: true,
configFields: [...cameraConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
+7
View File
@@ -27,6 +27,7 @@ export const RESOURCES = [
"event", // the signed ledger feed + void
"report", // events feed, occupancy, future reports
"log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge
] as const;
export type Resource = (typeof RESOURCES)[number];
@@ -56,6 +57,9 @@ export const PERMISSIONS: readonly Permission[] = [
"event:read", "event:void",
"report:read",
"log:read",
// Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
"recyclebin:read", "recyclebin:update", "recyclebin:delete",
] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL
@@ -343,6 +347,8 @@ export const REASON_CODES = [
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
"sub.refused.unpaidWindow",
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
"void.ticketCancelled",
] as const;
export type ReasonCode = (typeof REASON_CODES)[number];
@@ -371,6 +377,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
"void.ticketCancelled": "ticket cancelled — {reason}",
};
/**
+3 -1
View File
@@ -9,7 +9,9 @@
"cache": false,
"persistent": true
},
"lint": {},
"lint": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
},
+8
View File
@@ -29,6 +29,14 @@ With LUKS in place, **SQLCipher becomes optional** defence-in-depth rather than
layer. (The custom controller adds its own: ESP32 flash encryption + secure boot — see
[[esp32-custom-controller]].)
> **Step-by-step OS install + TPM-seal procedure** (BIOS → encrypted install → manual PCR-7 TPM
> seal, with the Dell-7070-specific `dbt` workaround) lives in [[appliance-provisioning]] — written
> from the first real provisioning (2026-06-23) and verified on hardware. **OS hardening on the first
> unit is COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7, unattended) + Secure Boot (Deployed) + GRUB
> edit-lock** (the GRUB password is the specific countermeasure to 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.
## Deploy-time server configuration (runbook)
Env in `apps/server/.env` on the appliance (see `apps/server/.env.example`). The security-load-bearing ones:
@@ -0,0 +1,127 @@
---
type: concept
tags: [parking, camera, anpr, subscription, lane, vision]
sources: []
updated: 2026-06-22
status: open
---
# Lane Presence & ANPR Subscriber Entry
Two related things a camera's vehicle detection feeds, worked out over a long field session
(2026-06-22, see [[lpr-camera]] for the camera-side saga + the corrected "it was the undrawn
detection area" conclusion):
1. **Lane busy/free** — BUILT. An advisory barrier light on the booth.
2. **ANPR subscriber entry** — BUILT (2026-06-22). A subscriber's plate, read from the lane camera,
drives their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below.
The camera (Hik `DS-2CD1043G2-LIU`) only emits `VMD` events with `eventState=active` and a
`targetType` of `vehicle`/`human` — a coarse **presence** signal, never an identity. Everything
here is built on that, and on the camera's hard limits.
## What the camera actually gives us (measured)
- **Push only, no poll.** No ISAPI endpoint reports "is a car in the zone now"; the only live source
is the event push. We tried to force a steadier signal by flipping `notificationRecurrence`
`beginning → recurring` via ISAPI — the firmware **accepts the PUT but silently reverts** (locked to
`beginning` on this value line). So: notify-once-at-motion-start is all we get.
- **No leave signal.** The camera never sends an `inactive`/end event. Confirmed by config
(`notificationRecurrence: beginning`) AND a controlled in/out test.
- **Re-fire is MOVEMENT-driven, not steady.** Controlled test (call out enter/leave, correlate to the
event log): while a car MOVES, `active` repeats ~1–3 s apart; while it sits MOTIONLESS, gaps stretch
to ~15–25 s. Crucially the camera has **~no dwell lag** — it goes silent within ~1 s of the car
leaving (last event 16:15:17 vs car-left ~16:15:30).
## 1. Lane busy/free (BUILT)
`apps/server/src/lane-status.ts` (`LaneStatus`) + the hik-alarm handler + the booth WS. A `vehicle`
`active` event on a camera bound to entry/exit marks THAT lane busy and arms an auto-clear timer; the
booth shows two barrier lights beside the scan input (green=free, red=busy). **Advisory only — gates
nothing** (never blocks a ticket or opens a barrier; the standing rule).
- **"Free" is timeout-driven** (no leave signal). The TTL must exceed the still-car gap (~25 s) or a
parked car flickers free — so `LANE_BUSY_TTL_MS` default is **30 s** (started at a guessed 90 s,
briefly 5 s, then set to 30 s from the measured data). The camera's lack of dwell lag means 30 s
also clears promptly after departure.
- A `both`-direction camera marks both lanes. Pushed over the existing `/api/ws` (kind `lane-status`).
## 2. ANPR subscriber entry — THE BRIDGE (BUILT 2026-06-22)
> **"Bridge" = a HANDLER CLASS in `apps/server/src/anpr-entry.ts` (`AnprBridge`). NOT a new
> service / container / app.** It is in-process glue that calls things that ALREADY exist.
**As built:** `hikvision-alarm.ts`, on a `vehicle`/non-`inactive` push from an `anpr`-opted-in
camera, hands the deviceId to `AnprBridge.onVehicleDetected()` (fire-and-forget, never awaited on the
camera's 200). The bridge: debounce (camera-level, pre-snapshot) → `captureSnapshot` (fresh pull, via
the reused `snapshot.ts buildCamera`) → `vision.analyze` → entry confidence floor
(`VISION_ENTRY_MIN_CONFIDENCE`, 0.85) → normalize plate → **`subscriptionFlow.match()` (match BEFORE
emit)** → if a subscriber, `deviceEvents.emitRead({kind:"plate"})`; if not, record an advisory
`anpr-skip` device_event and stop. The existing `onRead → ReadDispatcher → SubscriptionFlow.run()`
then does the gated entry/exit + barrier open. Constructed in `server.ts` (the flows were reordered
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
`anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3).
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
ONLY — no queue segmentation, no per-car tracking, no make/model, no ticket-button gating.
Almost everything already exists; the bridge is the one missing wire:
| Piece | Status |
| --- | --- |
| Camera vehicle event | ✅ [[lpr-camera]] (hik-alarm) |
| Pull a snapshot | ✅ `snapshot.ts` (`captureSnapshot`) |
| Read the plate | ✅ [[opencv-anpr-service]] `/analyze` (~50 ms on the DEV PC; appliance TBD) |
| Match a plate → subscriber | ✅ `subscription-flow.ts` `match()` + `subscription_plates` (`via:"plate"`) |
| Plate read → gated entry/exit | ✅ `read-dispatch.ts` + SubscriptionFlow (active/window/blocklist/car-count) |
| **Emit the plate onto the read bus** | ✅ `anpr-entry.ts` (`AnprBridge`) — on a vehicle push from an `anpr` camera it snapshots → analyzes → matches a subscriber → `emitRead({kind:"plate"})`. (Built 2026-06-22.) |
**The bridge logic:** on a camera `vehicle`/`active` event from an **opt-in** camera (`config.anpr`),
snapshot → `vision.analyze` → if a plate clears a **HIGH** confidence floor → **debounce** → emit
`DeviceReadEvent{kind:"plate", value, deviceId}`. The existing dispatcher + flow do the rest.
### Decisions settled with the user (2026-06-22)
- **Both directions.** Entry- and exit-bound cameras both work; the dispatcher infers the verb from
the camera's bound relay direction (an entry camera → entry, exit → exit). No per-read inference.
- **High confidence required.** A barrier-driving read needs a stricter bar than the advisory-record
floor — a NEW `VISION_ENTRY_MIN_CONFIDENCE` (≈0.85) distinct from `VISION_MIN_CONFIDENCE`. (The
subscriber still holds their card/QR, so a near-miss read just falls back to that.)
- **Opt-in** per camera (`config.anpr`), so a site that didn't ask for plate-entry is unaffected.
- **Debounce is REQUIRED — for correctness, NOT CPU.** The camera re-fires ~1 Hz while a car is
present; emitting a read every second would drive REPEAT entries (a fleet sub opens a 2nd
occurrence; a single-car sub spams "already inside") or, on an exit camera, repeat exits
(`sub.refused.noSession` after the first, and FIFO could phantom-close another occurrence). So the
same plate on the same camera within ~10–15 s = ONE credential presentation.
- NB: a direction-bound camera does NOT flip entry↔exit on repeat reads (the barrier's direction
fixes the verb), so the earlier "flip-flop" fear was wrong — but repeat-same-direction is still
bad. Debounce stands.
- **Threat-model rule preserved by construction.** A plate is trivially spoofable (print it on
paper); it must NEVER be the sole reason a barrier opens. Routing through the existing
SubscriptionFlow means the plate is just another credential through the same gate (active / window
/ blocklist / car-count) — not a bypass. See [[opencv-anpr-service]], [[append-only-event-chain]].
### To validate (booth-PC test day, ~2026-06-23)
The dev-PC ANPR is ~50 ms/frame, but that says little about the hardened **booth appliance** (likely a
low-power CPU, possibly 5–15× slower). Real-hardware latency is the open number. The user is bringing
the actual booth PC to test on.
## Rejected / out of scope (and why)
- **Vision monitors the RTSP livestream continuously** for presence — rejected: rebuilds (worse) what
the camera already does (presence), pegs the appliance CPU 24/7, and the leave-detection heuristic
is no cleaner than a 30 s timeout.
- **Vision polls every ~1 s to track THE car, segment a queue, count cars, read make/model** — a
genuinely harder need (bumper-to-bumper cars the camera can't tell apart). Set ASIDE, not dismissed:
it needs a general vehicle DETECTOR ([[opencv-anpr-service]] is plate-only today) + "same-car-vs-new"
logic + make/model (a third, weak, heavy model), and its viability is **gated on appliance
inference budget we cannot measure on the dev PC**. Revisit only if real traffic + hardware justify
it. The vision feasibility was checked: `fast_alpr` live + ready; plate-only; no vehicle/presence
detector exists yet.
- **Gate the entry ticket button on lane-busy** (don't print when no car) — deferred. The lane-busy
signal supports it, but it gates a PHYSICAL action so it must FAIL-OPEN (allow when presence is
unknown). Parked pending the user's real goal (anti-spam print vs one-ticket-per-car).
+28
View File
@@ -73,6 +73,34 @@ States, as derived from events:
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
### Cancel a wrongly-printed ticket — BUILT (2026-06-22)
A ticket printed in error (misprint, test press, wrong vehicle) is cancelled by appending a **signed
`void`** event — the `vehicle_entry` is NEVER edited or deleted (append-only; [[append-only-event-chain]]).
`apps/server/src/void-flow.ts` (`VoidFlow`) appends `{ type:"void", identity, payload:{ sessionRef,
voidedEntryRef:<entry id>, voidReason, operator, reasonCode:"void.ticketCancelled" } }`. Traceable: the
operator (from the JWT) + a **REQUIRED reason** are signed in. Route `POST /api/tickets/void` gated on
`event:void` + an open shift. **No barrier action** — a misprinted ticket's car never entered.
- **Refused** for: a subscription occurrence (closed via its own flow), an already-exited session, an
already-voided ticket, or a **paid** ticket (a refund is a separate, out-of-scope action) → 409.
- **The void folds the session CLOSED everywhere it's counted** — this is the correctness crux. A
`void` decrements like a `vehicle_exit` in `occupancy.ts` (count + reserved-spots), and reads as
closed in `pay-station.ts` (`lookup`/`activeSessions`) and `exit-flow.ts` (`#sessionFor`), and is
excluded from the `reports.ts` entries stat. So a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". The booth surfaces it in the pay/exit lookup modal
(transient + unpaid + open only).
### Live-feed display: refused-action WARNING vs. genuine ANOMALY
The signed ledger `type:"anomaly"` is overloaded: it carries both benign **refused-action** events
(`exitRefused` / `entryRefused` / `permitRefused` — e.g. a double card-scan, an at-capacity
subscriber, an exit on an already-closed session) AND genuine red-flags (barrier-open failure,
opened-without-ticket). The booth feed now classifies from those existing payload flags
(`event-detail.tsx isRefusedWarning`) and shows the refused ones as an amber **REFUZUAR / REFUSED**
warning, reserving red **ANOMALI** for true anomalies. **Display-only** — no ledger type/data change,
so historical events reclassify correctly too.
## Edge cases the model must name (not yet designed in full)
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
+74
View File
@@ -0,0 +1,74 @@
---
type: concept
tags: [parking, data, admin, safety]
sources: []
updated: 2026-06-22
status: settled
---
# Soft Delete & the Recycle Bin
A safety net for accidental admin deletes. Master-data deletes used to be **hard** and
**unrecoverable** — an admin who deleted a user, role, subscription, or plan lost it for good.
Now a delete **soft-deletes** (stamps the row) and the item waits in a **recycle bin** where an
admin can **restore** or **purge** it; unrestored items **auto-purge** after a retention window.
Built 2026-06-22 (migration `0012_soft_delete`).
## What it covers (and what it deliberately doesn't)
Soft-delete is for the **mutable master-data** tables only:
| Resource | Table(s) | Notes |
| --- | --- | --- |
| Users | `users` | A soft-deleted user **cannot log in** (the login route rejects `deleted_at != null`). |
| Roles | `roles` (+ `role_permissions` kept) | Permission rows survive, so a restore brings the role back intact. |
| Subscriptions | `subscriptions` (+ credentials/plates kept) | Distinct from `status: "revoked"` — see below. A soft-deleted sub does **not** open the barrier. |
| Plans | `subscription_plans` | **Versioned**: a soft-delete stamps **every version row** of the `plan_id`; the bin shows/restores it as ONE item. |
| Tariffs | `tariffs` | Has soft-delete for completeness; today the site runs one tariff and there's no delete button — recovery is via the bin. Immutable `tariff_versions` ride along (kept for repricing). |
**Out of scope — the signed ledger.** The append-only, hash-chained `ledger_events` has **no
delete path by design** ([[append-only-event-chain]]); soft-delete is purely for the mutable
master data. A correction to history is still a new *appended* event, never an edit/delete.
## Mechanics
- **Columns:** every covered table gets a nullable `deleted_at` (ISO instant; null = live) and
`deleted_by` (the admin user id). Additive `ALTER ADD COLUMN` — backward-compatible.
- **Delete = stamp.** Each resource's own `DELETE` route now sets the stamps instead of removing
the row. The row vanishes from every catalog because the list/lookup queries filter
`deleted_at IS NULL`.
- **Recycle bin API** (`recyclebin:*` permission): `GET /api/recycle-bin` lists everything
soft-deleted across kinds; `POST /api/recycle-bin/:kind/:id/restore` clears the stamps;
`DELETE /api/recycle-bin/:kind/:id` purges (the real `DELETE`, + children). UI: a **Recycle
bin** tab under Setup. Code: `apps/server/src/recycle-bin.ts` (+ `routes/recycle-bin.ts`),
`apps/web/src/RecycleBin.tsx`.
- **Retention sweep.** A 6-hourly (+ startup) job auto-purges items deleted longer than
`RECYCLE_BIN_RETENTION_DAYS` (default **30**) ago. `0`/negative = keep forever.
## Invariants & edge cases
- **No-lockout still holds.** The "last admin" check counts only **live** admins (a soft-deleted
admin can't log in, so they don't count) — you can't delete yourself into a locked-out box. See
[[local-jwt-auth]].
- **Soft-delete vs. domain lifecycle.** A subscription's `revoke`/`reactivate` and a plan's
`active=0` retire are **domain states** that keep the item *visible* in its catalog (barred /
unsellable). `deleted_at` is different: it removes the item from the catalog entirely,
recoverable only from the bin. Both coexist. See [[subscription]].
- **Unique-name reuse.** `username` / role `name` are `UNIQUE` across **live AND deleted** rows,
so you can't create a new user reusing a deleted user's name until that row is restored or
purged — the create route returns a clear 409 pointing at the recycle bin (rather than a raw
constraint error).
- **Dangling references on restore.** A restored user points at its `roleId`; if that role is
itself deleted, the user reappears with a deleted role. We **don't auto-cascade** (keep it
predictable) — the bin lists both; the admin restores the role too. The role guard resolves a
missing role to an **empty** permission set (safe-by-default), so a dangling role never
escalates.
- **"In use" checks count live only.** A plan blocked from deletion "while referenced" counts
only **live** subscriptions; a soft-deleted subscriber's `planId` reference doesn't block it.
## Permission
`recyclebin:read` (view), `recyclebin:update` (restore), `recyclebin:delete` (purge) — admin-grade
(a restore can revive a privileged user/role; a purge is permanent). Folded into the
code-defined PERMISSIONS grid; the built-in `admin` role holds them. See [[local-jwt-auth]].
+227
View File
@@ -0,0 +1,227 @@
---
type: reference
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
sources: []
updated: 2026-06-23
status: settled
---
# Appliance provisioning runbook (booth PC)
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
parking appliance. Written from the **first real provisioning, 2026-06-23**, on the actual hardware
below — every command here was run and verified on that machine, including the firmware-specific
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and
[[container-deployment]] (the images this runs).
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
> encryption + Secure Boot raise the cost of offline tamper, they don't replace reconciliation.
## Reference hardware (first unit, 2026-06-23)
- **Dell OptiPlex 7070**, **Intel Core i5-8500** (Coffee Lake), 238 GB SATA SSD (`/dev/sda`).
- **TPM 2.0 — discrete Nuvoton** (`Get-Tpm` → `ManufacturerIdTxt NTC`, fw 7.2.1.0). NOT Intel
PTT/fTPM. Discrete ⇒ an external LPC/SPI bus exists (bus-sniff is a theoretical physical attack on
PCR-only sealing — accepted; see [[tpm]]). Used PC — previous owner irrelevant.
- Shipped Windows 11; formatted to **Ubuntu 26.04 LTS** (the decided platform — [[desktop-shell-tauri]]).
## 1. BIOS (F2 at the Dell logo)
- **TPM**: leave **On**. Used PC → **Clear the TPM once** (Security → TPM → Clear) so the prior
owner's keys are wiped before LUKS enrollment. (PPI "Bypass for Clear" was unchecked → it asks for
physical confirmation at next boot; that's normal.)
- **Secure Boot**: **Enabled**, **Deployed Mode** (not Audit). **"Enable Custom Mode" UNCHECKED** =
Standard Mode with stock Microsoft keys — this is what Ubuntu's signed shim needs. Do NOT touch
PK/KEK/db/dbx. NB: the 7070's Expert Key Management is **edit-only** (Save/Replace/Append/Delete —
no read-only "View Key"), so you **cannot inspect db from BIOS**; verify via the live USB instead
(step 2).
- **Boot**: UEFI only (no CSM/Legacy — a Legacy install has no Secure Boot / TPM-seal path).
- Set a **BIOS admin password**.
## 2. Boot the Ubuntu 26.04 USB (Secure Boot ON)
- **Flash the ISO DIRECTLY** (Rufus GPT/UEFI, Etcher, or `dd`). **NOT Ventoy** — Ventoy's own
bootloader isn't in `db`, so Secure Boot rejects it with **`Verification failed: (0x1A) Security
Violation`** (this is Secure Boot working correctly, not a fault). A directly-flashed Ubuntu USB
boots the Microsoft-signed shim, which stock `db` trusts.
- **F12** at the Dell logo → pick the USB under **UEFI BOOT**.
- Reaching the installer with Secure Boot ON = positive proof the MS third-party UEFI CA is in `db`
(the verification the BIOS couldn't show us).
## 3. Encrypted install — the firmware workaround (IMPORTANT)
The 26.04 installer disk page offers: No Encryption / **Encrypt with a passphrase** / **Use
hardware-backed encryption** (+ advanced LVM/ZFS, both ZFS experimental).
- **"Use hardware-backed encryption" FAILS on this 7070** with:
`PCR_UNUSABLE … error with secure boot policy (PCR7) measurements: generating secure boot profiles
for systems with timestamp revocation (dbt) support is currently not supported.`
→ Ubuntu's *automated* FDE profiler can't model PCR7 on Dell firmware carrying a `dbt` (UEFI
timestamp revocation list). It is NOT a TPM or Secure-Boot fault — both are fine.
- **So: choose "Encrypt with a passphrase".** Set a strong passphrase and **SAVE IT OFF-MACHINE**
(phone / password manager). It is both the boot unlock (until TPM sealing) AND the permanent
recovery slot. Finish the install.
- Result (verify with `lsblk`): `sda1` vfat `/boot/efi`, `sda2` ext4 `/boot`, `sda3` `crypto_LUKS`
→ `dm_crypt-0` (LVM2) → `ubuntu--vg-ubuntu--lv` ext4 `/`.
## 4. Seal LUKS to the TPM (manual — PCR 7 only)
Do this AFTER first boot. Manual enrollment sidesteps the installer's dbt profiler and lets us pick
PCRs. **Bind to PCR 7 only** (Secure Boot state): it catches the attack that matters (disabling
Secure Boot to boot a tampered kernel) WITHOUT breaking on routine kernel/GRUB updates (which churn
PCRs 4/8/9 → would otherwise drop every boot to the passphrase). Firmware-only PCR 0 is the fallback
if PCR 7 ever errors.
```bash
sudo apt update && sudo apt install -y tpm2-tools
sudo tpm2_pcrread sha256 # sanity: PCRs 0-10 populated, PCR 7 has a real value
# Enroll the TPM (prompts for the EXISTING install passphrase to authorize the new slot):
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3
# Verify TWO slots — keep BOTH (slot 0 password = recovery, slot 1 tpm2 = auto-unlock):
sudo systemd-cryptenroll /dev/sda3
# SLOT TYPE
# 0 password
# 1 tpm2
```
Wire it into boot (back up first; the mapping is `dm_crypt-0`, the LUKS UUID is in `/etc/crypttab`):
```bash
sudo cp /etc/crypttab /etc/crypttab.bak
sudo sed -i 's/none luks$/none luks,tpm2-device=auto/' /etc/crypttab
cat /etc/crypttab # → dm_crypt-0 UUID=… none luks,tpm2-device=auto
sudo update-initramfs -u
sudo reboot
```
- **Boots straight to login, no passphrase prompt** = ✅ TPM auto-unlock works (unattended reboot
achieved — VERIFIED on this unit 2026-06-23).
- Still prompts = PCR mismatch; type the passphrase (NOT locked out), then retry with
`--tpm2-pcrs=0`. The `password` slot + `crypttab.bak` make this fully reversible.
> **Re-seal runbook:** a BIOS update / Secure Boot change alters PCR 7 → the TPM refuses → boot
> falls back to the passphrase prompt (not a brick). After such a change, re-run step 4's
> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind.
## 5. GRUB password — EDIT-ONLY (VERIFIED 2026-06-23)
Closes the `init=/bin/bash` / `systemd.unit=rescue.target` local-root hole: without it, anyone at
the keyboard presses `e` at the GRUB menu, edits the kernel cmdline, and boots to a **root shell with
no login**. **The PCR-7 TPM seal does NOT cover this** — editing the GRUB cmdline doesn't change
PCR 7 (Secure Boot policy), so the TPM still releases the key and the attacker lands on the decrypted
disk. This is the specific countermeasure for the [[threat-model|operator-at-the-booth]]. Use
**edit-only** mode (`--unrestricted`) so the box still boots UNATTENDED — the password is required
only to EDIT entries, never to boot.
```bash
grub-mkpasswd-pbkdf2 # enter a password (twice) → copy the grub.pbkdf2.sha512.* hash
```
Add the superuser (paste YOUR hash) to the end of `/etc/grub.d/40_custom`:
```
set superusers="admin"
password_pbkdf2 admin grub.pbkdf2.sha512.10000.<YOUR_HASH>
```
Make menu entries bootable WITHOUT the password (edit-only) — in `/etc/grub.d/10_linux`, set the
active `CLASS=` line to include `--unrestricted`:
```
CLASS="--class gnu-linux --class gnu --class os --unrestricted"
```
Regenerate + VERIFY BOTH HALVES landed in the real config BEFORE rebooting (a GRUB misconfig means a
rescue-USB recovery):
```bash
sudo update-grub
sudo grep -c "password_pbkdf2" /boot/grub/grub.cfg # want ≥1 (password present)
sudo grep -c "unrestricted" /boot/grub/grub.cfg # want ≥1 (entries bootable w/o password)
sudo reboot
```
✅ VERIFIED on this unit: boots straight to login (no GRUB prompt, TPM still auto-unlocks) AND
pressing `e` at the menu prompts for `admin` + password. Store the GRUB password off-machine
(alongside the LUKS passphrase).
> OS hardening on the first unit is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7) + Secure Boot
> (Deployed) + GRUB edit-lock.
## 5c. OS user model — admin vs operator (VERIFIED 2026-06-23)
The OS has TWO roles and they must be different identities ([[threat-model]]: the operator is the
adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) and keep the
**operator** as an auto-login, UNPRIVILEGED account.
```bash
sudo adduser admin && sudo usermod -aG sudo admin
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
sudo deluser <operator> sudo # demote the auto-login operator
groups <operator> # confirm: no 'sudo'
```
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
> launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
> `lpadmin` (printer admin, unneeded). And NEVER add the operator to `docker` (also root-equivalent).
## 5b. Further hardening (TODO — not yet done)
- **Key-based SSH only** (disable password auth) if SSH is enabled at all.
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
([[desktop-shell-tauri]]).
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
## 6. Runtime — Docker stack (VERIFIED 2026-06-23)
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
(chmod 600). The `.env` (driven into the containers by the base compose):
```
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
VISION_ENABLED=1
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
```
```bash
docker login git.infra.msai.al # a read-only package token, not the account password
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
```
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
`hosts`/DNS ON-SITE, never an image rebuild.
## Quick-reference: the gotchas, in order they bit us
1. Ventoy USB → `0x1A` Security Violation under Secure Boot → flash the ISO directly instead.
2. 7070 BIOS has no "View Key" → can't inspect db; the live-USB boot IS the verification.
3. Installer "hardware-backed encryption" → `PCR_UNUSABLE`/dbt → use passphrase LUKS + manual seal.
4. Bind TPM to **PCR 7 only**, not a multi-PCR set (kernel updates churn 4/8/9 → passphrase every boot).
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
+114
View File
@@ -0,0 +1,114 @@
---
type: decision
tags: [parking, deployment, docker, ci, offline-first]
sources: []
updated: 2026-06-22
status: settled
---
# Container deployment (Docker images for the non-desktop apps)
How the parking system's runtime apps are packaged as containers, tagged, and published.
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
## Two images (the desktop app is NOT containerized)
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
Fastify serves `apps/web/dist` via `@fastify/static` (wired in `apps/server/src/static-spa.ts`),
with an SPA fallback to `index.html` for client routing. This matches [[offline-first]] — the
booth appliance is one box, not a web host + an API host. `@fastify/web` static serving is a
**no-op in dev** (no build dir → the Vite dev server serves the UI), so local DX is unchanged.
- **`parking-vision`** — the Python/uv ANPR service ([[opencv-anpr-service]]). Ships WITH the
`alpr` extra (real fast-alpr/onnxruntime stack); the engine is env-selected
(`VISION_RECOGNIZER=stub|fast_alpr`, default `stub` so it boots anywhere). Model weights are
**pre-warmed at build** (best-effort) so the appliance's first scan needs no network.
The **desktop** app stays on its own tag-only `release.yml` (Tauri installers), not these images.
## Branch-aware (the user's hard requirement)
- **Image tags = branch + short SHA.** A push to `dev` builds `…/parking-server:dev` +
`…/parking-server:dev-<sha>`; `main` builds `:main` + `:main-<sha>`. The moving branch tag is the
deploy pointer; the branch-SHA tag is the immutable record. Same for `parking-vision`.
- **Per-env compose.** A base `docker-compose.yml` + overrides: `docker-compose.dev.yml` (build
locally, expose ports, `stub` recognizer) and `docker-compose.prod.yml` (pull pinned images,
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
## Registry + CI
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
parking-vision}`. Login via `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` secrets.
- New workflow **`.gitea/workflows/build-images.yml`** (separate from the checks-only `ci.yml` and the
tag-only `release.yml`): on push to `dev`/`main`, run the full `turbo build lint test` first (don't
ship a broken image), then buildx + `docker/build-push-action` for both images with branch+SHA tags
and a registry build cache. An optional Komodo redeploy webhook is guarded behind a `KOMODO_ENABLED`
var (mirrors the house `trm/processor` pattern). The vision checks need `uv` (the `astral-sh/setup-uv`
step), same as `ci.yml`.
## Build specifics that bit us (record so they don't recur)
- **`pnpm deploy --legacy --prod`, NOT `pnpm prune --prod`.** It's a pnpm/turbo monorepo; pruning at
the root leaves `packages/db/node_modules` empty, so the native **`better-sqlite3`** binding can't
resolve at runtime. `pnpm deploy` produces a self-contained, hoisted bundle (the workspace packages'
built `dist` + their native deps) — a single `COPY --from=build /deploy ./`. pnpm 10 needs `--legacy`
(or `inject-workspace-packages`).
- **Native modules**: Alpine build stage needs `python3 make g++` (node-gyp for better-sqlite3);
runtime needs `libstdc++`. `bcrypt` ships a `linux-x64/musl` prebuild, so it works on Alpine as-is.
- **`pnpm prune`/deploy refuse to run without a TTY** unless `CI=true` (or `ENV CI=true`) is set in
the build stage.
- **Migrations at boot, not at build.** The DB lives on a mounted volume (`/data`), so the entrypoint
runs them against the live file via a **drizzle-kit-free** runtime migrator
(`packages/db/scripts/migrate-runtime.mjs`, using `drizzle-orm/.../migrator` — drizzle-kit is a
devDep, pruned from the prod bundle). Idempotent: a restart re-applies nothing.
- **JWT_SECRET** must be a real value at deploy — `auth.ts` rejects anything `<32` chars or matching
`change.?me|insecure|dev-only`, so the dev compose default is a benign 32-char string, not a
"dev-only…" placeholder (which would crash boot).
- **Vision model pre-warm must run AS the runtime user.** fast-alpr's `open-image-models` caches
weights under `$HOME/.cache/open-image-models` keyed to `$HOME` — it ignores `HF_HOME`/
`XDG_CACHE_HOME`. A first attempt pre-warmed as root (`/root/.cache`), so the non-root runtime
re-downloaded at boot (offline-first BROKEN). Fix: create the `vision` user first, `USER vision`,
THEN run `python -c "from fast_alpr import ALPR; ALPR()"` so weights land in `/home/vision/.cache`
— exactly where the runtime reads. Verify the boot log shows NO "Downloading …onnx".
## Web access — relative API + Caddy proxy (2026-06-23)
- **The server-image SPA uses a RELATIVE `/api` base** (no baked origin), so the UI works loaded
from any hostname/IP. The Dockerfile empties `VITE_API_BASE` via `apps/web/.env.production.local`
before the web build — because Vite auto-loads `apps/web/.env.production`, which sets
`VITE_API_BASE=http://127.0.0.1:3000` for the **Tauri desktop** build only. Without the override
the browser bundle baked `127.0.0.1:3000` and failed Same-Origin Policy from any other host. **Do
NOT bake the domain via a build var** — relative means naming is controlled by hosts/DNS at deploy,
never a rebuild.
- **A Caddy reverse proxy** (prod override) publishes `:80` → `server:3000` (server is `expose`-only,
internal); `/api/ws` upgrades pass through. `Caddyfile` binds `:80` so it matches ANY host — booth
IP, localhost, or `parksystems.msai.al` (pointed at the booth IP via hosts/DNS on-site). TLS later:
swap `:80` for the real hostname + uncomment Caddy `:443` → auto-HTTPS.
- `WS_ALLOWED_ORIGINS` (env) must list any REMOTE origin admins use (same-origin always passes).
## Invariants (must hold)
- **Never bake the live DB.** `.dockerignore` excludes `**/parking.sqlite*` (incl. `-wal`/`-shm`/
`.bak-*`) — `pnpm deploy` copies the package dir's files ignoring `.gitignore`, so the
`.dockerignore` (which gates the build CONTEXT) is what keeps the signed ledger out of the image.
The DB is a host-volume asset ([[append-only-event-chain]], [[threat-model]]).
- **SPA serving must not shadow the API** — the fallback is GET-only and excludes `/api`, `/health`;
a missing `/api/*` still 404s as JSON, not the HTML shell.
- **Offline-first** — both images boot + serve with no network (vision default `stub`; `fast_alpr`
weights pre-warmed into the image layer).
- **Non-root runtime**, minimal final image (deploy bundle only; build toolchain dropped).
## Verified on hardware (2026-06-22)
Both images built + smoke-tested locally (Docker 29, buildx):
- **server**: build → run → entrypoint migrates `/data/parking.sqlite`, SPA static serving enabled,
server listens; `/health` 200, `/` + `/booth` serve the SPA (text/html), `/api/nope` → JSON 404;
no `parking.sqlite*` anywhere outside `/data` in the image.
- **vision** (1.8 GB, `--extra alpr`): build pre-warms the YOLOv9 + CCT weights into the image
(`/home/vision/.cache`); run as `fast_alpr` → `ready:true` with **0 downloads at boot** (offline-
first confirmed); `stub` mode also boots clean.
- **compose** (`docker-compose.yml` + `.dev.yml`): both containers come up healthy and the server
reaches the vision service over the private network (`wget http://vision:8089/health` from the
server container → 200).
+31
View File
@@ -167,3 +167,34 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
The desktop bundle now runs in CI under **two distinct workflows** — keep the split clear:
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
no `TAURI_SIGNING_*`, no `latest.json` — so it must NEVER be wired to the updater (an unsigned
artifact would be rejected anyway). It exists so each branch push yields a downloadable installer
for manual testing of the native shell, and catches a broken Tauri/Rust build early. Same
system-deps + cargo cache as `release.yml`. The container images (`build-images.yml`) and the
desktop installers are deliberately separate pipelines — the desktop app is **not** containerized
([[container-deployment]]).
- **Delivery: a rolling pre-release, NOT `actions/upload-artifact`.** That action's artifact
backend isn't reliable on the Gitea runner (the *Upload installers* step failed). Instead the
workflow mirrors `release.yml`'s proven path — plain `curl` + the built-in `GITHUB_TOKEN` to the
**Releases API**. It DELETEs any existing `desktop-<branch>` release + tag, recreates it against
the new commit as a **prerelease**, and uploads the two installers (renamed space-free,
`parking-desktop-<branch>-<sha>.{deb,AppImage}`). So `desktop-dev` always holds the newest dev
build; `v*` tags remain the only *signed* releases.
- **Gotcha (the unsigned build still demands the key).** `tauri.conf.json` sets
`bundle.createUpdaterArtifacts: true` (so `release.yml` produces the `.sig` updater signatures).
With that on, `tauri build` **fails** if `TAURI_SIGNING_PRIVATE_KEY` is absent — *"A public key
has been found, but no private key"* — even though the `.deb`/`.AppImage` themselves built fine.
The unsigned CI build therefore overrides it off with
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
@@ -110,3 +110,7 @@ The skeleton is **built and wired** (no recognizer models yet):
weights out of the build entirely (baked into the Docker image instead).
- Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail,
defer to the install/hardening pass.
> **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the
> `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image
> layer** at build (offline-first), and runs under **docker-compose** (base + per-env override).
+24 -3
View File
@@ -37,9 +37,15 @@ Authentication and authorization, kept **fully local** — a direct consequence
**last user holding admin** — administration can never be locked out of the appliance.
- `event:void` is a permission, NOT a ledger delete: the append-only signed chain is untouched; the
permission only gates who may APPEND a void event (there is no void API route yet — forward seam).
- The grid is **extensible** — adding a feature adds its `resource:action` rows. Latest: **`log:read`**
(a new `log` resource) gates the diagnostic-log viewer (`GET /api/logs`); admin holds it, and it's
grantable to a diagnostic role. See [[app-logs]].
- The grid is **extensible** — adding a feature adds its `resource:action` rows. Recent additions:
**`log:read`** (gates the diagnostic-log viewer, `GET /api/logs`; see [[app-logs]]); **`report:read`**
(the admin Reports dashboard; see [[reporting-analytics]]); and **`recyclebin:read/update/delete`**
(view / restore / purge soft-deleted master data; see [[soft-delete]]). Admin holds them all; each is
grantable to a scoped role.
- **Soft-deleted users can't authenticate.** The login route rejects a user whose `deleted_at` is set
(with the same generic "invalid credentials" so a deleted account isn't enumerable). The no-lockout
"last admin" check counts only LIVE admins, so soft-deleting can't strand administration. See
[[soft-delete]].
- **No privilege escalation through the RBAC system itself.** `role:create`/`role:update` and
`user:create`/`user:update` are themselves grantable, so a non-admin could otherwise self-escalate.
Guards (`routes/roles.ts`, `routes/users.ts`): a caller may only put permissions on a role that
@@ -63,6 +69,21 @@ The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and s
requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt.
Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev
**Self-service profile (added 2026-06-24).** Alongside the admin user-manager (`routes/users.ts`,
gated on `user:*`), any signed-in user has two **self-only** routes (no permission needed — they
act solely on `req.user.sub`):
- `PUT /api/auth/profile` — edit own `fullName` / `email` (`""` clears → null). Returns the
refreshed session (so the SPA header updates). **Cannot** touch `username` or `role` — those stay
admin-only, so this is not a privilege-escalation surface.
- `PUT /api/auth/password` — change own password, but **must prove the current one** first
(`bcrypt.compare`) → defends a walked-up, already-logged-in booth from a silent re-key. New
password ≥ 8 chars. Distinct from the admin reset (`PUT /api/users/:id/password`), which needs no
current password but DOES need `user:update` + the no-escalation guard.
Both are still CSRF-guarded (mutations). The SPA surfaces them at `/profile` (`apps/web/src/Profile.tsx`),
reachable from the header username chip. Covered by `apps/server/src/routes/profile.test.ts`.
The dev
[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API
**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only
`SETUP_AUTH_BYPASS` shim, now removed.)
+103
View File
@@ -58,3 +58,106 @@ A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.1
correct JPEG magic. Digest handshake works through `HttpCamera`.
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
us. Under **Event → Smart/VCA** (e.g. line crossing / intrusion / "Vehicle Detection") the unit
exposes **Detection Target: Human / Vehicle** — selecting **Vehicle** + **Notify Surveillance
Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST an
`EventNotificationAlert`** to a URL we host on each detection. Same machine-call shape as the
[[dingtian-relay]] Input Link push — no polling.
- **Ingress:** `POST /api/devices/hikvision/:deviceId/event` (`apps/server/src/routes/hikvision-alarm.ts`).
**Source-IP guarded** (must come from the device's configured `host`) + **optional HTTP Digest**
(some firmware can't authenticate the Alarm Server call → source-IP only). NOT behind the SPA
cookie/CSRF (it's a device call), exactly like the Dingtian push.
- **Config:** added to the `hikvision` driver — `alarmPushEnabled` (bool), `pushUser`/`pushPassword`
(optional Digest). The driver is now `pushesToBackend: true`, so first-run setup offers the backend
push IP. Point the camera's Alarm Server at `http://<backend-ip>:<port>/api/devices/hikvision/<deviceId>/event`.
- **Discovery-first:** the endpoint is **permissive** — accepts ANY content-type as raw bytes (event
XML, multipart-with-JPEG, or JSON; Hik's format varies by model/firmware), records the **verbatim
body** as a `kind:"alarm"` device_event, and best-effort extracts `eventType` / `target` / `plate`
/ `dateTime` / `channelID`. The point of this first cut is to **see exactly what a given camera
sends** (inspect via `GET /api/events` or the server log) before wiring it to the read bus.
- **Not yet a barrier trigger.** It records + breadcrumbs only; it does NOT emit a `DeviceReadEvent`
or open anything. A plate read is **advisory, never the sole reason** a barrier opens
([[append-only-event-chain]], [[opencv-anpr-service]]). Two consumers were since designed off this
same vehicle event — see **[[lane-presence-and-anpr-entry]]**: (a) BUILT — advisory lane busy/free
booth lights; (b) BUILT (2026-06-22) — the ANPR "bridge" (`anpr-entry.ts`) that snapshots → ANPR →
emits a `kind:"plate"` read for a SUBSCRIBER match through the existing gated flow (a small
`apps/server` handler class, not a service). If the camera ever emits its own `<plateNumber>` we'd
use it directly; this `DS-2CD1043G2`
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]].
### Gotchas learned the hard way (2026-06-22 field session)
Several traps surfaced trying to get a real camera to push. In order of how long each cost:
- **WSL rewrites the inbound source IP.** On the dev host (WSL mirrored mode), an inbound LAN packet
arrives at our server with its **source rewritten to the host's own IP** (`10.0.10.203`), not the
camera's. The source-IP guard then rejects every push as a mismatch. Fix: a per-device
**`skipSourceIpCheck`** config flag (a Setup checkbox) that bypasses the IP guard — the signed
ledger + optional Digest remain the real guards. Leave OFF on a normal LAN.
- **The setup checkbox saved booleans as the STRING `"true"`.** The generic config-field form had no
boolean renderer, so a `type:"boolean"` field fell through to a text input. Fixed (checkbox
renderer); the server also coerces `"true"`/`1`/`yes`/`on` defensively.
- **The camera's "Test" button proves almost nothing.** It does a TCP/connectivity probe and reports
"service available" on ANY HTTP reply (even our 404) — it does **not** POST a real event to your
URL. Only a real detection (or the ISAPI `httpHosts/<id>/test`) actually exercises the path.
- **`httpBroken` latches.** Once the camera marks the host broken (from earlier failed deliveries),
it stays `true` across reboots and won't retry. Clear it by **re-PUTting** the httpHost config
(`PUT /ISAPI/Event/notification/httpHosts/1` with `<httpBroken>false</httpBroken>`).
- **"Notify Surveillance Center" ≠ the HTTP Alarm Server** on some firmware (separate upload
channels). Always confirm the **Arming Schedule** covers the test time, too (a silent killer).
- **⭐ THE ROOT CAUSE (2026-06-22): no detection AREA drawn.** This is what actually defeated us for
most of a day. On the motion/smart-detection page there's a **Draw Area** step — if **no region is
drawn on the frame, the camera detects nothing, generates NO event, and therefore posts nothing**
anywhere (httpHost, FTP, alarm stream all stay silent because there's no event upstream). Enabling
the detection + ticking Notify Surveillance Center is **not enough** — you must draw the region.
Once an area was drawn, the very first vehicle produced a clean POST. **Check this FIRST.**
### Confirmed real payload (DS-2CD1043G2-LIU, V5.8.10, 2026-06-22)
What this camera actually POSTs on a motion event with a target — captured end-to-end:
- **`Content-Type: multipart/form-data; boundary=boundary`**, one XML part named `MoveDetection.xml`
(`Content-Type: application/xml`). A real frame/JPEG *may* be attached as a second part on other
event types — our endpoint stores the readable head; splitting an image part to `snapshots` is a
forward step (not needed for plain motion).
- The XML is an `EventNotificationAlert` with the fields we care about:
- `<eventType>VMD</eventType>` (Video Motion Detection) + `<eventState>active</eventState>`
- **`<targetType>vehicle</targetType>`** — the camera classifies **vehicle vs human ON-DEVICE**.
(Field is `targetType`, NOT `detectionTarget`.) This means simple presence + class comes for
free, no vision model needed for that part.
- `<targetInfo><targetRect>` with normalized `X/Y/width/height` (0–1) — the **bounding box**.
- `<channelID>`, `<macAddress>` (provenance), `<dateTime>` — **but the dateTime is GARBAGE**
(`2032-…`) because this unit's **RTC is dead** (see below); we use our own server receive time,
never the camera's. (No `<plateNumber>` — this is a motion event, not an ANPR camera.)
### If a camera still won't push — diagnostics (read its OWN state)
Only after confirming the **detection area is drawn** + arming schedule covers now + Notify
Surveillance Center is on. These read the camera directly (no cooperation from our server):
1. **`netstat` on the camera (via SSH) while you trigger** — watch for an OUTBOUND line
`cam:port → server:3000`. It appearing = the camera fired and is delivering (then check our
`/api/devices/hikvision/alarms`). None = no event was generated (almost always: **no area drawn**).
2. **`GET /ISAPI/Event/notification/alertStream`** (Digest, needs a clean handshake) — the live event
bus. NB: a `curl --digest` tap that fails the handshake returns empty and looks like "no events"
— don't over-read silence here (this misled us); the netstat watch above is more reliable.
3. **SSH `showStatus` / `dmesg`** expose internal state. ⚠ **Caveat learned the hard way:** these
surface scary-looking strings that are **red herrings** — `EventScribe: except`, a `diskfull`
error on `Event/triggers` (on a camera with **no disk**), and `fh rtc get time error` / a 1970
clock. On our unit ALL of these were present **and the camera worked fine** once an area was
drawn. The dead RTC is real (hence the bogus `dateTime`) but **harmless** to event push. **Do NOT
conclude "dead camera / RMA" from these** — they are not proof of a broken event engine.
> **Correction (2026-06-22):** an earlier version of this page concluded this DS-2CD1043G2-LIU was a
> **defective unit needing RMA**, based on the silent alertStream + `diskfull`/`EventScribe:except` +
> dead RTC surviving a full factory reset. **That was WRONG.** The camera was healthy; the real cause
> was simply **no detection area drawn**, so no event was ever generated. The `diskfull`/RTC findings
> were unrelated quirks (RTC genuinely dead, but it doesn't block event push). Lesson: don't
> escalate to "hardware fault" while a basic config precondition (the drawn region) is unmet — and
> treat vendor status-API error strings as unreliable. The pull + [[opencv-anpr-service|vision]] path
> remains a valid fallback, but it was not needed here.
+7 -3
View File
@@ -258,9 +258,13 @@ LPR/ANPR plate identity** (the plate binding below):
number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A
Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the
[[dingtian-relay]] has no onboard card list.
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
accepted identity too. The vision/ANPR service that produces plate reads is future work
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
- **Plate (LPR/ANPR) — matching is BUILT, the live SOURCE is the one missing wire.** When plate-bound
(below), a matching plate read is an accepted identity — and `subscription-flow.ts` `match()` +
`read-dispatch.ts` already handle a `via:"plate"` read end to end (gate + entry/exit). What's
missing is the thing that EMITS a plate read from the lane camera: the **ANPR "bridge"** (a small
handler in `apps/server`, not a new service) that snapshots on a camera vehicle event, runs
[[opencv-anpr-service|ANPR]], and on a high-confidence match emits the plate onto the read bus. PLANNED,
scoped to subscribers only. See [[lane-presence-and-anpr-entry]] for the full design + decisions.
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
already in the model) and whose value is the credential id.
+4
View File
@@ -96,8 +96,10 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap.
- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope.
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas.
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
## Concepts — frontend / operator UI
@@ -119,3 +121,5 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
+198
View File
@@ -1354,3 +1354,201 @@ so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/du
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
## [2026-06-22] feat | Soft delete + recycle bin for master data (migration 0012)
Accidental admin deletes used to be hard + unrecoverable. Now users/roles/subscriptions/plans/
tariffs soft-delete: migration 0012 adds nullable deleted_at + deleted_by; each resource's DELETE
route STAMPS instead of removing, and every catalog list filters deleted_at IS NULL. A recycle bin
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge — gated recyclebin:read/update/delete,
new resource in the PERMISSIONS grid) lists everything soft-deleted, restores, or purges; a 6-hourly
+ startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever).
Key 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 version rows 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 → empty perms, safe). Signed ledger is OUT of scope (no delete path).
Web: a Recycle bin tab under Setup (RecycleBin.tsx). 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 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]].
## [2026-06-22] feat | Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware (Event → Smart/VCA "Detection Target: Human/Vehicle" + Notify Surveillance
Center + Alarm Settings → Alarm Server) HTTP-POSTs an EventNotificationAlert on each detection.
Added POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts) — same machine-push
pattern as the Dingtian Input Link: source-IP guarded + OPTIONAL Digest, not behind SPA cookie/CSRF.
Permissive/discovery-first: a wildcard content-type parser takes ANY body as raw bytes (XML,
multipart+JPEG, JSON — Hik varies by firmware), stores it verbatim as a kind:"alarm" device_event,
and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + log line. The
hikvision DRIVER gained alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (so
setup offers the backend push IP). NOT yet a barrier trigger or DeviceReadEvent — records only; the
read-bus/ANPR wiring is the next step once the real payload is captured (advisory-only rule still
governs). 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. See [[lpr-camera]].
## [2026-06-22] debug | Hikvision event-push field session — fixes + a verified-dead camera
Long session getting a real Hik camera to POST events. Server/integration fixes (committed): listen
on ALL methods (camera probes with GET/etc, not just POST); record rejected pushes too (kind
"alarm-rejected" + reason) so "nothing arrived" is never ambiguous; a GET /api/devices/hikvision/
alarms read endpoint; per-device skipSourceIpCheck (WSL mirrored mode REWRITES the inbound source IP
to the host's own, so the source-IP guard rejected every push); and a real checkbox renderer for
type:"boolean" config fields (they were saving the STRING "true"). Then proved — via the camera's
OWN state, not our server — that the specific DS-2CD1043G2-LIU unit has a DEAD event engine: silent
alertStream (no heartbeat), EventScribe:except, diskfull on Event/triggers, dead RTC (rtc get time
error / clock at 1970), and ZERO outbound to :3000 over a 3-min netstat watch, surviving reboot +
basic reset + FULL factory reset. Verdict: defective camera (RMA), not our code. Captured the
diagnostic method (alertStream silence / SSH showStatus / netstat) in [[lpr-camera]]. Fallback for a
dead-push camera: pull + [[opencv-anpr-service|vision]] (the same camera still serves snapshots).
## [2026-06-22] CORRECTION | Hik camera was NOT defective — the cause was an undrawn detection area
Supersedes the earlier "[2026-06-22] debug" entry's conclusion that the DS-2CD1043G2-LIU had a dead
event engine needing RMA. WRONG. The camera is healthy; it pushed a clean event the instant a
detection AREA was drawn on the frame (the "Draw Area" step). With no region drawn, the camera
detects nothing → generates no event → posts nothing anywhere — which produced all the symptoms
(silent alertStream, zero outbound to :3000). The diskfull / EventScribe:except / dead-RTC findings
were red herrings (the RTC is genuinely dead, hence a bogus 2032 dateTime in the payload, but it does
NOT block event push). Lesson: don't escalate to "hardware fault" while a basic config precondition
is unmet; vendor status-API error strings are unreliable. Confirmed real payload: multipart/form-data
(MoveDetection.xml) with EventNotificationAlert -> eventType=VMD, eventState=active,
targetType=vehicle (vehicle/human classified ON-DEVICE), targetRect bounding box. The push endpoint +
all-methods + skipSourceIpCheck + rejection-recording are all validated against the real device now.
See [[lpr-camera]] (corrected).
## [2026-06-22] design+build | Lane presence (BUILT) + ANPR subscriber-entry "bridge" (PLANNED)
Off the now-working Hik vehicle event: BUILT advisory lane busy/free booth barrier lights
(LaneStatus + WS; timeout-driven "free" since the camera sends no leave signal — TTL settled at 30s
after a controlled in/out test showed movement-driven ~1-3s re-fire but ~15-25s gaps for a still
car, and ~no dwell lag on leave). Measured the camera's hard limits: no current-state poll exists,
and flipping notificationRecurrence beginning->recurring via ISAPI is silently reverted (firmware
locked). Then narrowed the bigger ambition to a clean, high-value scope: ANPR for SUBSCRIBERS ONLY —
a plate read at the lane admits a subscriber through the EXISTING gated subscription flow. Found the
whole subscription side already supports via:"plate" (match + dispatch + gate); the one missing piece
is a small `apps/server` HANDLER ("the bridge", ~40 lines, NOT a new service/container) that on a
camera vehicle event snapshots -> ANPR -> on a HIGH-confidence match (new VISION_ENTRY_MIN_CONFIDENCE)
-> debounces (required for ledger correctness, not CPU: ~1Hz re-fire would drive repeat entries) ->
emitRead{kind:"plate"}. Both directions, opt-in per camera (config.anpr), plate never the sole
authority (routes through the gate). REJECTED: continuous livestream presence + per-car queue
tracking/make-model (needs a vehicle detector the plate-only vision lacks + appliance compute we can't
measure on the dev PC). Vision checked: fast_alpr live, ~50ms/frame on DEV PC (appliance TBD —
booth-PC test ~2026-06-23). New page [[lane-presence-and-anpr-entry]]; updated [[lpr-camera]],
[[subscription]], index.
## [2026-06-22] build | ANPR subscriber-entry "bridge" — BUILT
Built the bridge planned in the previous entry: `apps/server/src/anpr-entry.ts` (`AnprBridge`). On a
vehicle/non-`inactive` push from an `anpr`-opted-in camera, `hikvision-alarm.ts` hands the deviceId
to the bridge (fire-and-forget, never awaited on the camera's 200). The bridge debounces
(camera-level, pre-snapshot), pulls a FRESH snapshot (reused `snapshot.ts buildCamera`), runs
`vision.analyze`, applies a stricter entry floor (`VISION_ENTRY_MIN_CONFIDENCE`=0.85), then — the key
safety choice settled with the user — MATCHES the plate to a subscription BEFORE emitting: a
subscriber → `emitRead{kind:"plate"}` (→ existing `ReadDispatcher`→gated `SubscriptionFlow`); a
non-subscriber → advisory `anpr-skip` device_event, nothing emitted (so a random/printed plate never
reaches the transient plate-as-ticket exit path). Fail-soft throughout. `server.ts` reordered so the
read flows are constructed before the hik-alarm registration. New env: `VISION_ENTRY_MIN_CONFIDENCE`,
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
## [2026-06-22] build | Cancel (void) a wrongly-printed ticket + refused-vs-anomaly display split
Operator need: cancel a misprinted/test/wrong-vehicle ticket, traceably. Built it as a SIGNED `void`
(append-only — the vehicle_entry is never touched): new `apps/server/src/void-flow.ts` (`VoidFlow`)
appends void{ voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; operator from JWT, reason REQUIRED. Refuses a
subscription / already-exited / already-voided / PAID ticket (refund = out of scope). The CRUX: a
void must fold the session CLOSED everywhere it's counted — done in occupancy.ts (count +
reserved-spots, −1 like an exit), pay-station.ts (lookup/activeSessions), exit-flow.ts (#sessionFor),
and reports.ts (excluded from the entries stat). No barrier action (the car never entered). Booth UI:
"Cancel ticket" in the pay/exit lookup modal (transient + unpaid + open; gated on event:void) with a
preset-or-free reason prompt. Part 2 (display-only): the Live feed mislabeled benign refused-action
events (exitRefused/entryRefused/permitRefused — e.g. a double card-scan) as red ANOMALI; now
classified via event-detail.tsx isRefusedWarning and shown as amber REFUZUAR/REFUSED, reserving red
ANOMALI for genuine red-flags. No ledger change → historical events reclassify too. New reason code
void.ticketCancelled (shared + both web catalogs). Tests: void-flow.test.ts (8) + occupancy void fold;
141 server + 87 shared green; build+lint (TS + i18n parity) green. Updated [[parking-session]].
## [2026-06-22] build | Docker images for non-desktop apps (server+SPA, vision) + branch-aware build pipeline
Containerized the two runtime apps. parking-server = Fastify API + the bundled React SPA (wired
@fastify/static in new static-spa.ts — serves apps/web/dist with an SPA index.html fallback, GET-only
and excluding /api + /health so it never shadows the backend; a NO-OP in dev where no dist exists).
parking-vision = the Python/uv ANPR service, ships --extra alpr with model weights pre-warmed into the
image (offline-first), engine env-selected (VISION_RECOGNIZER stub|fast_alpr). Branch-aware per the
user: images tagged branch + branch-<sha>; base docker-compose.yml + docker-compose.dev.yml (build
local, stub, ports) / docker-compose.prod.yml (pull pinned, fast_alpr, vision internal, restart
always). New .gitea/workflows/build-images.yml pushes both to git.infra.msai.al/mca/parking_solution
on push to dev/main, after a full turbo build+lint+test gate (mirrors trm/processor; optional Komodo
webhook behind KOMODO_ENABLED). KEY build lessons: use `pnpm deploy --legacy --prod` NOT
`pnpm prune` (monorepo: prune leaves the native better-sqlite3 unresolved); Alpine needs
python3/make/g++ (build) + libstdc++ (runtime); set CI=true so pnpm wipes node_modules; migrate at
BOOT via a drizzle-kit-free runtime migrator (packages/db/scripts/migrate-runtime.mjs) against the
mounted /data volume; .dockerignore must exclude **/parking.sqlite* (deploy ignores .gitignore) so the
signed ledger is NEVER baked. JWT_SECRET must be a real >=32-char value (auth.ts rejects dev-only/
insecure/change-me). VERIFIED: server image builds + runs — migrates, SPA serving on, /health 200,
/ + /booth serve HTML, /api/nope JSON 404, no sqlite outside /data. Vision image build + smoke in
progress. New page [[container-deployment]]; updated [[vision-service-packaging]] (resolved its two
open Qs), index. Server tests stay 141 green (SPA serving guarded on dist existence).
## [2026-06-23] provision | First booth appliance — Dell OptiPlex 7070, Win11 → Ubuntu 26.04 LTS, encrypted + TPM-sealed
Provisioned the first real booth PC. Hardware: Dell OptiPlex 7070, i5-8500, 238GB SSD, discrete
Nuvoton TPM 2.0 (NOT Intel PTT — Get-Tpm ManufacturerIdTxt NTC). Formatted Win11 → Ubuntu 26.04 LTS
(the decided platform). Gotchas hit + resolved, in order: (1) Ventoy USB → 0x1A Security Violation
under Secure Boot (Ventoy's loader not in db) → flash the ISO directly; (2) the 7070 BIOS Expert Key
Management is edit-only, no "View Key" → can't inspect db, so the live-USB boot IS the verification
(it reached the installer with Secure Boot ON → MS 3rd-party UEFI CA confirmed present); (3) the
installer's "Use hardware-backed encryption" FAILED with PCR_UNUSABLE / "secure boot policy (PCR7) …
timestamp revocation (dbt) … not supported" — Ubuntu's automated FDE profiler can't model PCR7 on
Dell firmware with a dbt; NOT a TPM/SB fault. Workaround: "Encrypt with a passphrase" (plain LUKS) +
MANUAL TPM seal after boot via systemd-cryptenroll --tpm2-pcrs=7 /dev/sda3 (PCR 7 only — avoids
kernel-churned 4/8/9 that would drop every boot to the passphrase). Two LUKS slots kept (0 password =
recovery, 1 tpm2 = auto-unlock); crypttab gets tpm2-device=auto; update-initramfs; reboot → BOOTS
STRAIGHT TO LOGIN, no passphrase → TPM auto-unlock VERIFIED (unattended reboot achieved). New runbook
page [[appliance-provisioning]] (every command verified on hardware); cross-linked from
[[disk-os-hardening]] (resolves impl half of open-questions #12 for unit 1) + index. REMAINING on the
box: GRUB password, Docker install, run the parking-server/parking-vision stack.
## [2026-06-23] provision | First booth appliance — GRUB edit-lock added; OS hardening COMPLETE
Added the GRUB password (edit-only mode via --unrestricted) to the first booth unit. WHY it matters
specifically: the PCR-7 TPM seal does NOT cover the GRUB-cmdline attack (editing the kernel line to
init=/bin/bash doesn't change PCR 7, so the TPM still releases the LUKS key → root shell on the
decrypted disk). Edit-only mode keeps unattended boot (the box still boots password-free; the
password is required only to EDIT entries / open the GRUB shell) — the right config for an unattended
booth. Verified BOTH halves in /boot/grub/grub.cfg before rebooting (password_pbkdf2 ≥1, unrestricted
≥1) and on reboot: boots straight to login (no GRUB prompt, TPM auto-unlock intact) AND pressing `e`
prompts for admin+password. OS hardening on unit 1 is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7)
+ Secure Boot (Deployed) + GRUB edit-lock. Updated [[appliance-provisioning]] (§5 GRUB now a verified
step, §5b further-hardening TODO: SSH key-only, kiosk lockdown, signing key→TPM, autoremove old
kernel) + [[disk-os-hardening]]. STILL TODO on the box: Docker install + run the parking stack (needs
the images pushed — dev push + registry secrets pending).
## [2026-06-23] deploy | First booth GO-LIVE — Docker stack running + web-access fixes (CI uv, compose env, relative /api, Caddy)
Deployed the two images onto the hardened booth (Dell 7070, Ubuntu 26.04) and worked through the
real-world bring-up issues. (1) Operator/admin OS user split: created a dedicated sudo `admin` user,
removed the auto-login operator from `sudo` (and should drop `lxd`/`lpadmin` — lxd is a root-escape
path); admin is the only sudo, operator auto-logs in unprivileged. (2) Docker 29.6 installed; deploy
dir /opt/parking_solution with hand-copied compose + .env; registry login to git.infra.msai.al; the
stack came up clean — vision fast_alpr loaded from the BAKED cache (0 downloads → offline-first
confirmed on real hardware), server migrated /data, both healthy. (3) Seeded the first admin via
`docker compose exec server node scripts/seed-admin.mjs` (bcrypt, writes users table — NOT the signed
ledger). FIXES committed this session: CI `astral-sh/setup-uv` action failed on the Gitea runner →
install uv via its official curl script instead (both ci.yml + build-images.yml) [0a22eab]; the base
compose only forwarded JWT_SECRET/DATABASE_URL/VISION_URL → added COOKIE_SECURE (CRITICAL on plain-
http or login cookies never send), WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED [1092316];
the SPA had VITE_API_BASE=http://127.0.0.1:3000 baked in (leaked from apps/web/.env.production, which
is for the TAURI build but Vite auto-loads it for every build) → server Dockerfile now empties it via
.env.production.local so the SPA uses RELATIVE /api and works from ANY host [77b2acb]; added a CADDY
reverse proxy (prod override) so the booth is reached on a clean port-80 URL, server goes internal,
Caddyfile binds :80 to match any hostname incl. parksystems.msai.al [c637b27]. NET RESULT: no domain
baked into any image — naming controlled by hosts/DNS on-site; admin can reach it from another LAN PC.
Verified the relative-/api + Caddy fix end-to-end locally (Host: parksystems.msai.al through :80 →
SPA + /api/auth/login reach the server, no CORS). See [[container-deployment]] "Web access",
[[appliance-provisioning]]. REMAINING on the box: push dev so CI rebuilds parking-server:dev with the
relative-/api fix, then pull on the booth; kiosk autostart; operator user lxd/lpadmin cleanup.
## [2026-06-24] build | Self-service user profile + desktop installers in CI
Two app-side additions. (1) **Self-service profile** — any signed-in user can now edit their OWN
`fullName`/`email` and change their OWN password (proving the current one), without any `user:*`
permission. New routes `PUT /api/auth/profile` + `PUT /api/auth/password` (act only on `req.user.sub`;
cannot touch username/role; CSRF-guarded), SPA screen `apps/web/src/Profile.tsx` at `/profile` (header
username chip links to it), `email` added to the session view + `SessionUser`. 7 new tests
(`routes/profile.test.ts`); server 148/148 green. Distinct from the admin user-manager (`routes/users.ts`,
`user:*`-gated). See [[local-jwt-auth]]. (2) **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); the signed/versioned release stays on `release.yml` (tag `v*`). See
[[desktop-shell-tauri]] "Desktop in CI".