A shift is two signed ledger events, no mutable table: new shift_open event type + existing shift_z_report. The operator is the logged-in user (carried in event identity); a shift is open iff their latest shift event is a shift_open. ShiftService: close sums payment events in [start,end] by tender (cash/card, by payment time), appends the signed shift_z_report (totals/counts/window), and prints via a new generic PrinterDevice.printReport(title, lines) (Rongta ESC/POS text) to a booth-receipt printer. Print is best-effort — a failed print does not undo the signed close. Routes (cashier/operator/admin): GET /api/shift/current, POST /api/shift/open (409 if open), POST /api/shift/close (409 if none). Web ShiftControl in the shell (non-readonly): Start/End + Z-report totals. Verified: open -> double-open 409 -> payments (cash+card; one outside the window excluded) -> close totals correct + signed + printed -> close-again 409 -> re-open ok; readonly 403; verifyChain ok.
45 KiB
Wiki Log
Append-only chronological record. Each entry: ## [YYYY-MM-DD] <op> | <subject>.
Query with grep "^## \[" log.md | tail -5.
[2026-06-14] ingest | Parking System — Architecture & Design Notes
First source ingested. Bootstrapped wiki scaffolding (CLAUDE.md schema, index.md, overview.md, log.md). Created source summary, 14 entity pages, 9 concept pages, and decision records (settled decisions + 6 open questions). Source is a dense design doc covering stack, threat model, device architecture, UHPPOTE access control, the custom ESP32 controller alternative, readers, and a reference BOM.
[2026-06-15] decision | JWT key choice + ESP32 deferred
From app work, not a new source. Added open-questions #7 (symmetric vs.
asymmetric JWT signing key — raised by the commit security review; prefer RS256/EdDSA
so verifying hosts hold only a public key, mirroring the ATECC608 / challenge-response
property). Marked esp32-custom-controller status: deferred per decision not to
implement device-level auth for now (access control stays on UHPPOTE + network
isolation); noted in open-questions #6. Updated local-jwt-auth (hardened secret
handling + 8h expiry, asymmetric-key pointer) and the index.
[2026-06-15] decision | Device-agnostic registry + first-run setup
From app work. Made the device-adapter-pattern selectable: added a
device-registry (catalog of drivers per category) and a first-run-setup
flow so the admin picks a device per lane at install. Categories: access
(ZKTeco / ESP32 relay), reader (Wiegand / TCP-IP), camera (Hikvision / Dahua,
snapshot-on-event), printer. Added a CameraDevice interface; new lane_devices
setup_statetables (migration 0001); admin-only setup endpoints. Stub drivers for now (no real vendor protocols yet). Verified catalog + assign + validation + auth end to end.
[2026-06-15] decision | UHPPOTE library chosen + real driver
Researched Node options for the UHPPOTE controller. Chose the official
uhppoted npm package (MIT, actively maintained, full API incl. openDoor,
get-event(s)/event-index, set-listener, restore-default — covers the whole
event-log-ingestion design). Rejected: raw-dgram DIY (reinvents the lib),
node-red-contrib-uhppoted (wrong model), Go REST sidecar (extra runtime). Added
it to @parking/devices and implemented a real uhppote uhppote-controller
access driver (pulseOpen→openDoor, healthCheck→getStatus), registered in the
catalog. CJS interop: default-import + destructure. Verified it builds, appears
in the catalog, and degrades to "offline" gracefully without hardware. Real
on-VLAN test still pending.
[2026-06-15] feature | Device discovery (UHPPOTE scan in setup)
The frontend had no way to find a UHPPOTE — but the controllers self-announce via
UDP broadcast. Added a generic device-discovery capability: optional
DiscoverableDriver.discover() on the registry, implemented by the uhppote
driver via getDevices. New admin-only GET /api/setup/discover/:driverId
(health-checks each found device); catalog now returns a discoverable list.
SetupWizard gains a "Scan for controllers" button that lists found devices with
health badges and auto-fills serial + host on selection. Verified: catalog flags
uhppote; discover runs and fails gracefully without hardware (broadcast EACCES);
non-discoverable driver → 400; no token → 401. Modeled generically so cameras
(ONVIF) can add discovery later.
[2026-06-15] test+blocker | UHPPOTE hardware bring-up + entry-flow blocker
Brought up the real UHPPOTE (serial 225088491, fw 09120) end to end. Fixed the networking path: WSL2 mirrored mode, then driver bugs — subnet-directed broadcast (the lib doesn't enable SO_BROADCAST for global 255.255.255.255), broadcast must match the target's subnet for unicast reply routing (health-check timeout fix), multi-subnet discovery, and serialized I/O (concurrent calls collided on :60001). Added .env loading (Node --env-file), env-gated+fail-closed SETUP_AUTH_BYPASS, and an authBypass flag so the wizard drops the token field. Test scripts in apps/server/scripts/ (uhppote-listen, uhppote-relay).
VERIFIED on hardware: discovery; host-commanded openDoor doors 1&2 (physical + reason="remote open door"); button presses live (reason="push button ok").
BLOCKER FOUND: the controller push-button input auto-opens the relay in firmware — no command to report-without-opening — so ticket-first entry (button→print→open) 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. Recorded in access-controller-button-flow + zkteco-controller. Entry-lane hardware decision paused to focus on the business side.
[2026-06-15] feature | Cookie-based auth/authz (login, CSRF)
Built real authentication: bcrypt login → JWT in an HttpOnly+SameSite=Strict
cookie, readable CSRF cookie + X-CSRF-Token header (double-submit) on mutations,
role-guarded routes. Routes: /api/auth/{login,logout,me}. First admin seeded via
pnpm --filter @parking/server seed-admin. Removed the SETUP_AUTH_BYPASS shim
and the wizard token field; the SPA gates on /api/auth/me and only shows setup to
admins. Same-origin via the Vite dev proxy and a new prod nginx config
(deploy/nginx.conf). Verified end to end (curl + browser): wrong pass→401,
login→cookies set, me→admin, assign without CSRF→403 / with→201, no cookie→401,
session persists across reload. Updated local-jwt-auth.
[2026-06-15] lint+docs | Dev-environment pages (WSL networking, workflow)
Captured hard-won dev knowledge that was only in commit messages: new
wsl-dev-networking (WSL2 NAT blocks UDP broadcast → mirrored mode + the
multi-interface / subnet-broadcast / IPv6-localhost gotchas that remained) and
local-dev-workflow (setup, seed:admin, the dev-server-hang from the broken
strip-types script → tsx, the 127.0.0.1 proxy fix, .env loading). Corrected the
earlier "broadcast permission (EACCES)" note in device-discovery — the real
cause was the lib not enabling SO_BROADCAST for the global 255.255.255.255;
documented the three verified broadcast gotchas + serialization. Added a reference
page type to the schema; new "Dev environment" index section.
[2026-06-15] decision | Dingtian relay chosen; HTTP over MQTT; unmanned direction
New relay+input controller on hand (Dingtian 4ch). Its inputs are decoupled from relays (configurable via input_link_relay) — solves the access-controller-button-flow blocker the UHPPOTE couldn't. Transport decision dingtian-vs-mqtt: direct HTTP/UDP now (UDP string for relay control on :60001; device input_link_url HTTP push for button events), MQTT skipped (broker = infra + failure mode + overkill at this scale) but kept for later multi-lane scale. Recorded the stated roadmap to fully unmanned, no-booth operation in autonomous-direction and its threat-model shift (operator-fraud → unattended-machine threats). New stub dingtian-relay with the full protocol from the SDK. Driver + on-hardware test still to build.
[2026-06-15] driver+test | Dingtian driver built; button blocker RESOLVED
Built the dingtian access driver (AccessControlDevice relay control + InputDevice
poll-based button events + new PreconditionDevice capability). Verified end to end on
real hardware (DT-R004 @ 10.0.10.172, HTTP config on :8080, UDP control :60001):
status read, relay pulse, input press/release. Disabled input_link_relay via the
driver's fixPreconditions (GET config → flag 0 + clear maps → POST config_set), then
confirmed: pressing inputs now fires NO relay (0000 status) — host-in-the-loop entry
works. The access-controller-button-flow blocker is RESOLVED. Gotcha recorded in
dingtian-relay: config_set requires injecting "command":"setconfig" after "status"
(GET omits it) or the write silently no-ops. Added httpPort config field (port 8080 ≠
default 80). Test script apps/server/scripts/dingtian-test.mjs. Next: input HTTP-push
endpoint + wiring input→ticket→pulseOpen.
[2026-06-15] cleanup | Remove UHPPOTE/ZKTeco code; wiki → rejected/historical
Neither UHPPOTE nor ZKTeco is used (Dingtian chosen). Removed their code:
deleted access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32 stubs), the
three uhppote-*.mjs scripts; dropped the uhppoted npm dep from both packages;
unregistered uhppote/zkteco/esp32-relay from the driver registry; updated example
comments. Catalog access drivers now = dingtian only. Build green.
Wiki: kept the pages but marked uhppote-controller + zkteco-controller
rejected/historical, uhppote-vs-esp32 historical; re-pointed all "current
device" framing (standing-decisions, bom, overview, open-questions) to
dingtian-relay; noted no current driver uses device-discovery. Transferable
concepts (network-isolation, event-log-ingestion, barrier-not-a-door, threat-model)
kept as-is. Links lint clean; raw source untouched (immutable).
[2026-06-15] feature | Dingtian input HTTP-push → backend (no polling)
Wired the device's "Input Link URL" feature so it HTTP-pushes button events to
our backend — no polling. Driver configureInputPush() writes input_link_url
(per-input server/port/path, en=1, active-LOW, plain HTTP) via the config API
(reusing the #writeConfig + command:setconfig helper). New backend route
routes/devices.ts: public GET/POST /api/devices/dingtian/:deviceId/input/:n/{on,off}
→ emits onto an internal device-events bus (device-events.ts, EventEmitter) for
the entry flow to consume. VERIFIED on hardware: configured device, real presses
on all 4 inputs pushed to the backend (input N on+off, source = device IP). Trust
model recorded in device-input-flow: flat network / no VLAN → backend is source
of truth, every open is a signed event (out-of-band open = anomaly); push endpoint
not behind cookie auth (machine call), shared-secret available as defence-in-depth.
Next: wire signed event + ticket print + pulseOpen.
[2026-06-15] feature | Dingtian push auth via HTTP Digest (hardware-tested)
Secured the device→backend input push. Empirically tested auth options on the
device: HTTPS-to-self-signed FAILS, Basic works, Digest works → chose Digest
(MD5, qop=auth): password never on the wire, single-use nonces. Backend
digest-auth.ts (challenge/verify) + source-IP allowlist on the push route;
per-device pushUser/pushPassword generated on assign, written to the device and
stored in lane_devices (admin never types a URL/secret). Driver
configureInputPush now sets auth=2 + creds; the assign flow auto-configures the
device and persists the creds (net.ts derives the backend IP on the device's
subnet). Removed the earlier URL-token approach (token in URL is sniffable/logged).
TWO HARD-WON DEVICE BUGS fixed: (1) config_set requires an explicit Content-Length
— the device silently ignores chunked bodies (Node's default), which masqueraded
as "writes don't apply" all session; (2) the pass field caps at 31 chars →
use a 24-char password. Driver #writeConfig now polls-until-verified (device
reboots on apply). VERIFIED on hardware: assign auto-configures the device, then
all 4 inputs push with Digest auth, zero failures. Recorded in device-input-flow.
[2026-06-15] feature | Setup wizard: Test connection + Save & configure
Two-step device setup UX. New admin-only POST /api/setup/test (healthCheck + checkPreconditions, no save / no device change). The assign (Save) step now also fixes preconditions (disables input_link_relay) before configuring push — closing a gap where assigned devices could still auto-fire relays; fails the save with no DB row if device config fails (no orphan rows). SetupWizard wires the config fields → Test button (health badge + precondition warnings) → Save & configure button. Verified in-browser against the real device: Test shows ● ready + preconditions OK; Save persists the row AND writes the device's Input Link URL (push path matches the saved device id). Admin never logs into the device web UI. Updated first-run-setup.
[2026-06-15] feature | Device hardening: binary relay + relay_pw + disable channels
Hardened the Dingtian relay control for the flat (no-VLAN) network. Switched pulseOpen from the unauthenticated string protocol (:60001) to the binary protocol (:60000) with a relay password — the only authenticated relay option (frame verified on hardware: FF AA 03 ). New HardenableDevice capability: harden() sets a random relay_pw + disables unused channels (rs485/can/tcp×2/mqtt → p:255, keep UDP binary+string). Folded into the assign/Save flow (preconditions → harden → push); relayPassword stored in lane_devices. Verified end to end: assign configures + hardens the device, config API stays reachable, pulseOpen with the stored password fires the relay, without it is rejected.
⚠️ LESSON: enabling the device's HTTP CGI session check (session_en) on this firmware breaks the config-READ API (ECONNRESET) — locked us out, needed a FACTORY RESET to recover. harden() deliberately does NOT touch session_en. The open CGI API is accepted as flat-network reality; the signed log is the real guarantee. Recorded in device-input-flow + dingtian-relay.
[2026-06-14] query | Dingtian web-login rotation + CGI API is unauthenticated
While addressing "change the device's default admin/admin", traced the device web
UI JS (system.js) → the change-login endpoint is
GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>& (response &0&/& = success,
&2&/& = wrong old pw). Added a best-effort setWebLogin/#rotateWebLogin step
to harden() (new pw stored back as config webPassword, stripped from API
responses). KEY FINDING: the device CGI API needs NO authentication — config dump,
config write, relay fire, and userset.cgi itself all return 200 unauthenticated
(verified on 10.0.10.5). admin/admin gates only the browser UI; there is no
inbound-auth setting (only session_en, which bricks the read API). So rotating the
login is COSMETIC, not a boundary — the signed event log remains the real
guarantee. Recorded in dingtian-relay (new Hardening section).
[2026-06-14] ingest | Rongta 80mm printer driver + printer roles/failover
- Added
rongtaPrinterDevice driver (ESC/POS over raw TCP 9100); registered in registry. - Decision: ≥2 printers per lane by role (entry-dispenser outside, booth-receipt inside); entry ticket fails over outside→booth (asymmetric — receipts never print outside).
- Selection logic lives in packages/devices/printer-routing.ts (orderForRole, printWithFailover).
- One unit verified reachable at 10.0.10.6:9100 from host (TCP connect OK).
- New pages: rongta-printer, printer-roles-failover. Updated bom, index.
- Open: all-printers-down policy belongs to the (not-yet-built) entry flow, not the printer layer.
[2026-06-14] ingest | Live printer status monitoring
- Added MonitorableDevice.readStatus()/PrinterStatus capability in packages/devices.
- Rongta readStatus() scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper End/Near End/ Off-Line) — chosen over hand-decoding DLE EOT because this clone's DLE EOT bytes don't match the canonical ESC/POS bit layout (verified on hardware; risk of false-healthy).
- Server PrinterMonitor: polls enabled monitorable printers (PRINTER_POLL_MS, default 5s), caches latest, emits "printer-status" on change. API: GET /api/printers/status + SSE stream.
- Verified live: 10.0.10.6 -> ready (all flags clear); unreachable host -> offline (no throw); bus emits on change, suppresses unchanged. Full repo typechecks (8/8).
- New page: printer-status-monitoring. Updated rongta-printer, index.
- Open: capture the page's actual text for an ACTIVE fault (pull paper / open cover) to confirm the Yes flip; wire degraded/offline into failover + entry-flow all-down policy.
[2026-06-15] ingest | Multi-instance device setup (add/remove per category)
- Confirmed the data model was already multi-instance (lane_devices = one row per instance, assign always inserts); the limitation was UI-only (one slot per category).
- Backend: added DELETE /api/setup/assign/:id (unassign); /state now redacts secrets (pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign.
- Web: SetupWizard reworked — each category lists assigned instances (with Remove) + "Add another" form; select-type config fields now render as dropdowns (fixes printer role input).
- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak, delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
- Updated first-run-setup.
[2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist)
- Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly.
- Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events; integrity: GET /api/events/verify (admin).
- Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY) shipped now since chip wiring is open-question #6. Caveat documented: software signer is tamper-evident but NOT unforgeable-by-owner.
- Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO).
- Added ParkingEventType 'input_received'.
- Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok; direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent appends -> indices 1..5 intact. Full repo typechecks.
- Updated append-only-event-chain, dingtian-relay.
[2026-06-15] ingest | Event log + Dingtian string-protocol security fix
- Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received. Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok.
- SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays ("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses, but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack is dead and binary control/status still work.
- GAP (user-identified): event log captures host-originated actions only; out-of-band relay actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware. Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built.
- Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area); pushPassword/relayPassword stay machine-only.
- harden() warnings surfaced via the assign response.
- localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts).
- INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults). Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs).
- Updated append-only-event-chain, dingtian-relay.
[2026-06-15] fix | Dingtian web-password: desired-vs-current split + verify + UI warnings
- BUG (found in real assign): admin typed a web password; harden used it as the OLD cred, rotation failed silently, DB saved the typed value but device login stayed admin/admin. Also UDP2 warning never reached the admin (frontend discarded the assign response).
- FIX: split config into webPassword (desired; blank→random) and webPasswordCurrent (existing old cred, default admin). harden() rotates current→desired, VERIFIES by re-auth with the new pw, and only returns secrets.webPassword on success (else warning, no save). assign strips typed webPassword/webPasswordCurrent and persists only verified secrets.
- SetupWizard now shows assign-response warnings (amber banner, per category) — closes the feedback loop for the UDP2-can't-disable case.
- Verified on hardware (192.168.1.100): harden set login to a chosen pw; device then rejects admin/admin (&2&) and accepts the chosen pw (&0&). UDP2 warning surfaced as designed.
- Updated dingtian-relay.
[2026-06-15] update | input_received lane resolution + source semantics
- Wired device→lane resolution:
LaneMap(apps/server/src/lane-map.ts) cacheslane_devices.id → lane, refreshed by setup routes on assign/unassign.input_receivedevents now carry the firing device's lane instead of a hardcodedlane: 0. Unmapped device →lane: -1+ warn (0 is a real lane; never mis-stamp). - Documented that
sourcestays null for raw inputs by design (it's an IdentitySource, not a device field); device provenance is inidentity. - Updated append-only-event-chain.
[2026-06-15] test+lesson | Hikvision camera verified; multi-subnet source-address trap
- Pulled a real snapshot from a Hikvision camera on the bench:
GET http://10.0.10.121/ISAPI/Streaming/channels/101/picture, Digest auth, admin/admin123 → HTTP 200, 2688×1520 JPEG. Path + auth + creds confirmed. ISAPI is the right surface; the device's "Enable Hikvision-CGI" toggle is a different legacy CGI API and is NOT needed. - Caveat recorded: the camera driver is still a STUB — the wizard's "● ready — stub / ●
preconditions OK" contacts nothing; cameras have no preconditions (only dingtian-relay
implements checkPreconditions). Noted the cosmetic "Backend push IP" bug (camera pulls, doesn't
push; field should gate on a
pushesToBackendcapability). - LESSON (cost an hour of "why can't we ping the subnet"): with two device subnets stacked on one
NIC (
192.168.1.123+10.0.10.203on eth1), Linux picked the WRONG source address for10.0.10.x→ ARP shows REACHABLE but all ping/TCP times out. Fix: pinsrcon the connected route (ip route change <subnet>/24 dev <nic> proto kernel scope link src <host-ip>), or force source per-call (ping -I/curl --interface). Devices arrive on assorted static/24s; the host carries one IP per subnet — this trap is the recurring cost of that. - Decision context: production is a dedicated hardened Linux appliance (this WSL2 box is a dev
stand-in). Multi-subnet config +
srcpinning is an appliance deployment concern (made persistent via networkd/netplan), riding on network-isolation; long-term answer is to re-IP devices onto one planned parking subnet at install. - Updated lpr-camera (snapshot driver + verified-on-hardware section), wsl-dev-networking (multi-subnet source-address trap + appliance pattern).
[2026-06-15] driver+fix | Real Hikvision/Dahua camera driver; push-IP field gated
- Replaced the camera STUB with a real
HttpCamera(packages/devices/src/drivers/camera.ts): Hikvision ISAPI (/ISAPI/Streaming/channels/<ch>01/picture) + Dahua CGI (0-based channel), both over client-side HTTP Digest (newdrivers/http-digest.ts, two-shot 401→challenge→response, qop=auth MD5 — the client counterpart to the server's digest-auth.ts).healthCheck()now actually pulls a frame instead of returningready/stub. AddedlocalAddress+timeoutMs+channelconfig; threads the device-facing NIC for the multi-subnet trap. - Snapshot interface:
Snapshotnow carriesbytes: Buffer(driver fetches);imageRefis optional and set by the CALLER once stored — keeps the adapter free of storage deps. Nothing consumed captureSnapshot yet, so no migration needed. - Cosmetic bug fixed: "Backend push IP" showed for any reachable host. Added a
pushesToBackendflag toDeviceDriver(only dingtian-relay sets it), exposed aspushCapablein the catalog (mirrorsdiscoverable), and gated both the wizard's backend-IP fetch and the field on it. Cameras/printers/readers no longer show it. - VERIFIED on hardware: built clean (5/5 packages); ran the real driver against the Hikvision at 10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic).
- Updated lpr-camera.
[2026-06-15] fix | Permanent WSL2 source-address fix (systemd hook)
- The multi-subnet source-address trap kept recurring (every
wsl --shutdownwipes the runtimeip routepin — mirrored mode re-clones the Windows NIC's addresses fresh each boot, and NOTHING inside Linux owns them: networkd/NM/netplan all inactive). Made it permanent on the dev box. deploy/wsl-fix-route-source.sh: walks eachproto kernel scope linkroute on the NIC and pinssrcto the host's own address in that same subnet — no hardcoded IPs (covers future device subnets), idempotent, preserves route metric, non-fatal per route.deploy/parking-net.service: oneshot, enabled, reapplies on every boot.- BUGS hit + fixed while building it: (1)
ip route changeerrorsRTNETLINK: No such filewhen the route isn't up yet at boot → usereplace; (2)set -emade one failedipabort the whole unit → dropped it, per-route warnings instead; (3)network.targetfires before mirrored-mode addresses land → script waits up to 15s for a route. - VERIFIED: service enabled+active, journal shows
pinned 10.0.10.0/24 -> src 10.0.10.203, camera pings with NO -I flag (0% loss), and the real Hikvision driver pulls a snapshot with NOlocalAddressset. Root cause noted as Windows-side (stray 192.168.1.x); this is the self-contained Linux answer. - Updated wsl-dev-networking.
[2026-06-15] design | Business layer kickoff — parking session model
- Pivoted from the (hardware-verified) device/integrity layer to the business domain. Wiki-first.
- KEY DECISION: a parking-session is a PROJECTION over the signed append-only-event-chain,
never a mutable table — a mutable sessions row with paid/owed would reopen the operator-fraud
hole the whole system closes. "Paid" = a signed
paymentevent (unforgeable, undeletable). - Scope (user): mixed site, TRANSIENT-FIRST; permit holders layered as a 2nd identity source that short-circuits payment. Payment = PAY-ON-FOOT / pay station (decoupled from exit; exit lane only validates paid + within walk-back grace). Matches autonomous-direction.
- New signed event types designed (not yet built):
vehicle_entry,vehicle_exit,payment,void— extendinput_received. Lifecycle OPEN→PAID→CLOSED (+VOIDED); overstay top-up is the one genuinely stateful edge case. - New pages: parking-session, tariff (pure/data-driven fee fn; gracePeriodExit is a real pay-on-foot revenue param), decision session-model. Updated append-only-event-chain, index. Closes the dangling entry-flow thread from device-input-flow.
- permit drafted + RESOLVED from user input: credentials = RF tag/chip/card + QR (optical
reader). Car limits = two numbers:
registeredCars[]whitelist + admin-setmaxConcurrent(in at once) — enforced as a fold over the permit's open sessions. Identity = card/QR OR matching plate (either opens; card-sharing not prevented by design, caught by reconciliation). Autonomy = host-in-the-loop for everything → Dingtian stays sufficient, no new controller; permit entry fails closed if host down. Remaining open: reader hardware models; lapsed/revoked policy. - NEXT: schema (
packages/db: permits/tariffs + session projection) + the input_received→vehicle_entry flow (closes the device-input-flow thread).
[2026-06-15] design | Host-side vision service (ANPR + vehicle verification)
- User: optionally bind camera images to an OpenCV service we build. Resolved scope: ANPR (plate →
IdentitySource='lpr'); a separate local Python/OpenCV microservice on the appliance (Node → localhost HTTP), offline; it replaces the dedicated edge-AI lpr-camera (recognition on ordinary Hikvision/Dahua snapshots — reusesSnapshot.bytes). - LICENSING: best ANPR/vehicle models are AGPL/commercial vs. the MIT/Apache/BSD standing rule. Decision: scoped AGPL exception — allowed INSIDE the vision service only (separate process, not linked); app stays permissive. Amended standing-decisions.
- USER ANTI-FRAUD INSIGHT: a fraudster can print a registered plate and enter with a different car. → service also does vehicle-attribute / fingerprint verification, so the car reconciles, not just the plate. This fills the independent-witness gap append-only-event-chain calls out: plate-on-different-car = anomaly. Recognition is advisory (confidence + ticket fallback), evidence (read + image) attaches to the signed event.
- New pages: opencv-anpr-service, decision vision-service. Updated standing-decisions, lpr-camera (host-side supersedes edge-AI), permit (plate-spoof defence), append-only-event-chain (vision as witness), index.
- Open: recognizer/vehicle-model choice + accuracy; fingerprint method + anomaly threshold; appliance compute (CPU vs GPU/NPU); per-camera opt-in; the still-unbuilt reconciliation logic.
[2026-06-15] design | Transient pricing — composable, versioned tariff
- User: pricing is unknown + constantly changing → must be admin-composable at runtime, currency selectable, FX later. Reframed tariff from "config we ship with numbers" to a first-class editable entity.
- DECISIONS: (1) rate structure = stepped duration blocks + rolling-24h daily cap (flat rate is
one block; expresses first-hour/taper/cap with no special cases); (2) overstay top-up =
reprice the difference (recompute entry→now − alreadyPaid); (3) tariffs are effective-dated
immutable versions — edits publish a new version, sessions reprice against the version in force,
the
paymentevent recordstariffVersionId(reproducible + fixed in the signed chain); (4) one active tariff per site, but modelled with id/scope so multi-tariff needs no migration; (5) currency selectable (ISO 4217), money ={minorUnits, currency}, payment reserves a nullfxRate→ FX-ready, FX engine deferred (needs offline rate source — new open-questions #8). - Ships with no rate card; owner must compose+publish one (blank = free or gated, operator policy — open). Numbers in the page are illustrative, not defaults.
- Wrote the pure integer fee algorithm into tariff (data model:
tariffs+ immutabletariff_versions). Updated open-questions (#8 FX), index. - NEXT: schema (
packages/db) for tariffs/versions + permits + session projection, then the composer UI + the input_received→vehicle_entry flow.
[2026-06-15] design | Shifts (manned-only) + Z-report; drop time-based token
- Q: what happens at operator shift end? Resolved scope, deliberately small.
- Shifts exist ONLY in manned mode — a human accountability boundary. The fully-automated/unmanned system has NO shifts; the pay-station cash-collection cycle + reconciliation replace it.
- Shift is NOT time-based: relief arrives late / no-shows / one operator forced into a double. → drop the 8h token expiry; login valid until explicit logout (updated local-jwt-auth; code change pending). Start/End Shift are explicit, independent of login — one login spans many shifts; a double = End then Start again, no re-login.
- End Shift = sum signed
paymentevents in the shift by tender → append a signedshift_z_report(type already in packages/shared, chained to prior Z) → PRINT cash total + POS total (if a POS is configured). That's the whole human-side ask. No blind count / variance gate / manager override. Fraud control stays in the signed chain + later reconciliation (catch a skim after the fact, not at close). Blind-count documented as an explicit optional add-on, not built. - New page shift; updated local-jwt-auth, index.
- Open: Z sums by payment-time (the operator who took the money) — confirm; X-report (read-only
mid-shift); per-operator vs per-booth vs per-site (ties to open-questions #1).
paymentevent needs atenderfield (cash/card) — fold into the schema step.
[2026-06-15] design | Scope sweep — capacity, validation, reporting, integrity gaps
- "What else can a PMS do?" — swept the full feature surface against the design; user picked the
in-scope gaps. New pages:
- capacity-occupancy — occupancy = fold over open sessions; refuse entry + drive a FULL sign when full; exit never blocked (fail-state-safety); zone-ready; counting-drift = anomaly.
- validation-discounts — merchant validates a ticket → signed discount event applied at fee time (tariff); over-validation visible to reconciliation; payment records gross/disc/net.
- reporting-analytics — revenue/occupancy/stay/permit/anomaly reports as projections over the chain; plate-search (admin looks up a session by plate IF captured — honest "not captured").
- clock-integrity — fees depend on the host clock; offline box → backdating attack; monotonic
index catches reorder, clock-regression =
anomaly, RTC + privileged-only time change. - blocklist — barred plates/cards refused at entry only; signed + attributed.
- Folded into existing pages: manual overrides = signed reason-coded events (legitimate counterpart to the out-of-band-open anomaly) + lost-ticket admin-arbitrary amount → parking-session + tariff; backup/restore confirmed in-scope, expanded open-questions #5 (restored copy must still verifyChain; doubles as the reconciliation export).
- NOT captured (flagged): intercom/help-call — user didn't select it, but it's the only human fallback for an unmanned lane; revisit. Deferred roadmap: reservations, mobile app, EV, loyalty.
- Updated index.
[2026-06-15] design | Second sweep — ticket encoding + anti-passback; money corners deferred
- More gap-hunting. New pages:
- ticket-encoding — the transient session key: opaque/unguessable ticket id printed as QR by rongta-printer, scanned at pay station + exit (new ReaderDevice/imager behind the adapter); plate-as-ticket ticketless alt coexists per lane. The physical backbone of the transient flow (was only implied).
- anti-passback — one id can't enter while it already has an OPEN session (card/ticket-passing
over the fence); a fold over the chain, under permit
maxConcurrent. Soft (flaganomaly) by default vs. hard (refuse); honest dependence on reliable exit detection.
- DEFERRED (user): receipts/VAT invoices + refunds/change/overpay — depend on pay-station
hardware + manned/unmanned payment subsystem; recorded as open-questions #9, revisit at
procurement (may change what the
paymentevent stores → flagged before schema). - Still open & load-bearing: lane topology (#1) — not resolved; scopes sessions/occupancy/shifts.
- Updated open-questions (#9), index.
[2026-06-15] decision | Split signed business ledger from device telemetry
- User correction before schema: the
eventstable conflated TWO things — the anti-fraud business ledger AND device telemetry (button pushes asinput_received). Split them. ledger_events(rename ofevents): signed, hash-chained, ATECC608-signed business facts only (vehicle_entry/exit, payment, void, shift_z_report + witness barrier_open_command/observed, anomaly). Reconciliation + session/tariff/occupancy projections run on this.device_events(new, device-events): UNSIGNED hardware telemetry (relay fired, paper-out, camera offline, reader read, raw input edges); high-volume, may rotate/prune; never reconciled.- A raw button press is telemetry → device_events; the entry flow then mints a SIGNED vehicle_entry.
So
input_received-as-signed-event is dropped (was transitional). No prod chain data exists, so the rename/restructure is safe now (no signatures to invalidate). - New: decision event-streams-split, concept device-events; updated append-only-event-chain (two streams + as-built-vs-pending), index.
- NEXT (schema): rename events→ledger_events; add device_events; split ParkingEventType in shared;
then tariffs/versions, permits, blocklist, sessions projection. EventLog/canonicalize/verifyChain
- /api/events follow the rename (code refactor, separate from this wiki commit).
[2026-06-15] design+build | Entry flow (start) + valet/over-capacity captured
- Building the entry flow: device input → signed
vehicle_entry→ print ticket → pulseOpen. - DECISION (print failure): hold — if all printers are down, sign an
anomaly(entry attempt, ticket unprinted) and do NOT open (no unticketed transient — couldn't pay on exit; operator handles the held car). Thevehicle_entryis appended ONLY on the success path, right before pulseOpen — preserving "signed before open" and never logging an entry for a car that didn't get in. - DECISION (capacity): wire transient entry now; the FULL gate comes later (needs capacity config + occupancy fold).
- VALET / OVER-CAPACITY (user): "full" is a soft, operator-configurable policy — operator may valet-accept over capacity (customer hands over keys + leaves, operator stacks the car). Manned-only, new custody/session shape. Captured as valet-overcapacity + made capacity-occupancy FULL a soft policy; NOT built into the entry flow (clean seam left). Deferred.
- New page valet-overcapacity; updated capacity-occupancy, index.
[2026-06-15] build | Exit flow (pay-on-foot validation)
- Built
apps/server/src/exit-flow.ts. Added areadchannel to the device bus (DeviceReadEvent: ticket/plate/qr/card) — readers/LPR emit reads; entry stays button-driven, so reads are unambiguously exit/identity events for now. - Flow: read → fold the SIGNED ledger for that identity → validate open + PAID + within
gracePeriodExitMin→ signedvehicle_exit→ pulseOpen → close the session cache. Unpaid / grace-expired / unknown → signedanomaly, barrier stays closed (a deliberate business reject, NOT a fail-state; "exit fails open" is about host/power loss). Validation reads the ledger (authoritative), not the cache. - Pay station doesn't exist yet → no
paymentevents → every transient exit currently REJECTS. Correct end-state, not passable until pay-station lands (decided). - VERIFIED against stubs: unpaid→anomaly+no-open; paid+grace→vehicle_exit+open+closed; grace-expired →anomaly; unknown ticket→anomaly; verifyChain ok across entry→pay→exit.
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit); fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to open-questions #1). Updated parking-session as-built + gap, index.
[2026-06-15] build | Pay station + fee calc; JWT 8h → until-logout
- JWT: dropped the 8h
expiresIn(server.ts global + login). Token now valid until explicit logout; cookie maxAge = 30 days so a browser restart doesn't log out an active operator (auth.tsCOOKIE_MAX_AGE_SECONDS). Closes the pending change from the shift decision; updated local-jwt-auth. computeFee(enteredAt, asOf, structure)inpackages/shared— pure integer fee calc. TWO BUGS caught by tests: (1) grace must use RAW duration, not the rounded-up minutes (a 10-min stay was being charged a full hour); (2) the block ladder must RESET each rolling-24h day (decision: day 2 restarts at first-block pricing → 25h = 1200 cap + 200). Both fixed; 9 cases pass.- Pay station (
apps/server/src/pay-station.ts+ routesGET /api/pay/quote,POST /api/pay): open session → active tariff version → computeFee → signedpaymentevent (amount/currency/tender/ tariffVersionId/graceExitMin);overrideMinorfor lost-ticket/dispute. Cashier/operator/admin guard. - VERIFIED: full loop entry→quote(300 for 90min)→pay→exit opens+closes, verifyChain ok. (A raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.)
- Updated tariff (settled edges + as-built), parking-session (pay station as-built; full loop passes).
[2026-06-15] build | Tariff composer (makes the pay station operable)
validateTariffStructureinpackages/shared— non-negative ints, ascending block bounds, only the last block open-ended; a malformed card can't be published.- Routes (
apps/server/src/routes/tariffs.ts):GET /api/tariff(active + history, any role) andPOST /api/tariff/versions(publish immutable version, ADMIN only). Single sitetariffsrow created lazily. Editing = publish a new version (effective-dated, immutable). - UI (
apps/web/src/TariffComposer.tsx, admin shell next to SetupWizard): currency, grace windows, increment, daily cap, lost-ticket, add/remove rate blocks; major-unit input → minor on submit; shows active + history. - VERIFIED via Fastify inject: GET empty→active null; invalid (out-of-order blocks)→400 w/ problem; valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404 (session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5.
- Updated tariff (composer as-built).
[2026-06-15] build | Permit entry/exit branch + read dispatcher
apps/server/src/permit-flow.ts+read-dispatch.ts. A credential read now routes by WHAT the credential is: matches a permit (card/QR credential, or a bound plate) → permit flow; else → transient exit flow. Lane resolved once (readerLaneWithAccess, shared in lane-map.ts). Refactored ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution.- Permit DIRECTION inferred from session state for that car (the read value is the per-car session key): no open session → ENTRY (enforce maxConcurrent, sign vehicle_entry, open); open → EXIT (sign vehicle_exit, open, close). Fleet permit = one session per car; anti-passback falls out.
- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed. No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open.
- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected, F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
- Updated permit (as-built), parking-session (read dispatch).
[2026-06-15] build | Permit admin CRUD (route + UI)
apps/server/src/routes/permits.ts: a permit is an aggregate (row + credentials + bound plates); create/update replace the child sets as one unit. GET (any role, for lookup), POST/PUT/DELETE + POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null; must have ≥1 credential OR ≥1 plate. Revoke = soft (keeps history); DELETE = hard (past ledger events untouched).apps/web/src/PermitManager.tsxin the admin shell: list + add/edit (holder, car-bound toggle → maxConcurrent or unbound, validity window, credentials add/remove, plates as a list), revoke, delete.- Makes permits usable without hand-seeding (companion to the tariff composer).
- VERIFIED via inject: empty + maxConcurrent=0 → 400 w/ messages; valid → 201; operator LIST 200 but create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then 404, children cleaned. Full build 5/5.
- Updated permit (CRUD as-built).
[2026-06-16] build | Shifts: open/close + signed Z-report (manned mode)
- Shift = two signed ledger events, NO mutable table: new
shift_openevent type + existingshift_z_report. Operator = logged-in user (in eventidentity); open iff their latest shift event is ashift_open.apps/server/src/shift-service.ts. - Close sums
paymentevents in the window by tender (cash/card, by payment time) → signedshift_z_report(totals/counts/window) → prints via the NEW genericPrinterDevice.printReport(title, lines)(Rongta ESC/POS text) to a booth-receipt printer. Print is best-effort — failure doesn't undo the signed close (printed:falsereturned). - Routes (
routes/shift.ts, cashier/operator/admin): GET /api/shift/current, POST open (409 if open), POST close (409 if none). UIShiftControlin the shell (non-readonly): Start/End + Z totals. - Added
printReportto the PrinterDevice interface + Rongta driver (reusable for receipts later). - VERIFIED: open→double-open 409→payments (cash+card; one dated outside the window excluded)→close totals (cash 500/card 250/3)→close-again 409→re-open ok; readonly 403; verifyChain ok. Full build 5/5.
- Updated shift (as-built).