Commit Graph

374 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

server 80/80; build+lint green.

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

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

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

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

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

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

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

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

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

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

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

server 75/75 green (8 suites).

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

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

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

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

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

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

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

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

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

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

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

server 22/22, shared 87/87 green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:47:45 +02:00