018328a877
No card processor / POS terminal on any site yet. Offering "Card" would let an operator record a card payment that never cleared a terminal, corrupting the till reconciliation — a fraud/error surface on an operator-adversary system. Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both tender pickers (BoothPayModal, SubscriptionManager). With card off there's nothing to choose, so the tender row is suppressed and payment defaults to cash. UI-only gate: the Tender type, payment events, shift accounting, and reports still understand `card`, so historical card events and a future re-enable stay coherent. Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open barrier" with no tender/cash/card row. Wiki: new concepts/card-payments.md records the current cash-only state, the PCI-scope-out-of-app constraint, the future-POS device requirements, and the re-enable path (flip the flag once a bank-certified P2PE terminal is provisioned). Linked from index, parking-session, open-questions #3. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2091 lines
220 KiB
Markdown
2091 lines
220 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]].
|
||
|
||
## [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).
|
||
|
||
## [2026-06-16] ingest | GEE-QR-ER80 QR access reader datasheet
|
||
- User has the reader; ingested `raw/GEE-QR-ER80 QR Code Access Control Reader.pdf`.
|
||
- CORRECTION: earlier guessed "ER80-EM" = a 125 kHz EM4100 prox-card reader. WRONG — the datasheet
|
||
shows **GEE-QR-ER80**, a **QR / DataMatrix / 1D barcode** optical access reader (optional ID/IC
|
||
card). It's the [[ticket-encoding|QR ticket]] scanner the design already needed, not a card reader.
|
||
- Specs: interfaces Wiegand 26/34 · RS-232 · RS-485 · USB · TCP/IP; 4–15 VDC <800 mA; 360°;
|
||
Windows + **Linux**; wiring VCC/GND/D0/D1/TX(R+)/RX(R-)/LED/BEEP. On hand: **`-Q-W`** (QR scanner;
|
||
Wiegand/RS-232/RS-485).
|
||
- Fit: host-side reader → a serial `ReaderDevice` adapter emitting `read` events → consumed by the
|
||
already-built exit flow + QR-permit path. Prefer RS-232/485 (serial) over Wiegand (Wiegand can't
|
||
carry variable-length QR; autonomy moot since [[dingtian-relay]] has no onboard ACL).
|
||
- New: source [[gee-qr-er80]] summary + entity [[gee-qr-er80]]. Updated [[ticket-encoding]],
|
||
[[entry-exit-readers]], [[index]].
|
||
- OPEN (blocks the adapter): the RS-232/485 **frame + baud** — is a QR scan an ASCII CR/LF string
|
||
(expected) or framed? Datasheet omits it; resolve via vendor docs or by observing the port.
|
||
|
||
## [2026-06-16] ingest+test | ER80 protocol = HTTP GET poll + JSON verdict (SDK)
|
||
- Hardware bring-up: configured the reader via the vendor Windows tool (server IP/port + "server
|
||
language"). Moved it to 10.0.10.7. It pings (source-pin must be 10.0.10.203 — trap recurs). No
|
||
beep on scans — initially looked like "not scanning."
|
||
- Found the QRCode SDK v1.6.5 (`QRCode_sdk - QRCode_v1_6_5/sdk/`). Protocol SETTLED, supersedes the
|
||
serial guess in [[gee-qr-er80]]: reader does **HTTP GET** `/qa/mcardsea.php?cardid&mjihao&cjihao&
|
||
status&time` on each scan; server replies **JSON** `{data:[{...,status,output}],code:0}`. Reply
|
||
`status` 1=valid(beep 2×)/0=invalid(beep 1×); `output` 0=Access/1=WG26/2=WG34; `time` syncs clock.
|
||
`status` low digit in the GET = direction (1=in/0=out).
|
||
- KEY: feedback/beep is decided by the SERVER REPLY, not locally → the "no beep" was my catch-all
|
||
replying plain "OK" not the JSON verdict, NOT a scan failure. Host-in-the-loop + SYNCHRONOUS.
|
||
- "Server language" (JSP/PHP/C#/ASP/CGI) only selects the URL PATH; transport is plain HTTP.
|
||
- New source [[qrcode-sdk]]; updated [[gee-qr-er80]] (protocol resolved, serial open-Qs dropped),
|
||
[[index]]. SDK kept in place (bulky+binaries), not copied to raw/.
|
||
- NEXT: backend route — parse GET, DECIDE (reuse permit/exit lookup), reply JSON verdict, emit on
|
||
read bus. Refactor read flows to RETURN an outcome so the reply can reflect accept/reject.
|
||
|
||
## [2026-06-16] build+fix | QR reader endpoint + ReadOutcome refactor; dev-DB migrate fix
|
||
- DB FIX: dev server crashed `no such table: lane_devices`. Cause: server `.env` DATABASE_URL points
|
||
at `apps/server/parking.sqlite` (the old dev DB I'd moved aside during the ledger split; new
|
||
migrations added since). Applied `drizzle-kit migrate` to that path → all 14 tables present. Fresh
|
||
DB → needs `seed-admin` + device re-assignment (empty, expected).
|
||
- REFACTOR: read flows now RETURN a `ReadOutcome {accepted,direction,reason}` (device-events.ts).
|
||
`ReadDispatcher.dispatch`, `ExitFlow.handleAt`, `PermitFlow.run` updated. A synchronous reader can
|
||
answer the device; fire-and-forget readers ignore it.
|
||
- ENDPOINT: `routes/qr-reader.ts` — `GET/POST /qa/mcardsea.php` (public; reader has no auth, on the
|
||
device subnet). Parses the SDK GET, dispatches the scan, replies the SDK verdict (status 1/0 →
|
||
beep 2×/1×, output 0, time-sync). Reader's lane keyed off device serial (cjihao) as lane_devices.id
|
||
for now.
|
||
- VERIFIED via inject: valid permit QR→status:1+open; re-scan→permit exit; unknown→status:0; reader
|
||
on barrier-less lane→status:0. Full build 5/5.
|
||
- Updated [[gee-qr-er80]] (endpoint as-built + hardware open items).
|
||
|
||
## [2026-06-16] test+fix | QR reader VERIFIED on hardware; path is .jsp not .php
|
||
- Ran a verbatim-vendor logger on :3000 (replies like mcardsea.php: status:0/output:2). Reader
|
||
**beeped** → it scans, sends, and acts on the reply. Earlier "no beep" = nothing was answering :3000.
|
||
- Real GET captured: `/qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=...`
|
||
from 10.0.10.7 (OEM = Fondvision, per referer).
|
||
- KEY FIX: the "server language" setting selects the URL EXTENSION — this unit is JSP → posts
|
||
**`.jsp`**, but our route was `.php` only (would 404 the reader). Route now registers
|
||
php/jsp/asp/aspx/cgi. Build green.
|
||
- Real serial **cjihao=H05M2AFA** = the lane key → assign reader as lane_devices.id="H05M2AFA".
|
||
Reader beeped on status:0 (invalid/1-beep); a matching permit/session → status:1 (2-beep accept).
|
||
- Updated [[gee-qr-er80]] (verified-on-hardware).
|
||
|
||
## [2026-06-16] feature | gee-qr-reader driver — assign by serial, resolve lane by config
|
||
- The QR reader is a push device; setup wizard always assigns a random-UUID id, so "id = serial"
|
||
isn't possible via the UI. Clean fix instead: new **`gee-qr-reader`** driver (reader category) with
|
||
a single `serial` config field. Admin assigns it in the wizard (UUID id) + types the serial.
|
||
- QR endpoint now resolves the lane by **matching `lane_devices.config.serial` to the scan's
|
||
`cjihao`** (was: row id == cjihao). `qrReaderRoutes(app, db, dispatcher)`. Unassigned serial →
|
||
no lane → status:0 (graceful).
|
||
- `tcpip-reader` flagged as the WRONG model for this device (host-connects-out stub).
|
||
- VERIFIED via inject through the real /api/setup/assign: assign {serial:"H05M2AFA"} → .jsp scan +
|
||
matching permit → status:1 + open; re-scan → exit; unknown card → status:0; unassigned serial →
|
||
status:0. Full build 5/5.
|
||
- Updated [[gee-qr-er80]] (assignment as-built).
|
||
|
||
## [2026-06-16] feature | stub-access driver (bench-test the flows without a relay)
|
||
- Live QR scan reached the real app (.jsp, serial resolved) but rejected: "reader not on an
|
||
access-equipped lane" — lane 1 had the reader but no access device. The dispatcher requires an
|
||
access device on the same lane.
|
||
- Added a no-op **`stub-access`** driver (access category, no config): `pulseOpen` just logs, no
|
||
device I/O — stands in on a lane to test QR→permit→accept (incl. the beep) without the
|
||
[[dingtian-relay]] connected. NOT for production. Registered in the catalog.
|
||
- To get a live accept: assign Stub barrier to the reader's lane + a permit whose QR = the scanned
|
||
code → status:1 (2-beep) + logged pulseOpen.
|
||
|
||
## [2026-06-16] fix | QR reader 10s beep delay — reply must Connection: close
|
||
- Live accept worked (status:1, pulseOpen, 2 beeps) but the beep came ~10 s LATE. Server responded
|
||
in 14.7 ms; user confirmed request is fast, only the beep lags → delay is the READER, not us.
|
||
- Cause: reader sends `Connection: keep-alive` but only ACTS on the verdict once the socket CLOSES;
|
||
Fastify kept it alive → reader waited out a ~10 s keep-alive timeout. Every vendor demo replies
|
||
`Connection: close` + shuts the socket.
|
||
- Fix: endpoint sets `reply.header("connection","close")`. Verified the header is now sent.
|
||
- Updated [[gee-qr-er80]] (⚠️ Connection: close requirement).
|
||
|
||
## [2026-06-16] feature | lane direction (per-device entry/exit) + camera snapshots
|
||
- Direction is per **device**, not per lane: added `direction` (`entry`/`exit`/`both`, default
|
||
`both`) to `lane_devices`. A lane's entry set = devices tagged entry|both, exit set likewise —
|
||
no join table; tagging in the wizard IS the grouping. Rejected a lane-level `lanes` table (can't
|
||
model one bidirectional lane). New [[lane-direction]] concept page; cross-linked [[entry-exit-readers]].
|
||
- All flows now resolve via `deviceRowsFor(lane, category, direction)` (lane-map.ts), replacing the
|
||
ad-hoc "first access on lane" lookups. Dispatcher trusts the **reader's own direction**; a
|
||
directional reader that contradicts the car's session state is a wrong-lane/anti-passback refusal.
|
||
`both` keeps the old infer-from-session behavior.
|
||
- Relay open channel is now config-driven (`config.openChannel`, default 1) since a lane can hold
|
||
an entry **and** an exit relay. LPR stays a snapshot sink — ANPR POSTs a `plate` read to the
|
||
reader endpoint (no camera-as-input coupling).
|
||
- Camera snapshots wired into all four paths (transient/permit × entry/exit): fired AFTER
|
||
pulseOpen, **never awaited** (evidence, not a gate — camera failure can't block an open). Stored
|
||
as BLOB in a new `snapshots` table (not files); telemetry `kind:"snapshot"` device_event per
|
||
capture/failure; linked to the signed event by `identity`. Served via `GET /api/snapshots/:id`.
|
||
- Migration `0002_wild_odin.sql` (additive). Snapshot **retention** left unresolved →
|
||
[[open-questions]] #10. Whole monorepo typechecks; no test suite exists in-repo.
|
||
|
||
## [2026-06-16] redesign | SUPERSEDES the above — pool-of-spaces, per-relay direction, NO lane
|
||
- User correction: direction is NOT a property of a device row. One Dingtian board has 2+ relays;
|
||
a single board drives both the entry barrier (relay 1) and the exit barrier (relay 2), and a
|
||
single relay can even serve **both**. So the row-level `direction` from the entry above was wrong.
|
||
- Further: the whole **"lane" concept was dropped**. Occupancy is site-wide, device grouping is now
|
||
the reader→relay binding, and anti-fraud never used lane. A parking lot = **one pool of spaces**
|
||
with a flexible set of entry/exit points (1 in + 2 out, etc). New [[entry-exit-points]] page
|
||
(replaces lane-direction); reworked [[entry-exit-readers]], [[parking-session]], [[first-run-setup]],
|
||
[[device-registry]].
|
||
- Model now: access `config.relays = [{ relay, direction: entry|exit|both, button? }]` (`button` =
|
||
the input terminal the entry button is wired to). Readers/cameras `config.controllerId + relay`
|
||
bind to the barrier they sit at; direction inherited ("the relay at that reader" opens on a read).
|
||
- Schema: dropped `lane` from `ledger_events`, `device_events`, `sessions`; renamed `lane_devices`
|
||
→ `devices` (no lane/direction columns). `lane` was in the SIGNED canonical form, so canonicalize()
|
||
dropped it and the signer keyId bumped **sw-hmac-v1 → sw-hmac-v2** (v1 events won't verify under
|
||
v2 — intentional, gated by per-event keyId; done pre-deployment on throwaway data). Migration
|
||
history reset to a fresh `0000_baseline` (dev DBs deleted + re-migrated).
|
||
- Resolvers in new `device-resolve.ts` (replaces lane-map.ts): `relayForButton`, `relayForDevice`,
|
||
`firstRelayByDirection`, `devicesByDirection`. `DeviceConfig` widened to nested JSON for `relays[]`.
|
||
- Wizard rewritten: no lane selector; Controllers section (relay map + entry-button terminal per
|
||
relay), then readers/cameras/printers bind to a controller relay. Whole monorepo typechecks +
|
||
builds; no test suite in-repo.
|
||
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
|
||
(device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass.
|
||
|
||
## [2026-06-16] build | Scannable ticket — QR + Code128 on the Rongta dispenser
|
||
|
||
`renderTicket()` now emits the ticket id as a printer-generated QR (ESC/POS `GS ( k`, model 2, ECC M) AND a Code128 1D barcode (`GS k`, set B), plus the human-readable id. Three independently-readable forms so a dead reader is recoverable (imager / 1D laser / phone camera / hand-keyed). No image rendering, no new dependency. Phone-scan operator fallback deferred (reuses the existing dispatch path). See [[ticket-encoding]].
|
||
|
||
## [2026-06-17] build | Ticket id -> all-numeric 13-digit (12 random + Luhn); barcode-only ticket
|
||
|
||
Replaced the `T-<uuid>` ticket id with a 13-digit all-numeric code (12 crypto-random digits + Luhn check) in `newTicketId()` so ANY legacy 1D barcode scanner reads it and the operator can hand-key it on total reader failure. Random keeps the unguessable anti-fraud property; Luhn lets manual entry reject typos (`validateTicketCode()`). `renderTicket()` now prints a centered Code128 barcode, the code in large digits below, then the issue time — QR dropped (may return as an admin toggle for mobile users). NOT a schema change: `identity`/`sessions.id` are free-form text; legacy ids coexist. See [[ticket-encoding]].
|
||
|
||
## [2026-06-17] build | Park metadata in site_config + ticket header
|
||
|
||
Extended `site_config` (single-row) with optional park identity: `park_name`, `operator_name`, `vat_number`, `registration_number`, `address`, `phone`, `email` — all nullable text (Drizzle migration 0001, additive). `GET`/`PUT /api/site-config` now read/write the full config (PUT is a partial patch; admin only); `SiteSettings.tsx` gained the fields. `renderTicket()` prints a header (park name large or "PARKING", then operator/VAT/Reg/address, plus a "Lost ticket? <phone>" footer) sourced from `site_config` via `EntryFlow.#ticketHeader()`. Open: non-ASCII (accent) chars need a printer codepage. See [[site-metadata]], [[ticket-encoding]].
|
||
|
||
## [2026-06-17] build | Ticket in Albanian; VAT->NIUS, drop registration; CP852 codepage
|
||
|
||
Ticket header now prints in Albanian and uses NIUS instead of VAT. Renamed `site_config.vat_number` -> `nius` and DROPPED `registration_number` (regenerated migration 0001 in place; only the dev DB had it, so no migration debt; dev DB reset + re-migrated). `renderTicket()`: NIUS line (only if set), "Printuar më:" before the timestamp, "Keni humbur biletën? <phone>" footer; strings centralised in a `STR` table for future i18n. Added CP852 (Latin-2) codepage support (`ESC t 18` + a Unicode->CP852 `line()` encoder with ASCII fallback) so `ë`/`ç` render. Touched: schema, migration, routes/site.ts, web api.ts + SiteSettings.tsx, devices interfaces + printer-rongta.ts, entry-flow.ts. Byte-verified ë->0x89. See [[site-metadata]], [[ticket-encoding]].
|
||
|
||
## [2026-06-17] ingest | ParkSQL2017 legacy schema + tariff research
|
||
|
||
Ingested `raw/parksql2017-legacy-schema.sql` (predecessor SQL Server 2017 schema, decoded from UTF-16; Albanian market — NIVF fiscal codes, Cupons, LostPrice1..4). Source summary in [[parksql2017-legacy-schema]]. Combined with a deep-research run (5 claims verified 3-0/2-0; synthesis + 20 claims aborted on a session limit — treat those as unverified, not refuted). Filed two design pages: [[tariff-time-tiers]] (happy-hour/off-peak/weekend/seasonal + vehicle categories via time-windowed rate cards; the hard part is wall-clock stay-slicing with a continuous block ladder) and [[validation-sponsorship]] (sponsor accounts + postpaid B2B billing; distinct from [[permit]]). Reconciled with the existing [[validation-discounts]] (cross-linked, no duplication — that page owns the signed-event discount mechanism, the new one owns sponsor/settlement). Updated [[tariff]] (new "Extensions under design" section; removed the now-addressed time-tier open item). Legacy confirms our stepped ladder + per-rate lost penalty + typed session discount; adds time-of-day windows + category axis; lacks any postpaid sponsor model (net-new). Flagged legacy anti-patterns we deliberately reject: float money, mutable rate rows, in-row image BLOBs.
|
||
|
||
## [2026-06-17] build | Booth actions — ticket input, pay/exit modal, voucher, snapshots
|
||
|
||
Made the booth screen operational (was a passive monitor). Backend: `site_config.exit_voucher_default` (additive migration 0002); `GET /api/session/:identity` (lookup + quote in one read), `POST /api/exit` (booth-driven, VALIDATED exit — reuses ExitFlow's paid+grace checks, no booth bypass; signs vehicle_exit + pulses an exit relay resolved via firstRelayByDirection; payment never rolled back, relay-open failure → signed anomaly + opened:false), `POST /api/voucher` (reprint paid ticket id barcode on the booth printer). Refactored ExitFlow into shared #signExit/#fireExitSnapshot/#closeSessionCache so the reader and booth paths are one validated code path. Frontend: ticket input on /booth (HID-scanner-friendly), Radix pay/exit modal (entry/now/duration/total + tender + 'Printo biletë dalje' checkbox defaulting from site_config), entry/exit SnapshotStrip (thumbnails → zoom). Verified end-to-end in-browser: scan → modal shows ALL 200 + real entry photo → pay → "barrier opened"; ledger recorded entry→payment→vehicle_exit in order; chain verify ok after. Decision recorded in [[booth-exit-flow]]. Unpaid exit correctly 409-refused (threat-model). NOT yet covered: voucher print success path (dev printers physically offline), automated tests.
|
||
|
||
## [2026-06-17] query | Walk-back grace renews on every payment (voucher overstay)
|
||
|
||
User flagged: customer pays, takes an exit voucher, dawdles past grace. Traced exit-flow.ts + pay-station.ts. Findings: refuse-on-expiry ✓ (no free exit) and reprice-from-entry ✓ (timer never restarts — `computeFee(enteredAt, now)`, NOT from paidAt) are both correct and deliberate. BUG: each `payment` writes its own `graceExitMin` and the exit flow reads the LATEST one, so every top-up re-grants a full walk-back window → grace doubles/repeats. Leak is TIME not money (fee always catches up from entry), bounded by increment coarseness but real. Flagged as an open question in [[booth-exit-flow]] (full analysis + 3 candidate fixes) and [[tariff]] (cross-ref + corrected the original `f(paidAt,now)` sketch to the as-built entry-based reprice). Decision deferred — fairness vs. anti-abuse business call. Recommended fix: grant grace on top-up only when it charged new money.
|
||
|
||
## [2026-06-18] build | Active sessions panel + audited barrier re-open
|
||
|
||
Operator escape hatch for stuck cars (damaged ticket / dead scanner / phantom barrier re-close). Key model from operator: the barrier state is ASSUMED not confirmed, so a session is "active" while OPEN or exited-but-within-grace — payment and a successful voucher scan do NOT remove it; only grace expiry does. Backend: PayStation.activeSessions() (one ledger fold, open OR within-grace, newest first), GET /api/sessions/active; ExitFlow.reopenBarrier() + POST /api/barrier/reopen — re-pulses an exit relay and signs an attributed `anomaly` (barrierReopen, operator), NEVER a 2nd vehicle_exit. Guard: no payment → 409 refuse (no-unpaid-bypass), enforced server-side AND the UI hides the button. Frontend: ActiveSessions panel on /booth (live via WS invalidation + 15s poll for grace expiry), row click → pay/exit modal, "Open barrier" only on paid rows. Verified in-browser: in-grace session stayed listed as "exiting" with the button; unpaid rows had none; click → audited anomaly #53 [op=boothtest], vehicle_exit count stayed 1 (no double-count); chain verify ok. Design recorded in [[booth-exit-flow]] (Active sessions & human-intervention barrier open). Standing gap: still no automated tests.
|
||
|
||
## [2026-06-18] build | Drawer balance — opening float carry-over + admin cash movements
|
||
|
||
Cash drawer that carries across shifts. New signed `cash_movement` ledger event type (shared); admin-only POST /api/cash-movement {amountMinor signed +load/-remove, reason}. ShiftService: #drawerBalanceAt(time) folds cash payments + cash_movements BY TIME (not operator — the movement is the admin's); open() auto-inherits openingFloat = drawerBalanceAt(start) and records it on shift_open; close() Z-report adds openingFloat/cashAdded/cashRemoved/expectedDrawer (= opening + taken + added − removed = next shift's opening float). Card payments excluded (settle to bank). GET /api/shift/current returns live drawerMinor. Frontend: ShiftControl shows live drawer + admin Load/Remove form + full Z-report drawer block (admin gate via router context). Verified the canonical scenario on a FRESH DB: load 5000 → shift1 takes 6500 → expected 11500 → shift2 inherits 11500, admin removes 5000, takes 4500 → expected 11000 → shift3 inherits 11000; chain ok. Also verified through the real UI (load/remove → Z-report opening 11200 removed 5000 expected 6200; both cash_movements signed+attributed; chain ok). Decision + worked example in [[shift]] (Drawer balance section). Standing gap: still no automated tests.
|
||
|
||
## [2026-06-17] build | Live booth WebSocket feed (/api/ws)
|
||
|
||
Added @fastify/websocket. EventLog.append fires a read-side onAppended callback after each durable insert (never touching the sign/chain path); device-events gained a `ledger` channel (emitLedger). New GET /api/ws fans out ledger + recomputed occupancy + printer-status to authenticated booth clients. Auth: JWT cookie (same as REST) + an Origin allowlist (WS_ALLOWED_ORIGINS) that REPLACES CSRF — a browser WebSocket can't send the double-submit header, so without an Origin check the read-only feed is open to Cross-Site WebSocket Hijacking (found + fixed by automated security review). See [[booth-console]], [[append-only-event-chain]].
|
||
|
||
## [2026-06-17] build | Frontend foundation — Tailwind terminal theme, Query/Router/Zustand, live booth screen
|
||
|
||
Operator UI outgrew plain React. Added TanStack Query (server state, wraps apiFetch), TanStack Router (role-guarded routes), Zustand (small client state: WS status + live feed), Tailwind v4 with a Bloomberg-terminal theme + Radix primitives. A /api/ws client invalidates Query caches on ledger pushes. Built the live /booth screen (occupancy gauge + streaming entry/exit/payment ticker). Vite proxies the WS upgrade. SUPERSEDES the "plain React, no framework" note on [[react-vite-spa]]. See [[booth-console]].
|
||
|
||
## [2026-06-18] build | i18n — Albanian default + English, per-user server-stored preference
|
||
|
||
Two languages via react-i18next, Albanian default/fallback. Language is a per-user preference: users.language (migration 0003), returned from login/me, changed via PUT /api/auth/language (NOT in the JWT — no re-login). Loaded on login, restored from any booth; SQ/EN header toggle persists. Type-safe key parity (en mirrors sq or the build fails). Translated booth + Login/Shift/Site/Permits/Tariff. SetupWizard deferred (server-provided catalog strings need backend i18n). Printed tickets stay Albanian (customer-facing). See [[i18n]], [[booth-console]].
|
||
|
||
## [2026-06-18] lint | Reconcile wiki with the session's work
|
||
|
||
Audited wiki vs. the session: three major builds (live WebSocket, frontend foundation, i18n) had NO log entry and NO concept page. Filed [[i18n]] (resolved a dangling code-comment link) and [[booth-console]] (operator-UI architecture: stack, /api/ws live feed, anti-CSWSH, booth screen). Updated stale [[react-vite-spa]] (the "plain React, no framework" claim is now qualified). Backfilled the three missing build log entries. Standing gaps flagged across pages: NO automated tests (front or back); ATECC608 not yet wired (software-HMAC signing is tamper-evident, not tamper-proof); pre-existing admin screens not on the terminal theme.
|
||
|
||
## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs
|
||
|
||
Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web.
|
||
|
||
## [2026-06-18] ingest | Device-status footer — unified monitor across all categories
|
||
|
||
Generalised printer-only status monitoring to a booth-wide DEVICE-STATUS FOOTER covering relays/readers/cameras/printers. New `DeviceMonitor` (`apps/server/src/device-monitor.ts`) polls every enabled device each tick (default 8s): printers via rich `readStatus()`, all others via the generic `healthCheck()` reachability probe, flattened to one traffic-light (ready/degraded/offline)+detail, deduped (emits on change only), fail-toward-offline (a throw/timeout → offline, never false-healthy). New `device-status` bus event + `GET /api/devices/status` snapshot; live updates ride the existing `/api/ws` (`hello` now carries the initial device set; `device-status` frame per change). Web: live-store `devices` map (setDevices/upsertDevice), WS handler wired, new `DeviceFooter` chip-per-device with an "all ready / N offline" roll-up, mounted in the app shell; `devices` i18n namespace (sq/en). The PrinterMonitor + its SSE stream stay as the printer-specific authority (both run — see the note in [[device-status-monitoring]]). Filed [[device-status-monitoring]] (resolves the code link), cross-linked [[printer-status-monitoring]] + [[booth-console]], indexed (concepts 41→42). Verified on a fresh DB (relay+reader → ready via healthCheck; unreachable printer → offline with detail, no throw; emit-once-then-silent) — 9/9; server+web build clean.
|
||
|
||
## [2026-06-18] refine | Device footer — role-only labels + click-to-see-issues
|
||
|
||
Two refinements to the device-status footer. (1) Chips label by ROLE, not vendor: the server sends a structured `roleKind` token per device (reader/camera → direction inherited from the bound relay via `directionOf()`; access → entry/exit/both, or "mixed" across relays; printer → lane/booth) and the client localises category+role → "Lexuesi hyrje", "Printer kabina", "Kamera dalje". Dropped `label`/`role`/driverId from the chip. (2) Fault detail no longer pollutes the footer: chips are compact (dot + label); a degraded/offline chip (or the "N with issues" roll-up) is clickable and opens a small issues panel above the footer listing only the problem devices with state/detail/checked-time (outside-click/Esc to close; no new dependency). i18n `devices.role.*` + issues keys (sq/en). Verified roleKind resolution on a fresh DB (access→mixed, reader(exit)→exit, camera(entry)→entry, printers→lane/booth) 7/7; server+web build clean. Updated [[device-status-monitoring]].
|
||
|
||
## [2026-06-18] fix | Stuck active session — paid ticket that never got a vehicle_exit (T-397815c0)
|
||
|
||
Investigated a paid ticket stuck forever in the Active Sessions tab. Root cause (confirmed from the live ledger): the car left via a **manual barrier re-open**, which by design signed an `anomaly` but **never a `vehicle_exit`** — so `activeSessions()` saw it as permanently `open` (the grace-expiry eviction only applied to *exited* sessions). The normal exit that would have signed the exit was refused because walk-back grace (5 min) had expired ~17h earlier. Two fixes: (1) `ExitFlow.reopenBarrier` now signs a `vehicle_exit` (`source:manual`) **when the session is still open**, closing it — while still NOT double-signing an already-exited session (phantom re-close). (2) `PayStation.activeSessions()` ages out a **paid** open session past grace even with no exit (unpaid open sessions never age out — a car owing money stays). Plus a one-off corrective: appended a signed `vehicle_exit` (index 68, `correction:true`) for T-397815c0 through EventLog (chain verified `{ok:true}`), clearing it from the list. Verified both fixes on a fresh DB (9/9; chain intact). Updated [[booth-exit-flow]] (active-session definition + the re-open rule, was "NEVER a vehicle_exit").
|
||
|
||
## [2026-06-18] ingest | Permit → Subscription rename + monthly pricing (timeframes deferred)
|
||
|
||
Renamed the "permit" feature to "subscription" (operator term: abonim) and added recurring monthly pricing. FULL rename of mutable master data: tables permits→subscriptions, permit_credentials→subscription_credentials, permit_plates→subscription_plates, sessions.permit_id→subscription_id (data-preserving ALTER RENAMEs, migration 0004); server permit-flow.ts→subscription-flow.ts (SubscriptionFlow), routes/permits.ts→routes/subscriptions.ts (/api/subscriptions), web PermitManager→SubscriptionManager, api types, i18n (sq "Abonimet"/en "Subscriptions"). The signed ledger `permitId` payload field is INTENTIONALLY kept (immutable hash-chained history — renaming would break verification of past events); code/data are "subscription", the on-chain field stays `permitId`. Pricing: per-subscription priceMinor + period("monthly") + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form; collecting the fee into the ledger/shift is DEFERRED (wiki note only). Time-of-day access windows (e.g. overnight subscriber 19:00–07:00, transient outside) documented as a design note in [[subscription]] — NOT implemented; legacy precedent in [[parksql2017-legacy-schema]] (MembershipPlansTime). Renamed [[entities/permit|permit]]→[[subscription]] and swept all [[permit]] wikilinks across the wiki (log.md historical entries left as-was). Verified end-to-end on a fresh migrated DB (schema+pricing, card entry/exit, maxConcurrent cap, on-chain permitId carries the sub id, chain verify) 6/6; migration also applied cleanly to a copy of the live DB (18 sessions preserved). Full monorepo builds clean.
|
||
|
||
## [2026-06-18] note | Subscription-fee collection is a SHIFT transaction
|
||
|
||
Clarified (user): collecting/renewing a subscription's monthly fee is a financial transaction a common operator makes DURING their shift — it must reflect in THAT shift's drawer + Z-report, not be an admin-only edit. Updated [[subscription]] (Pricing → "Collecting the fee is a SHIFT transaction"): model it as a signed `payment` event (same `{amountMinor,currency,tender}` shape) tagged `{subscriptionId}` at collection time, so it folds into the open shift automatically (Z-report sums payments by time; drawer adds cash tenders) with no new summing logic. Admin edits the master data; operator takes the money. Subscription entry/exit stay free — only the plan fee is a payment. Still DEFERRED build; cross-linked from [[shift]] ("What End Shift does"). Open: plain `payment`+tag vs. a distinct `subscription_payment` type (leaning plain).
|
||
|
||
## [2026-06-18] note | Subscription credential type — operator chooses, QR-only for now
|
||
|
||
The subscription form lets the operator choose the credential type; for now only QR is live. UI change only: the new-credential default is now QR (was RF), and the RFID option is shown DISABLED ("soon", `subs.rfCardTagSoon`) so the choice is visible. Backend + schema keep accepting `kind:'rf'|'qr'` unchanged — re-enabling RFID later is just dropping `disabled` (no migration). Updated [[subscription]] Credentials section.
|
||
|
||
## [2026-06-18] feat | Subscription QR auto-generation + multi-month coverage
|
||
|
||
QR credentials are now AUTO-GENERATED server-side (`SUB-<15×base32>`, crypto-random, globally-unique-checked) — the operator/customer never picks the code (anti-fraud); the UI sends a blank QR credential and the server mints+returns the value to print. RF credentials still carry the operator-entered card id. Reader output decided = TCP/IP full string (host-in-the-loop), so the code length is free; noted the Wiegand-26/34 numeric-truncation alternative if ever wired that way (+ the manufacturer reader's ID/IC/NFC+QR / Wiegand/TCP/USB/RS485 / 125kHz+13.56MHz spec — one device covers QR and future RFID). Multi-month: the form takes a `months` count → server sets `validTo = validFrom + N months` (day-clamp), one record/one window, total = N×monthly (collection still deferred); explicit `validTo` override still works; `months` is input-only (truth is validFrom/validTo). Backend: routes/subscriptions.ts (newQrCode/addMonths/resolveValidTo, validate RF-needs-value + months-needs-validFrom). Web: SubscriptionManager (QR shown auto-gen/read-only, months field + live coverage+total preview), api types, i18n (sq/en). Verified via buildServer+inject 9/9 (autogen, uniqueness, RF-blank reject, Jan31+3mo→Apr30, supplied-value preserved). Updated [[subscription]]. No new migration (uses existing columns).
|
||
|
||
## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering
|
||
|
||
The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration.
|
||
|
||
## [2026-06-18] feat | Subscription RFID enrollment — "Read card" capture on a chosen reader
|
||
|
||
Enabled RFID subscription credentials with a card-enrollment flow. The operator picks a reader and presents the physical card; the value is captured into the credential instead of being typed. New in-memory `CredentialCapture` (single-shot + ~30s TTL): `arm(deviceId)`; `routes/qr-reader.ts` checks `tryConsume()` on each read — an armed reader's read is captured and NOT dispatched (no barrier for an enrolled card), then auto-disarms; reads on the OTHER reader dispatch normally, so its live entry/exit flow is never blocked. Routes (operator/admin): `GET /api/subscriptions/readers` (picker), `POST /capture/arm`, `GET /capture` (poll: idle|armed|captured|expired), `POST /capture/cancel`. Web: RFID re-enabled in the form (was disabled "soon"); "Read card" → reader picker → arm → poll → fills the value; i18n (sq/en). The GEE readers are combo QR+RFID (ID/IC/NFC), same endpoint, so one device captures both. Verified via buildServer+inject + reader-scan simulation (12/12: captured-not-dispatched, single-shot, other reader still drives a live vehicle_exit while armed, value retrievable, cancel). Updated [[subscription]]. No migration.
|
||
|
||
## [2026-06-18] feat | Subscriptions — enter with one credential, exit with another (+ FIFO fleets)
|
||
|
||
Decoupled subscription exit from the entry credential. Previously the session was keyed by the exact credential value read (an accidental coupling → must exit with the same QR/RFID you entered with). Now sessions are keyed by a per-occurrence id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`; `payload.permitId`=subId), so ANY of a subscription's credentials (QR/RFID/NFC/plate) opens or closes. Direction is now decided by the BARRIER the reader sits at (entry-lane→entry, exit-lane→exit; a "both" barrier infers from open state) — this lets a FLEET (maxConcurrent>1) admit several cars (each entry-lane read is an entry) yet exit any of them with any credential; exit closes the OLDEST open occurrence (FIFO). Per-car identity within a fleet isn't tracked (never was once credentials are shared). `#openOccurrences()` replaced `#carHasOpenSession`/`#subscriptionOpenCount`. Exit with nothing open → signed anomaly (anti-passback). Verified 11/11 (enter-QR/exit-RFID + reverse, fleet 2-in mixed-credential FIFO out, capacity, anti-passback, chain intact). Updated [[subscription]] (multi-credential + entry-decoupled-from-exit). No migration.
|
||
|
||
## [2026-06-18] fix | Subscription occurrences in the booth — prepaid, barrier-open assist (not transient)
|
||
|
||
A subscription occurrence (SUBSESS-…) showed in Active Sessions but was wrongly treated as an unpaid transient: the modal tried to quote/charge it and the "open barrier" button only appeared for PAID sessions, so a subscriber with a faulty exit reader / missing card couldn't be assisted. Fix: `pay-station.ts` lookup/activeSessions now flag `subscription`/`subscriptionId`/`subscriptionHolder` from the entry payload (permit:true/permitId) and DON'T quote a subscription (amountMinor null). `exit-flow.ts` reopenBarrier now authorizes `paidAt != null || subscription` (prepaid). UI: the pay modal renders a SUBSCRIPTION mode (PREPAID badge, snapshots, single Open-barrier action, no tender/voucher) and the active row badges "abonim" + shows the holder name; both labelled by holder, not the raw key. Also SHORTENED the occurrence id (was SUBSESS-<subId>-<uuid>, ~80 chars) to `SUBSESS-<12hex>` — the subscriptionId lives in the payload (which every fold matches on), so it needn't be embedded in the key. Verified 9/9 (subscription flagged + not charged in lookup/active, reopen works without payment, unpaid-transient guard intact). Updated [[booth-exit-flow]]. No migration.
|
||
|
||
## [2026-06-18] fix | Tariff versioning was retroactive — forbid backdated effectiveFrom
|
||
|
||
The version selector picks "latest tariff_version with effectiveFrom ≤ session entry time" (correct intent: a past session reprices against the rate in force when it was incurred). But the publish handler (`routes/tariffs.ts`) accepted ANY `effectiveFrom` (defaulting to now). So an admin could publish a version with a **backdated** effectiveFrom and silently reprice sessions that had already entered — exactly the retroactive rewrite the versioning exists to prevent. Pricing itself was already sound: `quote()` resolves by `entry.occurredAt` and the `payment` event records `tariffVersionId`, so a COMPLETED session is frozen; the leak was entirely the publish side. Fix: reject `effectiveFrom` earlier than now (60 s skew tolerance); future-dated (scheduling a price change) stays allowed; bad ISO → 400. Decision (with user): forbid backdating + keep entry-time pinning; did NOT add tariffVersionId to vehicle_entry (entry-time selection + no-backdating already freezes the price). Together these make it structural: once a car has entered, no later publish can reprice it. Verified 5/5 via inject against a copy of the live DB (now→201, -1h→400, +1h→201, garbage→400, -10s skew→201). Updated [[tariff]]. No migration.
|
||
|
||
## [2026-06-18] feat | Tariff: complete the progressive ladder — open-ended last block required + hours-based composer
|
||
|
||
The owner asked for "first N hours × X, next N hours × Y, …, 24h cap" — which the stepped-block engine ALREADY does (ordered blocks, per-block rate, rolling-24h cap; computeFee tested). So no new axis: the work was making the model complete + footgun-free. Two changes. (1) **Forbid a bounded last block** — `validateTariffStructure` (shared) now rejects a final block with a non-null `uptoMin`, so the "thereafter" rate is always explicit; previously a bounded tail silently inherited its own rate past its bound (a hidden, never-stated price — e.g. the live ALL tariff's 180-min last block billed hour 4+ at the 3rd-hour rate). `rateAt()` still prices legacy bounded-tail versions gracefully and validation is publish-only, so immutable published versions are unaffected (no migration). (2) **Composer edits bands as a DURATION in hours**, not cumulative minutes — `BlockForm` carries `hours`; `toStructure` accumulates into cumulative `uptoMin` minutes; the last row is a pinned, non-removable, hours-less "thereafter (open-ended)" band; `blocksToForm` round-trips stored minutes back to band hours (legacy bounded tails still load). i18n: replaced `upToMin`/`egExample` with `bandDuration`/`hoursUnit`/`egHours` in sq+en (catalog parity green). Verified: validator rejects bounded-last / accepts open-ended; computeFee correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full build green (shared/server/web). Updated [[tariff]]. No migration.
|
||
|
||
NB considered-and-rejected: time-of-day / weekday wall-clock tiers ("timeframe") were offered but the owner explicitly chose the elapsed-duration ladder only — see [[tariff-time-tiers]] for the deferred wall-clock axis.
|
||
|
||
## [2026-06-18] feat | Tariff V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
|
||
|
||
Brought the legacy ParkSQL2017 pricing BREADTH onto our engine (keeping integer-minor-unit money + immutable signed versions; rejecting legacy float money / mutable rows). `TariffStructure` is now a discriminated union: V1 = the original bare ladder (UNCHANGED, verbatim algorithm — golden-regression-tested against the live production version); V2 = `{version:2, tz, <shared knobs>, defaultCard, windowedCards[]}` where each card is flat OR a block ladder and may be scoped by wall-clock hour window / day-of-week / date range / vehicle category. computeFeeV2 prices by stepping one increment at a time, advancing the ladder by ELAPSED minutes (continuous) while selecting the active card by WALL-CLOCK time in the version's FROZEN tz. Decisions (with user): tz is a per-site setting (site_config.timezone, default Europe/Tirane) stamped server-side into each version on publish — never the host clock (reproducibility); default-card cap governs a mixed day; precedence = specificity (date>dow>hour) → priority → name (total, order-independent), validation rejects ambiguous ties; category = a card FIELD (not tariff scope), frozen in the signed vehicle_entry payload (site_config.default_vehicle_category default), read at both pricing call-sites. Composer: default card front-and-centre (flat/ladder toggle), tiers under an "Advanced" disclosure (window builder), emits BARE V1 when no tiers (back-compat). DB: migrations 0005 (timezone) + 0006 (default_vehicle_category) — applied to the live DB (backed up). Stood up vitest in @parking/shared (was zero tests on the ledger-feeding fee fn); 36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism, validation matrix — all green. Full monorepo build green; V2 publish verified end-to-end via inject (tz stamped from config not client; malformed V2 rejected). Updated [[tariff-time-tiers]] (status open→settled, as-built), [[tariff]], index. No event-chain change.
|
||
|
||
NB incident: the running tsx-watch dev server (server + vite) crashed mid-edit on a half-saved file + a schema column the live DB lacked; recovered by finishing the edits, applying the migration to the live DB, and restarting both watchers. Live DB backup left at apps/server/parking.sqlite.bak-*.
|
||
|
||
## [2026-06-18] feat | Booth UI — adopted "TRM" design-system tokens (tokens only)
|
||
|
||
The owner linked a Claude Design project (`019ddfee-…`, "TRM — Tracking & Race Management") and asked to implement its designs in `apps/web/`. TRM is a RACE-TIMING design system (dashboard/leaderboard/marketing/mobile kits: RaceControl, HeroClock, LiveTable, BibCard, Ticker) — NOT a parking design; literally porting it would have reskinned the booth UI with race components. Flagged the mismatch; owner chose **tokens only**. So: brought TRM's token VOCABULARY into the Tailwind v4 `@theme` (`apps/web/src/index.css`) and ALIGNED the existing `term-*` accents onto TRM's exact values — surfaces → TRM `night` scale (#0b0d10/#14171c/#1e222a/#2a2f38), amber #f5a623→#f2a516, green #2ecc71→#2e8c4a, red #ff4d4f→#e8412b (flag), cyan #38bdf8→#2563c8 (blue). No component touched (170+ `term-*` references resolve unchanged). Also exposed TRM's full vocabulary as utilities for new work: night/ink/paper scales, flag/amber/green/blue + tints, viz-1..8, the 4px spacing scale (s0..s13), type scale (overline..jumbo), square radii, sharp "printed" offset shadows, control/table-row heights. Offline-appliance constraint ⇒ deliberately did NOT keep TRM's Google-Fonts `@import` (no runtime network); Goldplay (TRM display face) left un-self-hosted — display/heading falls back to a sans stack (mono is the booth's primary face anyway), noted for later wiring. Verified: web build green; login renders on the new palette (amber focus ring = #f2a516). Updated [[booth-console]]. No logic/schema/event-chain change.
|
||
|
||
## [2026-06-18] fix | Cashino printer — ping-only driver (no false status) + Albanian device-role wording
|
||
|
||
Two device-feedback issues. (1) **Cashino 80mm printer reported wrong status.** It was configured on the `rongta` driver, whose `readStatus()` scrapes the Rongta board's `/prn_stat.htm` status page — which the Cashino does NOT serve. Result: a bogus `degraded`/page-error verdict while the printer was actually online (it printed fine; `healthCheck` TCP-ping passed). Root cause: the Cashino is an ESC/POS PRINT clone but has no trustworthy STATUS mechanism. Fix: extracted the shared ESC/POS rendering + transport (renderTicket/renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/qrCode) from `printer-rongta.ts` into a new `drivers/printer-escpos.ts`; added a dedicated `cashino` driver that reuses that print path but is deliberately **NOT** `MonitorableDevice` (no readStatus). So `isMonitorable()` is false and the device monitor falls back to the generic `healthCheck()` — a plain TCP reachability ping of the print socket: reachable→ready, unreachable→offline, never a guessed paper/cover state. Rongta driver unchanged (still scrapes its page, still monitorable). Registered `cashinoDriver`; re-exported from the package. Switched the live entry-dispenser printer at **10.0.10.9** from `rongta`→`cashino` in the DB (backed up incl. WAL: apps/server/parking.sqlite*.bak-cashino-*); 10.0.10.10 (booth Rongta) left as-is. Verified at runtime: cashino registered, isMonitorable=false, no readStatus, healthCheck→offline on unreachable; live /api/devices/status → both printers `ready` (lane via ping, booth via page). (2) **Albanian device-role chip wording was wrong.** The footer label is `"{category} {role}"`; the role suffixes read badly: access `mixed`="i përzier" gave `Barriera i përzier` ("Barrier mixed" — wrong word + wrong gender; `mixed` actually means a barrier spanning >1 direction) → now `hyrje/dalje` (entry/exit). printer `lane`="korsia" gave `Printer korsia` ("Printer the-lane") → now `në korsi` (at the lane); `booth`="kabina" (`Printer kabina`) → `në kabinë` (at the booth). English tidied to match: mixed→"entry/exit", lane→"at lane", booth→"at booth". i18n catalog parity green. Updated [[printer-status-monitoring]]. No schema/event-chain change.
|
||
|
||
## [2026-06-18] feat | Payment receipt — transparency slip (entry/paid/duration/amount), voucher or standalone
|
||
|
||
After a completed payment the customer now always gets a transparency record: ENTRY time, PAYMENT time, DURATION parked, AMOUNT + tender. One shared ESC/POS renderer (`renderReceipt` + `ReceiptData` in @parking/devices), two modes: (a) VOUCHER mode = those figures PLUS the scannable Code128 barcode and an emphasised walk-back-grace line ("Dilni brenda N min — skanoni këtë biletë në dalje") so the one slip both proves payment and self-exits at a distant exit reader — this REPLACED the old barcode-only voucher (printExitVoucher → printPaymentReceipt); (b) STANDALONE mode = detail-only (no barcode), AUTO-printed at payment when the voucher checkbox is OFF (booth at the exit). Figures are folded from the SIGNED ledger (latest payment event), printed on the booth printer (failover to dispenser). Money via Intl minor-units (no float); duration = whole minutes (mirrors UI formatDuration); timestamps use the host-local clock (appliance = site time; distinct from the tariff's frozen tz, which governs PRICING reproducibility not display). Server: booth-print.ts `printPaymentReceipt(db,id,{voucher},log)` + `receiptFigures()`; routes `POST /api/voucher` (voucher) and new `POST /api/receipt` (standalone/reprint — requires paid, allows already-exited so reprint works). Both ESC/POS drivers (rongta + cashino) gained `printReceipt` (PrinterDevice interface). Frontend: BoothPayModal auto-prints the standalone receipt after a non-voucher payment (BEST-EFFORT — a printer fault must not block the exit that already happened; shows a note + a "Reprint receipt" button in the done phase, also for slip jams / later asks); api.ts `printReceipt()`. Decision (with user): auto-print on payment (not on-demand-only / not a new config toggle), with reprint fallback. i18n: receiptPrintFailed/receiptReprinted/reprintReceipt/reprinting (sq+en, parity green). Verified: full build green; renderReceipt output inspected in both modes (correct figures, barcode, grace line, Albanian); /api/receipt + /api/voucher live (404 on unknown id — route+validation reached, no physical print fired on the real booth printer). Updated [[booth-exit-flow]]. No schema/event-chain change.
|
||
|
||
## [2026-06-18] fix | Receipt/voucher misprint — CP852 `Ë` byte + Intl NBSP (found on a real printout)
|
||
|
||
A printed exit voucher (photo from the booth) surfaced three glitches in the new payment receipt, all fixed in `printer-escpos.ts`. (1) **Title garbled**: uppercase `Ë` was mapped to CP852 `0xEB` — wrong (that's `ű`); the correct byte is `0xD3` (U+00CB). `BILETË DALJE`/`FATURË PAGESE` now print correctly (lowercase `ë`=0x89 was always fine). (2) **`1000?Lekë`**: `Intl.NumberFormat("sq-AL", currency:"ALL")` separates amount from currency with a NO-BREAK SPACE (U+00A0; some locales U+202F narrow NBSP), which isn't in CP852 → printed as `?`. `line()` now normalises U+00A0/U+202F → plain space before encoding, so any Intl-formatted value on a ticket is safe, not just money. (3) **Grace line wrapped mid-word** ("…në dali / 8."): split `graceLine` into two short lines (`graceLines`) that each fit 80mm, emitted as two centered line() calls. Re-rendered & byte-verified: 0xD3 present, no 0x3f (`?`) byte, two clean grace lines, `1000 Lekë`. Full build green. Documented the CP852 gotchas in [[rongta-printer]]. NB: verify CP852 bytes against Unicode.org CP852.TXT, never guess. No schema/event-chain change.
|
||
|
||
## [2026-06-18] feat | Dynamic RBAC — composable roles + resource×CRUD permissions
|
||
|
||
Replaced the hardcoded role enum (admin/operator/cashier/readonly, checked literally as requireRole("admin",...) across ~15 routes) with DYNAMIC RBAC: roles are DATA, route guards check a PERMISSION. @parking/shared now defines a code-defined grid — RESOURCES (user/role/tariff/subscription/site/device/shift/payment/session/event/report) × Action (create/read/update/delete + domain verbs void/cash) → PERMISSIONS (resource:action). DB: new `roles` + `role_permissions` tables; users.role enum → role_id FK; migration 0007_rbac (create tables, seed builtin admin role + all 26 perms, seed operator/cashier/readonly composable roles matching old behaviour, rebuild users to swap the column copying all rows). auth.ts: JWT payload role→roleId; permissionsFor(roleId) with in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler (jwtVerify+CSRF+perm check); requireAuth for /me & /language; initAuth(db) wires the resolver once in buildServer. Every route guard mapped to a permission (tariff:read/update, payment:create/read, session:read, shift:read/create/cash, site:read/update, device:read, subscription:*, event:read; ws→report:read); device ingress (devices.ts/qr-reader.ts) stays auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated against the grid, cache bump on write). auth/me + /login now return {roleId, roleName, permissions, language}. seed-admin.mjs → roleId:'admin'. Frontend: api.ts SessionUser carries permissions + can() helper + users/roles CRUD fns; router.tsx nav/route guards gate by permission (requirePerm factory replaces adminOnly), SiteSettings edit now gated by site:update; new UsersManager.tsx + RolesManager.tsx (permission checkbox grid; admin role read-only/locked); i18n nav.users/roles + users/roles blocks (sq+en, parity green). DECISIONS (with user): one role per user; protected built-in admin (no-lockout); JWT carries roleId, perms resolved per-request (role edits apply immediately). VERIFIED: full monorepo build green; a 20-assertion inject test (cashier 403s on tariff publish + user list, admin passes, granting tariff:update to the cashier role applies on the NEXT request = cache invalidation works, last-admin delete/downgrade → 409, builtin admin role edit/delete → 409) all pass; migration 0007 applied to a COPY of the live DB (incl WAL/shm) → existing admin maps to role_id='admin', 4 roles seeded, 26 admin perms, all user rows preserved. Updated [[local-jwt-auth]]. Append-only event chain untouched (event:void gates appending a void, not a delete).
|
||
|
||
## [2026-06-19] feat | Explainable activity log — reasons, subscriber names, snapshot gaps
|
||
|
||
The booth live-feed flagged anomalies with no explanation (a red row + an id). Made events self-describing + clickable. (1) **Localized reason codes (backend i18n for the signed ledger):** reasons were free-text English baked into the immutable signed `payload.reason` → unlocalizable at render time. Now the ledger signs a stable `reasonCode` + `reasonParams` (+ English fallback) from a closed `REASON_CODES` set in @parking/shared (entry.*/exit.*/sub.* groups + `reasonPayload()` helper; emitted from entry/exit/subscription flows). UI translates `reason.<code>` via sq/en catalogs — an Albanian operator reads Albanian from the SAME immutable event; adding a language = catalog change, no re-signing. Legacy events show the signed English fallback. (2) **Clickable rows → event-detail modal:** humanized labelled fields (not raw JSON) + entry/exit snapshots + signed-chain provenance (signature/keyId/prevHash) collapsed behind an "audit data" disclosure. Rationale for showing signatures: makes tamper-evidence visible vs the booth-operator threat, survives a signer swap. (3) **Subscriber names:** a SUBSESS-… occurrence renders the holder name (fallback "Abonent"/"Subscriber"), resolved read-time server-side (events API + WS push attach a non-signed `subscriberLabel` from permitId→holder_name; cached, invalidated on sub edit/delete). (4) **Failed-snapshot visibility:** the snapshots API returns failures[] from telemetry; UI shows a "⚠ camera unreachable" tile so a missing image isn't a silent gap (surfaced a real EHOSTUNREACH on a subscriber entry camera; by design snapshot = evidence not gate, so the open proceeded). Committed f31e57b. Updated [[booth-console]], [[i18n]].
|
||
|
||
## [2026-06-19] feat | Human + relative dates across UI and printed slips
|
||
|
||
Dates were raw ISO on paper and time-only in the UI (a 2-day-old session showed just "10:48"). (1) **Printed slips** (tickets/receipts/subscription cards): `stamp()` now formats "19 Qershor 2026 10:48:25" (Albanian month, 24h+seconds) via a hardcoded `SQ_MONTHS` table; exported as `formatStampSq` so the shift Z-report shares it. (2) **Z-report** is now fully Albanian (Operatori/Nga/Deri/Para në dorë/-- Arka --/Arka e pritur…), was English-only with ISO dates. (3) **Web** sessions/logs/history: `formatRelativeDateTime` → "Sot/Today 10:48" / "Dje/Yesterday 17:33" / "17 Qershor/June 10:48". GOTCHA: the appliance browser's ICU has NO Albanian locale data — `Intl.DateTimeFormat("sq",{month:"long"})` returns English ("June"), so month names come from a `common.months` catalog array, not Intl. Also FIXED a latent bug: the SQ/EN + dark/light toggles read the active value from TanStack Router context (`useRouteContext()`), which is captured at route-resolution and does NOT re-render on setUser — so after one switch the highlight froze + switching back was blocked until a page refresh. Now driven off live state (language from i18n.language via useTranslation; theme from local useState). Committed 00f3d14. Updated [[shift]], [[i18n]].
|
||
|
||
## [2026-06-19] fix | KP-300H barcode line-overflow — ticket id 13→11 digits
|
||
|
||
The Cashino KP-300H entry dispenser printed entry tickets as RASTER GARBAGE (solid black bars/banding) while the Rongta printed the IDENTICAL byte stream fine. Diagnosed on hardware: plain-text-only prints were clean → isolated to the `GS k` Code128 barcode. ROOT CAUSE = barcode line-overflow, not corruption: a Code128-B symbol is (11·chars+35)·moduleWidth dots; the old 13-digit id at module width 3 = ~534 dots OVERRAN the KP-300H's 72mm line (512 usable dots @ 203 dpi). The Rongta runs 80mm (576 dots) and had just enough room — why only the Cashino failed. FIX: shorten the ticket id 13→11 digits (10 random + Luhn) → ~468 dots, fits 72mm; scanned the full value at the exit reader (verified). Length is driven by GUESS-RESISTANCE not volume (10^10 space, ~1-in-10^7 to hit a live open ticket vs the booth-operator threat); chose 11 over the requested 9 (10^8 → ~1-in-10^5, too weak). validateTicketCode made length-agnostic (\d{10,14}+Luhn) so legacy 13-digit tickets still validate. NB: module width must stay 3 — a width-2 test scanned but returned TRUNCATED values (partial reads logged as exit.refused.noSession anomalies). Also fixed a separate latent transport bug in sendRaw: write-then-destroy could RST mid-stream (the write callback ≠ peer-flushed) and truncate a job; now end(payload)+FIN, resolve on socket `close`, timeout-after-write = success. NOT the cause of the garbage but a real risk. Committed bbf61c4. Updated [[ticket-encoding]], [[rongta-printer]].
|
||
|
||
## [2026-06-19] feat | Snapshots on refused entry/exit + subscriber access medium in the activity log
|
||
|
||
Two booth-evidence gaps closed. (1) **Refused entry/exit now snapshot.** Originally only the OPEN paths fired the directional camera; refusal/hold anomalies didn't — yet a turned-away car is exactly the evidence an operator/auditor wants (fraud/dispute signal). Added `#fireSnapshot` to every refusal: entry refused-full + held-no-ticket (a refused entry has no ticket id, so mint a synthetic `REFUSED-…` ref to key the anomaly + photo together), exit refused closed/no-session/unpaid/grace-expired (BOTH booth `exitForBooth` and reader `#runExit` paths), and refused [[subscription]] (the lane the reader sits at — `resolved.direction`, "both"→entry — picks the camera). Same fire-and-forget contract: a refusal is never delayed/blocked by a camera; failed captures still surface as "⚠ camera unreachable" tiles. (2) **Subscriber access medium (`via`) surfaced.** The subscription flow already SIGNED `via` (`"qr"|"card"|"plate"`) into the entry/exit payload but the activity log never showed it. Added it as a typed `LedgerPayload.via` field, a cyan chip in the ticker, and an "Entry medium / Mënyra e hyrjes" row in the detail modal (QR code / RFID card·chip / plate, localized sq+en) — a lost-card investigation can now see which credential opened a barrier. Display-only, no re-signing. Refused-subscription anomalies also now carry `via`. Build+lint green. Updated [[entry-exit-points]], [[booth-console]].
|
||
|
||
## [2026-06-19] feat | One car = one ticket (entry anti-double-press) + refusal snapshots + subscriber via
|
||
|
||
FLAW found: the entry button could be pressed without limit — each press minted a fresh ticket + signed vehicle_entry, corrupting occupancy (one car counts as many) and letting a transient SHOP the cheapest ticket at exit. The old `#inFlight` guard only blocked OVERLAPPING presses (released in finally). FIX is per-relay config (`config.relays[]`), mode chosen by available barrier feedback: (1) PRESENCE (preferred) — `presenceInput` ties ticketing to a vehicle loop on a Dingtian input; a press prints only with a car present, and NO second ticket until the loop CLEARS (car drove in) and a new car re-occupies it → physical one-car-one-ticket; (2) COOLDOWN (fallback, no feedback) — `entryCooldownSec` suppresses repeat presses for N seconds (a timer, mitigation not guarantee). New `relayForPresence()` resolves a loop edge to its entry relay; `EntryFlow` keeps a per-relay `#guard` map (present/armed), disarms on PRINT success, re-arms on loop clear. A suppressed press = UNSIGNED device_events telemetry (entrySuppressed:true), NOT a signed anomaly (operator's call — it's a correct no-op, not fraud). SetupWizard relay editor exposes Presence-loop + Cooldown fields (sq+en). Fail-closed entry + barrier-is-not-a-door invariants untouched; guard state is in-memory/rebuildable, starts armed after restart (safe default). New page [[entry-double-press]]; updated [[entry-exit-points]], index. Build+lint green. (Bundled with this session's earlier refusal-snapshots + subscriber-`via` work.)
|
||
|
||
## [2026-06-19] feat | Application logs — backend pino DB sink + frontend error collection (app_logs)
|
||
|
||
Added a THIRD data stream (`app_logs`) alongside the signed ledger and device telemetry — operational/diagnostic logs, since an OFFLINE appliance has no Sentry to ship to. BACKEND: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only — no bloat) with NO call-site change; the DB is now built BEFORE Fastify so the logger stream has its sink. FRONTEND (lib/logger.ts): always ships failed API requests (apiFetch non-OK path, minus 401 pre-login churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary (render crash → fatal, not a white screen); console.warn/error forwarded ONLY at client debug/trace level (noisy otherwise). Batched/throttled POST, flush via raw fetch + sendBeacon on pagehide. Reliability invariants: never log the /api/logs call itself (loop guard), LogService reentrancy guard, all writes best-effort/swallowed, bounded queue + clamped rows. API: POST /api/logs (any signed-in user, CSRF, tolerant — never 4xx on a bad entry) + GET /api/logs gated by a NEW `log:read` permission (new `log` resource in the RBAC grid; admin holds it). Retention: pruned by age (LOG_RETENTION_DAYS=30) AND row cap (MAX_ROWS=50k), hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack, 15s poll), sq+en. DB migration 0009_app_logs (+journal idx 9, seeds admin log:read) applied to the live apps/server DB. Verified end-to-end via app.inject: login→POST 204→GET 200 with the record; backend warn/error persisted + info dropped; non-admin GET 403 / POST 204 (the intended split). Build+lint green. New page [[app-logs]]; updated [[event-streams-split]], [[device-events]], index.
|
||
|
||
## [2026-06-19] query | ANPR recognizer options — fast-alpr evaluated as the baseline
|
||
|
||
Q: LPR/ANPR options — YOLO, OpenCV, both, another framework? Reframed: "YOLO vs OpenCV" is a category error — they're different pipeline LAYERS (YOLO = plate detector; OpenCV = Apache-2.0 image-handling glue, used regardless; plus an OCR stage). The real choice is which end-to-end recognizer. Researched [fast-alpr](https://github.com/ankandrew/fast-alpr) (latest **v0.4.0, 15 Mar 2026, MIT**): a thin orchestrator over two swappable ONNX stages — detection via [open-image-models](https://github.com/ankandrew/open-image-models) (`yolo-v9-t-384-license-plate-end2end`, MIT) + OCR via [fast-plate-ocr](https://github.com/ankandrew/fast-plate-ocr) (`cct-xs-v2-global-model`, MIT; also has a EUROPEAN model trained on 40+ countries — relevant for AL plates). MIT top-to-bottom (code AND published weights), one maintainer across all three repos, CPU-only + fully offline, backend extras for CPU/CUDA/OpenVINO/DirectML/QNN. KEY FINDING: its detector is open-image-models' OWN YOLOv9 ONNX export, NOT the Ultralytics AGPL package — so fast-alpr is a PERMISSIVE baseline that may not even need the scoped AGPL exception from [[vision-service]]. CAVEAT (flagged, not closed): a repo's LICENSE covers code, not necessarily redistributed model WEIGHTS (YOLOv9 upstream is GPL-3.0; Ultralytics YOLO AGPL) — verify weight provenance before relying on "MIT weights". fast-alpr is PLATE-ONLY → Job 2 (vehicle-attribute anti-spoofing) is still ours to build, but shares the same ONNX runtime. Recommendation: prototype fast-alpr now; Ultralytics-YOLO+PaddleOCR fine-tune only if accuracy disappoints. Recorded as an evaluated-options note; decision kept status:open pending the provenance check + an AL-plate accuracy benchmark. Updated [[opencv-anpr-service]] (new "Recognizer evaluation" section + licensing nuance), [[vision-service]] (open/next), index.
|
||
|
||
## [2026-06-19] decision | Vision service packaging — apps/vision/ in this monorepo, Turbo shim
|
||
|
||
Q: how to IMPLEMENT the vision service — can we use this Turborepo? Settled (status:settled): the Python/FastAPI ANPR service lives in THIS monorepo at `apps/vision/`, NOT a separate repo. Key clarification: Turbo orchestrates JS/TS package.json TASKS (+ caches outputs); it has no native Python build — but "in the repo" ≠ "in the Turbo graph", and "separate process" ≠ "separate repo". Decision: (1) co-locate source at apps/vision/ (pnpm-workspace already globs apps/*, so it auto-joins) for atomic cross-cutting changes (the /analyze contract + the Node adapter together), one wiki/history; (2) still a SEPARATE OS process (uvicorn over localhost HTTP) — co-location is source-level only, runtime isolation intact; (3) wire into Turbo via a THIN package.json shim whose scripts shell to Python (dev→uv run uvicorn, lint→ruff, test→pytest, build→no-op/model-fetch since Python has no dist/**), so `turbo run lint/test` covers vision too — deps stay uv/pyproject, not pnpm; (4) Node talks to it via a VisionClient interface (device-adapter style), swappable. WHY co-location honors the [[vision-service]] isolation decision: that decision is about RUNTIME + LICENSE isolation (separate process; AGPL doesn't reach Node because it's not LINKED, just HTTP) — AGPL's reach is a linking/distribution-boundary question, NOT a which-folder question. And with the MIT-end-to-end [[opencv-anpr-service|fast-alpr]] baseline the AGPL pressure to split the repo largely evaporates anyway. Rejected: separate repo (loses atomic changes; fallback if AGPL acute or another team owns it), embed-in-Node (already rejected by vision-service), packages/ (that's for shared JS libs, vision is a deployable app). NOT built yet — packaging decision only; scaffold when vision work starts. New page [[vision-service-packaging]]; updated [[vision-service]], [[opencv-anpr-service]], CLAUDE.md layout, index.
|
||
|
||
## [2026-06-19] build | Scaffold apps/vision (ANPR microservice skeleton)
|
||
|
||
Scaffolded the [[opencv-anpr-service|vision service]] per [[vision-service-packaging]]: `apps/vision/` Python/FastAPI, uv-managed, wired into Turbo via a thin package.json shim. Structure: pyproject.toml (light core: fastapi/uvicorn/pydantic; HEAVY recognizer = optional `alpr` extra = fast-alpr+onnxruntime, so `uv sync`+tests run OFFLINE in stub mode with no model download), per-package turbo.json (extends ["//"], build outputs [] → warning-free no-op), .gitignore (venv/caches/*.onnx/models out). vision_service/: app.py (GET /health + POST /analyze, raw octet-stream body so Node POSTs Snapshot.bytes directly; empty→400, oversize→413, not-ready→503), settings.py (env VISION_*), schemas.py (the /analyze contract + a not-yet-populated `vehicle` field for Job 2), recognizer.py (a Recognizer Protocol + StubRecognizer/FastAlprRecognizer — the device-adapter pattern applied to the model; fast-alpr imported lazily so missing models ⇒ ready=False, not a crash). VERIFIED: turbo run lint|test|build includes @parking/vision (ruff/pytest/no-op shim) green; uv run mypy strict-clean; uvicorn boots + serves /health (ready, stub-0) and /analyze (contract shape) live; pnpm workspace 6→7. NOT built: the Node VisionClient adapter, a Dockerfile + model fetch, and Job 2 (vehicle verification). Updated [[vision-service-packaging]] (As-scaffolded section), CLAUDE.md layout already lists apps/vision.
|
||
|
||
## [2026-06-19] query | Albanian-plate OCR benchmark — keep the default (cct-xs-v2-global)
|
||
|
||
Benchmarked fast-alpr's four candidate fast-plate-ocr models via the FULL pipeline (YOLOv9 detect → OCR) on real AL plate photos (Wikimedia: AA558EE, AA687KE), CPU, scaffolded apps/vision service. ALL FOUR read both plates correctly; the differentiator is confidence + speed: cct-xs-v2-global (default) 0.999/1.000 @ 33–39ms AND returns region=Albania; cct-s-v2-global same accuracy ~50% slower; global-mobile-vit ~0.955 fast; european-mobile-vit-v2 (the "40+ country EU" model) correct but MUCH lower confidence (~0.77) and misread a synthetic AB123FG→AB123FO. FINDING (overturns the "EU model → better for AL" assumption from the prior research turn): the global cct-xs default WINS for Albania — most accurate AND fastest. Decision: no config change, VISION_OCR_MODEL stays cct-xs-v2-global-model. Caveat: test photos were clean head-on shots; real booth captures (angle/night/dirt/blur) will lower confidence — the min_confidence=0.5 floor → low_confidence → ticket-path fallback covers it; re-benchmark on on-site captures once cameras installed. Resolves the AL-accuracy-benchmark open item in [[opencv-anpr-service]] (added a results table + the keep-default finding); the weight-provenance check remains the one open recognizer item.
|
||
|
||
## [2026-06-19] query | Vision service fitness for entry/exit flows — advisory YES, sole-authority NO
|
||
|
||
Q: is the scaffolded ANPR service worthy to consume in entry/exit flows? Assessment recorded in [[opencv-anpr-service]] ("Fitness for the entry/exit flows"). Benchmark settled ACCURACY (0.99+ clean AL plates); "worthy" turns on AUTHORITY. Split verdict: (✅) worthy NOW as an ADVISORY identity source (Job 1) — the flows are ALREADY built for a plate (kind:"plate" read is first-class: exit-flow signs source:"lpr"; subscription-flow matches read plate vs subscriptionPlates), so the service just produces the plate string → DeviceReadEvent{kind:"plate"} on the existing read bus; no flow rewrite. Worthy for hands-free subscriber open + evidence enrichment. (⚠️) NOT worthy as SOLE AUTHORITY to open a TRANSIENT barrier: a plate ≠ payment (would be an unpaid-exit bypass; min_confidence floor → ticket/manual fallback is the guard) and plate-spoofing (printed plate, different car) needs Job 2 vehicle-verification which is NOT built. Gaps before consuming: (1) the Node VisionClient adapter (real integration work), (2) trigger wiring — snapshots fire AFTER open today (evidence); plate-as-identity needs a snapshot BEFORE the decision on a per-camera opt-in lane, (3) field accuracy unknown (re-tune threshold on on-site captures), (4) weight-provenance check. Next step: VisionClient adapter + opt-in trigger, not more model work. (Scaffolding VisionClient next.)
|
||
|
||
## [2026-06-19] feat | VisionClient Node adapter (apps/server/src/vision-client.ts)
|
||
|
||
Scaffolded the Node-side adapter to the host vision microservice per the fitness assessment. VisionClient calls apps/vision over localhost HTTP (POST /analyze with snapshot Buffer bytes, GET /health), returning a normalised/camelCased VisionResult (best plate + all plates + lowConfidence + modelVersion + tookMs) or null. THREE guardrails enforce "advisory, never sole authority" at the boundary: (1) OPT-IN — VISION_ENABLED (default OFF), so the appliance runs with no vision service; (2) FAIL-SOFT — disabled/unreachable/timeout/non-2xx/bad-body all resolve to null and NEVER throw into the entry/exit path (→ ticket/manual fallback, never strand a car); (3) CONFIDENCE FLOOR re-applied (VISION_MIN_CONFIDENCE) on top of the service's own low_confidence flag. Per-request AbortController timeout (VISION_TIMEOUT_MS, default 1500ms) so a slow call can't hang the lane. Constructed in server.ts (logs when enabled). VERIFIED: fail-soft (disabled→null, unreachable→null no-throw) and LIVE end-to-end (Node client → running fast_alpr service → AA558EE 0.999 region=Albania, camelCased). NOT yet wired into the read bus — the opt-in snapshot-before-decision trigger that emits DeviceReadEvent{kind:"plate"} is the next deliberate step. Build+lint green. Updated [[opencv-anpr-service]] (gap 1 marked done).
|
||
|
||
## [2026-06-19] feat | VisionReader — wire ANPR into the read bus (apps/server/src/vision-reader.ts)
|
||
|
||
Wired the vision service into the entry/exit flows via the READ BUS. VisionReader polls each OPT-IN camera (config.anpr===true, off by default) every VISION_POLL_MS, captures a snapshot → VisionClient.analyze → on a CONFIDENT plate calls deviceEvents.emitRead({kind:"plate", value:PLATE, deviceId, driverId}) — the SAME event a physical plate reader emits, so the existing ReadDispatcher routes it to the subscription/exit flow UNCHANGED (no flow rewrite). The plate stays ADVISORY by construction: the exit flow still demands a covering payment (a plate can't bypass it), the subscription flow only matches a BOUND plate (subscriptionPlates). Guards: low-confidence reads DROPPED (a shaky read isn't an identity); DEBOUNCE (VISION_DEDUPE_MS, default 15s) so a parked car in frame doesn't re-fire the same plate; per-camera in-flight guard; idle when VISION_ENABLED off or no camera opts in; #recognizeOn is public for a future on-demand trigger (loop edge / API). Direction from directionOf (both→entry context). Constructed in server.ts, start on onReady / stop on onClose. VERIFIED END-TO-END: in-memory anpr camera returning the AL plate image + live fast_alpr service → VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} read onto the bus; debounce held it to 1 emit over 7 polls. Build+lint green. Updated [[opencv-anpr-service]] (trigger-wiring gap + per-camera opt-in marked done). Remaining: SetupWizard anpr toggle, field tuning, weight-provenance, Job 2.
|
||
|
||
## [2026-06-19] feat | Persist every recognized plate + snapshot (ANPR audit trail, non-blocking)
|
||
|
||
VisionReader now PERSISTS every confident plate read so a recognition is investigable — and switched from emitRead to calling ReadDispatcher.dispatch directly (like qr-reader) to capture the OUTCOME. On a confident plate it: (1) stores the SNAPSHOT bytes in `snapshots` keyed by identity=PLATE — the SAME identity the flow signs its anomaly/event with — so GET /api/snapshots/by-identity/:plate (the booth event-detail modal's SnapshotStrip, which already uses e.identity) shows the car's photo against that anomaly with ZERO UI changes; (2) records an unsigned device_events{kind:"read"} breadcrumb with plate/confidence/region/modelVersion/snapshotId + the dispatch outcome (accepted + reason) = a queryable ANPR log independent of the signed ledger; (3) dispatches the read — NON-BLOCKING: a refused read just returns rejected (no barrier hold), logged with its snapshot for investigation. Plate stays advisory (exit demands payment; subscription matches only a bound plate). VERIFIED e2e: a recognized AL plate (AA558EE) with no open session → signed exit.refused.noSession anomaly (identity=plate), stored a 555KB snapshot under that plate, read-breadcrumb accepted:false reason:"no open session", and by-identity returned the image (status 200, 1 snapshot) → the refused read is investigable with its picture. Build+lint green. VisionReader constructor now takes the ReadDispatcher (wired in server.ts). Answers "is a recognized plate saved?" — now YES for both transient + subscriber, as telemetry + evidence image, regardless of match. Updated [[opencv-anpr-service]].
|
||
|
||
## [2026-06-19] feat | Vision service configuration — SetupWizard ANPR toggle, footer health chip, .env.example
|
||
|
||
Made the vision service genuinely configurable (was env-only). THREE additions: (1) SetupWizard CAMERA form now has an "ANPR / Njohja e targave" checkbox (writes config.anpr; only persisted when on; sq+en) — opt-in is no longer raw JSON. (2) DeviceMonitor now optionally takes the VisionClient and probes its /health each tick, emitting a "vision" pseudo-device status (id vision-service, category "vision" — widened the DeviceStatusEvent + frontend DeviceStatus category unions + the footer CATEGORY_KEY/ORDER maps + devices.catVision sq/en) → a "Vision · ready/degraded/offline" chip in the booth footer; NO chip when VISION_ENABLED off. Verified: emits ready/fast_alpr when up, 0 chips when disabled. (3) apps/vision/.env.example (the Python service env) + VISION_* block appended to apps/server/.env.example (the Node side) + a "Configuration" section in [[opencv-anpr-service]] documenting all FOUR layers (python env / node env / per-camera anpr+binding / footer health) and the caveats: the two processes SHARE the VISION_ prefix but need SEPARATE .env files; bind /analyze to 127.0.0.1 (Node is the only caller); models download on first run so cache at deploy; an unbound anpr camera recognizes but every read is refused. Build+lint green. Updated [[opencv-anpr-service]] (Configuration section; SetupWizard-toggle gap closed), vision README.
|
||
|
||
## [2026-06-19] refactor | ANPR rides the entry/exit snapshot (replaces polling VisionReader)
|
||
|
||
Reworked the ANPR TRIGGER per the real design goal: when a transient pushes the button or a subscriber passes QR/RFID, the entry/exit fires and takes the evidence snapshot — THAT is the moment to recognize the plate, off the SAME image, tied to the SAME session. So snapshotAsync now takes the VisionClient and, after storing each snapshot from an opt-in (config.anpr) camera, runs ANPR on shot.bytes and records the plate against the session identity (device_events kind:"read" with plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both evidence + plate extraction; recognition fires ONLY on a real entry/exit — NO POLLING. The flows (entry/exit/subscription) now take an optional VisionClient and pass it through; server.ts wires it. REMOVED the polling VisionReader (vision-reader.ts deleted) + VISION_POLL_MS/VISION_DEDUPE_MS env. Advisory + fire-and-forget: low-confidence/no-plate records nothing, a vision failure never delays/changes the open, the plate does NOT feed the access decision (the flow already decided) — it's a record ("session X entered on plate AA558EE"). VERIFIED e2e: a simulated entry snapshot on an anpr camera (live fast_alpr) stored the snapshot for session TICKET-SG-1 AND recorded {identity:TICKET-SG-1, plate:AA558EE, confidence:0.999, region:Albania, snapshotId:…}. Build+lint green. Updated [[opencv-anpr-service]] (trigger section + Configuration, polling refs removed) + both .env.example.
|
||
|
||
## [2026-06-19] feat | Surface recognized plate in the booth UI (SnapshotStrip)
|
||
|
||
Made the ANPR plate VIEWABLE (it was saved but had no UI). Extended GET /api/snapshots/by-identity/:identity to also query device_events kind:"read" for that identity and return plates[] (plate, confidence, region, direction, snapshotId, at) alongside the existing snapshots + failures. The SnapshotStrip now renders each recognized plate as a cyan "Plate: AA558EE 100%" chip above the images (deduped by plate+direction; title shows region + time) — so it appears in BOTH the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. session:read gated (same as snapshots). i18n pay.plate sq+en. VERIFIED: by-identity returns plates[] for a seeded read (status 200, {plate:AA558EE, confidence:0.999, region:Albania, direction:entry, snapshotId}). Build+lint green. Updated [[opencv-anpr-service]].
|
||
|
||
## [2026-06-20] feat | Flag stuck sessions + booth filters (session list & live feed)
|
||
|
||
Replaced the silent paid age-out in PayStation.activeSessions() with a derived `stuck` flag: an
|
||
open + paid + past-grace session with no signed vehicle_exit is no longer dropped — it stays listed
|
||
with a red "stuck" badge so the operator can reconcile (top-up exit / void). Root cause surfaced via
|
||
the live ledger: 4 such orphans (5717802544704, 1245791632490, 7985713986045, 9340902468934) each
|
||
entry=1/exit=0/pay=1, grace=5min lapsed; they linger in the occupancy fold (so occupancy diverges
|
||
from the active-list count) and a re-scan re-quotes the tariff from entry (paid customer charged
|
||
again). Signed log untouched; subscriptions never stuck (no paidAt). This removed the earlier
|
||
unbounded "presumed-left (N)" counter (occupancy − sessions), which had been growing.
|
||
|
||
Added a shared client-side FilterBar (ui/FilterBar.tsx: search + SegGroup toggles, matched/total
|
||
count). Active Sessions: search (ticket/holder) + status (unpaid/paid/exiting/stuck) + transient-vs-
|
||
subscriber. Live feed: search (identity/subscriber/plate) + event (entry/exit/pay/void/anomaly) +
|
||
direction (entry/exit) + source (booth=manual vs reader=device). No new API. Exit grace re-scan
|
||
logic UNCHANGED (still refuse + send to booth — user choice). Build+lint green across the monorepo.
|
||
Updated [[booth-exit-flow]].
|
||
|
||
## [2026-06-20] fix | No free exit on overstay (stuck session) + top-up pricing
|
||
|
||
SECURITY FIX correcting same-day stuck-flag work. A stuck session (paid + walk-back grace expired +
|
||
no signed exit) is AMBIGUOUS — the car may have left OR be overstaying inside. The first cut kept the
|
||
"Open barrier" button on these rows (gated on paidAt != null), which would let an operator wave out a
|
||
2-day overstay for free — the operator-as-adversary path. Fix: reopenBarrier now refuses a transient
|
||
whose payment grace has expired (allow only subscription OR paid-and-within-grace), enforced
|
||
server-side (exit-flow.ts), mirrored in the UI (no button on s.stuck → routes to pay/exit modal).
|
||
Verified against the live ledger: ticket 5717802544704 (entered 73.9h ago, paid 200000, grace 5min)
|
||
computes stuck=true and reopenBarrier REFUSES it.
|
||
|
||
Top-up pricing — "full stay minus paid" (user choice): quote() now returns grossMinor (whole stay
|
||
entry→now) and paidMinor (fold of prior signed payment amounts), with amountMinor = max(0, gross −
|
||
paid) — the delta only, never the full stay twice. lookup()/SessionLookup gained `stuck`; the pay
|
||
modal shows OVERSTAY status + "Top-up due" + a hint, and canPay now allows payment for a stuck
|
||
session. Taking the top-up restarts grace so the car exits normally. i18n pay.overstay/overstayHint/
|
||
topUp in sq+en. Partially resolves the grace-overstay Open question (the amount); whether to bill the
|
||
overstay delta-from-grace vs full-minus-paid left open. Build+lint green. Updated [[booth-exit-flow]].
|
||
|
||
## [2026-06-20] fix | Rename stuck→overstay + price overstay as a NEW period (fixes ALL 0)
|
||
|
||
Two user-driven corrections to the same-day overstay work. (1) NAMING: "stuck"/"i ngecur" wrongly
|
||
implied a system fault trapping the customer — but a paid-then-grace-expired car means a NEW parking
|
||
period began (re-parked) or the car is faulty/abandoned. Renamed the flag + badge + filter +
|
||
SessionLookup/ActiveSession field to `overstay` / "tej afatit" across server + web + i18n.
|
||
|
||
(2) PRICING BUG: "full stay minus paid" collapsed to 0 under a daily cap — ticket 1245791632490
|
||
(entered 06-17, paid 330000, cap 100000/day) had gross=330000, so delta=0 → "Diferenca për pagesë
|
||
ALL 0", a free multi-day exit. Fix (user choice): quote() now prices an overstay as a NEW period
|
||
anchored at grace-expiry (paidAt+graceExitMin)→now with its own daily-cap ladder, NOT entry→now. The
|
||
tariff version stays the one frozen at entry. Quote gained periodStart + overstay; removed
|
||
grossMinor/paidMinor. Verified: 1245791632490 now owes 20000 ALL (first half-hour of overstay), not 0.
|
||
pay modal: "New period due"/OVERSTAY; handlePayAndExit now charges when canPay (was: only if
|
||
!alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp +
|
||
booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]]
|
||
(overstay section + naming history + partial-resolution note on the grace-renewal open question).
|
||
|
||
## [2026-06-20] feat | Tariff Lab — pure session-pricing simulator (test rates in time)
|
||
|
||
The tariff engine is pure but could only be EXERCISED by waiting (booth reads real
|
||
wall-clock). Added a simulator. Extracted priceSession(enteredAt, asOf, structure,
|
||
payments[], category?) into @parking/shared — the grace/overstay wrapper over
|
||
computeFee (unpaid→entry→now; within-grace→settled 0; grace-expired→overstay new
|
||
period from grace-expiry). PayStation.quote() now calls it, so booth + lab can't
|
||
diverge. New routes (tariffs.ts, tariff:read, no ledger writes): POST
|
||
/api/tariff/simulate (price a hypothetical session vs active/any version/inline
|
||
structure; returns priceSession outcome + a 30m..3d duration curve) and GET
|
||
/api/tariff/simulate/session/:identity (prefill from a real ledger session). UI
|
||
apps/web/src/TariffLab.tsx at Setup→"Tariff Lab": version picker, entry/asOf, optional
|
||
payment+grace, category, load-a-ticket; shows amount due, billed period, overstay/
|
||
settled, curve. i18n lab.* + nav.tariffLab (sq+en). 4 new priceSession unit tests
|
||
incl. the ticket-1245791632490 overstay-not-zero regression (40 tests pass). Verified
|
||
live via the real UI: a 3h stay → ALL 3,000, curve shows the daily cap flattening at
|
||
6h and multi-day stepping; ticket-load returned a real session. Build+lint green.
|
||
Updated [[tariff]] + [[booth-exit-flow]].
|
||
|
||
## [2026-06-20] feat | Stepped ("up-to") tariff mode — total-by-duration pricing
|
||
|
||
The owner needed a total-by-duration matrix (0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900,
|
||
0-12h=1000) the marginal hourly ladder CANNOT express (ladder sums per-increment
|
||
rates; this is cumulative totals at thresholds). Added STEPPED pricing as a third
|
||
mode alongside ladder + flat. New TariffStep{uptoMin,totalMinor} + steps[] on V1
|
||
structures and V2 cards (mutually exclusive w/ blocks/flatMinor). Engine steppedFee():
|
||
smallest tier with uptoMin>=duration wins (INCLUSIVE <=), top tier repeats as per-day
|
||
cap; wired into computeFeeV1 + computeFeeV2 (V2 defaultCard only — a whole-stay total
|
||
can't be sliced by a windowed card). Validation: ascending uptoMin, non-neg totals, no
|
||
dailyCap-with-steps, steps-only-on-default. priceSession/quote/booth/Lab all price it
|
||
via the shared core (no extra wiring). Composer UI: "By duration (up-to)" radio +
|
||
up-to/total table (base card only); i18n modeStepped/steppedHint/stepUpTo/stepTotal/
|
||
addStep (sq+en). 8 new unit tests incl. the exact owner matrix + multi-day + overstay
|
||
+ validation (53 pass). VERIFIED end-to-end via the real UI: authored the matrix in the
|
||
composer, published, Tariff Lab priced it exactly (3h->500, 6h->800, 12h->1000,
|
||
2d->2000). Build+lint green. Updated [[tariff]].
|
||
|
||
## [2026-06-20] fix | Reject stepped base + time tiers (silently-ignored tiers)
|
||
|
||
Found live: the active tariff had a STEPPED ("up-to") base card AND two windowed tiers
|
||
(weekday-night "Nata gjate javes", weekend "Fundjava"). computeFeeV2 short-circuits to
|
||
steppedFee on a stepped default card, so the tiers NEVER fired — a 3h stay was 600 ALL
|
||
at every hour/day. The composer happily let this contradictory combo be built + published.
|
||
Fix: validateTariffV2 now rejects a stepped defaultCard combined with windowedCards
|
||
(clear message: switch base to ladder/flat or remove tiers); the composer shows an inline
|
||
red warning when base.mode==="stepped" && tiers>0. Also: ApiError now carries the
|
||
server's problems[] so the publish error shows the SPECIFIC reason (was generic "invalid
|
||
tariff structure"). 2 new validation tests (55 pass). Verified live: warning renders +
|
||
publish blocked with the full message. Build+lint green. Updated [[tariff]].
|
||
|
||
## [2026-06-20] query | "Tariff Lab wrong: weekend 3h shows 600, expected 300"
|
||
|
||
NOT a bug — the engine was correct. The active tariff's billing increment is 30 min,
|
||
and `priceMinorPerIncrement` is PER INCREMENT, not per hour. The Fundjava (weekend) tier
|
||
DID apply (traced: every increment of the Saturday stay selected the Fundjava card), but
|
||
it bills 100 per 30-min increment = 200/hour, so 3h = 6 increments x 100 = 600. To get
|
||
300, set the price to 50/increment OR the increment to 60 min. This per-increment-vs-per-
|
||
hour confusion has recurred; documented it as a ⚠ callout in [[tariff]] and filed a
|
||
per-hour-preview composer UX idea under Open. No code change.
|
||
|
||
## [2026-06-20] fix | Subscription sale was off the books — append a signed payment
|
||
|
||
Operator-reported [[threat-model]] hole: creating a priced [[subscription]] wrote ONLY the
|
||
mutable `subscriptions` master row and appended NOTHING to the signed ledger. The cash the
|
||
operator collected (e.g. 10,000 ALL) showed in the live feed / drawer / Z-report nowhere —
|
||
a clean off-book channel. Confirmed live on the appliance: three priced subscriptions
|
||
(27,000 ALL sold) had ZERO payment events. This is the canonical booth-operator-as-adversary
|
||
path the [[append-only-event-chain]] exists to close; the "collect-in-shift later" deferral
|
||
(decided 2026-06-18) had left it open.
|
||
|
||
Fix: selling a priced subscription now appends a signed `payment` event (the long-planned
|
||
plain-`payment`-not-new-type choice, resolved) at create time — amount = `priceMinor x
|
||
months` (full multi-month prepay), operator-chosen tender (cash->drawer / card->bank),
|
||
payload `{ subscriptionSale: true, permitId, operator, months }`. Folds into the [[shift]]
|
||
Z-report/drawer with no new summing logic; the live feed badges it "subscription sale" and
|
||
resolves the holder name. NOT hard-gated on an open shift (a sale can happen outside the
|
||
booth money path) — it warns instead; flagged as a remaining sub-question. The 3 historical
|
||
off-book sales are NOT back-fillable (append-only forbids forging dated events) —
|
||
reconcile via `cash_movement` / a Z-report note.
|
||
|
||
Verified against a COPY of the live DB with the real signing modules: signed payment
|
||
appended (30,000 ALL, 3-month), hash-chain still verifies, lands in shift cash totals.
|
||
Build + lint 12/12. Updated [[subscription]] (Collecting the fee → BUILT; data model;
|
||
open-question #3 resolved), [[shift]] (sale folds in), [[threat-model]] (worked example:
|
||
"store the price ≠ account for the sale").
|
||
|
||
## [2026-06-20] feat | Show recognized plate in live feed + active sessions
|
||
|
||
The advisory ANPR plate (device_events kind="read", keyed by session identity — unsigned,
|
||
prunable, NEVER an access decision) is now surfaced next to entry/exit events in the live
|
||
feed and on active-session rows. Resolved at serialize time (new `plate-lookup.ts`, prefers
|
||
an entry read; one device_events scan for the whole page), like subscriber-name enrichment —
|
||
the signed ledger is untouched. Added `plate?` to the shared `LedgerEvent` + `ActiveSession`/
|
||
`SessionLookup`; a small amber badge in the UI. Caveat: a `vehicle_entry` is signed + pushed
|
||
over WS BEFORE the async ANPR read lands, so a fresh feed row may show no plate until reload;
|
||
always present on active sessions. Build + lint 12/12.
|
||
|
||
## [2026-06-20] feat | Drawer cash re-modelled as directional vouchers (Mandat Arkëtimi / Pagese)
|
||
|
||
Replaced the single signed-± `cash_movement` (one event, +load/−removal in the sign of an
|
||
amount) with two distinct financial documents — the direction is now the event TYPE:
|
||
`cash_in` = **Mandat Arkëtimi** (receipt / pay-IN, +) and `cash_out` = **Mandat Pagese**
|
||
(disbursement / pay-OUT, −). Each carries a positive magnitude, a voucher number (`AR-NNNN`
|
||
/ `PA-NNNN`), reason, the operator who raised it and the admin who authorized it, and prints
|
||
an Albanian slip. **Authorization changed**: was admin-only; now **operator-RAISED,
|
||
admin-AUTHORIZED** — any `shift:create` holder raises the voucher but `POST /api/cash-voucher`
|
||
only commits if `authorizedBy` is a real admin (`shift:cash`) re-entering their password.
|
||
Legacy `cash_movement` events are KEPT (still verify, still fold into the drawer signed-±) —
|
||
the append-only chain is never rewritten. Drawer fold + Z-report window updated to sum all
|
||
three types. Verified against a COPY of the live DB with the real signing modules: cash_in
|
||
3000 + cash_out 5000 → drawer −2000, hash-chain still verifies OK. Build + lint 12/12.
|
||
Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the
|
||
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
|
||
not yet built (see [[shift]] Open).
|
||
|
||
## [2026-06-20] feat | Mid-shift X-report (read-only takings-so-far)
|
||
|
||
The operator can now see, on demand during an open shift, the opening float inherited,
|
||
cash/card collected so far, pay-ins/pay-outs, and the current expected drawer balance —
|
||
without closing. `GET /api/shift/report` (shift:read; 204 when no shift open) returns the
|
||
SAME projection the Z-report prints, factored into a shared `ShiftService.#summariseWindow
|
||
(open, asOf)` so X (asOf=now, read-only) and Z (asOf=endedAt, signed) can't drift. Appends
|
||
NOTHING — it's a snapshot, not an accountability mark (the Z at close is the signed record).
|
||
UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header
|
||
keeps the live drawer total. Verified on a copy of the live DB: matches drawerBalance(),
|
||
drawer identity holds (expected = opening + cash + added − removed), 0 events appended, chain
|
||
verifies. Build + lint 12/12. Resolves the X-report item flagged the same day in [[shift]].
|
||
|
||
## [2026-06-20] feat | Subscription plan catalog — config-defined, dated spans, no typed prices
|
||
|
||
Re-modelled subscription pricing from per-row operator-typed `priceMinor` + monthly-only `period`
|
||
into an admin-composed, versioned PLAN CATALOG (the tariff pattern). Plans (`subscription_plans`,
|
||
migration 0010) are immutable effective-dated versions keyed by a stable planId, with period ∈
|
||
day/week/month + per-period price. The operator SELLS by selecting a plan over a date span (start
|
||
defaults to today, end required); the price is LOOKED UP — periods = ceil(span / period), amount =
|
||
periods × per-period price (ceil = any started period is full; hotel/parking practice). The hotel
|
||
1–N day case is a daily plan over a check-in→check-out span. `POST /api/subscriptions/quote` gives a
|
||
live server-computed quote so the operator can't override the amount. New `subscription:plan`
|
||
permission (admin-only) composes the catalog; selling stays operator-grade `subscription:create`.
|
||
The signed-`payment` sale fix is unchanged — only the amount SOURCE moved to the plan quote; payload
|
||
now carries planId/planVersionId/periods. Pure span math lives + is unit-tested in @parking/shared
|
||
(68 tests incl. ceil/Jan-31 clamp). New SubscriptionPlansManager screen (Setup tab) + reworked
|
||
SubscriptionManager sell form (plan picker + dates + quote, no price field). Verified on a copy of
|
||
the live DB: 0010 applies (existing subs intact, monthly plan seeds from site default), a 3-night
|
||
hotel sale prices to 2,400 ALL, appends ONE signed payment with planVersionId, chain verifies.
|
||
Build + lint 12/12. Updated [[subscription]] (plan catalog supersedes typed price; data model) +
|
||
[[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the
|
||
first plan.
|
||
|
||
## [2026-06-20] feat | Subscription v2 — quantity, plan timeframes (tariff bridge), reserved spots
|
||
|
||
Three subscriber enhancements (migration 0011, additive columns):
|
||
(1) QUANTITY — one subscription covers N cars (a family pays once for 2); sale = span price × quantity,
|
||
maxConcurrent defaults to it.
|
||
(2) PLAN TIMEFRAMES → TARIFF BRIDGE — a plan may restrict when a subscriber may park (weekday
|
||
20:00→08:00, weekend all-day). Outside the window they're charged the TRANSIENT tariff for the gap
|
||
(not refused): early entry = arrival→window-open (deferred, signed as windowOwedMinor on the
|
||
vehicle_entry); late exit = window-close→departure, and exit is GATED (sub.refused.unpaidWindow) until
|
||
paid at the booth. Pure tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing reuses
|
||
computeFee + the active tariff version (apps/server/src/subscription-window.ts). The exit refusal is a
|
||
host-ONLINE business gate — fail-open still governs the offline path (flagged in the wiki).
|
||
(3) RESERVED SPOTS — site toggle reserve_subscriber_spots: occupancy holds max(0, quantity−inside) per
|
||
active sub, so transients see "full" sooner; effectiveFree = capacity − count − reserved. Subscribers
|
||
never gated by full.
|
||
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor (SubscriptionPlansManager);
|
||
reserve checkbox (SiteSettings); booth pay modal shows an "OUT-OF-WINDOW" charge + takes payment.
|
||
Verified on a copy of the live DB: qty 2 = 2× price; night-plan 19:30 entry → 30min/15,000 ALL owed,
|
||
stamped + paid → gate clears, chain verifies; reserve toggle holds a qty-2 sub's 2 spots. Build+lint
|
||
12/12; 80 shared tests. Updated [[subscription]], [[capacity-occupancy]], [[tariff]].
|
||
|
||
## [2026-06-21] query | Desktop shell: Tauri v2 vs. Electron
|
||
Compared Tauri v2 and Electron for shipping the operator UI as a desktop app (mobile deferred).
|
||
Decision (with user): **Tauri v2** — small footprint, no bundled Chromium to patch, deny-by-default
|
||
native surface fitting the booth-operator threat model; MIT/Apache. Shell stays thin (device/auth/
|
||
ledger/pricing remain in Fastify, per user). Filed [[desktop-shell-tauri]]; cross-linked from
|
||
[[standing-decisions]], [[overview]], [[index]]. Open dependency: appliance WebKitGTK version
|
||
([[open-questions]] #11) — flips to Electron if ancient/unavailable.
|
||
|
||
## [2026-06-21] query | Desktop shell — target OS (best/worst case)
|
||
User specified deployment span: best = Ubuntu 26.04 LTS desktop, worst = Windows 11 + WSL + Docker.
|
||
Refined [[desktop-shell-tauri]] + [[open-questions]] #11: Ubuntu 26.04 LTS ships a current
|
||
distro-maintained WebKitGTK → effectively closes the WebView risk; Tauri unconditional there. The
|
||
Windows+WSL case is NOT an "Electron instead" fallback — it conflicts with the standing Linux-
|
||
appliance platform decision and can't host a GUI shell in headless WSL/Docker; fallback is a kiosk
|
||
browser at the local Fastify-served SPA (Electron only if a standalone Windows installer is
|
||
mandated). Thin-shell architecture makes that fallback cheap.
|
||
|
||
## [2026-06-21] query | TPM 2.0 hardening — analysis + pull-the-disk attack trace
|
||
How a TPM works (non-extractable keys + PCR sealing) and its limits, recorded after tracing the
|
||
"pull the SSD, tamper parking.sqlite offline, reboot" attack against event-log.ts/signer.ts.
|
||
Findings: verifyChain() catches every blind tamper (bad sig / index gap / prevHash / unknown keyId)
|
||
but (a) nothing runs it at boot, and (b) the software HMAC key lives in .env on the same disk →
|
||
attacker can re-sign undetectably. Only a secure-element key (TPM on a PC, ATECC608 on embedded)
|
||
makes it tamper-PROOF; TPM-sealed LUKS additionally blocks off-host mount. TPM verdict: recommended
|
||
not required on the Ubuntu appliance (sealed-LUKS auto-unlock + non-extractable signing key); does
|
||
NOT defend a rooted live host or the operator; bus-sniff/PCR-brittleness caveats → prefer fTPM + PIN,
|
||
keep recovery passphrase + re-seal runbook; complements not replaces reconciliation. Also corrected:
|
||
ATECC608 is NOT in a PC (external I²C part) → on a PC appliance the TPM is the host secure-element,
|
||
ATECC608 reserved for the ESP32 controller. New page [[tpm]]; cross-linked [[disk-os-hardening]],
|
||
[[threat-model]], [[atecc608]], [[append-only-event-chain]]; open-questions #12 (TPM impl, to build),
|
||
#13 (startup verifyChain self-check, to build); index + counts updated.
|
||
|
||
## [2026-06-21] build | apps/desktop — Tauri v2 kiosk shell scaffolded
|
||
Built the thin Tauri v2 shell per [[desktop-shell-tauri]]: new apps/desktop package wrapping the
|
||
SAME apps/web SPA (dev → localhost:5173 with HMR; prod → bundled web dist/), so desktop and browser
|
||
UIs can't drift (user requirement). Rust core holds no business logic; capabilities core:default
|
||
only (deny-by-default). One apps/web change: centralized the backend origin into lib/origin.ts
|
||
(API_BASE/apiUrl/wsUrl from VITE_API_BASE) — no-op in the browser, lets the Tauri build target the
|
||
Fastify origin. Turbo build is a no-op; real bundle = `pnpm --filter @parking/desktop bundle`.
|
||
VERIFIED: cargo check + full tauri build → working .deb/.rpm/.AppImage; turbo run build lint 14/14
|
||
green; prereqs present (Rust 1.93, WebKitGTK 4.1, libsoup-3, WSLg). Filled the As-built section of
|
||
[[desktop-shell-tauri]]. Deferred: kiosk lockdown, auto-update, signing, Windows kiosk-browser path.
|
||
|
||
## [2026-06-21] build | apps/desktop — window/right-click, auto-update, code-signing, env wiring
|
||
Per user choices on the Tauri shell: window starts MAXIMIZED (not fullscreen — operator keeps OS
|
||
access); right-click context menu blocked in PROD only (lib/kiosk.ts, dev keeps devtools).
|
||
VITE_API_BASE wired via apps/web/.env.production (committed non-secret, allow-listed in .gitignore;
|
||
auto-loaded by vite build → desktop bundle targets Fastify, no manual export). Auto-update built:
|
||
tauri-plugin-updater + -process, prompt-on-update flow (lib/desktop-updater.ts, no-op in browser/
|
||
offline) → downloadAndInstall + relaunch; endpoint is a self-hosted PLACEHOLDER to fill in. Updater
|
||
keypair generated: pubkey embedded in tauri.conf.json; private key + password kept OUTSIDE the repo
|
||
(~/.parking-updater-keys, 0600) + as TAURI_SIGNING_* build secrets. VERIFIED: signed bundle →
|
||
.deb/.rpm/.AppImage + .sig updater signatures; turbo run build lint 14/14 green; no key material in
|
||
the repo. Updated As-built in [[desktop-shell-tauri]]. Deferred: real update URL, OS installer
|
||
signing, Windows kiosk-browser fallback.
|
||
|
||
## [2026-06-21] fix | Subscription out-of-window charge — marker-not-fixed-amount; scannable ticket; booth flow
|
||
Corrected the [[subscription]] tariff-bridge charging model after operator feedback. The entry path
|
||
stamped a FIXED `windowOwedMinor` = the whole gap-to-window-open (e.g. 800 ALL for a 13:21 arrival to
|
||
a 20:00 window) and deferred it — over-charging anyone who left before the window opened (a 1-hour
|
||
visit billed as 6.5h). Now the `vehicle_entry` carries only a MARKER (`outOfWindow` +
|
||
`windowTariffVersionId`); the amount is priced LIVE from `minutesOutsideWindow(entry → settle-time)`,
|
||
which caps at the window edges, so one hour parked = one hour's transient fee, in-window time free, and
|
||
the late-exit tail keeps accruing until payment. The advisory slip is now a scannable Code128 + QR
|
||
TICKET of the occurrence id (operator scans it into the booth pay modal). Also: removed the always-on
|
||
"Open barrier" from the active-sessions list AND modal for subscribers — a prepaid sub shows only a
|
||
small "assist open" reveal; an out-of-window sub is pay-first-then-open. Fixed the ESC/POS encoder so
|
||
typographic chars (— ⚠ … ' ") transliterate to ASCII instead of "?". `windowOwedMinor`/`windowGap*`
|
||
kept as deprecated read-only in `LedgerPayload` for historic events. Verified live model on a DB copy
|
||
(13:21→14:30 = 200 ALL; 19:55-grace→23:00 = 0; 19:00→21:30-cross = 100 ALL). build+lint 14/14, shared
|
||
87/87. Existing signed occurrences left untouched (immutable). See [[subscription]] tariff-bridge-history.
|
||
|
||
## [2026-06-21] feat | Subscription plan-version correction (admin)
|
||
Added an admin-only path to move an existing [[subscription]] to a different VERSION of its SAME plan
|
||
(e.g. v1 "every day" → v2 "weekdays only" of mujor-naten-cdo-dite). PUT /api/subscriptions/:id now
|
||
accepts planVersionId, gated on subscription:plan (403 for non-privileged), validated to share the
|
||
sub's existing planId (cross-plan = 400 — that'd be a re-sale). Price/currency/period stay frozen;
|
||
only the access rules change going forward (past signed events keep their own windowTariffVersionId).
|
||
Server-logged for audit. UI: admin-only "Versioni" picker in the edit modal, listing every version by
|
||
effective date + timeframe summary, current pre-selected. Verified on a writable DB copy: version
|
||
changed, price + planId frozen, cross-plan rejected. build+lint 14/14, i18n parity (sq+en). Live DB
|
||
untouched. See [[subscription]] "Version correction".
|
||
|
||
## [2026-06-21] feat | Shift report split (tickets vs subscriptions) + confirm-before-close + dark <select>
|
||
Three UI/report changes. (1) The [[shift]] report now splits takings by SOURCE — Tickets (transient)
|
||
vs Subscriptions (monthly sales + a subscriber's out-of-window charge), derived from the signed
|
||
payment payload flags (subscriptionSale / subscriptionWindowCharge), always reconciling to cash+card.
|
||
Carried on the signed shift_z_report payload + shown in X-report, close modal, history detail, and the
|
||
printed Z-report; pre-split reports default subscription to 0. (2) The header shift button no longer
|
||
closes directly — it opens a confirm modal showing the live X-report (the split + expected drawer)
|
||
before signing the irreversible Z-report. Opening stays immediate. (3) Fixed dark-theme native
|
||
<select> popups rendering WHITE on WebKitGTK (Tauri Linux) via color-scheme + explicit option colours.
|
||
Verified the split on a read-only DB copy (tickets 0, subs 10,200 = 10,000 sale + 200 out-of-window,
|
||
reconciles). build+lint 14/14, i18n parity (sq+en). See [[shift]] "Takings split by source".
|
||
|
||
## [2026-06-21] feat | Promote Subscriptions to a top-level section with its own tabs
|
||
Moved Subscriptions out of /setup into a standalone /subscriptions section with a header nav entry
|
||
(Kabina · Turni · Abonimet · Konfigurimi) and its own tab bar: Abonimet (/subscriptions), Planet
|
||
(/subscriptions/plans), Lab Tarife (/subscriptions/tariff-lab). Removed those three tabs from the Setup
|
||
layout (Setup now: Pajisjet · Tarifa · Park · Përdoruesit · Rolet · Turnet · Loget). Tabs are
|
||
permission-gated (subscription:read / subscription:plan / tariff:read), so an operator with only
|
||
subscription:read sees just Abonimet; the index redirects to the first allowed sub-tab otherwise.
|
||
Legacy /setup/subscriptions, /setup/plans, /setup/tariff-lab redirect to the new paths; the old
|
||
/subscriptions→/setup redirect was removed (it's a real route now). Verified at runtime via Playwright
|
||
(header order, the 3 sub-tabs, Setup no longer shows them, /setup/subscriptions redirects). The Tariff
|
||
COMPOSER stays in Setup; only the Tariff LAB simulator moved. build+lint 14/14.
|
||
|
||
## [2026-06-21] fix | Header "Turni"→"Turnet" (plural); drop the duplicate Setup shifts tab
|
||
The header shift link was nav.shift (singular: Turni/Shift) but points at the /shift HISTORY hub →
|
||
relabelled to nav.shifts (plural: Turnet/Shifts). Removed the duplicate "Turnet" tab from Setup (the
|
||
/setup/shifts route + tab rendered the SAME ShiftsHistory as the standalone /shift). /setup/shifts now
|
||
redirects to /shift; the operator-landing fallback (shift:read user opening /setup) points at /shift.
|
||
nav.shift key left in the catalogs (now orphaned, harmless). Verified at runtime. build+lint 14/14.
|
||
|
||
## [2026-06-21] feat | /shift→/shifts; clickable activity log (shared event-detail); booth-style full-height layout
|
||
Three changes to the shift hub. (1) Renamed the route /shift→/shifts (matches the plural Turnet label
|
||
+ the section); /shift and /setup/shifts both redirect there. (2) The activity-log rows are now
|
||
CLICKABLE and open the SAME read-only event-detail modal the booth live feed uses (full signed payload
|
||
+ snapshots + chain provenance). Extracted EVENT_STYLE + the row + the modal + their helpers from
|
||
BoothScreen into a shared apps/web/src/ui/event-detail.tsx, imported by both — so the booth feed and the
|
||
shift log render/behave identically and can't drift. (3) Reworked the /shifts layout to fill the
|
||
viewport like /booth: a fixed title+filters, then a two-pane area (shift list | activity log) where each
|
||
pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the whole page growing.
|
||
Verified at runtime (Playwright): /shift redirects, rows open the detail modal, layout fills height,
|
||
booth still works (0 console errors). build+lint 14/14.
|
||
|
||
## [2026-06-21] feat | Booth: focus-independent hardware-scan capture
|
||
A scan now opens the /booth pay/exit modal no matter what's focused (or if nothing is) — the operator
|
||
needn't click the ticket field first. New useScanner hook (apps/web/src/lib/use-scanner.ts): a
|
||
document-level keydown listener that detects the HID scanner's fast keystroke burst ended by Enter (gap
|
||
> 50ms resets the buffer, so human-paced typing never triggers it; min length 3) and fires
|
||
setActiveTicket. Ignores keystrokes into editable fields so the manual ticket input is unaffected;
|
||
paused while a modal is open so a scan can't abandon an in-progress payment. Verified at runtime
|
||
(Playwright): scan with focus on BODY opens the modal; second scan while open is ignored; slow typing
|
||
doesn't trigger; manual form submit still works. build+lint 14/14. See [[booth-console]].
|
||
|
||
## [2026-06-21] test | Automated test coverage across every service (was shared + vision only)
|
||
Added a fresh-SQLite test harness and suites for all six packages; `pnpm test` (turbo `test` task) now
|
||
covers them all (previously only @parking/shared + @parking/vision had test scripts). New
|
||
`@parking/db/testing` exports `createTestDb()` — an in-memory SQLite with the real Drizzle migrations
|
||
applied, so server tests run against the production schema with NO live-DB risk. Coverage: **server**
|
||
(anti-fraud core) — event-log hash-chain linkage + `verifyChain` catching every tamper class (edited
|
||
payload, deleted row/index gap, broken prevHash, unknown keyId), signer round-trip/forgery/rotation,
|
||
occupancy fold + reserved-spots (no double-count of a parked subscriber), pay-station quote/sign/lookup,
|
||
the exit GATE (refuse unknown/unpaid/grace-expired; no booth subscription bypass; assist path), and the
|
||
shift takings-SPLIT by source (subscription sales vs out-of-window vs transient tickets) + drawer
|
||
carry-forward + Z-report; plus an HTTP integration suite booting the real Fastify app via `app.inject`
|
||
for the auth/RBAC/CSRF guards. **devices** — ESC/POS byte stream (CP852 ë/Ë mapping + em-dash/⚠ ASCII
|
||
fallbacks, no stray "?"; the Code128 module-width contract: width 2 for the ~20-char out-of-window id so
|
||
it fits the 80mm head) + printer-routing failover. **web** — booth formatters + the focus-independent
|
||
`useScanner` hook (jsdom). **vision** — fixed 2 pre-existing stub-mode test failures via a conftest
|
||
autouse fixture that pins `VISION_RECOGNIZER=stub` (the dev `.env` had set `fast_alpr`, which broke the
|
||
model-free smoke tests). Also stopped `*.test.ts` leaking into shipped `dist/` (server + shared
|
||
tsconfig excludes). Totals: shared 87, server 75, devices 18, web 17, vision 7 = 204 tests; build/lint
|
||
14/14. See [[booth-console]].
|
||
|
||
## [2026-06-21] fix | Auth cookies Secure-by-default; COOKIE_SECURE=0 in the appliance deploy runbook
|
||
secureCookies() keyed off NODE_ENV==="production", so an appliance deployed without that var
|
||
silently dropped the Secure flag on the auth/CSRF cookies (the code-review's one Medium finding).
|
||
Flipped to FAIL-SAFE: Secure by DEFAULT, dropped only on a deliberate COOKIE_SECURE=0/false/no/off
|
||
(or NODE_ENV=development as a dev fallback). The plain-http LAN appliance sets COOKIE_SECURE=0 ON
|
||
PURPOSE (a Secure cookie is never sent over its http origin → operators couldn't log in); a TLS
|
||
deploy leaves it unset. Added a "Deploy-time server configuration (runbook)" section to
|
||
[[disk-os-hardening]] documenting COOKIE_SECURE=0 (+ JWT_SECRET / EVENT_SIGNING_KEY) and corrected
|
||
the stale "Secure when NODE_ENV=production" line on [[local-jwt-auth]]. auth.test.ts (5) pins the
|
||
matrix; server 80/80.
|
||
|
||
## [2026-06-22] feat | Admin Reports dashboard v1 (ledger-first charts) + camera "Test ANPR"
|
||
Built the admin Reports screen (`/setup/reports`, gated `report:read`): one server call
|
||
(`GET /api/reports/summary?from&to&bucket`, + `.csv` export) aggregates entry/exit counts and all
|
||
money straight from the signed `ledger_events` (LEDGER-FIRST decision) — the same source the
|
||
`shift_z_report` reconciles, so totals tie out to the drawer; the 3-way revenue split (ticket /
|
||
subscription sale / out-of-window) mirrors the Z-report. Duration/occupancy stats come from the
|
||
`sessions` cache (flagged). All bucketing is in the SITE timezone (`siteTz()`). Views: KPI cards,
|
||
entry/exit line, revenue bar + cash/card split, revenue-mix pie, peak-hours histogram, numeric
|
||
breakdown, subscription stats. Charts via Recharts (MIT), lazy-loaded into its own chunk (111KB gz)
|
||
so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/duration/subs; server
|
||
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
|
||
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
|
||
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
|
||
|
||
## [2026-06-22] feat | Soft delete + recycle bin for master data (migration 0012)
|
||
Accidental admin deletes used to be hard + unrecoverable. Now users/roles/subscriptions/plans/
|
||
tariffs soft-delete: migration 0012 adds nullable deleted_at + deleted_by; each resource's DELETE
|
||
route STAMPS instead of removing, and every catalog list filters deleted_at IS NULL. A recycle bin
|
||
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge — gated recyclebin:read/update/delete,
|
||
new resource in the PERMISSIONS grid) lists everything soft-deleted, restores, or purges; a 6-hourly
|
||
+ startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever).
|
||
Key invariants: soft-deleted users CAN'T log in (login rejects deleted_at; no-lockout counts live
|
||
admins only); a soft-deleted subscription doesn't open the barrier; PLANS are versioned so a delete
|
||
stamps all version rows of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
|
||
rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role
|
||
(guard resolves missing role → empty perms, safe). Signed ledger is OUT of scope (no delete path).
|
||
Web: a Recycle bin tab under Setup (RecycleBin.tsx). Tests: recycle-bin.test.ts (9 unit) +
|
||
recycle-bin-routes.test.ts (4 integration: delete→can't-login→restore→login, purge, gating, 409
|
||
reuse); server 103/103, build+lint 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]].
|
||
|
||
## [2026-06-22] feat | Hikvision Alarm Server event-push ingress (discovery-first)
|
||
Newer Hik firmware (Event → Smart/VCA "Detection Target: Human/Vehicle" + Notify Surveillance
|
||
Center + Alarm Settings → Alarm Server) HTTP-POSTs an EventNotificationAlert on each detection.
|
||
Added POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts) — same machine-push
|
||
pattern as the Dingtian Input Link: source-IP guarded + OPTIONAL Digest, not behind SPA cookie/CSRF.
|
||
Permissive/discovery-first: a wildcard content-type parser takes ANY body as raw bytes (XML,
|
||
multipart+JPEG, JSON — Hik varies by firmware), stores it verbatim as a kind:"alarm" device_event,
|
||
and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + log line. The
|
||
hikvision DRIVER gained alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (so
|
||
setup offers the backend push IP). NOT yet a barrier trigger or DeviceReadEvent — records only; the
|
||
read-bus/ANPR wiring is the next step once the real payload is captured (advisory-only rule still
|
||
governs). Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw JSON, wrong-IP
|
||
404, disabled 404, unknown-device 404); server 109/109, build+lint 14/14. See [[lpr-camera]].
|
||
|
||
## [2026-06-22] debug | Hikvision event-push field session — fixes + a verified-dead camera
|
||
Long session getting a real Hik camera to POST events. Server/integration fixes (committed): listen
|
||
on ALL methods (camera probes with GET/etc, not just POST); record rejected pushes too (kind
|
||
"alarm-rejected" + reason) so "nothing arrived" is never ambiguous; a GET /api/devices/hikvision/
|
||
alarms read endpoint; per-device skipSourceIpCheck (WSL mirrored mode REWRITES the inbound source IP
|
||
to the host's own, so the source-IP guard rejected every push); and a real checkbox renderer for
|
||
type:"boolean" config fields (they were saving the STRING "true"). Then proved — via the camera's
|
||
OWN state, not our server — that the specific DS-2CD1043G2-LIU unit has a DEAD event engine: silent
|
||
alertStream (no heartbeat), EventScribe:except, diskfull on Event/triggers, dead RTC (rtc get time
|
||
error / clock at 1970), and ZERO outbound to :3000 over a 3-min netstat watch, surviving reboot +
|
||
basic reset + FULL factory reset. Verdict: defective camera (RMA), not our code. Captured the
|
||
diagnostic method (alertStream silence / SSH showStatus / netstat) in [[lpr-camera]]. Fallback for a
|
||
dead-push camera: pull + [[opencv-anpr-service|vision]] (the same camera still serves snapshots).
|
||
|
||
## [2026-06-22] CORRECTION | Hik camera was NOT defective — the cause was an undrawn detection area
|
||
Supersedes the earlier "[2026-06-22] debug" entry's conclusion that the DS-2CD1043G2-LIU had a dead
|
||
event engine needing RMA. WRONG. The camera is healthy; it pushed a clean event the instant a
|
||
detection AREA was drawn on the frame (the "Draw Area" step). With no region drawn, the camera
|
||
detects nothing → generates no event → posts nothing anywhere — which produced all the symptoms
|
||
(silent alertStream, zero outbound to :3000). The diskfull / EventScribe:except / dead-RTC findings
|
||
were red herrings (the RTC is genuinely dead, hence a bogus 2032 dateTime in the payload, but it does
|
||
NOT block event push). Lesson: don't escalate to "hardware fault" while a basic config precondition
|
||
is unmet; vendor status-API error strings are unreliable. Confirmed real payload: multipart/form-data
|
||
(MoveDetection.xml) with EventNotificationAlert -> eventType=VMD, eventState=active,
|
||
targetType=vehicle (vehicle/human classified ON-DEVICE), targetRect bounding box. The push endpoint +
|
||
all-methods + skipSourceIpCheck + rejection-recording are all validated against the real device now.
|
||
See [[lpr-camera]] (corrected).
|
||
|
||
## [2026-06-22] design+build | Lane presence (BUILT) + ANPR subscriber-entry "bridge" (PLANNED)
|
||
Off the now-working Hik vehicle event: BUILT advisory lane busy/free booth barrier lights
|
||
(LaneStatus + WS; timeout-driven "free" since the camera sends no leave signal — TTL settled at 30s
|
||
after a controlled in/out test showed movement-driven ~1-3s re-fire but ~15-25s gaps for a still
|
||
car, and ~no dwell lag on leave). Measured the camera's hard limits: no current-state poll exists,
|
||
and flipping notificationRecurrence beginning->recurring via ISAPI is silently reverted (firmware
|
||
locked). Then narrowed the bigger ambition to a clean, high-value scope: ANPR for SUBSCRIBERS ONLY —
|
||
a plate read at the lane admits a subscriber through the EXISTING gated subscription flow. Found the
|
||
whole subscription side already supports via:"plate" (match + dispatch + gate); the one missing piece
|
||
is a small `apps/server` HANDLER ("the bridge", ~40 lines, NOT a new service/container) that on a
|
||
camera vehicle event snapshots -> ANPR -> on a HIGH-confidence match (new VISION_ENTRY_MIN_CONFIDENCE)
|
||
-> debounces (required for ledger correctness, not CPU: ~1Hz re-fire would drive repeat entries) ->
|
||
emitRead{kind:"plate"}. Both directions, opt-in per camera (config.anpr), plate never the sole
|
||
authority (routes through the gate). REJECTED: continuous livestream presence + per-car queue
|
||
tracking/make-model (needs a vehicle detector the plate-only vision lacks + appliance compute we can't
|
||
measure on the dev PC). Vision checked: fast_alpr live, ~50ms/frame on DEV PC (appliance TBD —
|
||
booth-PC test ~2026-06-23). New page [[lane-presence-and-anpr-entry]]; updated [[lpr-camera]],
|
||
[[subscription]], index.
|
||
|
||
## [2026-06-22] build | ANPR subscriber-entry "bridge" — BUILT
|
||
Built the bridge planned in the previous entry: `apps/server/src/anpr-entry.ts` (`AnprBridge`). On a
|
||
vehicle/non-`inactive` push from an `anpr`-opted-in camera, `hikvision-alarm.ts` hands the deviceId
|
||
to the bridge (fire-and-forget, never awaited on the camera's 200). The bridge debounces
|
||
(camera-level, pre-snapshot), pulls a FRESH snapshot (reused `snapshot.ts buildCamera`), runs
|
||
`vision.analyze`, applies a stricter entry floor (`VISION_ENTRY_MIN_CONFIDENCE`=0.85), then — the key
|
||
safety choice settled with the user — MATCHES the plate to a subscription BEFORE emitting: a
|
||
subscriber → `emitRead{kind:"plate"}` (→ existing `ReadDispatcher`→gated `SubscriptionFlow`); a
|
||
non-subscriber → advisory `anpr-skip` device_event, nothing emitted (so a random/printed plate never
|
||
reaches the transient plate-as-ticket exit path). Fail-soft throughout. `server.ts` reordered so the
|
||
read flows are constructed before the hik-alarm registration. New env: `VISION_ENTRY_MIN_CONFIDENCE`,
|
||
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
|
||
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
|
||
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
|
||
|
||
## [2026-06-22] build | Cancel (void) a wrongly-printed ticket + refused-vs-anomaly display split
|
||
Operator need: cancel a misprinted/test/wrong-vehicle ticket, traceably. Built it as a SIGNED `void`
|
||
(append-only — the vehicle_entry is never touched): new `apps/server/src/void-flow.ts` (`VoidFlow`)
|
||
appends void{ voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
|
||
POST /api/tickets/void gated event:void + open shift; operator from JWT, reason REQUIRED. Refuses a
|
||
subscription / already-exited / already-voided / PAID ticket (refund = out of scope). The CRUX: a
|
||
void must fold the session CLOSED everywhere it's counted — done in occupancy.ts (count +
|
||
reserved-spots, −1 like an exit), pay-station.ts (lookup/activeSessions), exit-flow.ts (#sessionFor),
|
||
and reports.ts (excluded from the entries stat). No barrier action (the car never entered). Booth UI:
|
||
"Cancel ticket" in the pay/exit lookup modal (transient + unpaid + open; gated on event:void) with a
|
||
preset-or-free reason prompt. Part 2 (display-only): the Live feed mislabeled benign refused-action
|
||
events (exitRefused/entryRefused/permitRefused — e.g. a double card-scan) as red ANOMALI; now
|
||
classified via event-detail.tsx isRefusedWarning and shown as amber REFUZUAR/REFUSED, reserving red
|
||
ANOMALI for genuine red-flags. No ledger change → historical events reclassify too. New reason code
|
||
void.ticketCancelled (shared + both web catalogs). Tests: void-flow.test.ts (8) + occupancy void fold;
|
||
141 server + 87 shared green; build+lint (TS + i18n parity) green. Updated [[parking-session]].
|
||
|
||
## [2026-06-22] build | Docker images for non-desktop apps (server+SPA, vision) + branch-aware build pipeline
|
||
Containerized the two runtime apps. parking-server = Fastify API + the bundled React SPA (wired
|
||
@fastify/static in new static-spa.ts — serves apps/web/dist with an SPA index.html fallback, GET-only
|
||
and excluding /api + /health so it never shadows the backend; a NO-OP in dev where no dist exists).
|
||
parking-vision = the Python/uv ANPR service, ships --extra alpr with model weights pre-warmed into the
|
||
image (offline-first), engine env-selected (VISION_RECOGNIZER stub|fast_alpr). Branch-aware per the
|
||
user: images tagged branch + branch-<sha>; base docker-compose.yml + docker-compose.dev.yml (build
|
||
local, stub, ports) / docker-compose.prod.yml (pull pinned, fast_alpr, vision internal, restart
|
||
always). New .gitea/workflows/build-images.yml pushes both to git.infra.msai.al/mca/parking_solution
|
||
on push to dev/main, after a full turbo build+lint+test gate (mirrors trm/processor; optional Komodo
|
||
webhook behind KOMODO_ENABLED). KEY build lessons: use `pnpm deploy --legacy --prod` NOT
|
||
`pnpm prune` (monorepo: prune leaves the native better-sqlite3 unresolved); Alpine needs
|
||
python3/make/g++ (build) + libstdc++ (runtime); set CI=true so pnpm wipes node_modules; migrate at
|
||
BOOT via a drizzle-kit-free runtime migrator (packages/db/scripts/migrate-runtime.mjs) against the
|
||
mounted /data volume; .dockerignore must exclude **/parking.sqlite* (deploy ignores .gitignore) so the
|
||
signed ledger is NEVER baked. JWT_SECRET must be a real >=32-char value (auth.ts rejects dev-only/
|
||
insecure/change-me). VERIFIED: server image builds + runs — migrates, SPA serving on, /health 200,
|
||
/ + /booth serve HTML, /api/nope JSON 404, no sqlite outside /data. Vision image build + smoke in
|
||
progress. New page [[container-deployment]]; updated [[vision-service-packaging]] (resolved its two
|
||
open Qs), index. Server tests stay 141 green (SPA serving guarded on dist existence).
|
||
|
||
## [2026-06-23] provision | First booth appliance — Dell OptiPlex 7070, Win11 → Ubuntu 26.04 LTS, encrypted + TPM-sealed
|
||
Provisioned the first real booth PC. Hardware: Dell OptiPlex 7070, i5-8500, 238GB SSD, discrete
|
||
Nuvoton TPM 2.0 (NOT Intel PTT — Get-Tpm ManufacturerIdTxt NTC). Formatted Win11 → Ubuntu 26.04 LTS
|
||
(the decided platform). Gotchas hit + resolved, in order: (1) Ventoy USB → 0x1A Security Violation
|
||
under Secure Boot (Ventoy's loader not in db) → flash the ISO directly; (2) the 7070 BIOS Expert Key
|
||
Management is edit-only, no "View Key" → can't inspect db, so the live-USB boot IS the verification
|
||
(it reached the installer with Secure Boot ON → MS 3rd-party UEFI CA confirmed present); (3) the
|
||
installer's "Use hardware-backed encryption" FAILED with PCR_UNUSABLE / "secure boot policy (PCR7) …
|
||
timestamp revocation (dbt) … not supported" — Ubuntu's automated FDE profiler can't model PCR7 on
|
||
Dell firmware with a dbt; NOT a TPM/SB fault. Workaround: "Encrypt with a passphrase" (plain LUKS) +
|
||
MANUAL TPM seal after boot via systemd-cryptenroll --tpm2-pcrs=7 /dev/sda3 (PCR 7 only — avoids
|
||
kernel-churned 4/8/9 that would drop every boot to the passphrase). Two LUKS slots kept (0 password =
|
||
recovery, 1 tpm2 = auto-unlock); crypttab gets tpm2-device=auto; update-initramfs; reboot → BOOTS
|
||
STRAIGHT TO LOGIN, no passphrase → TPM auto-unlock VERIFIED (unattended reboot achieved). New runbook
|
||
page [[appliance-provisioning]] (every command verified on hardware); cross-linked from
|
||
[[disk-os-hardening]] (resolves impl half of open-questions #12 for unit 1) + index. REMAINING on the
|
||
box: GRUB password, Docker install, run the parking-server/parking-vision stack.
|
||
|
||
## [2026-06-23] provision | First booth appliance — GRUB edit-lock added; OS hardening COMPLETE
|
||
Added the GRUB password (edit-only mode via --unrestricted) to the first booth unit. WHY it matters
|
||
specifically: the PCR-7 TPM seal does NOT cover the GRUB-cmdline attack (editing the kernel line to
|
||
init=/bin/bash doesn't change PCR 7, so the TPM still releases the LUKS key → root shell on the
|
||
decrypted disk). Edit-only mode keeps unattended boot (the box still boots password-free; the
|
||
password is required only to EDIT entries / open the GRUB shell) — the right config for an unattended
|
||
booth. Verified BOTH halves in /boot/grub/grub.cfg before rebooting (password_pbkdf2 ≥1, unrestricted
|
||
≥1) and on reboot: boots straight to login (no GRUB prompt, TPM auto-unlock intact) AND pressing `e`
|
||
prompts for admin+password. OS hardening on unit 1 is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7)
|
||
+ Secure Boot (Deployed) + GRUB edit-lock. Updated [[appliance-provisioning]] (§5 GRUB now a verified
|
||
step, §5b further-hardening TODO: SSH key-only, kiosk lockdown, signing key→TPM, autoremove old
|
||
kernel) + [[disk-os-hardening]]. STILL TODO on the box: Docker install + run the parking stack (needs
|
||
the images pushed — dev push + registry secrets pending).
|
||
|
||
## [2026-06-23] deploy | First booth GO-LIVE — Docker stack running + web-access fixes (CI uv, compose env, relative /api, Caddy)
|
||
Deployed the two images onto the hardened booth (Dell 7070, Ubuntu 26.04) and worked through the
|
||
real-world bring-up issues. (1) Operator/admin OS user split: created a dedicated sudo `admin` user,
|
||
removed the auto-login operator from `sudo` (and should drop `lxd`/`lpadmin` — lxd is a root-escape
|
||
path); admin is the only sudo, operator auto-logs in unprivileged. (2) Docker 29.6 installed; deploy
|
||
dir /opt/parking_solution with hand-copied compose + .env; registry login to git.infra.msai.al; the
|
||
stack came up clean — vision fast_alpr loaded from the BAKED cache (0 downloads → offline-first
|
||
confirmed on real hardware), server migrated /data, both healthy. (3) Seeded the first admin via
|
||
`docker compose exec server node scripts/seed-admin.mjs` (bcrypt, writes users table — NOT the signed
|
||
ledger). FIXES committed this session: CI `astral-sh/setup-uv` action failed on the Gitea runner →
|
||
install uv via its official curl script instead (both ci.yml + build-images.yml) [0a22eab]; the base
|
||
compose only forwarded JWT_SECRET/DATABASE_URL/VISION_URL → added COOKIE_SECURE (CRITICAL on plain-
|
||
http or login cookies never send), WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED [1092316];
|
||
the SPA had VITE_API_BASE=http://127.0.0.1:3000 baked in (leaked from apps/web/.env.production, which
|
||
is for the TAURI build but Vite auto-loads it for every build) → server Dockerfile now empties it via
|
||
.env.production.local so the SPA uses RELATIVE /api and works from ANY host [77b2acb]; added a CADDY
|
||
reverse proxy (prod override) so the booth is reached on a clean port-80 URL, server goes internal,
|
||
Caddyfile binds :80 to match any hostname incl. parksystems.msai.al [c637b27]. NET RESULT: no domain
|
||
baked into any image — naming controlled by hosts/DNS on-site; admin can reach it from another LAN PC.
|
||
Verified the relative-/api + Caddy fix end-to-end locally (Host: parksystems.msai.al through :80 →
|
||
SPA + /api/auth/login reach the server, no CORS). See [[container-deployment]] "Web access",
|
||
[[appliance-provisioning]]. REMAINING on the box: push dev so CI rebuilds parking-server:dev with the
|
||
relative-/api fix, then pull on the booth; kiosk autostart; operator user lxd/lpadmin cleanup.
|
||
|
||
## [2026-06-24] build | Self-service user profile + desktop installers in CI
|
||
Two app-side additions. (1) **Self-service profile** — any signed-in user can now edit their OWN
|
||
`fullName`/`email` and change their OWN password (proving the current one), without any `user:*`
|
||
permission. New routes `PUT /api/auth/profile` + `PUT /api/auth/password` (act only on `req.user.sub`;
|
||
cannot touch username/role; CSRF-guarded), SPA screen `apps/web/src/Profile.tsx` at `/profile` (header
|
||
username chip links to it), `email` added to the session view + `SessionUser`. 7 new tests
|
||
(`routes/profile.test.ts`); server 148/148 green. Distinct from the admin user-manager (`routes/users.ts`,
|
||
`user:*`-gated). See [[local-jwt-auth]]. (2) **Desktop in CI** — new `.gitea/workflows/build-desktop.yml`
|
||
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
|
||
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
||
[[desktop-shell-tauri]] "Desktop in CI".
|
||
|
||
## [2026-06-24] build | Radar presence input + button-light output on the Dingtian
|
||
The first booth wired an **entry button on I1** and a **[[hikvision-radar|Hikvision radar]] on I2**
|
||
(closes a dry contact on detection), plus the **button's 12 V lamp on a spare relay**. Modelled as
|
||
children of the access controller config — no new device category. (1) The radar reuses the existing
|
||
`relays[].presenceInput` one-car-one-ticket gate; added `presenceKind: loop|radar` (label) and
|
||
`presenceActiveLow` (a radar may idle opposite the button — the Dingtian has ONE board-wide resting
|
||
level, so a per-input override `inputActiveLow` inverts just that terminal; pure helper
|
||
`inputActive()`). (2) New device-agnostic **`AuxOutputDevice.setAux(channel,on)`** capability (Dingtian
|
||
latch) so business logic drives a NON-barrier lamp through the interface — barriers still only
|
||
`pulseOpen` ([[barrier-not-a-door]] preserved). (3) New `ButtonLightController`
|
||
(`apps/server/src/button-light.ts`): subscribes to the radar input edge + the camera
|
||
[[lpr-camera|lane status]] and drives a **3-state lamp** — radar+car=SOLID, radar-only=BLINK (~1 Hz),
|
||
else OFF; **fails OFF**; de-duped. (4) SetupWizard: presence kind + active-low + a button-light relay
|
||
picker; i18n parity (sq+en). Tests: `button-light.test.ts` (truth table + blink + fail-OFF + de-dupe),
|
||
`access-dingtian.test.ts` (active-level inversion). Workspace build+lint+test green (158 server tests).
|
||
A radar detection NEVER opens a barrier on its own — it only gates the button ([[threat-model]]). See
|
||
[[hikvision-radar]], [[button-light-indicator]], [[entry-double-press]], [[dingtian-relay]].
|
||
|
||
## [2026-06-24] fix | Booth bring-up fixes — relay password, form split, lamp concurrency
|
||
Three fixes from wiring the radar/lamp on the first booth (committed 420542c, fd15988, 830993b on
|
||
top of the 2915d14 feature). (1) **"Offline despite ping"** — the Dingtian's `relay_pw` is in every
|
||
binary frame incl. the status read, but had NO form field, so Test connection sent 0 → device
|
||
silently drops the packet → "offline" (ping is ICMP, unrelated). Added a **"Relay control password"**
|
||
secret field; because the secret is redacted, the test endpoint re-merges it by device id but ONLY
|
||
when host/port/driver match the stored row (a redirected probe can't exfiltrate it — `setup-secrets.test.ts`).
|
||
(2) **Form split** — the controller editor now has separate **Outputs** (relays + pulse-open + lamp)
|
||
and **Inputs** (button + presence/radar terminals, "For relay N") sections; UI-only, storage
|
||
unchanged. `pulse open (ms)` clarified as a relay/output setting, not an input. (3) **Lamp stuck
|
||
on/off** — the blink fired fire-and-forget `setAux` over UNORDERED UDP; concurrent on/off packets
|
||
reordered and the relay latched on the last-processed one. Replaced with a serialized desired-state
|
||
worker (one in-flight send/lamp, re-converges to the latest state → final state authoritative). Also
|
||
**hot-reload**: the lamp map now reconciles against live config each event, so a button light added
|
||
in the UI works without a server restart. Workspace build+lint+test green (163 server tests). See
|
||
[[dingtian-relay]] ("offline despite ping" + secret re-merge), [[button-light-indicator]] (serialized
|
||
sends + hot-reload).
|
||
|
||
## [2026-06-24] build | Printer USB transport behind the ESC/POS render layer
|
||
The ESC/POS printer drivers were **TCP-only** (every path went through `sendRaw`/`probe` to a raw
|
||
socket on port 9100); the original BOM intended one adapter to cover "USB **or** network". Added a
|
||
**USB transport** behind the existing render layer without touching a single `render*()` function:
|
||
a discriminated `Transport` (`transportFromConfig` → `{kind:"tcp",host,port}` | `{kind:"usb",
|
||
devicePath}`) and `sendTo`/`probeTo` dispatchers in `printer-escpos.ts`; USB writes the same ESC/POS
|
||
bytes to a kernel **`usblp`** char device (`/dev/usb/lp0`) via a plain `fs` write — **no libusb/CUPS/
|
||
native dep** (keeps MIT-only + minimal-deps appliance). `cashino` + `rongta` resolve a Transport once;
|
||
both are reachability-only over USB, and the Rongta's HTTP **status page degrades to the open-the-node
|
||
probe** over USB (no guessed paper/cover — the standing honesty rule). Non-`usb` configs are unchanged
|
||
(host-only = TCP), so no migration. Setup UI gains a **Connection** select + **USB device** field;
|
||
host/port made not-required so a USB printer needs neither. Tests: `printer-escpos.test.ts` (USB writes
|
||
the exact rendered bytes; probe present/absent; `transportFromConfig` TCP back-compat) +
|
||
`printer-cashino.test.ts` (USB-configured driver prints to the node, ready/offline). Devices suite
|
||
green (29). **Flagged open-questions #14**: confirm the on-site printer is USB and bake the
|
||
**usblp + udev write-access** rule into the appliance image (provisioning, not app code; unverified on
|
||
hardware). See [[printer-usb-transport]], [[rongta-printer]].
|
||
|
||
## [2026-06-24] build | Booth operator wrapper script — scripts/booth.sh
|
||
The booth PC (Ubuntu) needs one command instead of the long
|
||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …` line over the
|
||
three compose files. Added **`scripts/booth.sh`** (+ root **`.env.example`**): **prod by default**
|
||
(`ENV=dev` for the dev override); subcommands `up`/`down`/`restart`/`status`/`logs`/`pull`/`config`/
|
||
`exec`, and the requested **`update`** = `compose pull` the moving branch tag → `up -d --remove-orphans`
|
||
(recreates only digest-changed services, **named volumes/SQLite ledger preserved**) → `docker image
|
||
prune -f`. Prod **refuses to run without `.env`** (no safe `JWT_SECRET` default); dev with no `.env`
|
||
injects the documented benign local secret (the base file makes `JWT_SECRET` shell-required via
|
||
`${JWT_SECRET:?}`, which the dev override's service-level default alone can't satisfy). `down` never
|
||
passes `-v` (would wipe the signed [[append-only-event-chain|ledger]] volume); `help`/unknown-command
|
||
short-circuit before any Docker/.env requirement. Verified: prod `config` renders Caddy:80 + internal
|
||
server + pinned images + `fast_alpr`; dev `config` renders `:dev` images + `stub` + published ports.
|
||
Documented in [[container-deployment]] ("Booth operator wrapper").
|
||
|
||
## [2026-06-25] fix | Local ANPR silently degraded — `uv run` strips the alpr extra
|
||
Diagnosed via the live DB (read-only `VACUUM INTO` copy) why entry `26799912337` recorded a snapshot
|
||
but no plate: the dev box's vision service was running **stub**, and earlier real ANPR had stopped.
|
||
Root cause (NOT the Docker/compose work, which was an innocent coincidence): the dev machine runs vision
|
||
as **bare `uv run uvicorn`** against `apps/vision/.venv`, and a plain `uv run`/`uv sync` re-resolves the
|
||
venv to the lockfile **defaults**, **stripping** fast-alpr/onnxruntime — so after any `pnpm dev` the
|
||
recognizer vanishes (weights orphaned in `~/.cache`, no module in the venv) and ANPR silently becomes
|
||
"snapshot, no plate". Evidence: 28 real reads through 06-22 (yolo-v9 model, ~99% conf), venv frozen lean
|
||
since 06-19, no other env with fast_alpr on the box. **The BOOTH was never affected** — it runs the
|
||
Docker image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights pre-warmed); a
|
||
booth `ModuleNotFoundError` is a STALE image (fix: `booth.sh update`). **Fix:** vision `package.json`
|
||
`dev`/`start`/`recognize` now `uv sync --extra alpr &&` first (self-healing), `.env` set to `fast_alpr`,
|
||
+ a `dev:stub` escape hatch. Restored real ANPR locally (`/health` → `fast_alpr` ready, model loaded from
|
||
cache, no download). Documented in [[vision-service-packaging]] ("Two runtimes, one fragile").
|
||
|
||
## [2026-06-26] fix | Hikvision snapshot 503 "Device Busy" — stream selection + retry + Alarm URL helper
|
||
Three camera fixes. (1) **503 Device Busy — the REAL fix is stream selection.** First framed as
|
||
"transient, just retry" — WRONG for this camera. Hardware probe of **DS-2CD1047G3H-LIU** (10.0.10.13):
|
||
`channels/101/picture` (MAIN) → 503 `deviceBusy` on 5 consecutive probes 800ms apart, while
|
||
`channels/102/picture` (SUB) → 200 clean JPEG every time. The main encoder is PERSISTENTLY saturated;
|
||
a retry loop can't fix it. Added a **`stream` config field** to the Hikvision driver (1=main default
|
||
for back-compat, 2=sub; ISAPI id `<channel><stream>`). Verified live: setting the camera to Sub flips
|
||
its status degraded→ready (14.7KB JPEG in ~87ms). (2) **Transient retry** (still useful for a genuine
|
||
momentary blip + the de-dup case): `HttpCamera.captureSnapshot` retries 503/500 with linear backoff
|
||
(250/500/750ms ×4), fails naming it `(device busy)`, does NOT retry 401/404. Plus the already-landed
|
||
`captureSnapshotShared` removing concurrent self-collision. `healthCheck` reports a live 503 as
|
||
`degraded` (surfaces a saturated main stream rather than hiding it). Covered by `camera.test.ts`
|
||
(10 tests: retry + main/sub path). (3) **Alarm Server URL helper:** the camera setup form now generates the camera's Alarm
|
||
Settings (Destination IP / URL / Protocol / Port) ready to paste, so the operator never hunts the
|
||
deviceId or memorises the endpoint. CRUCIAL: host/port come from the **backend address on the camera's
|
||
subnet** (`backendIpForDevice` + server port, the same probe the push-IP picker uses) — NOT
|
||
`window.location.origin` (the SPA's dev/proxy origin, which would wrongly say `localhost:5173`).
|
||
Verified live: matches the on-camera config field-for-field (10.0.10.203 / …/event / HTTP / 3000).
|
||
Shows a "save first" (needs a deviceId) then "test first" (needs the resolved backend IP) hint.
|
||
Documented in [[lpr-camera]] ("503 Device Busy"). Devices 6 new tests; server 168 green.
|
||
|
||
## [2026-06-26] fix | QR reader status was a LIE (hardcoded "ready") → real ICMP liveness
|
||
Two genuinely-OFFLINE QR readers showed GREEN in the status bar. Cause: the QR-reader adapter
|
||
(`StubReader`) had `healthCheck → { ready, "stub" }` hardcoded — it never probed anything. These are
|
||
PUSH devices (scan → GET our backend, resolve by serial) that expose **no TCP port**, so a connect
|
||
probe (cameras/printers) has nothing to hit; the stub "solved" that by lying. False-healthy is the
|
||
worst failure for a status bar. Fix: an **optional reader IP** (monitor-ONLY — scans still resolve by
|
||
serial, operation unchanged) + an **unprivileged ICMP ping** (`drivers/icmp.ts`: shells `/bin/ping`
|
||
`-c1`, exit-0 = reply; no native dep, no CAP_NET_RAW). `healthCheck`: IP replies → `ready`, no reply →
|
||
`offline`, **no IP → `degraded` ("set IP to monitor")** (never a false green). Booth compose
|
||
(`docker-compose.prod.yml`) sets `net.ipv4.ping_group_range=0 2147483647` so `/bin/ping` works
|
||
unprivileged for the non-root container user. Verified on hardware: the readers (10.0.10.7/.8) answer
|
||
ICMP on the device VLAN (eth1) — distinct MACs — and the UI Test connection shows "● ready — ping
|
||
10.0.10.7". (NB: an earlier "offline" reading was a WSL wrong-route artifact, not the readers.) Covered
|
||
by `reader.test.ts` (4 tests). Documented in [[device-status-monitoring]]. Devices +4 tests, all green.
|
||
|
||
## [2026-06-27] fix | booth.sh failed in the flat /opt layout (couldn't find compose files)
|
||
|
||
The booth deploys the compose files **flat** in `/opt/parking_systems/` with `booth.sh` next to
|
||
them, but the script assumed `<repo>/scripts/` and did `cd ..` → `REPO_DIR=/opt` (no compose
|
||
files); `usage()` then `sed`-read a now-relative `$0` → "can't read booth.sh". That's why
|
||
`sudo ./booth.sh` only printed help and `/bin/bash booth.sh` errored. Fix: **discover** the
|
||
compose files (script's own dir → `../` → `$PWD`), `usage()` reads an absolute `$SELF`. Also:
|
||
`.env.example` defaulted `TAG=main`, but the registry only has `dev`/`dev-<sha>` (no main build) →
|
||
`compose pull` 404s; default to `TAG=dev` + documented the moving-vs-immutable tag scheme.
|
||
Reproduced the booth's flat layout in a scratch dir; all forms (`./booth.sh`, `/bin/bash
|
||
booth.sh`, `config`, absolute-path) verified. Commit 83298bc.
|
||
|
||
## [2026-06-27] decision | Fleet deployment → Komodo Periphery over NetBird
|
||
|
||
booth.sh hit its ceiling: fine for one SSH-able box, but no remote/no-SSH op, no fleet view, no
|
||
deploy history, no rollback — and the fleet is **many/growing**. Decision: **Komodo Periphery**
|
||
on each appliance, driven by an existing **Komodo Core** over the **NetBird** mesh, running the
|
||
**same** compose files ([[container-deployment]] pipeline unchanged); `booth.sh` demoted to
|
||
break-glass. Three settled choices: many/growing fleet · deploys **manual + pinned** to a
|
||
`dev-<sha>` (no webhook — preserves the determinism we chose by pinning) · secrets
|
||
**Komodo-managed, per-booth + unique**. Threat-model caveats recorded: Periphery is a root agent
|
||
(bind to NetBird interface only, passkey+TLS, part of the TCB); `EVENT_SIGNING_KEY` in Core is a
|
||
fraud-root blast radius → per-booth keys + [[atecc608|ATECC608]] as the real
|
||
long-term signer; Core becomes Tier-0. GPL-3.0 OK (external ops tooling, not a shipped dep — same
|
||
boundary logic as the AGPL vision exception). New page [[fleet-deployment-komodo]]; infra-as-code
|
||
sketch in `komodo/` (`resources.toml` + README + `.env.komodo.example`). Catalogued in `index.md`;
|
||
`container-deployment` cross-linked + reframed (booth.sh = fallback).
|
||
|
||
## [2026-06-27] deploy | First Komodo booth deploy VERIFIED end-to-end (park-buzi)
|
||
|
||
Took the first booth through the whole Komodo flow on real hardware (Core v2.1.2 → agent reported
|
||
v2.2): onboarding key → Periphery installed **user-mode** (runs as `admin`, no root daemon,
|
||
**outbound** so the booth opens no inbound port) → server `park-buzi` **OK** in Core → Stack
|
||
(repo `mca/parking_solution`@`dev`, base+prod compose, registry account `komodo`, per-booth
|
||
`[[…]]` secrets) → all containers green → admin seeded via Komodo's container terminal (no SSH).
|
||
Then `komodo/resources.toml` rewritten to mirror the **working** Stack (exported from Core, v2.2
|
||
field shape, **Stack-only — no `[[server]]`** since onboarding owns the server), committed + pushed
|
||
(`dev` 9918f27); a ResourceSync reads it clean — **empty diff / Execute disabled = already in
|
||
sync** (success, not error). `booth.sh` fixed for the flat `/opt` layout earlier (83298bc).
|
||
Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `core_address` is Core's
|
||
**proxy URL** not `:9120` (exposed-not-published → Connection refused); **git-auth ≠ registry-auth**
|
||
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
|
||
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
|
||
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.
|
||
|
||
## [2026-06-27] query | "Subscribers auto-enter but don't auto-exit" → NOT a bug; G3H main-stream snapshot is structurally dead
|
||
|
||
Investigated via a read-only VACUUM copy of the dev DB. Caca subscriber's ledger: clean
|
||
entry/exit pairs until ~16:38, then 6 entries + 0 exits. Traced to the **exit camera 10.0.10.13
|
||
(DS-2CD1047G3H-LIU)** producing only 2 reads ever (vs 60 on the entry cam) — every exit-direction
|
||
snapshot after 16:38 was **HTTP 503 "device busy"**, so the ANPR exit read never got a frame →
|
||
no `emitRead` → no auto-exit. The subscription-flow exit logic, camera→relay binding (relay 2 =
|
||
exit, anpr on), and `stream: "2"` config were all **correct**. Direct hardware re-probe after a
|
||
camera reboot + closing web connections: **sub (102) 10/10 rapid → 200 (~50 ms)**, **main (101)
|
||
3/3 → 503 in ~20 ms (instant reject)**. So main-stream snapshots are **structurally unavailable**
|
||
on this model (sub mandatory), and the 503 storm was **connection-slot exhaustion from manual
|
||
main-stream testing** holding the camera's slots (reboot clears). Recorded in the
|
||
the `g3h-main-stream-snapshot-503` LLM memory + a 2026-06-27 sharper-finding note in [[lpr-camera]]
|
||
("503 Device Busy"). No code changed — diagnosis only.
|
||
|
||
## [2026-06-27] query | "Auto-exit" RESOLVED — a 4-layer CAMERA fault on the G3H, never our code
|
||
|
||
Continuation of the above. Drove the full diagnosis to ground using a **dumb HTTP sink**
|
||
(`scratch-camera-sink.py`) the exit camera's Alarm Server was pointed at, to capture the verbatim
|
||
push. Every assumption about *our* code was wrong; all real causes were camera-side on the
|
||
**DS-2CD1047G3H-LIU** (`10.0.10.13`):
|
||
(1) 503s were mostly **manual main-stream testing** (main 101 = instant 503 structurally; sub 102 =
|
||
perfect) + connection-slot exhaustion (reboot clears) — NOT a retry/flow bug.
|
||
(2) **Blocker #1:** the camera never POSTed at all (0 alarms ever vs 1126 on the entry cam); its
|
||
**Diagnose dump showed a CORRUPT config DB** (`Main Db is broken`/`db_restore failed`/`reset cfg`) —
|
||
factory-reset fixed it.
|
||
(3) Gateway/DNS was a **red herring** (working cam had none; broken cam had both).
|
||
(4) **Blocker #2:** post-reset it pushed **plain `VMD` with no target** → backend gates on
|
||
`target=="vehicle"` (`hikvision-alarm.ts` reads `<targetType>`/`<detectionTarget>`/`<objectType>`)
|
||
→ ignored. Enabling the AcuSense **Vehicle target filter** made the push carry
|
||
`<targetType>vehicle</targetType>` (confirmed live on a drive-through). "VMD/Motion" is the right
|
||
event for this class — it's the *target filter* that matters.
|
||
Flagged an **observability gap**: a camera with `alarmPushEnabled=true` and 0 pushes ever should be
|
||
a surfaced status (cf. the reader-liveness fix). Recorded in the `g3h-anpr-push-gotchas` memory + a
|
||
new troubleshooting section in [[lpr-camera]]. No code changed — diagnosis + camera reconfig only.
|
||
|
||
## [2026-06-28] refactor | Unified controller relays into one event→action list (drop config.buttonLight)
|
||
Reframed the controller "Outputs — relays" model with the user: **Entry / Exit / Both are EVENTS**,
|
||
not a "direction" — a relay is uniformly *"when EVENT X happens, do action Y"*. Barrier events
|
||
(`entry`/`exit`/`both`) `pulseOpen`; a new **`radarAlert`** event drives a non-barrier alert lamp
|
||
(blink while its trigger input is active, SOLID once the camera confirms a car). **Dropped the
|
||
separate `config.buttonLight` block** — the lamp is now just another `config.relays[]` row
|
||
(`direction:"radarAlert"`, carrying `triggerInput` + blink cadence). One list, one editor, one shape;
|
||
a future "R4 alert" is just another row with its own trigger input — no new config, no code change.
|
||
The proven `ButtonLightController` 3-state machine (serialized UDP, fail-OFF, hot-reload) is kept
|
||
verbatim — only its source changed from `buttonLightOf()` to `alertRelaysOf()`, keyed per
|
||
`controllerId:relay` so several alert relays on one controller run independently. Every barrier
|
||
resolver skips `radarAlert` rows (no auto-open; barrier-not-a-door intact). Touched
|
||
`device-resolve.ts`, `button-light.ts`, `device-monitor.ts`, web `api.ts` + `SetupWizard.tsx` (the
|
||
dropdown gained a "Radar alert" option that reveals trigger/blink inputs), i18n sq+en. Tests:
|
||
rewrote `button-light.test.ts` to the `radarAlert` row + added a two-independent-alert-relays case;
|
||
full workspace `build lint test` green (173 server tests). Updated [[button-light-indicator]],
|
||
[[dingtian-relay]], memory `access-direction-is-per-relay`.
|
||
|
||
## [2026-06-28] refactor | Generic controller inputs (config.inputs[]) — the twin of unified relays[]
|
||
After unifying OUTPUTS into one event→action `relays[]`, did the same for INPUTS — the user hit the
|
||
wall that **there was no way to add a free-standing input** (e.g. an EXIT radar): inputs were fields
|
||
bolted onto an entry barrier relay (`relays[].button/presenceInput/...`) and the UI only rendered a
|
||
button+presence block per entry/both relay. Now a first-class **`config.inputs[]`** list — each row
|
||
`{ input, role: "button"|"presence"|"alertTrigger", relay?, kind?, activeLow?, cooldownSec? }` — with
|
||
a "+ Add input" button. An exit radar = just another `presence` row serving the exit relay. **Keystone:
|
||
`inputsOf(row)`** returns `config.inputs[]` or SYNTHESIZES it from the legacy per-relay fields, so
|
||
`relayForButton`/`relayForPresence` resolve identically from either shape — **zero-downtime, no DB
|
||
migration** (old configs keep working until re-saved; the UI seeds its editor from the synth).
|
||
`entry-flow.ts` is unchanged (resolves through the same functions). Also fixed a latent bug this
|
||
exposed: the alert lamp's camera **lock** was hardcoded to the ENTRY camera — added
|
||
`relays[].lockLane: "entry"|"exit"` (button-light tracks both `#entryBusy`/`#exitBusy`; a lamp goes
|
||
SOLID off its own lane's camera), so an exit radar's lamp locks on the EXIT camera. Driver: extracted
|
||
`activeLowFrom(config)` (merges inputs[] `activeLow` + legacy `presenceActiveLow` + the `inputActiveLow`
|
||
escape hatch). Touched `device-resolve.ts`, `button-light.ts`, `access-dingtian.ts`, web `api.ts` +
|
||
`SetupWizard.tsx` (InputEditor rewritten to a generic list; role select folds loop/radar; OutputEditor
|
||
radarAlert row gained a lock-lane select), i18n sq+en. Tests: new `device-resolve.test.ts` (inputs[]
|
||
resolution + legacy-fallback identical + exit-radar resolves to the exit relay), exit-lamp lockLane
|
||
case in `button-light.test.ts`, `activeLowFrom` cases in the dingtian suite. Full workspace
|
||
`build lint test` green. Updated [[entry-double-press]], [[button-light-indicator]], [[dingtian-relay]],
|
||
memory `access-direction-is-per-relay`.
|
||
|
||
## [2026-06-28] feat | Booth Entry/Exit lights blink on radar presence (mirror relay 3)
|
||
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy) off the camera
|
||
lane-status only — they couldn't show the radar-only "detected, not yet confirmed" state that makes
|
||
the physical button lamp (relay 3) blink. Added that signal end-to-end: a small server tracker
|
||
**`LanePresence`** (lane-presence.ts) subscribes to `deviceEvents.onInput`, resolves each presence
|
||
edge to its lane via a new **`presenceLaneOf`** (device-resolve.ts) — direction-agnostic (entry AND
|
||
exit), unlike the entry-gated `relayForPresence` — and emits a `lane-presence {entry,exit}` bus
|
||
event on change. The WS forwards it (hello snapshot + push) into `live-store.radar`; `BarrierLight`
|
||
(BoothScreen.tsx) became **3-state**, mirroring relay 3 exactly: radar+camera-free → BLINK green↔red
|
||
~1 Hz (`.lane-blink` keyframe in index.css, holds solid-red under prefers-reduced-motion);
|
||
camera-busy → SOLID red; else SOLID green. Same input + same rule as the lamp, so screen and post
|
||
never disagree. A test (lane-presence.test.ts) caught a real bug: the first cut reused
|
||
`relayForPresence`, so the EXIT lane never resolved (entry-gated) and never blinked —
|
||
`presenceLaneOf` fixes it. Full workspace build/lint/test green (185 server tests). Updated
|
||
[[button-light-indicator]] (new "On-screen twin" section).
|
||
|
||
## [2026-06-28] fix+feat | Booth feed plate backfill, plate search, + per-user font scale
|
||
Three booth fixes + one prefs feature:
|
||
- **Plate not showing until refresh (fixed).** Plate recognition is async/advisory
|
||
(snapshot.ts recognizePlate → a kind:"read" device_event keyed by session identity), so it
|
||
lands AFTER the entry/exit event already shipped over the WS without a plate. Added a
|
||
`plate-recognized` bus event (device-events.ts) emitted when the read is written; ws.ts
|
||
forwards it; the client `patchPlate(identity,plate)` (live-store) backfills the already-
|
||
rendered feed row in place and invalidates the Query-owned active-sessions list. No refresh.
|
||
- **Plate search didn't filter (fixed).** Both the live-feed (BoothScreen) and active-sessions
|
||
(ActiveSessions) search haystacks used the wrong field — the plate is the ENRICHED top-level
|
||
`e.plate`/`s.plate` (set by enrichEvent), not `payload.plate` (plate is unsigned, never in the
|
||
payload). Switched the haystacks to the displayed field.
|
||
- **Per-user font scale (new).** A header A−/value/A+ control scales the root font-size app-wide
|
||
(rem-based tokens scale proportionally), persisted on `users.font_scale` (migration
|
||
0014_user_font_scale, percent 100=base, clamp 80–160 step 10) and restored on login — cloning
|
||
the theme-pref pattern end to end (PUT /api/auth/font-scale, sessionView, setFontScalePref,
|
||
applyFontScale in App). i18n sq+en. Tests: 4 font-scale auth-route cases (persist+/me, clamp/
|
||
snap, 400, default). Full workspace build/lint/test green (189 server tests).
|
||
|
||
## [2026-06-28] fix | Font scale: rem-based root scaling (CSS `zoom` broke modal/footer layout)
|
||
The first cut of the per-user font scale used CSS `zoom` on the root so it would scale the app's
|
||
px-pinned type (text-[12px] etc.). But `zoom` scales the WHOLE box model including viewport-locked
|
||
containers — the h-screen app frame and max-h-[90vh] modals — so at 130% they overflowed the viewport
|
||
and modal headers/footers were pushed out of view (user had to scroll, big-modal chrome hidden).
|
||
Reworked to the correct fix: converted ALL `text-[Npx]` font utilities to rem across the web app
|
||
(~230 sites in 25 .tsx files + the .label/.hint/.btn component classes + body in index.css; 16px root
|
||
→ 12px=0.75rem etc., so 100% is visually identical), and applyFontScale now sets the ROOT font-size
|
||
(percent) instead of zoom. Only TEXT scales; vh/h-screen layout stays viewport-locked, so modals cap
|
||
at 90vh and scroll their own body — chrome never clips. Verified with Playwright: at 130% root, sample
|
||
text 12px→15.6px while the h-screen frame stayed exactly viewport-height and 90vh resolved unchanged.
|
||
Full workspace build/lint/test green.
|
||
|
||
## [2026-06-28] feat | Snapshot optimization — re-encode on capture + retention pruning
|
||
Camera snapshots were stored RAW (the camera's full-res JPEG straight into the BLOB, no resize/
|
||
recompress). Measured on the dev DB: 300 snapshots = 81.7 MB = ~72% of the 114 MB SQLite file;
|
||
the big ones 2688×1520 / ~600 KB (Hikvision main stream). Two fixes:
|
||
- **Re-encode on capture** (`snapshot.ts` `encodeForStorage`, via `sharp`/libvips, Apache-2.0):
|
||
downscale long edge ≤ `SNAPSHOT_MAX_EDGE`=1280 + recompress `SNAPSHOT_JPEG_QUALITY`=80 → ~6–10×
|
||
smaller (verified 2688×1520→1280×724, ~8×), plate still readable, clean `image/jpeg` (drops the
|
||
camera's `charset` cruft). STORAGE-ONLY — recognition keeps the ORIGINAL full-res bytes (downscale
|
||
hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot or blocks
|
||
the (already-open) path. `sharp` lives in `apps/server` (owns the capture path), where `bcrypt`
|
||
already establishes the native-dep pattern.
|
||
- **Retention** (`snapshot-retention.ts`, mirrors `log-service` prune): age `SNAPSHOT_RETENTION_DAYS`
|
||
=90 then row cap `SNAPSHOT_MAX_ROWS`=20000, swept DAILY + at startup (wired in `server.ts`). Resolves
|
||
the "retention is an open question" the schema flagged. Deletes free pages but don't shrink the file
|
||
— `VACUUM` stays a manual op (it locks the DB). None of this touches the signed ledger (snapshots
|
||
are unsigned/advisory, referenced only by id).
|
||
Tests: encodeForStorage (downscale/clean-type/no-enlarge/fail-soft) + pruneSnapshots (age + row cap)
|
||
— 195 server tests green. Env documented in komodo/.env.komodo.example. Existing 81.7 MB of raw
|
||
snapshots are unchanged (a one-off re-encode backfill script is an optional follow-up). Updated
|
||
[[entry-exit-points]] + [[technology-stack]].
|
||
|
||
## [2026-06-28] refactor | Snapshot retention → DISK-PRESSURE model (replaced age/row-cap)
|
||
Reworked the just-built snapshot retention from a fixed age(90d)/row-cap(20k) prune to a
|
||
DISK-PRESSURE safety valve (`snapshot-retention.ts` `pruneSnapshots`, now async). Daily check
|
||
reads the DB filesystem used% (`statfs` on `db.$client.name`); no-op unless ≥
|
||
`SNAPSHOT_DISK_HIGH_PCT`=70. Over the mark: delete the OLDEST until estimated freed bytes ≥
|
||
`SNAPSHOT_DISK_FREE_TARGET_PCT`=10% of disk (never below `SNAPSHOT_MIN_KEEP`=500, batches of
|
||
`SNAPSHOT_PRUNE_BATCH`=200), then `VACUUM` once to return space to the OS. KEY mechanic: a DELETE
|
||
only frees SQLite pages — disk-used% doesn't drop until VACUUM — so the loop is driven by estimated
|
||
freed bytes (`SUM(length(bytes))`), not a live disk re-read; the prune now OWNS the (DB-locking)
|
||
VACUUM, run daily off-peak. `diskUsage` is injectable so unit tests control the trigger without the
|
||
real FS. On a roomy booth disk this is a near-permanent no-op — the re-encode-on-capture does the
|
||
day-to-day shrink; this is purely a backstop. 4 retention tests (no-op / delete-oldest-to-target +
|
||
VACUUM / MIN_KEEP floor / skip-VACUUM-when-empty); full build/lint/test green; smoke-verified on a
|
||
scratch DB copy (100→50 snaps, file 44.7→38.1 MB after VACUUM). Updated [[entry-exit-points]] + the
|
||
snapshot-storage memory + komodo env. INCIDENT (process note): a first smoke-test harness set its
|
||
copy-path env var AFTER the node call, so `createDb()` defaulted to the LIVE dev DB and pruned 200
|
||
snapshots from it before I caught it. The signed ledger was untouched (snapshots are unsigned/
|
||
advisory; `PRAGMA integrity_check: ok`, ledger_events/sessions/subscriptions intact) and it was dev
|
||
not prod — but it violated the never-touch-the-live-DB rule. Lesson: pass the scratch path
|
||
explicitly + guard-refuse any non-scratch path BEFORE any destructive op (the corrected harness does).
|
||
|
||
## [2026-06-28] fix | The QR reader is a Dingtian DT-008, NOT "GEE" (correct an early mis-id)
|
||
A wrong early assumption named the QR/RFID access reader "GEE" / "GEE/Fondvision" / "GEE-QR-ER80"
|
||
(and summarized a `raw/GEE-QR-ER80….pdf` as its datasheet). There is no GEE device — it's the
|
||
**Dingtian DT-008** (https://www.dingtian-tech.com/en_us/qr_code_reader.html), the SAME vendor as
|
||
the relay board, which is why it integrates the identical HTTP-GET-push way. Corrected across the
|
||
codebase + wiki: driver symbol `geeQrReaderDriver`→`dingtianQrReaderDriver`, **persisted driverId
|
||
`gee-qr-reader`→`dingtian-qr-reader`** (migration **0015** rewrites the existing `devices.driver_id`
|
||
rows so readers keep resolving — applied to the dev DB, 2 rows; the booth applies it on boot), label
|
||
"Dingtian DT-008 QR/RFID reader (HTTP push)", and all server/test/comment refs. Wiki: renamed
|
||
`entities/gee-qr-er80.md`→`entities/dingtian-dt008-reader.md` and `sources/gee-qr-er80.md`→
|
||
`sources/dingtian-dt008.md`, rewrote both to the REAL DT-008 specs (Wiegand26/34 + TCP/IP + USB +
|
||
RS485 — NOT RS-232; QR/barcode + ID/IC/NFC — NOT DataMatrix/1D; 9–16 V/800 mA) while keeping all the
|
||
verified-on-hardware protocol facts (cjihao serial, `.jsp` path, `Connection: close`). Memory
|
||
`gee-reader-serial-binding`→`dingtian-reader-serial-binding`. The only surviving "GEE" mentions are
|
||
deliberate naming-correction notes + the raw PDF filename. Behaviour unchanged — naming + the
|
||
persisted id only. build/lint/test green.
|
||
|
||
## [2026-06-29] design | On-site encrypted backup + disaster recovery (resolves open-question #5 design)
|
||
New concept page [[backup-recovery]]. Driving scenario: the PC is stolen/destroyed and its LUKS+TPM
|
||
SSD is unrecoverable by design — recovery must stand up a NEW PC, restore a backup, and keep signing
|
||
the SAME chain. Settled: admin-driven **encrypted full-DB backup** (SQLite online-backup/`VACUUM INTO`,
|
||
snapshots INCLUDED) to **local/USB · SMB/NFS · SFTP** targets; **manual button + in-process daily timer**
|
||
(same pattern as snapshot prune); **keep-last-N + dailies** retention; restore is **admin-only/out-of-band**
|
||
(operator-adversary surface). Restored copy must still `verifyChain`.
|
||
|
||
KEY-CUSTODY decision (the load-bearing part, bears on #6): three independent keys — (1) `EVENT_SIGNING_KEY`
|
||
kept an **extractable, escrowed software key DECOUPLED from the TPM** so the ledger survives total hardware
|
||
loss [conscious trade: a TPM-sealed signing key would be unforgeable but PERMANENTLY UNVERIFIABLE after the
|
||
machine dies — same property from two sides]; (2) **new dedicated `park_buzi_backup_key`** in Komodo for
|
||
backup encryption, SEPARATE from the signing key (independent rotation; backups travel, signing key
|
||
shouldn't; keeps the #6 TPM door open); (3) LUKS/TPM disk key, appliance-only, deliberately non-recoverable.
|
||
Keys are NEVER inside the backup they unlock — recovery = backup file + both escrowed keys, out-of-band.
|
||
|
||
Updated: [[open-questions]] #5 (design SETTLED) + #10 note (backup includes snapshot BLOBs by default, future
|
||
exclude toggle); [[disk-os-hardening]] deploy env runbook (EVENT_SIGNING_KEY-not-sealed rationale +
|
||
`park_buzi_backup_key`); index catalog + concept count 45→46. Design only — NOT yet built.
|
||
|
||
## [2026-06-29] feat | Backup engine + local target (first slice of backup-recovery)
|
||
Built the durability engine designed in [[backup-recovery]]. `apps/server/src/backup.ts`: consistent
|
||
online copy via better-sqlite3 `.backup()` (NOT a raw file copy of a live WAL DB) → AES-256-GCM with a
|
||
scrypt-derived key from BACKUP_KEY, self-describing header (magic|ver|salt|iv|…|tag), zero new deps;
|
||
plaintext intermediate kept in scratch + wiped in finally; keep-last-N + dailies retention. Tested:
|
||
round-trip → byte-identical queryable DB, GCM tamper/wrong-key fails, short-key rejected, scratch always
|
||
cleaned. `backup-service.ts` (env config, single in-flight guard, last-success/error) + `routes/backup.ts`
|
||
(GET /api/backup/status backup:read, POST /api/backup/run backup:create, 409 when unconfigured; NO restore
|
||
route — out-of-band by design). New `backup` permission resource in @parking/shared. server.ts: unref'd
|
||
daily timer, no-op until configured, NOT run at startup. `openRawDb()` added to @parking/db/testing.
|
||
SMB/NFS work as mount paths; SFTP + admin UI + restore runbook deferred. build/lint/test green (212 server
|
||
tests, 25 files). Updated [[open-questions]] #5 (first slice BUILT). NOT yet committed beyond this branch.
|
||
|
||
## [2026-06-29] feat | Backup admin UI + admin-chosen target (site_config, not env)
|
||
The backup TARGET DIRECTORY is now chosen by the on-site admin in the UI, not a server env var — env
|
||
target defeats the purpose (admin can't change where backups land without editing .env + restart). Moved
|
||
to `site_config.backup_target_dir` (migration 0016, nullable); BackupService reads it fresh each run (no
|
||
restart to change). Only BACKUP_KEY stays an env secret — a key must NEVER live in the DB it backs up.
|
||
New routes: PUT /api/backup/config (set/clear target, backup:update, upserts the id=1 row), POST
|
||
/api/backup/test (server-side path probe: exists/is-dir/writable, backup:update). status() now exposes
|
||
targetDir + keyPresent so the UI tells "no target" from "no key". UI: Setup → Backup tab
|
||
(apps/web/src/BackupSettings.tsx) — editable target field + Test-target probe (localized reasons) + Save +
|
||
status panel (distinct amber "BACKUP_KEY missing" warning) + Back-up-now + restore-out-of-band note; full
|
||
i18n sq+en; nav.backup. Verified live with Playwright: typed path → Test "writable" → Save persisted →
|
||
status reflects it + key-missing warning shown. build/lint/test green (whole monorepo). Updated
|
||
[[backup-recovery]] as-built + [[open-questions]] #5.
|
||
|
||
## [2026-06-29] feat | Backup retention admin-tunable + BACKUP_KEY wired into Komodo
|
||
Same reasoning as the target dir: backup retention is operational policy the on-site admin tunes, not a
|
||
server env var requiring a redeploy. Moved BACKUP_KEEP_LAST/BACKUP_KEEP_DAILY_DAYS env → site_config
|
||
(migration 0017: backup_keep_last, backup_keep_daily_days, both nullable → code default 7/30 per field).
|
||
BackupService reads retention fresh each run; status() now exposes keepLast/keepDailyDays. PUT
|
||
/api/backup/config extended to accept keepLast/keepDailyDays (non-negative int or null=reset-to-default,
|
||
400 on negative). UI: two retention number fields on the Backup config card, one Save covers target +
|
||
retention; i18n sq+en. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads dropped). Komodo:
|
||
BACKUP_KEY wired as a per-booth secret ([[park_buzi_backup_key]] in komodo/resources.toml; documented in
|
||
komodo/.env.komodo.example as the ONLY backup env var — target+retention are UI/DB). Server .env.example
|
||
trimmed to just BACKUP_KEY. build/lint/test green (218 server tests, incl. retention persist/reset/negative
|
||
+ updated status shape). NOTE: dev API process was down after this round (live process, not code) — verified
|
||
via the full test harness, not a live click-through this time. Updated [[backup-recovery]] as-built.
|
||
|
||
## [2026-06-29] decision | Staging tier: dev → stage → main; park-buzi is the staging booth
|
||
Modelled the staging-vs-production split that fleet-deployment-komodo flagged as open. THREE tiers: dev
|
||
(working, no booth runs it) → stage (staging booth park-buzi, real-world test) → main (production, manual+
|
||
pinned). park-buzi tracks the `stage` branch + `:stage` image but is deployed MANUAL + PINNED (TAG=stage-<sha>,
|
||
NO webhook — we hold the no-moving-tag-on-a-booth line even on staging, rejecting the earlier 'webhook on
|
||
staging' sketch). Promotion = merge dev→stage when confident → CI builds :stage/:stage-<sha> → bump TAG in
|
||
resources.toml → deploy from Core. `stage` branched from dev HEAD (84f00db) so the first real-world test
|
||
carries the full current app. Changes: build-images.yml triggers on [dev, stage, main] (tagging already
|
||
branch-derived, so :stage works with no other change); komodo/resources.toml park-buzi branch=stage +
|
||
TAG=stage-84f00db; komodo/README.md promotion section + per-booth secret list now includes backup_key;
|
||
fleet-deployment-komodo open-item resolved + new 'Promotion tiers' table; container-deployment tag list +
|
||
:stage. Per-booth secrets (jwt/event_signing/backup) must pre-exist in Core for park-buzi; migrations run at
|
||
boot so a promotion auto-migrates the staging ledger (where a bad migration is caught before prod).
|
||
|
||
## [2026-06-29] fix+doc | First park-buzi backup deploy: BACKUP_KEY allowlist + container mount constraint
|
||
First real-world staging deploy surfaced two backup gotchas, both now in [[backup-recovery]]:
|
||
(1) BACKUP_KEY was wired as a Komodo secret + Stack-env line but NEVER added to docker-compose.yml's
|
||
server `environment:` ALLOWLIST — so the container came up without it (docker inspect: JWT/SIGN present,
|
||
BACKUP_KEY absent-not-empty; Backup screen "BACKUP_KEY missing"). A whole session was lost chasing Komodo
|
||
(secret name, re-sync, destroy/redeploy, env-only-change-doesn't-force-recreate) before checking the
|
||
compose allowlist. Fix: `BACKUP_KEY: ${BACKUP_KEY:-}` next to EVENT_SIGNING_KEY (commit on dev 8f32d90,
|
||
promoted dev→stage merge d0b609e → built stage-d0b609e). Lesson recorded: a new server env var ALSO needs a
|
||
line in the compose environment block.
|
||
(2) The backup target must be a HOST path BIND-MOUNTED into the container — a casually-plugged USB at
|
||
/run/media/<user>/<UUID> is invisible inside the container, so Test target rightly says "does not exist".
|
||
Provisioning = fstab-by-UUID a stable host path (e.g. /mnt/backup) + bind-mount it in prod compose + set
|
||
the in-container path as the UI target. Acknowledged limitation: backups are NOT operator-flexible (no
|
||
plug-a-USB-and-go); adding a destination is an admin host+compose change. Partly a feature vs the
|
||
operator-adversary threat model (operator can't redirect backups to a removable stick). USB-automount-to-
|
||
container flow deferred/not built.
|
||
|
||
## [2026-06-30] note | UEFI dbx / firmware update vs TPM-sealed LUKS — GRUB panic + PCR-7 re-seal + operator lockdown (park-buzi)
|
||
|
||
Real-world on park-buzi. The GNOME "Firmware Updater" (Ubuntu = the `firmware-updater` snap) surfaced a
|
||
pending UEFI dbx (Secure Boot revocation DB) update, vendor Microsoft, delivered by fwupd/LVFS — a channel
|
||
SEPARATE from APT (apt list --upgradable was clean except 2 cups packages). 2026-06-28 a dbx update against a
|
||
stale GRUB revoked the bootloader → GRUB panic / unbootable → user reinstalled Ubuntu 26.04 LTS (resolute) to
|
||
recover (fresh install ships a current GRUB). Correct order is `apt full-upgrade` (current grub-efi/shim-signed)
|
||
FIRST, then dbx.
|
||
|
||
Even with a current GRUB, applying dbx moves PCR 7 (Secure-Boot-policy measurement) → the TPM (slot 1) refuses
|
||
to release the LUKS key → next boot drops to the slot-0 passphrase prompt. VERIFIED end-to-end: proved the
|
||
slot-0 typed passphrase first, applied dbx, rebooted to a passphrase prompt, unlocked with slot 0, re-enrolled
|
||
`systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` → silent auto-unlock restored.
|
||
|
||
Gotcha: `cryptsetup open --test-passphrase /dev/sda3` SILENTLY passes via the TPM token (auto-unlocks the tpm2
|
||
slot without prompting) — a false safety signal. Force a real typed-passphrase test with
|
||
`--disable-external-tokens` (→ "No usable token is available." then prompts; success on slot 0 proves it).
|
||
|
||
Threat-model lockdown (operator is the adversary): a firmware/dbx update makes the booth need the passphrase to
|
||
boot unattended, so operators must not be able to trigger one and must never hold the passphrase. Applied on
|
||
park-buzi: `systemctl mask fwupd.service fwupd-refresh.timer` (→ masked/masked, persists), `snap remove
|
||
firmware-updater` (remove the GUI; re-check, seeded snaps can re-install), BIOS admin password gates setup
|
||
entry, slot-0 passphrase stays escrowed off-machine. Firmware updates are now admin-only/on-site/deliberate.
|
||
Recorded in appliance-provisioning.md §4 re-seal runbook + new §4a + gotchas #12/#13.
|
||
|
||
## [2026-06-30] note | Created disk-os-hardening.md (resolved a long-standing orphan)
|
||
|
||
`[[disk-os-hardening]]` was referenced from ~18 pages (overview, threat-model, tpm, fleet-deployment,
|
||
appliance-provisioning, backup-recovery, index, …) but never written — a dangling wikilink. Wrote it as
|
||
the *rationale* page (the why): the five host controls (LUKS FDE, TPM-sealed PCR-7 auto-unlock, Secure
|
||
Boot Deployed, GRUB edit-lock, unprivileged-operator) + the firmware/dbx lockdown (§4a cross-ref), each
|
||
with its load-bearing nuance, plus the standing caveat that this is the SECONDARY control —
|
||
reconciliation over the signed chain is the main anti-fraud event. Commands stay in appliance-provisioning
|
||
(the how); this page points there. Updated the index.md line accordingly.
|
||
|
||
## [2026-06-30] fix | Snapshot content-type bug — every legacy image rendered blank
|
||
|
||
Symptom: no snapshot showed in the booth modal. Root cause: Hikvision-style cameras return
|
||
`Content-Type: image/jpeg; charset="UTF-8"` (a charset param on a binary body = malformed; browsers
|
||
refuse to decode an <img> declared that way). Old capture code persisted that raw header into
|
||
snapshots.content_type (100 of 101 dev-DB rows); the serve route GET /api/snapshots/:id re-emitted it
|
||
verbatim → broken render for every legacy row. Capture was already hardened (encodeForStorage →
|
||
clean image/jpeg, fail-soft cleanType), but the serve route trusted the stored value. Fix: route now
|
||
runs cleanType(row.contentType) on the way OUT too → bare image/jpeg, un-breaks all legacy rows with
|
||
NO data migration. Verified via Playwright: a previously-unrenderable 2560×1440 row now decodes
|
||
in-browser; clean + malformed rows both load. Exported cleanType from snapshot.ts + unit tests.
|
||
Recorded in entry-exit-points.md. Lesson: normalize a device-supplied content-type on capture AND on
|
||
serve (a stored value from an untrusted camera is itself input).
|
||
|
||
## [2026-06-30] feat | Booth Active-Sessions + pay/exit modal rework
|
||
|
||
(1) The inline "Open barrier" button on paid-in-grace Active-Session ROWS was removed; the audited
|
||
re-pulse now lives only in the modal. Reason: a paid+exited session is open=false, so clicking its
|
||
row dead-ended on "already closed" — useless for the exact case (paid, barrier unconfirmed) that
|
||
needs a re-pulse. The modal now recognizes closed-within-grace (found && !open && withinGrace) and
|
||
shows the session view + Open barrier. Server reopenBarrier guard unchanged (already handled the
|
||
closed-in-grace case — the T-397815c0 fix). (2) Active-Sessions rows show a LIVE grace-remaining
|
||
countdown badge (exited · M:SS, 1s tick off graceExpiresAt) instead of a static label. (3) Settled
|
||
sessions show the ACTUAL sum paid (new SessionLookup.paidMinor, summed across payment events) not a
|
||
flat "PAID". (4) A fully-closed (grace-expired) session's modal is no longer a dead-end: it shows a
|
||
read-only review view (figures + paid amount + entry/exit snapshot strip) for dispute/audit review,
|
||
with no pay/exit/open controls. i18n sq+en parity kept; web build/tests green. Recorded in
|
||
booth-exit-flow.md.
|
||
|
||
## [2026-06-30] feat | DB reset CLI for training/demo (packages/db/scripts/reset-db.mjs)
|
||
|
||
A site is sometimes run live to train operators/admins; afterwards the demo data must go WITHOUT an
|
||
obvious self-serve button (operator must not wipe history). So: a CLI script `pnpm db:reset`, not UI.
|
||
Category flags grounded in the table map — --financial (ledger/telemetry/snapshots/subscription
|
||
instances/blocklist; keeps users/devices/config/tariffs/plans), --config, --users, --all. Because
|
||
shifts/cash/payments all live as event types INSIDE the hash-chained ledger_events, "financial" =
|
||
truncate the whole signed ledger back to empty (re-seed starts a new chain under the SAME
|
||
EVENT_SIGNING_KEY — key untouched). Two safety gates (decided with user): RESET_ALLOWED=1 env (real
|
||
booths never set it) + typed DB-filename confirmation (--yes skips for CI). Single txn + VACUUM;
|
||
re-seed admin after --users/--all. On the BOOTH there is no pnpm — only containers — so it runs via
|
||
`docker exec` into the server container (node node_modules/@parking/db/scripts/reset-db.mjs,
|
||
DATABASE_URL=/data/parking.sqlite); the script ships in the deploy bundle next to the boot migrator
|
||
(@parking/db has no `files` allowlist → whole pkg copied). Verified on throwaway dev-DB copies (both
|
||
gates refuse correctly; each flag wipes/keeps the right tables; real dev DB never touched). Recorded
|
||
in local-dev-workflow.md + appliance-provisioning.md §7d.
|
||
|
||
## [2026-07-01] feat | Card tender DISABLED until a P2PE POS is on-site (cash-only)
|
||
|
||
No card processor / POS terminal on any site yet, so offering "Card" would let an operator record a
|
||
card payment that never cleared → corrupts till reconciliation ([[threat-model]] surface). Disabled
|
||
the card option in the UI: new apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false gates both
|
||
tender pickers (BoothPayModal.tsx, SubscriptionManager.tsx); with card off there's nothing to choose,
|
||
so the tender row is suppressed entirely and payment silently defaults to cash. UI-only gate — the
|
||
Tender="cash"|"card" type, payment events, shift accounting, and reports still understand card (so
|
||
historical card events + a future re-enable stay coherent). Verified via Playwright: an unpaid-ticket
|
||
modal shows Total + "Pay + open barrier" with NO tender/cash/card row. Re-enable = flip the flag once
|
||
a bank-certified P2PE terminal is provisioned (PCI scope stays out of the app — the terminal captures
|
||
card data, not the app). New page concepts/card-payments.md documents current state + future-POS
|
||
device requirements + re-enable path; linked from index, parking-session, open-questions #3.
|