feat(logs): app log store — backend pino DB sink + frontend error collection

Add a third data stream (app_logs), distinct from the signed ledger and device
telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to
ship to, so the host is the log store.

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

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

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

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 12:54:22 +02:00
parent 0074e82a2a
commit bfb6ab0b36
20 changed files with 1064 additions and 9 deletions
+12
View File
@@ -892,3 +892,15 @@ Dates were raw ISO on paper and time-only in the UI (a 2-day-old session showed
## [2026-06-19] fix | KP-300H barcode line-overflow — ticket id 13→11 digits
The Cashino KP-300H entry dispenser printed entry tickets as RASTER GARBAGE (solid black bars/banding) while the Rongta printed the IDENTICAL byte stream fine. Diagnosed on hardware: plain-text-only prints were clean → isolated to the `GS k` Code128 barcode. ROOT CAUSE = barcode line-overflow, not corruption: a Code128-B symbol is (11·chars+35)·moduleWidth dots; the old 13-digit id at module width 3 = ~534 dots OVERRAN the KP-300H's 72mm line (512 usable dots @ 203 dpi). The Rongta runs 80mm (576 dots) and had just enough room — why only the Cashino failed. FIX: shorten the ticket id 13→11 digits (10 random + Luhn) → ~468 dots, fits 72mm; scanned the full value at the exit reader (verified). Length is driven by GUESS-RESISTANCE not volume (10^10 space, ~1-in-10^7 to hit a live open ticket vs the booth-operator threat); chose 11 over the requested 9 (10^8 → ~1-in-10^5, too weak). validateTicketCode made length-agnostic (\d{10,14}+Luhn) so legacy 13-digit tickets still validate. NB: module width must stay 3 — a width-2 test scanned but returned TRUNCATED values (partial reads logged as exit.refused.noSession anomalies). Also fixed a separate latent transport bug in sendRaw: write-then-destroy could RST mid-stream (the write callback ≠ peer-flushed) and truncate a job; now end(payload)+FIN, resolve on socket `close`, timeout-after-write = success. NOT the cause of the garbage but a real risk. Committed bbf61c4. Updated [[ticket-encoding]], [[rongta-printer]].
## [2026-06-19] feat | Snapshots on refused entry/exit + subscriber access medium in the activity log
Two booth-evidence gaps closed. (1) **Refused entry/exit now snapshot.** Originally only the OPEN paths fired the directional camera; refusal/hold anomalies didn't — yet a turned-away car is exactly the evidence an operator/auditor wants (fraud/dispute signal). Added `#fireSnapshot` to every refusal: entry refused-full + held-no-ticket (a refused entry has no ticket id, so mint a synthetic `REFUSED-…` ref to key the anomaly + photo together), exit refused closed/no-session/unpaid/grace-expired (BOTH booth `exitForBooth` and reader `#runExit` paths), and refused [[subscription]] (the lane the reader sits at — `resolved.direction`, "both"→entry — picks the camera). Same fire-and-forget contract: a refusal is never delayed/blocked by a camera; failed captures still surface as "⚠ camera unreachable" tiles. (2) **Subscriber access medium (`via`) surfaced.** The subscription flow already SIGNED `via` (`"qr"|"card"|"plate"`) into the entry/exit payload but the activity log never showed it. Added it as a typed `LedgerPayload.via` field, a cyan chip in the ticker, and an "Entry medium / Mënyra e hyrjes" row in the detail modal (QR code / RFID card·chip / plate, localized sq+en) — a lost-card investigation can now see which credential opened a barrier. Display-only, no re-signing. Refused-subscription anomalies also now carry `via`. Build+lint green. Updated [[entry-exit-points]], [[booth-console]].
## [2026-06-19] feat | One car = one ticket (entry anti-double-press) + refusal snapshots + subscriber via
FLAW found: the entry button could be pressed without limit — each press minted a fresh ticket + signed vehicle_entry, corrupting occupancy (one car counts as many) and letting a transient SHOP the cheapest ticket at exit. The old `#inFlight` guard only blocked OVERLAPPING presses (released in finally). FIX is per-relay config (`config.relays[]`), mode chosen by available barrier feedback: (1) PRESENCE (preferred) — `presenceInput` ties ticketing to a vehicle loop on a Dingtian input; a press prints only with a car present, and NO second ticket until the loop CLEARS (car drove in) and a new car re-occupies it → physical one-car-one-ticket; (2) COOLDOWN (fallback, no feedback) — `entryCooldownSec` suppresses repeat presses for N seconds (a timer, mitigation not guarantee). New `relayForPresence()` resolves a loop edge to its entry relay; `EntryFlow` keeps a per-relay `#guard` map (present/armed), disarms on PRINT success, re-arms on loop clear. A suppressed press = UNSIGNED device_events telemetry (entrySuppressed:true), NOT a signed anomaly (operator's call — it's a correct no-op, not fraud). SetupWizard relay editor exposes Presence-loop + Cooldown fields (sq+en). Fail-closed entry + barrier-is-not-a-door invariants untouched; guard state is in-memory/rebuildable, starts armed after restart (safe default). New page [[entry-double-press]]; updated [[entry-exit-points]], index. Build+lint green. (Bundled with this session's earlier refusal-snapshots + subscriber-`via` work.)
## [2026-06-19] feat | Application logs — backend pino DB sink + frontend error collection (app_logs)
Added a THIRD data stream (`app_logs`) alongside the signed ledger and device telemetry — operational/diagnostic logs, since an OFFLINE appliance has no Sentry to ship to. BACKEND: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only — no bloat) with NO call-site change; the DB is now built BEFORE Fastify so the logger stream has its sink. FRONTEND (lib/logger.ts): always ships failed API requests (apiFetch non-OK path, minus 401 pre-login churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary (render crash → fatal, not a white screen); console.warn/error forwarded ONLY at client debug/trace level (noisy otherwise). Batched/throttled POST, flush via raw fetch + sendBeacon on pagehide. Reliability invariants: never log the /api/logs call itself (loop guard), LogService reentrancy guard, all writes best-effort/swallowed, bounded queue + clamped rows. API: POST /api/logs (any signed-in user, CSRF, tolerant — never 4xx on a bad entry) + GET /api/logs gated by a NEW `log:read` permission (new `log` resource in the RBAC grid; admin holds it). Retention: pruned by age (LOG_RETENTION_DAYS=30) AND row cap (MAX_ROWS=50k), hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack, 15s poll), sq+en. DB migration 0009_app_logs (+journal idx 9, seeds admin log:read) applied to the live apps/server DB. Verified end-to-end via app.inject: login→POST 204→GET 200 with the record; backend warn/error persisted + info dropped; non-admin GET 403 / POST 204 (the intended split). Build+lint green. New page [[app-logs]]; updated [[event-streams-split]], [[device-events]], index.