Commit Graph

5 Commits

Author SHA1 Message Date
julian c2f06a5d2a feat(server): live booth WebSocket feed (/api/ws)
Add @fastify/websocket. EventLog fires an onAppended callback after each durable
append; device-events gains a ledger channel (emitLedger). /api/ws fans out
ledger + occupancy + printer-status to authenticated booth clients. Origin
allowlist (WS_ALLOWED_ORIGINS) replaces CSRF for the handshake (anti-CSWSH).

Note: server.ts also reflects later booth route wiring; the final HEAD builds.
2026-06-18 11:00:22 +02:00
julian 727c62da90 ticket: site metadata header + scannable Albanian ticket; widen barcode
- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
2026-06-17 12:17:21 +02:00
julian 64d5e45f11 Cookie-based auth/authz with CSRF; remove auth bypass
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.
2026-06-14 10:45:38 +02:00
julian 77606da2c9 UHPPOTE hardware bring-up + entry-flow blocker
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.
2026-06-14 10:29:43 +02:00
julian 94dd3fcff4 Harden JWT auth: require strong secret, expire tokens
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.
2026-06-14 07:42:22 +02:00