Files
parking_solution/wiki/log.md
T
julian e579fe5b6e server+web: capacity / FULL gate (occupancy fold + transient refuse)
Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).

FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).

Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).

Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
2026-06-16 08:13:06 +02:00

610 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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]].
## [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 `pushesToBackend` capability).
- 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.203` on eth1), Linux picked the WRONG source address for
`10.0.10.x` → ARP shows REACHABLE but all ping/TCP times out. Fix: pin `src` on 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 `/24`s; 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 + `src` pinning 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 (new `drivers/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 returning `ready/stub`. Added `localAddress` + `timeoutMs` +
`channel` config; threads the device-facing NIC for the multi-subnet trap.
- Snapshot interface: `Snapshot` now carries `bytes: Buffer` (driver fetches); `imageRef` is
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 `pushesToBackend`
flag to `DeviceDriver` (only [[dingtian-relay]] sets it), exposed as `pushCapable` in the catalog
(mirrors `discoverable`), 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 --shutdown` wipes the runtime
`ip route` pin — 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 each `proto kernel scope link` route on the NIC and pins
`src` to 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 change` errors `RTNETLINK: No such file` when
the route isn't up yet at boot → use `replace`; (2) `set -e` made one failed `ip` abort the whole
unit → dropped it, per-route warnings instead; (3) `network.target` fires 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 NO
`localAddress` set. 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 `payment` event (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` — extend `input_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-set `maxConcurrent` (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 — reuses `Snapshot.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 `payment` event records `tariffVersionId` (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 null
`fxRate` → 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` + immutable
`tariff_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 `payment` events in the shift by tender → append a signed `shift_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). `payment` event
needs a `tender` field (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 (flag `anomaly`) 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 `payment` event 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 `events` table conflated TWO things — the anti-fraud business
ledger AND device telemetry (button pushes as `input_received`). Split them.
- `ledger_events` (rename of `events`): 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). The `vehicle_entry` is 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 a `read` channel 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` → signed `vehicle_exit` → pulseOpen → close the session cache. Unpaid /
grace-expired / unknown → signed `anomaly`, 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 `payment` events → 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.ts `COOKIE_MAX_AGE_SECONDS`). Closes the pending change from the shift decision; updated
[[local-jwt-auth]].
- `computeFee(enteredAt, asOf, structure)` in `packages/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` + routes `GET /api/pay/quote`, `POST /api/pay`):
open session → active tariff version → computeFee → signed `payment` event (amount/currency/tender/
tariffVersionId/graceExitMin); `overrideMinor` for 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)
- `validateTariffStructure` in `packages/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) and
`POST /api/tariff/versions` (publish immutable version, ADMIN only). Single site `tariffs` row
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.tsx` in 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_open` event type + existing
`shift_z_report`. Operator = logged-in user (in event `identity`); open iff their latest shift
event is a `shift_open`. `apps/server/src/shift-service.ts`.
- Close sums `payment` events in the window by tender (cash/card, by payment time) → signed
`shift_z_report` (totals/counts/window) → prints via the NEW generic
`PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt printer.
Print is best-effort — failure doesn't undo the signed close (`printed:false` returned).
- Routes (`routes/shift.ts`, cashier/operator/admin): GET /api/shift/current, POST open (409 if
open), POST close (409 if none). UI `ShiftControl` in the shell (non-readonly): Start/End + Z totals.
- Added `printReport` to 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).
## [2026-06-16] build | Capacity / FULL gate (occupancy fold + transient refuse)
- Occupancy = fold over the ledger (entries−exits per identity; `apps/server/src/occupancy.ts`),
`getOccupancy` → {count, capacity, free, full}. Capacity = single-row `site_config` table (admin,
null=uncapped); migration 0001 (additive, no prompt).
- FULL gate in the TRANSIENT entry flow: occupancy.full → refuse (no ticket/entry/open) + signed
anomaly. Permit entry NOT gated (subscribers admitted past transient-full; their maxConcurrent
still applies) — occupancy can read over-capacity by design.
- Routes (`routes/site.ts`): GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-neg int or null). UI `SiteSettings`: live occupancy + FULL badge
(all), capacity editor (admin).
- VERIFIED: fill to cap=2 → 3rd transient refused (anomaly, no open); permit still admitted (occ 3/2,
free −1); exit frees a slot; routes RBAC (op can't set, −5→400, set/clear ok); verifyChain ok.
Full build 5/5. Physical FULL-sign relay output deferred.
- Updated [[capacity-occupancy]] (as-built).