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
Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.
The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.
Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Make the vision service genuinely configurable (was env-only).
- SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted
only when on; sq+en) — opt-in is no longer raw JSON.
- DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting
a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the
booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category
union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when
up, 0 chips when disabled.
- apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example
(Node side) + a Configuration section in opencv-anpr-service.md covering all four
layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE
.env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr
camera recognizes but every read is refused.
Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Add @fastify/websocket. EventLog fires an onAppended callback after each durable
append; device-events gains a ledger channel (emitLedger). /api/ws fans out
ledger + occupancy + printer-status to authenticated booth clients. Origin
allowlist (WS_ALLOWED_ORIGINS) replaces CSRF for the handshake (anti-CSWSH).
Note: server.ts also reflects later booth route wiring; the final HEAD builds.
- site_config gains optional park identity (park_name, operator_name, nius,
address, phone, email); additive Drizzle migration 0001. GET/PUT
/api/site-config read/write the full config (PUT partial patch, admin only);
SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
short-range "Simple" QR/barcode reader decodes reliably (was barely reading
at module width 2 on the 80mm head).
See wiki/concepts/site-metadata.md and ticket-encoding.md.
Replace the dev-only token shim with real authentication.
Backend:
- @fastify/cookie; JWT carried in an HttpOnly + SameSite=Strict cookie
(parking_token), read from the cookie not the Authorization header.
- Double-submit CSRF: readable parking_csrf cookie + X-CSRF-Token header, both
cross-checked against a csrf claim baked into the JWT; enforced on mutations.
- Routes: POST /api/auth/login (bcrypt, constant-time-ish), POST logout,
GET me. requireRole now verifies the cookie + CSRF + role.
- seed-admin script (pnpm --filter @parking/server seed-admin) for the first
admin; no bootstrap endpoint.
- Removed SETUP_AUTH_BYPASS and catalog.authBypass entirely; setup endpoints
use the cookie admin guard like everything else.
Frontend:
- apiFetch wrapper: credentials:'include' + X-CSRF-Token on mutations.
- Login form; App gates on /api/auth/me and only shows setup to admins; logout.
- Wizard token field removed (auth is the session cookie).
Deploy:
- deploy/nginx.conf: prod reverse proxy, SPA + /api same-origin, TLS, so the
Secure cookies work. Dev stays same-origin via the Vite proxy.
Verified (curl + browser): wrong pass -> 401; login sets cookies; me -> admin;
assign without CSRF -> 403, with -> 201; no cookie -> 401; session persists
across reload. wiki/local-jwt-auth updated.
Brought up the real UHPPOTE controller (serial 225088491, fw 09120) end to end
and recorded a procurement-level blocker.
Verified on hardware:
- discovery (LAN scan), host-commanded openDoor on doors 1 & 2 (physically
actuated; reason="remote open door"), and live button capture
(reason="push button ok").
Driver/networking fixes (packages/devices/src/drivers/access-uhppote.ts):
- broadcast to subnet-directed address (lib doesn't enable SO_BROADCAST for the
global 255.255.255.255 -> EACCES);
- Config broadcast must match the target's subnet for unicast reply routing
(fixes the health-check timeout: 5s -> 24ms ready);
- discover across all local subnets, dedupe by serial;
- serialize all controller I/O (concurrent calls collided on UDP :60001).
Server/UX:
- load .env via node --env-file-if-exists (vars weren't being read before);
- SETUP_AUTH_BYPASS hardened: env-gated, dev + loopback only, fails closed
otherwise; surfaced as catalog.authBypass so the wizard drops the token field;
- .env.example documents all vars; inline favicon stops a 404.
- apps/server/scripts/: uhppote-listen (live events, restores prior listener)
and uhppote-relay (guarded door-open test).
BLOCKER (wiki/decisions/access-controller-button-flow.md): the controller's
push-button input auto-opens the relay in firmware with no report-without-open
mode, so ticket-first entry (button -> print -> open, fail-closed) is impossible
as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a programmable
aux input + PULL SDK but that's unverified and needs a new driver. Entry-lane
hardware decision paused to focus on the business side.
wiki: access-controller-button-flow (blocker), zkteco-controller (stub +
assessment), uhppote-controller callout, index + log.
Security review flagged a hardcoded JWT secret fallback. A booth machine
started without JWT_SECRET would have signed tokens with a publicly-known
default, letting anyone forge an admin token — defeating the local-auth
anti-fraud model.
- requireJwtSecret() refuses to start on a missing, <32-char, or placeholder
secret (no insecure default).
- Add sign.expiresIn: 8h so minted tokens expire (bound to a shift).
- Add apps/server/.env.example documenting JWT_SECRET + how to generate it.
Verified: refuses with no secret and with the old placeholder; boots and
serves /health with a valid `openssl rand -hex 32` secret.