Camera snapshots were stored RAW — the camera's full-res JPEG straight into
the BLOB, no resize/recompress. Measured on the dev DB: 300 snapshots = 81.7 MB
= ~72% of the 114 MB SQLite file (the big ones 2688×1520 / ~600 KB, Hikvision
main stream). They dominated the appliance's single backed-up DB file.
Re-encode on capture (snapshot.ts):
- Downscale each frame to SNAPSHOT_MAX_EDGE (1280px long edge) + recompress at
SNAPSHOT_JPEG_QUALITY (80) via sharp (libvips, Apache-2.0) before storage —
~6-10× smaller (verified 2688×1520 → 1280×724, ~8×), plate still readable,
clean image/jpeg (drops the camera's charset cruft). STORAGE-ONLY: recognition
keeps the ORIGINAL full-res bytes (downscaling hurts OCR). Fail-soft — a
re-encode error stores the original, never drops the snapshot or blocks the
(already-open) path. sharp lives in apps/server (owns the capture path), where
bcrypt already establishes the native-dep pattern.
Disk-pressure retention (snapshot-retention.ts) — a SAFETY VALVE, not the daily
mechanism (the re-encode does that). Daily check reads the DB filesystem used%
(statfs on db.$client.name); no-op unless ≥ SNAPSHOT_DISK_HIGH_PCT (70). Over the
mark: delete the OLDEST until an estimated SNAPSHOT_DISK_FREE_TARGET_PCT (10%) of
disk is freed — never below SNAPSHOT_MIN_KEEP (500) — then VACUUM once to return
space to the OS. A DELETE only frees SQLite pages (disk doesn't drop until VACUUM),
so the loop is driven by estimated freed bytes (SUM(length(bytes))), not a live
disk re-read; the prune owns the DB-locking VACUUM, run daily off-peak. diskUsage
is injectable for tests. None of this touches the signed ledger — snapshots are
unsigned/advisory, referenced only by id.
Tests: encodeForStorage (downscale / clean-type / no-enlarge / fail-soft) +
pruneSnapshots (no-op below mark / delete-oldest-to-target + VACUUM / MIN_KEEP
floor / skip-VACUUM-when-empty). All four snapshot env knobs documented in the
komodo env reference. Full workspace build/lint/test green; the prune smoke-verified
on a scratch DB copy (file shrank after VACUUM).
Existing ~81.7 MB of raw snapshots are unchanged (a one-off re-encode backfill is
a separate optional follow-up). Updated entry-exit-points + technology-stack wiki.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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.
Neither UHPPOTE nor ZKTeco is used — the Dingtian relay controller was chosen
and verified. Remove their code and re-scope the wiki.
Code:
- delete access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32-relay stubs),
and the three uhppote-*.mjs hardware test scripts.
- remove the `uhppoted` npm dependency from @parking/devices and @parking/server.
- unregister uhppote/zkteco/esp32-relay from the driver registry; drop their
exports. Catalog access drivers = dingtian only. Build green (5/5).
- refresh now-stale example comments (registry/interfaces/setup/api) to use
current examples; keep the two "UHPPOTE blocker" references that explain why
the precondition capability exists.
Wiki (kept pages, re-scoped):
- uhppote-controller, zkteco-controller -> rejected/historical with callouts;
uhppote-vs-esp32 -> historical (detection-vs-prevention lens still useful).
- re-point all "current device" framing (standing-decisions, bom, overview,
open-questions, device-registry, device-discovery, index) to dingtian-relay.
- transferable concepts (network-isolation, event-log-ingestion, barrier-not-a-
door, threat-model) untouched. Raw source immutable. Links lint clean.
`pnpm dev` never started the backend: the server dev script ran
`node --experimental-strip-types src/index.ts`, but type-stripping doesn't
rewrite the `.js` import specifiers to `.ts`, so it crashed on
ERR_MODULE_NOT_FOUND. With no backend on :3000, the Vite proxy waited the full
timeout on every /api call — the "Loading…" and "Signing in…" hangs.
- server dev script -> `tsx watch` (resolves .js->.ts, has built-in watch).
- Vite proxy targets 127.0.0.1 (not "localhost") so it never tries IPv6 ::1
first and stall — the backend binds IPv4. Belt-and-suspenders for the same
hang, notably under WSL2 mirrored networking.
Verified: pnpm dev boots the backend; health/auth/me ~5ms; browser login is
instant (was minutes).
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.
Turborepo (pnpm workspaces) with all dependencies pinned to latest
mutually-compatible versions: turbo 2.9, TypeScript 6, Fastify 5,
React 19, Vite 8, better-sqlite3 12 + Drizzle ORM 0.45.
Layout:
- apps/server Fastify backend (local JWT auth + role guard, /health)
- apps/web React 19 + Vite 8 operator SPA
- packages/db Drizzle schema on SQLite/WAL; append-only events + users
- packages/devices reader/printer/relay adapter interfaces (intent-only relay)
- packages/shared shared domain types
Architecture constraints from the design wiki are encoded in the scaffold:
append-only hash-chained + signed event log, device-agnostic adapters,
"a barrier is not a door" (relay expresses intent only), fully-local
offline-first auth.
wiki/ is an LLM-maintained Obsidian knowledge base (28 pages) ingested
from the architecture & design notes, with its own maintenance schema.
Verified: pnpm install, full turbo build (5/5), server boots and serves
/health, drizzle-kit generates the initial migration.