Fix two bugs found running the real assign flow: the saved web password didn't match the device (login stayed admin/admin), and the UDP2 warning never reached the admin. Web password: - Split the conflated field into webPassword (the DESIRED login; blank -> auto-generate) and webPasswordCurrent (the device's EXISTING password used as the old cred, default admin). Before, an admin typing a desired password made harden send it as the old cred -> rotation failed -> but the DB still saved the typed value, so it claimed a password the device never accepted. - harden() now rotates current -> desired, VERIFIES by re-authenticating with the new password, and only returns secrets.webPassword on success (else a warning, nothing saved). Stores webPasswordCurrent for future re-runs. - assign strips the typed webPassword/webPasswordCurrent and persists only the verified secret -- the DB never claims an unapplied password. Warnings to the UI: - assignDevice returns warnings[]; SetupWizard shows them in an amber "saved, but action needed" banner per category. This is how the admin learns the firmware wouldn't disable UDP2 (finish in the device web UI). Verified on hardware: after harden the device rejects admin/admin and accepts the chosen password; the UDP2 warning surfaces.
20 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.