59bfe2013f
Replace the hardcoded lane: 0 on input_received events with a real device->lane lookup. A new LaneMap caches lane_devices.id -> lane, built at startup and refreshed by the setup routes on assign/unassign. An unmapped device logs lane: -1 + a warning (0 is a real lane) and is still recorded faithfully (append-only chain). source stays null for raw inputs by design: it's an IdentitySource (how a vehicle was identified), not a device field; device provenance remains in identity. Documented both in the wiki.
300 lines
21 KiB
Markdown
300 lines
21 KiB
Markdown
# 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_state` tables (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 <sess> 03 <pwLE> <relayByte> <jogLE>). 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 `rongta` PrinterDevice 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`) caches
|
||
`lane_devices.id → lane`, refreshed by setup routes on assign/unassign. `input_received`
|
||
events now carry the firing device's lane instead of a hardcoded `lane: 0`. Unmapped device →
|
||
`lane: -1` + warn (0 is a real lane; never mis-stamp).
|
||
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
|
||
device field); device provenance is in `identity`.
|
||
- Updated [[append-only-event-chain]].
|