1a0fe59488
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
3146 lines
301 KiB
Markdown
3146 lines
301 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.
|
||
|
||
## [2026-07-01] feat | Drawer redesign — operator records freely, admin reviews after; moved to /drawer
|
||
|
||
Reworked drawer cash movements from synchronous admin-authorization-at-creation (operator typed an
|
||
admin's password inline at the booth for every receipt/disbursement) to operator-records → admin-
|
||
reviews-after. An operator with drawer:create RECORDS a cash_in/cash_out freely; it counts in the
|
||
drawer immediately. An admin with drawer:review AUTHORIZES/DENIES it after via a new signed cash_review
|
||
event { refId, decision, reviewedBy, note? }. THE LOAD-BEARING CHOICE (settled with user): a denial is
|
||
a FLAG, not a reversal — it never appends reversing cash and never touches the drawer balance (the
|
||
correction is the admin's/accountant's job outside the app; we are NOT building accounting). This kills
|
||
the cross-shift-leak problem the user raised: a denial that lands after the reviewed shift closed can't
|
||
pollute the next operator's inherited drawer, because it moves no cash. New `drawer` resource +
|
||
drawer:create (per-role revocable) / drawer:review permissions; migration 0018 grants operator
|
||
drawer:create. Feature moved OFF the polluted /shifts route to a top-level /drawer (operator: record +
|
||
own; admin: review queue + all). New routes/drawer.ts (lifted from routes/shift.ts, retired the
|
||
authorizer-password gate; kept shift:cash for its other job = admin-sees-all-shifts scope),
|
||
DrawerManager.tsx, drawer.* i18n (sq+en). Verified: full monorepo build/lint/test green (225 server
|
||
tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer
|
||
(record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no
|
||
authorizedBy). Recorded in shift.md "Drawer review".
|
||
|
||
## [2026-07-01] feat | Operator-issued entry + exit plate-swap reconciliation (one anti-fraud design)
|
||
|
||
Two halves of one design. (A) When the physical entry button is broken, an operator can ISSUE an entry
|
||
ticket so a real car isn't blocked out of the lot — but this hands the operator-adversary a mint, so
|
||
it's (1) PRESENCE-GATED exactly like the physical button (radar/loop present AND camera busy = a real
|
||
car; enforced BOTH sides, server re-checks so a direct POST can't bypass a disabled button; no presence
|
||
loop → feature unavailable; a no-presence attempt signs an entry.issue.noPresence anomaly), (2) FLAGGED
|
||
(vehicle_entry source=manual + operatorInitiated + operator, PLUS a companion entry.operatorIssued
|
||
anomaly), (3) capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap a legit car).
|
||
New session:create permission (migration 0019 → operator role; admin-revocable), POST /api/entry/issue
|
||
(open-shift gated), EntryFlow.issueForOperator; the fraud-critical print→sign→open→snapshot sequence
|
||
factored into one shared #issueTicket (button + operator). UI: the entry BarrierLight becomes a
|
||
clickable issue-control when presence+permission+shift meet (confirm → issue).
|
||
|
||
(B) Plate-swap fraud (user's scenario): operator scans exiting ticket 1234 (owes 10000), pockets cash
|
||
WITHOUT recording payment, mints fresh 1237 (owes ~0), lets the car out on 1237 → 1234 lingers "inside"
|
||
forever, occupancy drifts up by phantom cars. Defense = ANPR plate as invariant: the car's plate is the
|
||
same either way. ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN sessions'
|
||
entry plates — EXACT, HIGH-CONFIDENCE only (≥0.85; a fuzzy/low read never gates, ANPR is advisory). On
|
||
a match under a DIFFERENT ticket: BOOTH path returns swap_suspected + signs exit.plateSwapSuspected
|
||
anomaly + the pay/exit modal shows a red warning with "Override & release" (override signs an attributed
|
||
exit.plateSwapOverride) — flag+override, never a silent hard block (exit fails-open, plate never the
|
||
sole gate). READER path (no operator) = log-only anomaly + fail-open (user's call). Extended
|
||
BoothExitResult + /api/exit (override param), boothExit client returns a structured swap result.
|
||
|
||
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new: hold-on-swap,
|
||
override-releases-with-attribution, low-confidence-no-warning, own-plate-no-warning). New wiki pages
|
||
operator-issued-entry.md + plate-reconciliation.md; cross-linked from entry-exit-points,
|
||
capacity-occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never TRAPS a car
|
||
alone either."
|
||
|
||
## [2026-07-01] deploy | Promote dev → stage (d2ab2e0) → park-buzi, pinned TAG=stage-d2ab2e0
|
||
|
||
Merged dev → stage (no-ff, clean — stage content was fully contained in dev). Shipped to the staging
|
||
booth: snapshot content-type fix, Active Sessions/modal rework, DB reset CLI, drawer redesign (operator
|
||
records / admin reviews), card tender disabled (no POS), operator-issued entry + exit plate-swap
|
||
reconciliation. Push to stage triggered CI → built parking-server/vision :stage + :stage-d2ab2e0. Pinned
|
||
TAG=stage-d2ab2e0 in komodo/resources.toml on BOTH stage and dev (the ResourceSync's source branch is a
|
||
Core-side config, so both agree — see komodo/README.md; they're identical content anyway).
|
||
|
||
Migration note: this promotion carries migrations 0018 (drawer:create) + 0019 (session:create). BOTH are
|
||
DATA SEEDS, not schema — INSERT OR IGNORE one role_permissions row each for the built-in `operator` role;
|
||
idempotent, no CREATE/ALTER, existing data untouched. They apply automatically at container boot
|
||
(docker-entrypoint.sh → migrate-runtime.mjs, before the server starts) against the /data volume DB, which
|
||
survives the redeploy. Caveat recorded in container-deployment.md: a permission seeded to the built-in
|
||
operator role does NOT reach a CUSTOM role — an admin toggles it in Setup → Roles.
|
||
|
||
Deploy (operator, in Komodo Core): refresh ResourceSync (TAG diff enables Execute) → Execute → Deploy
|
||
(Destroy+Deploy for a clean recreate; parking-data volume persists). Watch for `[migrate] done` in logs.
|
||
|
||
## [2026-07-02] query | Physical-tamper of the booth disk + ledger signing reality (ATECC608 is upcoming, not present)
|
||
|
||
Q (operator): can a malicious user boot a live Ubuntu / reset the BIOS (coin cell or PSWD jumper) and
|
||
get root on the storage? Traced on the actual box (Dell OptiPlex 7070): the BIOS admin password DOES
|
||
gate the F12 boot menu (selecting the USB prompts for it), so the live-USB path is closed **while the
|
||
password holds**. But a CMOS reset clears the admin password + reopens the boot menu WITHOUT wiping the
|
||
Secure-Boot key DBs (SPI-flash NVRAM, not RTC), and the 7070 default is Secure Boot=Enabled → PCR 7
|
||
reconstructs to the SAME value → a signed live Ubuntu (same signing authorities) matches the PCR-7-only
|
||
seal → the TPM releases the LUKS key → root on the decrypted disk. Battery-pull alone = nothing;
|
||
battery-pull → live-USB → PCR-7 unseal = realistic root-on-data. (Disabling Secure Boot instead CHANGES
|
||
PCR 7 → passphrase prompt → locked out; the same-signer default is the hole. systemd docs: PCR 7 + PIN.)
|
||
|
||
BIGGER correction surfaced: the ledger is NOT ATECC608-signed today. No secure element is on-site. Signing
|
||
runs on the software SoftwareSigner (HMAC, key = EVENT_SIGNING_KEY, an env var on the host disk). So the
|
||
chain is tamper-EVIDENT but forgeable by whoever owns the host — the disk-decryption chain above hands
|
||
them the key too. The ATECC608 was overstated as present in several pages; it's also the wrong part for a
|
||
PC (external I²C, embedded-native) — reserve it for the deferred ESP32; the realistic host signer is the
|
||
on-board TPM or a USB HSM.
|
||
|
||
Actions:
|
||
- NEW concepts/hardware-signer-options.md — four options (USB HSM/Nitrokey HSM 2 [target], YubiKey, reuse
|
||
the TPM [free interim, bind signing key with NO PCR policy], plain USB dongle [trap, avoid]) + the
|
||
recommendation (TPM now → USB-HSM target; ATECC608 stays for embedded). Notes the signer.ts keyId seam.
|
||
- Retag pass ATECC608 → UPCOMING/NOT-PRESENT + "software-signed today, forgeable by host owner" caveat:
|
||
entities/atecc608.md (status banner + PC-vs-embedded), append-only-event-chain already honest,
|
||
standing-decisions.md, overview.md, threat-model.md, open-questions.md #6 (reframed), index.md.
|
||
- disk-os-hardening.md: fixed the live-USB row (BIOS boot-order password is load-bearing, not Secure
|
||
Boot — signed live USB runs), added a caveat banner (software signer → disk decryption = ledger
|
||
forgery) + a "Physical-tamper chain & accepted risks" section (CMOS-reset chain; accepted risks:
|
||
PCR-7 same-signer unseal, unsigned initramfs evil-maid, operator-USB read TODO).
|
||
- Verify items for the box: (a) confirm F12/one-time-boot is password-gated (done — it is); (b) after a
|
||
CMOS clear does Secure Boot return Enabled? (expected yes on Dell); (c) can the unprivileged operator
|
||
login read /data or EVENT_SIGNING_KEY?
|
||
- Residual: signer.ts still uses HMAC (no code change this pass); the load-bearing anti-fraud control
|
||
remains reconciliation + escrowed offsite backups, NOT on-disk confidentiality/signature.
|
||
|
||
## [2026-07-02] review | Vision service (apps/vision/) hardening + fix backlog
|
||
Two code reviews of the Python/FastAPI ANPR service (general: bottlenecks/bugs/best-practice,
|
||
and a security-focused pass). Filed the findings as a prioritised, not-yet-fixed to-do list at
|
||
[[vision-service-hardening]]; cross-linked from [[opencv-anpr-service]] ("consult before touching")
|
||
and cataloged in index.md. Headline items: DoS (12MB cap checked *after* the body is buffered;
|
||
`cv2.imdecode` pixel-bomb; CPU inference on the async event loop stalling `/health`);
|
||
unauthenticated **and** operator-writable model weights → persistent recognition-poisoning
|
||
([[threat-model]]); `0.0.0.0`-by-default bind at all three layers; dev compose publishing 8089 on
|
||
all interfaces; plus correctness/hygiene (cwd-relative `.env`, `/health` always-200, pre-warm
|
||
swallowing failures, unbounded `min_confidence`). Reassurance recorded: a forged image can't open a
|
||
barrier (server re-gates at 0.85 + debounce), content-type isn't trusted, non-root, `.env` not baked
|
||
into the image. Nothing fixed yet — this is the backlog to work from.
|
||
|
||
## [2026-07-04] update | Entry press gate: camera enforced, cooldown backstop, duplicate-plate anomaly
|
||
|
||
Field report from park-buzi: a BLINKING entry button (radar-only, no camera confirmation) still
|
||
printed a ticket — the [[button-light-indicator]] encoded blink-vs-solid but `#suppressReason`
|
||
only ever checked the radar. Fixed in [[entry-double-press]]: (1) CAMERA gate on the physical
|
||
press — live only in the lamp's SOLID state when an entry camera is configured; honors
|
||
[[entry-presence-bypass]]; suppress-only, so the camera stays advisory; (2) cooldown now a REAL
|
||
backstop behind presence (the old code returned early, so `entryCooldownSec` was dead in presence
|
||
mode) — bounds the stationary-car motion-radar-dropout double-ticket; (3) post-hoc
|
||
`entry.duplicatePlate` signed anomaly when the recognized entry plate is already open under a
|
||
recent session (entry-side twin of [[plate-reconciliation]]; ANPR stays non-blocking). REJECTED
|
||
along the way: camera-vetoed re-arm (defer re-arm until the lane flips free) — the camera's ~30 s
|
||
silence-timeout "free" never fires inside a queue, so it would suppress every queued car after the
|
||
first. Proper preventive fix noted open: a pass-through sensor (`passedInput`). 13 new tests
|
||
(`entry-press-gate.test.ts`, `entry-duplicate-plate.test.ts`); suite 258 green.
|
||
|
||
## [2026-07-04] update | Exit reader phantom scans traced to optical 1D decodes (sun patterns)
|
||
|
||
Pre-opening park-buzi, empty site: the exit [[dingtian-dt008-reader]] pushed spontaneous 6-digit
|
||
numeric scans (+ one lone "C") at low-sun afternoon hours; all refused fail-closed as
|
||
exit.refused.noSession. Server READ logs confirmed the reader's own serial (H05MA5B0) → the
|
||
physical device decoding, not a network source; a live snapshot confirmed nobody present.
|
||
Diagnosis: default-enabled weak-checksum 1D symbologies (I2of5 6-digit signature; "C" =
|
||
Code39/Codabar artifact) decoding sun-made stripe patterns (striped barrier arm, fence shadows,
|
||
glare). No fraud exposure (11-digit Luhn ids can't match a 6-digit read). Fix recorded on the
|
||
entity page: vendor-tool symbology cut to QR+Code128 + min length, BOTH readers, re-apply after
|
||
any factory reset (config lives on the device). Deliberately NOT filtering impossible codes
|
||
server-side — probe recording is the anomaly path's job.
|
||
|
||
## [2026-07-04] update | Backfilled missing concept pages: entry-presence-bypass + setup-relay-test
|
||
|
||
Two shipped features (2026-07-01/02) had no wiki pages — worse, six code files and
|
||
[[entry-double-press]] already linked [[entry-presence-bypass]] as if it existed. Written now:
|
||
[[entry-presence-bypass]] (admin drops a faulty radar/camera signal, granular by decision, every
|
||
flip a signed config_change, tickets stamped presenceBypassed, radar-bypass cooldown tradeoff,
|
||
"the admin is not the adversary" threat-model nuance) and [[setup-relay-test]] (admin-only
|
||
commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the
|
||
out-of-band-open fraud signal, saved-controllers-only, radarAlert lamps excluded). Cross-linked
|
||
from [[operator-issued-entry]] (bypass note) and cataloged in index.md.
|
||
|
||
## [2026-07-04] update | Reader channel tagging: printed-card-clone hole closed
|
||
|
||
Investigating the phantom scans surfaced a real vulnerability: the DT-008 push is channel-blind
|
||
and SubscriptionFlow.match matched by value only, so printing an RF card's UID (written on the
|
||
card face) as a barcode cloned the card. Fixed with channel tagging: vendor-tool output prefixes
|
||
(Q:/K:) → routes/qr-reader.ts strips + tags DeviceReadEvent.channel (optical|rf) → match requires
|
||
channel agreement, refusing a mismatch + signing a sub.refused.channelMismatch anomaly (a clone
|
||
attempt is a fraud signal). Untagged (unprefixed) reads keep legacy behavior — enforcement only
|
||
bites where prefixes are deployed. Enrollment capture stores bare values. Recorded on
|
||
[[dingtian-dt008-reader]] incl. the two device-side settings now part of the credential contract
|
||
(prefixes + Card Input format 6H — re-apply after factory reset). 14 new tests; suite 272 green.
|
||
|
||
## [2026-07-04] update | Structural read filter: phantom scans out of the signed feed
|
||
|
||
Operator-requested reversal of the earlier "do not filter" position (recorded as superseded on
|
||
[[dingtian-dt008-reader]]): phantom decodes were signing exit.refused.noSession anomalies — red
|
||
rows for nobody, training the operator to ignore the feed. read-dispatch.ts now drops a no-match
|
||
reader value that cannot possibly be ours (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
|
||
confirmed-RF, not a plate) to unsigned device_events telemetry (unrecognizedRead:true). The
|
||
plausibility rule is deliberately wide so every real probe (forged ticket shape, unknown physical
|
||
card, unknown SUB- code) still signs the normal anomaly; enrolled credentials match before the
|
||
filter and can never be hidden by it. Works for legacy unprefixed reads too, so the feed cleans up
|
||
before the vendor-tool visit. 6 new tests; suite 278 green. Also this session: reader channel
|
||
tagging (clone defense) — see the prior entry.
|
||
|
||
## [2026-07-04] update | Logging: 2-month rotation, ISO timestamps, level names
|
||
|
||
Operator asked for bounded container logs (~2 months), human-readable timestamps, and clarity on
|
||
levels. Findings + changes on [[app-logs]]: levels EXISTED (LOG_LEVEL env, pino, warn+ teed to
|
||
app_logs); the "level":30 numbers and epoch-ms times were pino defaults — the logger now stamps
|
||
ISO-8601 UTC + level names (pinoDbStream hardened to accept both encodings so the DB tee can't
|
||
silently break). Rotation: docker json-file caps in docker-compose.prod.yml resized from 10m×3
|
||
(≈30 MB!) to ≈2 months by volume (server 20m×30, vision 20m×10, proxy 10m×5; json-file rotates by
|
||
SIZE — time-based isn't a driver feature). app_logs retention default aligned 30→60 days.
|
||
|
||
## [2026-07-05] update | Window-package tariff mode (packageMinor) + honest flat labels
|
||
|
||
Tariff-lab verification of a 1,850 ALL bill exposed a field misread: the V2 card "flat price" is
|
||
PER INCREMENT (400/h), not per window — park-buzi's "night 400" card billed each night hour 400.
|
||
Built the missing concept on [[tariff-time-tiers]]: `packageMinor`, a whole-window package
|
||
("any presence in 20:00–07:00 = 400 total"). Operator decisions: per-occurrence repeat (two nights
|
||
= two charges), any-touch-pays-full, windowed-cards-only (base "price per day" = a 1-row up-to
|
||
table). Engine charges once per contiguous run of increments the card wins, tracked across
|
||
rolling-day segments (a night crossing the 24h boundary charges once). Validator: exclusive with
|
||
flat/blocks/steps, no per-card cap, defaultCard forbidden. Composer offers the mode on tier cards;
|
||
flat relabeled "Flat price / hour" (sq+en). Also flagged from the same session: windowed-card
|
||
dailyCapMinor is inert by design (only the base card's cap clamps a day) — park-buzi's weekend
|
||
card carries a dead 1000 cap. Tests: shared 93 green (6 new), server 283 green.
|
||
|
||
## [2026-07-05] update | Tariff Lab redesign: DB-backed drafts, sidebar, composer modal
|
||
|
||
The lab previously simulated only against PUBLISHED versions, so experimenting meant publishing —
|
||
churning the immutable history and risking real tickets pricing against a half-baked card while
|
||
the admin iterated (operator: "we risk taking tickets with a grossly wrong version"). Redesign on
|
||
[[tariff]] (Tariff Lab section): new mutable `tariff_drafts` table (migration 0021 — the one
|
||
deliberate exception to "editing publishes a version"; a draft prices/signs nothing, only the
|
||
normal publish path makes it real), drafts validated + tz-stamped on save exactly like a publish,
|
||
CRUD under /api/tariff/drafts (list tariff:read, mutations tariff:update). UI rebuilt: sidebar
|
||
lists active card + drafts (click to price against), main pane cut to pure entry/exit (ticket
|
||
loader, payment, category inputs dropped), composer form extracted to TariffEditorForm.tsx and
|
||
reused in a modal (new drafts prefill from the active card), per-draft Publish with confirm.
|
||
Simulation passes the draft's stored structure inline to the existing /api/tariff/simulate.
|
||
Tests: server 288 green (5 new: RBAC, roundtrip, validation, tz-stamp + simulate + publish flow).
|
||
|
||
## [2026-07-05] update | Published tariff versions get optional names + lab sidebar lists history
|
||
|
||
Follow-up to the lab redesign (same session): the sidebar now also lists the PUBLISHED versions
|
||
(active first, then the immutable history; click to price against by tariffVersionId), and
|
||
`tariff_versions` gained a nullable `name` (migration 0022) — stamped at publish, immutable like
|
||
the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew
|
||
an optional version-name field (never prefilled — republishing a tweak under last season's name
|
||
would mislabel history). Details on [[tariff]] (Tariff Lab section).
|
||
|
||
## [2026-07-05] update | Drawer hub: balance now, live daily activity, shift history
|
||
|
||
Operator: "/drawer is too simple — no daily activity, current shift incomings not reflected, closed
|
||
shifts history missing, drawer current state not shown." Rebuilt DrawerManager as a five-panel hub
|
||
(details on [[shift]] §Drawer hub): Drawer-now panel (new GET /api/drawer/balance exposing the
|
||
service's existing drawerBalance(); open-shift X-report breakdown alongside so float + takings +
|
||
vouchers = expected = balance is explicit), today's cash feed (existing /api/events since local
|
||
midnight, cash-only, live), the unchanged record/review flow, and a drawer-focused closed-shifts
|
||
list (existing scope-aware /api/shifts). No new ledger surface — one read-only endpoint, everything
|
||
else composes what the chain already records. RBAC test added (shift:read 200 / other 403 / anon 401).
|
||
|
||
## [2026-07-05] update | Drawer "this shift" figure + spinners on shift buttons; O(chain) flagged
|
||
|
||
Follow-ups to the drawer hub (same session): the Drawer-now panel gained "This shift: ±X"
|
||
(expected − opening float — the shift's own contribution vs what it inherited), and every shift
|
||
open/close button got an animated spinner + dim while busy (ui/Spinner.tsx) — the old label-swap
|
||
read as a dead click on a slow open. Root cause of the slowness recorded as an open item on
|
||
[[shift]]: drawer/shift reads fold the WHOLE chain (O(chain) — grows forever); fix when it bites =
|
||
fold from the last z-report's signed expectedDrawerMinor forward. Also from this session: dev-DB
|
||
migrations are MANUAL (pnpm db:migrate in packages/db) — a 500 "no such column" after pulling a
|
||
migration means it was skipped; booth containers migrate on boot and are immune.
|
||
|
||
## [2026-07-05] update | Camera health-check detail bucketed — device-monitor log noise fix
|
||
|
||
The monitor logs + re-emits a device status only when state OR detail changes, but the camera
|
||
probe's detail was the exact snapshot byte count — which differs on every JPEG frame, so healthy
|
||
cameras "changed" on nearly every poll (a log line + websocket event each, inflating the freshly
|
||
budgeted container logs). Camera healthCheck detail is now a stable power-of-two bucket
|
||
("snapshot ≈16 KB" / "≈256 KB"; "<1 KB" stays exact as a suspicious-frame flag), so it moves only
|
||
on a real shift (stream/resolution change, empty body). packages/devices camera.ts + 3 tests.
|
||
|
||
## [2026-07-05] query | Industry survey: most-used parking tariff systems
|
||
|
||
Web research filed as [[tariff-industry-survey]] (reference). Field taxonomy: linear-hourly per
|
||
started increment (per-MINUTE tried and rolled back — Leuven), degressive ladders, day caps,
|
||
grace; whole-stay prices (day tickets, up-to matrices, evening/overnight packages, event rates);
|
||
entry-time-conditioned EARLY BIRD (enter-before cutoff, anti-arbitrage exit conditions); calendar
|
||
windows (day/night, weekend/holiday, seasonal); category pricing; monthly contracts; merchant
|
||
VALIDATIONS (amount/percent/time-credit/full-comp/re-rate); dynamic demand pricing (SFpark 60-80%
|
||
occupancy banding — municipal/airport scale only). Coverage map: our engine expresses everything
|
||
a staffed single lot advertises; the two real gaps are early bird (= the pick-table-by-entry-time
|
||
future design, same mechanism as weekend menus) and validation overlays. Anti-features confirmed:
|
||
per-minute billing, dynamic pricing.
|
||
|
||
## [2026-07-05] update | Composer page gets the published-versions sidebar too
|
||
|
||
The published-history sidebar built for the lab landed only there; the operator expected it on
|
||
/setup/tariff as well. TariffComposer now mirrors it: right sidebar of published versions (name or
|
||
effective date, active badge), click → formFromVersion loads it into the editor as the seed for
|
||
the next publish (which, per the sidebar hint, always creates a NEW immutable version). Details on
|
||
[[tariff]] (Composer section).
|
||
|
||
## [2026-07-05] update | Reports dashboard: occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
|
||
|
||
Operator ask on /reports. Added the parking-shaped graphics (details on [[reporting-analytics]]):
|
||
occupancy step-area vs capacity line (prior-ledger fold for range-start, per-bucket occupancyEnd),
|
||
entries heatmap hour×day-of-week (replaces the flat hour histogram; feeds tariff-window design per
|
||
[[tariff-industry-survey]]), stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h),
|
||
voids+anomalies KPI counters (accented when >0 — the look-closer signal), cash/card stacked revenue
|
||
bars, peak-occupancy KPI, richer CSV (cash/card/occupancy_end). Server: reports.ts aggregation +
|
||
Intl formatter cached per tz; db package re-exports lt/gt. 5 new tests (295 server green).
|
||
|
||
## [2026-07-06] update | USB printer truncation fixed (NONBLOCK partial write) — ICS XP-K200L
|
||
|
||
First on-hardware USB print test failed exactly as the transport bug predicts: text printed,
|
||
barcode + cut missing (both live in the dropped tail). sendRawUsb did ONE write() on an O_NONBLOCK
|
||
usblp fd and never checked bytesWritten — anything past the printer's ~8 KB USB buffer was
|
||
silently discarded; TCP was immune. Fixed with writeAllUsb (4 KB chunks, partial-write
|
||
continuation, EAGAIN retry, deadline with N/M diagnostic) + 4 fake-handle tests. Also verified on
|
||
hardware (10.0.10.11): the ICS XP-K200L serves NO /prn_stat.htm → on network use the cashino
|
||
driver (reachability-only), not rongta, or monitoring calls a working printer offline. Details on
|
||
[[printer-usb-transport]].
|
||
|
||
## [2026-07-06] update | Driver rename: "cashino" → "escpos" (generic ESC/POS printer)
|
||
|
||
The ICS XP-K200L exposed that the reachability-only clone driver carried its first unit's vendor
|
||
name — "cashino" in the setup UI was misleading for every other clone. Renamed properly:
|
||
printer-cashino.ts → printer-generic.ts, id "cashino" → "escpos", label "Generic ESC/POS 80mm
|
||
printer (Cashino, ICS/Xprinter…)". Stored rows rewritten by migration 0023; the registry keeps a
|
||
PERMANENT cashino→escpos alias so restored pre-rename backups still resolve. Prose mentions of the
|
||
Cashino as hardware stay (it's a real printer). Driver guidance for the XP-K200L: escpos on both
|
||
transports (it serves no /prn_stat.htm — verified; rongta would false-flag it). See
|
||
[[printer-usb-transport]], [[printer-status-monitoring]].
|
||
|
||
## [2026-07-06] update | Setup wizard: printers no longer forced to bind to a barrier relay
|
||
|
||
Operator hit the wizard's blanket "pick the controller and relay this device sits at" gate while
|
||
adding the ICS printer. The binding (controllerId+relay → which barrier a scan opens + inherited
|
||
direction) is load-bearing for READERS and CAMERAS only; nothing consumes it on a printer —
|
||
printer routing is role + failoverRank (printer-routing.ts). Wizard now skips the requirement,
|
||
hides the "Cilën barrierë shërben kjo pajisje?" panel, and stops persisting the binding for
|
||
printers (a stale pre-fix binding drops off on next edit); the device list shows the printer's
|
||
ROLE instead of a bogus amber "unbound". Server never required it (no validation change).
|
||
|
||
## [2026-07-06] update | Tariff Lab: fee breakdown — "how is this sum produced"
|
||
|
||
Operator: a lab outcome of "ALL 740 / 3h 2m" gave no derivation. Added explainFee to
|
||
@parking/shared: the SAME computeFee walk with an optional trace collector (zero fee change —
|
||
golden V1 regression still green), so Σ line items ≡ the amount by construction. Items: banded
|
||
same-price increment runs (time window · N × unit · card name), window-package occurrences,
|
||
stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE adjustments, entry
|
||
grace. /api/tariff/simulate returns `breakdown` (null when settled); the lab's Outcome panel
|
||
renders it as a lined table with the rounding note (raw min → billed min at the increment) and a
|
||
total row. 4 new engine tests pin the sum invariant + item shapes. This also largely delivers the
|
||
wiki's open "composer price preview" item — see [[tariff]].
|
||
|
||
## [2026-07-06] update | Composer: increment-unit price labels + ≠60 warning (the 60→10 trap)
|
||
|
||
Operator walked into the sharp edge the wiki flat-rate warning had already named: ladder/flat
|
||
prices are PER BILLING INCREMENT, so changing "Intervali i faturimit" 60→10 silently multiplies
|
||
every price ×6, while the price header just said "Çmimi / interval". Composer now: price labels
|
||
are DYNAMIC ("Çmimi / orë" at 60, "Çmimi / {{N}} min" otherwise — same for the flat-mode radio),
|
||
and an amber warning appears whenever the increment ≠ 60 ("çdo çmim faturohet për çdo N minuta,
|
||
JO për orë"). Band DURATIONS stay in hours — they're real wall time, increment-independent (the
|
||
operator asked if "orë" there was wrong; it isn't). See [[tariff]] (§increment).
|
||
|
||
## [2026-07-06] update | UI-wide date standard ("25 Qer") + currency-scaled composer examples
|
||
|
||
Two operator UX complaints. (1) Dates were a mix of browser-locale "7/6/2026" (raw
|
||
toLocaleString) and catalog "25 Qershor" — unified: formatDate/formatDateTime/formatClock in
|
||
lib/format.ts using new common.monthsShort ("25 Qer 14:30", year only when ≠ current, 24h clock);
|
||
formatRelativeDateTime switched to short months; ALL ~20 raw toLocale* date call sites swept
|
||
(shifts, subs, plans, drawer, snapshots, device footer, event detail, tariff composer + lab incl.
|
||
the fee-breakdown row times). Number toLocaleString (thousand separators on money) untouched.
|
||
(2) Composer example defaults were euro-scaled ("2.00"/hour ≈ 2 lekë) — now currency-aware
|
||
(ALL: 200/100 ladder, 200/500 steps, 2000 lost ticket; EUR/USD keep 2/1/2/5/20), threaded through
|
||
emptyForm/emptyLadder/emptyTier/pricingFromCard so a mode switch on an ALL card also shows lek-
|
||
plausible templates. Blank-form currency stays ALL.
|
||
|
||
## [2026-07-06] update | seed-admin signs a ledger event; lost-app-admin-password runbook (§7e)
|
||
|
||
Follow-through on the FK fix: seed-admin.mjs now appends a signed config_change
|
||
(admin.passwordReset / admin.seeded, operator console:seed-admin) via the server's compiled
|
||
EventLog + signer from dist/ — a console reset by the Linux admin can't gate on the app, but it
|
||
stays attributable in the chain. Best-effort: no build/key → loud warning, seed still proceeds
|
||
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
|
||
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
|
||
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.
|
||
|
||
## [2026-07-06] update | Runbook §5c: gpasswd -d, not deluser (hyphenated-username perl bug)
|
||
|
||
Demoting the operator on park-buzi hit `sanitize_string: invalid characters in 'park-operator'` —
|
||
Ubuntu's perl adduser/deluser tooling rejects the hyphenated username. [[appliance-provisioning]]
|
||
§5c now uses `gpasswd -d <operator> sudo|lxd|lpadmin` (shadow-suite, no perl sanitize) and notes
|
||
that group removal applies at NEXT login — the auto-login operator session keeps old memberships
|
||
until reboot/relog, so verify `groups` from inside the session afterwards.
|
||
|
||
## [2026-07-07] update | Periphery v2.2.0 --user installer writes /etc/komodo root_directory
|
||
|
||
Lab test box (park-test): periphery crash-looped at startup — panic writing the private key to
|
||
/etc/komodo/keys/periphery.key, Permission denied. Gotcha #9 was documented as a hand-config
|
||
hazard, but v2.2.0's installer now DEFAULTS root_directory to /etc/komodo even with --user (the
|
||
June park-buzi install defaulted under $HOME). [[appliance-provisioning]] §7a now says: verify
|
||
root_directory after every install + the sed one-liner fix; also notes `sudo systemctl restart
|
||
periphery` → "unit not found" (user unit) and that the single-use onboarding key survives a
|
||
pre-connect crash.
|
||
|
||
## [2026-07-07] update | Setup: printers addable with NO controller configured
|
||
|
||
Lab bench (USB printer test, no relays on hand) hit a SECOND printer/relay coupling the
|
||
2026-07-06 fix missed: the category section's add-button gate ("Shto fillimisht një kontroller…")
|
||
blocks every non-access category while zero controllers exist. Printers are now exempt there too
|
||
— the binding fix removed the requirement inside the form; this removes the gate in front of it.
|
||
A controller-less box can configure + test a printer.
|
||
|
||
## [2026-07-07] update | USB truncation, mode 2: close() kills the in-flight usblp URB
|
||
|
||
Lab hardware test of the chunked-write fix STILL truncated (slip stopped mid-sentence, no cut,
|
||
text hidden until the feed button). Root cause verified against kernel usblp.c: write() returns at
|
||
URB submission; one URB in flight; usblp_release (close) kills it; the printer drains at print
|
||
speed — so the accepted-but-untransferred tail (incl. feed+cut, always the last bytes) died at
|
||
close. writeAllUsb now holds back the FINAL byte as its own write — usblp's one-URB rule makes its
|
||
acceptance a completion certificate for everything before it — then drains 300ms for that single
|
||
packet before close. Tests updated (+ final-byte-alone assertion). [[printer-usb-transport]] has
|
||
the full kernel-level account.
|
||
|
||
## [2026-07-07] update | USB printing HARDWARE-VERIFIED; wizard lists real /dev/usb devices
|
||
|
||
Lab retest after the close-cancel fix: full slip + feed + CUT over USB — parity with TCP; the
|
||
transport is done. Follow-up UX (operator had to `ls /dev/usb` to find lp1 on park-buzi): new
|
||
GET /api/setup/usb-printers enumerates /dev/usb/lpN + sysfs ieee1284_id make/model; the wizard's
|
||
devicePath is now a select of PRESENT printers (fresh form preselects the first; a saved-but-
|
||
absent path stays selectable, flagged; none found → free-text + hint). Transport option label no
|
||
longer hardcodes lp0. Details on [[printer-usb-transport]].
|
||
|
||
## [2026-07-07] update | Camera clock sync via ISAPI — the 1970 power-cut reset healed
|
||
|
||
park-buzi observation: power-cut Hik cameras reboot at the 1970 epoch (no RTC battery, no NTP)
|
||
until a web-UI login pushes the browser clock — corrupting snapshot OSD timestamps (evidence) and
|
||
ANPR push times meanwhile. Built host-as-time-authority sync (details on [[lpr-camera]] §Clock
|
||
sync): device monitor triggers at the offline→ready edge + 24h backstop; HikvisionCamera.syncClock
|
||
GETs /ISAPI/System/time, and beyond 60s drift PUTs manual time with the site's wall-clock + explicit
|
||
offset, echoing the camera's timeZone verbatim; >1h jumps log warn (persisted). digest client
|
||
generalised GET→GET/PUT/POST with body (the handshake was already method-aware). Capability-guarded
|
||
(isClockSyncable — hikvision only). 8 new tests (5 devices, 3 tz-offset).
|
||
|
||
## [2026-07-07] lint | Wiki catch-up sweep after the lab-bench sprint
|
||
|
||
Audit found five pages lagging the log: [[rongta-printer]] still named the `cashino` driver id
|
||
(→ escpos + migration note); [[tariff-time-tiers]] still listed the composer price preview as
|
||
deferred (→ delivered by the lab fee breakdown); [[tariff]] lab section gained the breakdown +
|
||
composer increment-guard paragraph; [[i18n]] now records the "25 Qer 14:30" date standard and the
|
||
never-toLocaleString-for-dates rule; [[fleet-deployment-komodo]] gained the park-lab stack + tier
|
||
table (also logging the park-lab addition itself, which had slipped the log).
|
||
|
||
## [2026-07-08] update | Log-storm hardening + reset-db drift guard
|
||
|
||
Field incident 2026-07-07: an unreachable UHPPOTE (`ENETUNREACH 10.0.10.5:60000`) put the
|
||
button-light `#pump` worker in a zero-backoff hot loop — hundreds of identical `setAux failed`
|
||
error rows per minute into [[app-logs]]. Three-layer fix: (1) failed sends now arm a 1s→30s
|
||
exponential retry (reset on success), with only the first failure logged, one summary/minute
|
||
after, and one info on recovery ([[button-light-indicator]] §Implementation); (2) LogService
|
||
coalesces a row identical to the last (level+source+message+path, 5-min refreshing window) by
|
||
bumping `context._repeat` instead of inserting — the viewer badges `×N` ([[app-logs]]);
|
||
(3) the user's training reset had ALSO left logs behind: `app_logs` and `tariff_drafts` belonged
|
||
to no reset-db category, silently surviving even `--all`. Added `--diagnostics` (app_logs), put
|
||
tariff_drafts under `--config`, and a drift guard that refuses to run when any table is
|
||
uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests
|
||
(3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB.
|
||
|
||
## [2026-07-13] decision | Cloud service — multi-tenant SaaS (postponed, context captured)
|
||
From a design conversation, not a source. The user floated an online, multi-tenant SaaS (the
|
||
"cloud service") on TOP of the offline backup model (which stays, as the offline-site tradeoff):
|
||
subscribing park sites get real-time (link-up) monitoring of the signed ledger, device status,
|
||
and financial reports; one admin owns many sites; the cloud custodies per-site secrets; recurring
|
||
per-site fee = a revenue line. Recorded as [[cloud-service-saas]] (status: open, POSTPONED per the
|
||
user) so it isn't re-derived later. It productises the off-site control plane already stood up in
|
||
[[fleet-deployment-komodo]] (Komodo Core + NetBird). Captured: the four hard tensions (offline-first
|
||
vs real-time; the ledger must be VERIFIABLE not just displayed in the cloud; central secret custody;
|
||
two-level tenancy under operator-as-adversary), the secrets boundary the user confirmed (sync creds
|
||
+ device-password ESCROW + app identity — but NOT the signing/ATECC608 key, which stays on the
|
||
booth), and TWO in-discussion corrections that stand: (1) NetBird already solves the "cloud reaches
|
||
booth" isolation objection — park-buzi is monitored that way today, booth-dialed, nothing exposed;
|
||
(2) remote barrier-open is COMPATIBLE with [[barrier-not-a-door]] (it's `pulseOpen`/intent, never
|
||
timed-close) and is DRIVEN by the [[autonomous-direction]] unmanned future — gated as a distinct
|
||
privilege + a signed ledger event with actor+reason, with the booth as enforcer and a local
|
||
fail-open that can't depend on the cloud. Four open questions parked (real-time definition, where
|
||
reports are computed, hosting/licensing, custodianship-as-liability). Cross-linked; index count
|
||
7→8 decisions.
|
||
|
||
## [2026-07-13] update | Owner requirement — in-park merchant validations (car-wash "lavazh", bar)
|
||
|
||
The [[validation-discounts]] feature is now asked-for, not just an industry-survey gap: the park
|
||
may host an in-park car-wash and/or bar whose customers the owner wants discharged for the stay —
|
||
full comp, free-first-N-minutes (`time-credit`), or consumption-offset (`fixed`, variable amount:
|
||
300 ALL consumed vs 500 ALL fee → pay 200). Must be admin-composable at runtime like
|
||
tariffs/subscription plans. Driving-cases section added to [[validation-discounts]]. Open: merchant
|
||
ownership (owner-run → pure discount; tenant → [[validation-sponsorship]] settlement), who applies
|
||
(operator vs merchant code/portal), stacking rules, caps.
|
||
|
||
## [2026-07-13] update | Merchant validations refined — merchant STATIONS (users), not sponsors
|
||
|
||
Second pass on the [[validation-discounts]] requirement: ownership immaterial, sponsor layer
|
||
dropped. Merchant = a system user on their own device who scans the ticket to validate (signed,
|
||
attributed); admin checkbox per station = may collect parking payments (then shift + till +
|
||
Z-report apply to them like the booth); paid/zero-due tickets self-exit at the reader.
|
||
Consequences: per-station shifts/drawers (breaks the site-wide single-open invariant), exit-reader
|
||
live due=0 branch. Details on [[validation-discounts]].
|
||
|
||
## [2026-07-13] update | Merchant validations settled — validation-only merchants, all money at the booth
|
||
|
||
Third pass, settled: the merchant-collects-payments variant is REJECTED. Merchant users only scan
|
||
+ validate (signed, attributed); every car checks in at the booth to settle (net may be 0 — still
|
||
a signed payment) and gets the detailed gross/discount/net receipt there. Per-station
|
||
shifts/drawers and the exit-reader due=0 branch are no longer needed — shift/drawer/exit flows
|
||
stay as built; Z/X-reports gain discount lines. Build surface: validation_programs master data,
|
||
signed validation event, priceSession validations[] extension, merchant scan page, booth
|
||
quote/receipt/Z-report lines. Details on [[validation-discounts]].
|
||
|
||
## [2026-07-13] decision | Merchant validations — design SETTLED, build started
|
||
|
||
Setup UX on /setup/site (Bar/Lavazh checkboxes → right-column config panel, tabs when both);
|
||
fixed UI over generic storage (validation_programs + user binding, well-known bar/lavazh rows,
|
||
mutable config — the signed validation event carries resolved values); RBAC = new `validation`
|
||
resource (create/read), guard = permission AND station binding; merchant-only users land on
|
||
/validate; merchants may void their own unused validation. See [[validation-discounts]].
|
||
|
||
## [2026-07-13] update | Merchant validations BUILT end-to-end (bar / lavazh)
|
||
|
||
Shipped the settled design: `validation` permission + ledger event (resolved values, refId-void),
|
||
priceSession validations[] canonical fold (timeCredit→percent→fixed→comp, Σ lines ≡ gross−net),
|
||
validation_programs(+users) tables (migration 0024, reset-db config category), routes/validations.ts
|
||
(programs PUT signs config_change; apply guards: binding → open transient → no dup → maxPerDay →
|
||
amount cap; void own-unused-only), PayStation quote/pay/lookup net folding + payment consumption
|
||
(grossMinor/discountMinor/validationIds/validationLines), receipt gross+discount lines, Z/X-report
|
||
discountTotalMinor ("Zbritje (validime)", printed only when >0), /setup/site two-column Bar/Lavazh
|
||
checkboxes + config panel (tabs), /validate merchant screen (merchant-only users land there),
|
||
booth-modal gross→lines→net, feed label VALIDIM. 8 new route integration tests + shared fold suite;
|
||
workspace build/typecheck/test green. As-built + remaining polish on [[validation-discounts]].
|
||
|
||
## [2026-07-13] decision | Merchant scan input: HID barcode scanner on web/desktop; camera paths postponed
|
||
|
||
The bar/lavazh stations use a USB/HID scanner (or hand-keying + Luhn) into /validate on the
|
||
web/desktop app. Two evaluated camera alternatives deliberately POSTPONED: web getUserMedia
|
||
scanning (blocked on secure-context TLS for LAN phones + weak Code128-via-camera — would want
|
||
QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning via the official
|
||
barcode-scanner plugin; deferred over Android build/distribution overhead + the
|
||
configurable-server-URL prerequisite). Full analysis on [[validation-discounts]].
|
||
|
||
## [2026-08-23] ingest | HIKVISION DS-2CD1047G3H-LIU-F datasheet
|
||
|
||
Vendor datasheet dropped in `raw/DS-2CD1047G3H-LIU.md`. Key new fact: main stream on this model
|
||
supports H.265+/H.265/H.264+/H.264 only — **no MJPEG**; sub-stream adds MJPEG. Likely mechanical
|
||
explanation for the persistent ISAPI main-stream snapshot 503 (`deviceBusy`) already logged on this
|
||
model in [[lpr-camera]] (2026-06-26/27): the on-demand JPEG snapshot has no native path on main,
|
||
so it has to transcode from H.264/H.265 live, which this SKU's firmware apparently can't do
|
||
reliably at 2560×1440. Bitrate spec (32Kbps–16Mbps) also confirms the site's main-stream config
|
||
(6144–12288Kbps) was never out of range — rules out misconfiguration definitively.
|
||
|
||
## [2026-08-23] query | Main-stream 503: model-specific or config? RTSP as a workaround?
|
||
|
||
Live-compared two Hikvision units at park-buzi via their public port forwards
|
||
(`park-buzi.msai.al:8081`/`:8082`) plus the `:8082` unit directly on the LAN (`10.0.10.13`).
|
||
Channel-101 (main) config is **byte-identical** between a working `DS-2CD1043G2-LIU` (8081, 200 OK)
|
||
and the failing `DS-2CD1047G3H-LIU` (8082 / `10.0.10.13`, persistent 503 `deviceBusy`, 3/3 retries
|
||
instant) — same resolution/bitrate/framerate/SmartCodec state. Rules out config as the cause;
|
||
confirms it's model/firmware-specific (matches the datasheet finding above). Then tested RTSP
|
||
(`rtsp://…@10.0.10.13:554/Streaming/Channels/101` via ffmpeg, TCP transport) against the SAME
|
||
failing camera: returned a valid 2560×1440 JPEG on the first try. RTSP taps the continuously-
|
||
running encode rather than asking for an on-demand re-encoded JPEG, so it sidesteps whatever the
|
||
ISAPI snapshot path chokes on. Not yet built into `camera.ts` (would add an `ffmpeg` child-process
|
||
dependency + RTSP auth/transport handling) — filed as a viable, proven fallback if full-resolution
|
||
main-stream stills are ever needed; sub-stream ISAPI snapshot remains sufficient for current ANPR
|
||
use. Full comparison table + RTSP command on [[lpr-camera]].
|
||
|
||
## [2026-08-23] update | Firmware update tested and ruled out; sub-stream confirmed too weak for ANPR — RTSP is now required
|
||
|
||
Two developments on the DS-2CD1047G3H-LIU (`10.0.10.13`) main-stream 503: (1) the owner reports the
|
||
sub-stream (768×432) **fails to read plates "from time to time"** in real use — sub-stream-only is
|
||
no longer an acceptable mitigation, it's an accuracy problem. (2) Before building RTSP, tested
|
||
whether this was a day-one firmware bug: the camera's original `V5.8.11`/250415 build was confirmed
|
||
(via Hikvision's own release note) to be the FIRST H13U firmware to support this camera family at
|
||
all. Upgraded live to `V5.11.0`/260701 (~15 months newer, spanning an intermediate release that
|
||
explicitly claimed "image stability" fixes). Result: **no change** — identical `deviceBusy` 503,
|
||
5/5 attempts, post-upgrade. Firmware is now a ruled-out cause, not a theory; this looks like a real
|
||
encoder/hardware ceiling on this SKU. Next step: build the RTSP-based main-stream capture path into
|
||
`packages/devices/src/drivers/camera.ts` (not yet started). Full detail on [[lpr-camera]].
|
||
|
||
## [2026-08-23] update | Root cause nailed down: broken ISAPI handler, not "busy" — decision to REPLACE the camera line
|
||
|
||
Final test on the DS-2CD1047G3H-LIU snapshot 503: swept every channel/stream ID against
|
||
`GET .../channels/<id>/picture`, including nonexistent ones (1, 100, 103, 201, 999). Every single
|
||
ID returns the identical `503 deviceBusy` body EXCEPT exactly `102` (the real sub-stream), which is
|
||
always 200. A real busy/saturated encoder would not succeed on one specific value while failing
|
||
garbage IDs identically — this is a generic fallback error: the firmware's snapshot handler is only
|
||
correctly wired for channel 102, and everything else (valid main-stream 101 included) falls through
|
||
to a stock, mislabeled "Device Busy" response. Confirms the firmware-upgrade non-result from
|
||
earlier today (a wrong-code-path bug wouldn't be fixed by more capacity). Owner's decision: replace
|
||
the DS-2CD1047G3H-LIU units rather than carry an RTSP/ffmpeg workaround dependency — the sibling
|
||
DS-2CD1043G2-LIU (no such bug, ISAPI main-stream snapshot works natively) is the reference model
|
||
going forward. RTSP main-stream capture remains documented as a proven, viable fallback if a G3H
|
||
camera is ever unavoidable, but is not being built. Full sweep table + reasoning on [[lpr-camera]].
|
||
|
||
## [2026-08-30] update | Booth USB printer cover-open bug: leading theory is a stale container bind-mount, not a stale app-layer handle
|
||
|
||
Live troubleshooting request (park-buzi): opening the printer's paper-roll cover reliably wedges its
|
||
status to offline/faulty, surviving a full appliance reboot; only `docker restart server` clears it.
|
||
Traced `sendRawUsb`/`probeUsb` end-to-end in `printer-escpos.ts` plus both poll loops
|
||
(`device-monitor.ts`, `printer-monitor.ts`): every print AND every poll does a fresh
|
||
open→write/probe→close with no persistent fd/socket/driver instance anywhere — ruling out a naive
|
||
"stale Node handle" explanation. Leading hypothesis instead: the cover-open microswitch cuts power
|
||
to the printer's USB interface board, causing a real bus re-enumeration; the container's directory
|
||
bind-mount of `/dev/usb` (chosen specifically to survive `lpN` renumbering) can retain a stale view
|
||
of the old device node until the container's mount namespace is recreated — which `docker restart`
|
||
does and a policy-driven reboot-time restart may not (boot-order race). Not yet confirmed on
|
||
hardware (host-vs-container `stat`/inode comparison at the next occurrence is the next step); lab
|
||
repro is blocked because the lab has a RONGTA, not the park-buzi unit's actual (still unidentified,
|
||
"Generic (unknown)") model. Full writeup, confirmation commands, and candidate fixes on
|
||
[[printer-usb-transport]].
|
||
|
||
## [2026-08-30] update | Backup status "Never" despite valid rotating backups — restart amnesia in BackupService, fixed
|
||
|
||
Admin noticed park-buzi's Backup screen showed "last successful backup: Never" despite 7 real,
|
||
correctly-rotating encrypted backup files on disk, plus a 2-day gap since the last file. Traced
|
||
both symptoms to the same cause: `BackupService` tracked last-success/last-error as PLAIN
|
||
IN-PROCESS FIELDS (never written to the DB), and the daily schedule was a `setInterval(...,24h)`
|
||
measured from PROCESS START, not wall-clock time since the last real backup — so any server
|
||
restart (routine under `restart: always`: deploy/crash/OOM/host reboot) simultaneously wiped the
|
||
visible status back to "Never" and reset the 24h countdown, independent of the actual
|
||
file-writing/retention engine (`backup.ts`), which was working correctly the whole time and
|
||
explains why files existed on disk despite the UI's contradictory-seeming status. Fix: four new
|
||
nullable `site_config` columns (migration `0025_backup_last_status.sql`) persist last-success/
|
||
error there instead of in memory; `BackupService.status()` reads them fresh each call so a new
|
||
instance (= a restart) sees the prior instance's outcome; a new `isDue()` method computes
|
||
schedule-due-ness from the persisted last-success timestamp; `server.ts`'s scheduler is now a
|
||
15-minute poll gated by `isDue()` instead of a 24h `setInterval`, making the real cadence immune
|
||
to restart timing. New test file `backup-service.test.ts` (6 tests) covers restart-durability and
|
||
`isDue()` directly; full existing suite (319 tests) still green. No API/UI contract change. Not
|
||
yet committed (holding per instruction). Full writeup on [[backup-recovery]].
|
||
|
||
## [2026-08-30] update | Two Komodo Periphery gotchas: connect_as renaming, agent upgrade procedure
|
||
|
||
Two real incidents this session, both closed out as new gotchas (#12, #13) on
|
||
[[appliance-provisioning]] §7: (1) a lab box installed with a leftover template placeholder
|
||
left in `--connect-as` kept reappearing under that name in Core no matter how many times it was
|
||
renamed in the UI — because `connect_as` is a plain field in the agent's own
|
||
`periphery.config.toml`, and a Core-UI rename never touches it; fixed by editing the field
|
||
directly on the host + `systemctl --user restart periphery`, no reinstall needed. (2) Upgrading
|
||
Periphery from a version-mismatch (Core bumped to v2.3.2, an agent still on v2.2.0) has no
|
||
separate update mechanism — confirmed against Komodo's own `setup-periphery.py` source that
|
||
re-running the same installer with unchanged `--connect-as` is config-preserving (it explicitly
|
||
skips rewriting an existing config) and safe; verified dry-run on `art-docker-station` (lab) then
|
||
applied to `park-buzi` (live booth) with no disruption to the running app containers. Full detail
|
||
+ exact commands on [[appliance-provisioning]].
|
||
|
||
## [2026-09-03] fix | Desktop updater endpoint was unreachable — pointed at a private repo
|
||
|
||
The Tauri auto-updater ([[desktop-shell-tauri]]) was fully implemented — signed builds, keypair,
|
||
`latest.json`, `release.yml` — but its endpoint pointed at `mca/parking_solution`'s own Gitea
|
||
"latest release" redirect, and that repo is **private**. Field appliances have no Gitea
|
||
credentials, so every update check was silently failing (caught by a `try/catch`); this was never
|
||
actually field-verified end to end. Fix: signed installers now mirror to a new public,
|
||
installers-only repo `mca/public_releases` (org-shared, not parking-specific), published to a fixed
|
||
`desktop-latest` tag so other apps releasing there later can't shadow ours. Considered and rejected
|
||
embedding a `read:repository` token in the app instead — ruled out given the appliance's own threat
|
||
model (booth operator as primary adversary) makes an extractable, hard-to-rotate credential in every
|
||
deployed binary worse than just publishing installers publicly. `release.yml`,
|
||
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
|
||
[[desktop-shell-tauri]].
|
||
|
||
## [2026-09-03] fix | Desktop login broken by a VITE_API_BASE regression from the booth same-origin fix
|
||
|
||
The 2026-06-27 booth fix (commit 96fd97e) correctly blanked `apps/web/.env.production`'s
|
||
`VITE_API_BASE` for the browser/booth same-origin case, but the desktop build shares that same
|
||
file and was never given its own override — the desktop shell has been building with an empty
|
||
API base since that commit, unnoticed until now. Symptom: login threw `DOMException: "The string
|
||
did not match the expected pattern."` — WebKitGTK rejecting a relative `fetch()` URL with no base
|
||
to resolve against, since the desktop window's origin is `tauri://localhost`. Browser login was
|
||
unaffected (same-origin, no absolute URL needed), which is why this went unnoticed through the CI
|
||
mirror-repo debugging session. Fixed by setting `VITE_API_BASE=http://127.0.0.1:3000` inline in
|
||
`tauri.conf.json`'s `beforeBuildCommand`, overriding the shared `.env.production` for the desktop
|
||
build only (process env wins in Vite's load order) — verified both builds independently. Full
|
||
detail on [[desktop-shell-tauri]].
|
||
|
||
## [2026-09-03] fix | Desktop updater silently failed: tag/version drift + swallowed install errors
|
||
|
||
Two compounding bugs, both closed out on [[desktop-shell-tauri]] §"Desktop in CI": (1) the v0.1.1
|
||
release bumped only the git tag — tauri.conf.json's own "version" field (what Tauri actually bakes
|
||
into the bundle filename and internal version) stayed at 0.1.0, so the signed binary didn't match
|
||
what latest.json claimed to describe, and signature verification failed on every download; (2)
|
||
desktop-updater.ts's single blanket try/catch swallowed that failure identically to "offline/no
|
||
update," so the operator saw the prompt, watched it download, then nothing — repeating forever with
|
||
zero diagnostic trail. Fixed release.yml to sed-patch tauri.conf.json's version from the git tag
|
||
right before building (checked-in value is now dev-only, never hand-maintained for releases), and
|
||
split desktop-updater.ts's catch so a real post-accept failure logs instead of vanishing. Full
|
||
detail on [[desktop-shell-tauri]].
|
||
|
||
## [2026-09-03] fix | Desktop login "Load failed": WebKit mixed-content, not CORS/CSP
|
||
|
||
After fixing VITE_API_BASE, login still failed with WebKit's generic "Load failed" — a raw browser
|
||
fetch() rejection with no server-side trace, since the request never reached the network. Root
|
||
cause: WebKitGTK treats tauri://localhost as a secure origin, so http://127.0.0.1:3000 (and
|
||
ws://127.0.0.1:3000) from inside it is blocked as mixed content — a known WebKit limitation, NOT
|
||
fixable via CSP connect-src. Fixed by routing both through Tauri plugins that use the native (Rust)
|
||
HTTP/WS client instead of the webview's own: tauri-plugin-http (a genuine fetch() drop-in, wired
|
||
into api.ts/logger.ts via a new platformFetch() in origin.ts) and tauri-plugin-websocket (NOT a
|
||
drop-in — async/listener API — adapted behind a native-WebSocket-shaped interface in the new
|
||
platform-ws.ts so use-live-feed.ts needed no changes). Full detail on [[desktop-shell-tauri]].
|
||
|
||
## [2026-09-03] fix | Desktop live feed offline: native WS plugin sends no Origin, prod allowlist was empty
|
||
|
||
Login worked after the mixed-content fix, but the live feed showed offline in the desktop app while
|
||
the browser showed LIVE, same server. tauri-plugin-websocket's connect() runs on Tauri's Rust side,
|
||
not inside the webview page, so it never auto-attaches an Origin header — routes/ws.ts's anti-CSWSH
|
||
check treats a missing Origin as untrusted and 403s before auth. Compounded by a second, independent
|
||
gap: komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production, despite
|
||
.env.example documenting tauri://localhost as required for the desktop app. Fixed both: platform-ws.ts
|
||
now passes Origin: tauri://localhost explicitly in connect()'s headers; resources.toml's two Stacks
|
||
get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, not just a git push.
|
||
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
|
||
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
|
||
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
|
||
|
||
## [2026-09-03] fix | Update failures were invisible: console-forward gate blocked the error logging
|
||
|
||
The desktop-updater.ts error logging added earlier this session used console.error/console.warn,
|
||
but logger.ts only forwards console output to the server when the client log level is debug/trace
|
||
(default: info) — so the "fix" never actually surfaced anything, and a real v0.1.3→v0.1.4 update
|
||
failure showed zero logs anywhere, sending debugging in circles (a WebKit remote-inspector attempt
|
||
via WEBKIT_INSPECTOR_SERVER also dead-ended — this build doesn't answer standard discovery
|
||
endpoints). Fixed by calling logClient() directly in desktop-updater.ts, unconditionally, bypassing
|
||
the console-forward gate entirely — a genuine post-accept install failure now always reaches
|
||
app_logs regardless of client log level. Also added download-progress logging. Separately: found
|
||
and fixed a real, pre-existing Komodo ResourceSync misconfig (resource-sync-park-systems pointed at
|
||
`dev`, not `stage`, silently reading resources.toml from the wrong branch for months with zero
|
||
effect until dev/stage first diverged today) — full writeup on [[fleet-deployment-komodo]], which
|
||
had already warned about exactly this gotcha back in 2026-07-07 and it happened anyway.
|
||
|
||
## [2026-09-03] feat | Desktop app version now visible in the UI (was invisible)
|
||
|
||
There was no way to see which desktop build was actually installed anywhere in the app — an
|
||
operator debugging a stuck update had to infer it backwards from the update prompt's target
|
||
version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBadge next to the
|
||
existing server-side VersionBadge in router.tsx, using @tauri-apps/api's getVersion() (the real
|
||
running app version, synced to the git tag at build time by release.yml). No-ops in a browser.
|
||
Full detail on [[desktop-shell-tauri]].
|
||
|
||
## [2026-09-04] feat | Desktop backend origin is now runtime-configurable (was build-time)
|
||
|
||
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases — not built
|
||
per-booth — but VITE_API_BASE was a build-time env var hardcoded to http://127.0.0.1:3000, so the
|
||
same installer could only ever talk to a server on its own machine. Added ConnectScreen (shown
|
||
before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the
|
||
operator-entered URL across restarts; origin.ts's API_BASE became a runtime-settable `let`. CSP's
|
||
connect-src tightened to 'self' only (all backend traffic already went through
|
||
tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway); the
|
||
real boundary moved to capabilities/default.json's http:default scope, wildcarded to any host so
|
||
the operator-chosen address is actually reachable. Added a "Change server" control (Setup nav,
|
||
desktop-only) that clears the saved URL and reloads back to ConnectScreen.
|
||
|
||
While tracing the desktop auth path for this, found a pre-existing (not newly introduced) bug:
|
||
tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate
|
||
from the webview — document.cookie on tauri://localhost never sees the parking_csrf cookie the
|
||
server sets (open upstream bug, tauri-apps/tauri#13045/#11518), so the desktop app has likely been
|
||
silently sending no CSRF header on every mutation since the shell was first built, regardless of
|
||
which host it targeted. Fixed by having sessionView() (routes/auth.ts) also echo the same csrf
|
||
value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of
|
||
reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified,
|
||
and reqwest was already sending it correctly; this only fixes how the desktop client *learns* the
|
||
value. Full detail (including the exact CSP/capability tradeoffs) on [[desktop-shell-tauri]].
|
||
|
||
## [2026-09-04] fix | Desktop live feed: WS handshake can't carry the cookie → single-use ticket; desktop logs never reached app_logs
|
||
|
||
Retrospective of the 2026-09-03/04 desktop run (six releases in 26 h) found the v0.1.4 Origin fix
|
||
cleared only gate one of two in routes/ws.ts: gate two is req.jwtVerify() reading the HttpOnly
|
||
cookie, and tauri-plugin-websocket has no cookie jar at all — so every desktop handshake 401'd and
|
||
use-live-feed reconnected every 10 s (confirmed in the park-2 server log). Fixed with a 30-second,
|
||
single-use, in-memory WS ticket minted by POST /api/ws/ticket over normal cookie+CSRF auth and
|
||
presented in an x-ws-ticket header; Origin check still runs first, browser path unchanged, JWT
|
||
stays out of JS. Second finding: logger.ts read the CSRF cookie via document.cookie, null on
|
||
desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had
|
||
EVER reached app_logs, which is why "no logs whatsoever" kept happening and why yesterday's
|
||
logClient fix couldn't help. Stash moved to lib/desktop-csrf.ts, shared by api.ts and logger.ts;
|
||
WS connect failures now go through logClient (rate-limited). Third: the ConnectScreen probe now
|
||
uses the unauthenticated /health (extended with app: "parking-system") instead of accepting any
|
||
401. Also corrected four wiki citations (WebKit 171934 scope, tauri#11518 is closed, the HTTP
|
||
plugin does set Origin itself, the http-scope "quirk" is URLPattern default-port semantics) and
|
||
added a local-AppImage pre-tag gate to the desktop README, since tauri dev cannot reproduce any
|
||
of these origin-dependent bugs. Full detail on [[desktop-shell-tauri]].
|
||
|
||
## [2026-09-04] fix | Desktop in-app update never worked: latest.json described only the AppImage, booths run the .deb
|
||
|
||
tauri-plugin-updater looks up `{os}-{arch}-{installer}` first (linux-x86_64-deb — the bundler
|
||
stamps the installer type into the binary; verified with strings on a local .deb) and only then
|
||
bare linux-x86_64. release.yml's latest.json carried only the bare key → the AppImage, so every
|
||
.deb install downloaded the AppImage, passed signature verification, then failed install_deb()'s
|
||
is_deb check with InvalidUpdaterFormat — invisible until v0.1.6 fixed the desktop log channel.
|
||
This, not version drift or swallowed errors, is why v0.1.0→…→v0.1.6 never self-updated.
|
||
latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb update ends in a
|
||
polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed
|
||
package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the
|
||
prompt on the v0.1.6 booth.
|
||
|
||
## [2026-09-04] decision | Desktop updates are admin-only: keep the polkit prompt; AppImage rejected on field evidence
|
||
|
||
First successful desktop self-update (v0.1.6 → v0.1.7, pkexec dpkg -i + polkit dialog) raised
|
||
the question of the admin password the operator lacks. Tried the AppImage as the no-root path:
|
||
it fails to start on the Ubuntu 26.04 booth (bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa —
|
||
EGL_BAD_PARAMETER abort), and structurally it abandons the distro-maintained WebKitGTK the
|
||
platform decision depends on. Passwordless polkit for dpkg is root-for-the-operator, rejected.
|
||
Decision (user, 2026-09-04): the .deb stays, updates are an admin action behind the prompt; the
|
||
in-app prompt now says so (en + sq). A root systemd updater timer shipped in the .deb (minisign-
|
||
verified, notify-only in-app) is recorded as the deferred fleet-grade option on
|
||
[[desktop-shell-tauri]].
|
||
|
||
## [2026-09-04] decision | Venue modules design recorded as OPEN — Car Wash / Bar as peers of Parking
|
||
|
||
Captured the 2026-09-04 design conversation on [[venue-modules]]: a manifest-registry module
|
||
system (folder per module, always-migrated schema, one ledger union with prefixed event types,
|
||
relations only via manifest dependsOn + ledger events), enablement as entitled ∩ activated
|
||
(vendor-set Komodo env vs site-admin site-config toggle recorded as config_change; server
|
||
enforces with requireModule, web only hides; disabling never deletes), Parking recast as one
|
||
module on a venue POS/audit core, validation decommissioned (ledger type kept for history), the
|
||
desktop identifier / .deb name flagged as the one irreversible naming step, and vision-derived
|
||
vehicle category (SUV vs Car for the wash tariff) as an advisory signal that raises an `anomaly`
|
||
ledger event on operator override — never a tariff input by itself. Added as open-questions #15;
|
||
indexed.
|
||
|
||
## [2026-09-05] decision | Car Wash is the pilot module; wash inside the parking; bay camera
|
||
|
||
Settled with the user on [[venue-modules]]: Car Wash is built as the first module and is the
|
||
acceptance test of the registry (zero changes outside its folder). The wash sits inside the
|
||
parking, so every vehicle already has a session, plate and entry snapshot — no intake capture,
|
||
walk-ins out of scope. A bay camera (same stateless vision service, presence/counting not
|
||
plates) gives two anti-fraud signals for the unrecorded-wash vector: session-vs-order and
|
||
bay-count-vs-order-count per shift, both as `anomaly` ledger events, never blocking. v1 scope
|
||
(catalogue by category, queue, advisory vision category, existing shift/cash/receipts, one
|
||
parking comp/credit event, per-operator reports) and the out-list (memberships, loyalty, stock,
|
||
scheduling, booking, accounts) recorded, plus the build order. Remaining before code: platform
|
||
name (→ desktop identifier) and the validation-removal go.
|
||
|
||
## [2026-09-05] decision | Name stays parking-system; validation kept for the Bar, only the Lavazh station retires
|
||
|
||
Two revisions to [[venue-modules]] from the user: (1) the platform name stays — "this is a
|
||
Parking Systems after all" — so `com.parking.desktop` and the .deb name are untouched and the
|
||
irreversible-identifier concern is moot; the peer-module architecture, not a rename, answers the
|
||
second-tier worry. (2) Validation is NOT decommissioned: the merchant-scan flow is needed as is
|
||
for the Bar until a Bar module exists and absorbs it. Only the Lavazh station is retired when Car
|
||
Wash ships (Car Wash sponsors parking via its own order event). Two sponsorship mechanisms
|
||
coexist for now, accepted. Build order updated; validation is registered as its own module in the
|
||
registry so Bar can later depend on or absorb it.
|
||
|
||
## [2026-09-05] feat | Venue-module registry built (pilot groundwork): entitled ∩ activated, requireModule, Setup panel
|
||
|
||
Implemented build-order steps 1 + 3 of [[venue-modules]]: Lavazh validation station retired
|
||
(STATIONS = ["bar"], rows untouched); @parking/shared gains MODULE_IDS / ModuleManifest / MODULES
|
||
(parking required, validation dependsOn parking) plus the pure rule functions; site_config
|
||
gets modules_json (migration 0026, hand-written — drizzle-kit generate needs a TTY and the
|
||
snapshots end at 0003); server modules.ts adds requireModule(db, id) (403 module_disabled,
|
||
composed before requirePermission), modules/index.ts registers folder-based modules by
|
||
iterating the registry (validation is the first), site-config GET/PUT expose and set the
|
||
activation with dependency rules and one signed config_change per module that actually flips,
|
||
/api/auth/me carries the effective set; web gains lib/modules.ts + modules/{index,validation}
|
||
and router.tsx spreads WEB_MODULES into nav + route tree, SiteSettings gets a Modules panel and
|
||
hides the validation section when the module is off; MODULES_ENTITLED set explicitly in both
|
||
booth stacks. 7 new server tests, suite 329/329, web build clean. Car Wash next.
|
||
|
||
## [2026-09-05] feat | Car Wash v1 built as the pilot venue module — one manifest, two folders, two core seams
|
||
|
||
Delivered the pilot on [[venue-modules]]: carwash_{categories,services,prices,orders} (migration
|
||
0027), ledger types carwash_order (created/done/void, names + price frozen) and carwash_payment
|
||
(bay money), a server module (settings, oldest-first queue, intake against an open ticket, done →
|
||
applies the "carwash" validation program via the extracted applyValidation(), bay payment →
|
||
signs carwash_payment and, if the sponsorship made the session zero-due, the $0 parking payment
|
||
the exit reader needs; void takes back a live sponsorship) and a web module (/wash desk,
|
||
/setup/carwash master data + sponsorship editor). Two deliberate core seams: PayStation charge
|
||
providers (a wash paid at the booth rides the parking payment as chargeLines; BoothPayModal shows
|
||
them) and applyValidation() shared with the merchant route; shift money folds include bay
|
||
payments. Registry proof: the module needed exactly one manifest entry + one line in each
|
||
registry + its folders. 7 new tests, suite 337/337; full bay flow verified live in the browser.
|
||
Booth stacks not yet entitled to carwash. Next increment: vision category flag + bay camera.
|
||
|
||
## [2026-09-05] query | Car Wash review pass: discount modes, price matrix, 74-day comp bug, finished list — and the tills requirement
|
||
|
||
Hands-on review of Car Wash v1 with the user, recorded on [[venue-modules]] §"Review log":
|
||
renamed the discount section; added two wash-only discount modes (free during the wash +
|
||
tolerance; wash price off the fee, floored at 0) resolved at done via applyValidation() and
|
||
refused at merchant scan; hid typed-amount and percent from the wash editor (typed amount =
|
||
the operator picks the money → highest fraud exposure; kept for the Bar); fixed the
|
||
price-matrix cells for unsaved rows (two-request save); found and fixed a real money bug —
|
||
"free until done" was anchored at entry and would have comped a 74-day stay for one wash
|
||
(ticket 92498375903) — now anchored at the order's intake; long durations render y/d/h/m;
|
||
added a Finished list to the desk. Raised a structural requirement: shifts must become
|
||
per-till (booth / carwash) — a bay payment currently needs the booth shift and folds into
|
||
the booth drawer, which breaks both operators' Z-reports; design recorded, awaiting a go
|
||
([[shift]] carries a forward pointer).
|
||
|
||
## [2026-09-05] ingest | Tills built: one shift + one drawer per money-taking desk
|
||
|
||
The user confirmed the tills design ("go ahead and start building it"). Built in the core:
|
||
a shift is opened on a till (`booth` | `carwash`), every money event names its till
|
||
(`payload.till`, absent = booth so the chain re-folds identically), the ShiftService folds,
|
||
X/Z-reports, vouchers and carry-forward are per till, single-open is per till, and a bay
|
||
payment now requires the **carwash** shift. Web: the shift button became a per-till
|
||
component (header = booth, wash desk = carwash, with "Wash drawer now" and pay buttons
|
||
gated on my wash shift); the shift hub lists every open shift with till badges + a till
|
||
filter; the drawer hub switches tills. 6 new shift tests + the bay test now proves the booth
|
||
shift does not cover the bay; verified live with the booth held by another operator. Recorded
|
||
on [[shift]] §"Tills" and [[venue-modules]] §"Tills → As-built". Follow-ups: per-till
|
||
activity log, wash bucket on the booth Z, wash-desk printer role.
|
||
|
||
## [2026-09-05] ingest | Where wash money is taken became a site setting (Setup → Car wash)
|
||
|
||
User: the booth|bay choice belongs in `/setup/carwash` ("Pagesa: në kabinë / në lavazh"), and
|
||
the per-order radio goes away from `/wash`. Built: `carwash_config` singleton (migration 0028,
|
||
default booth), `payAt` on the settings view/body, signed `config_change carwash.payAt` on a
|
||
flip, orders freeze the policy in force, `409 pay_at_policy` for a stale client; Setup radio;
|
||
desk shows the policy read-only. Tests: 1 new (default, persist, sign, freeze, refuse).
|
||
Recorded on [[venue-modules]] §"v1 answers" item 2.
|
||
|
||
## [2026-09-05] ingest | Till access by module permission; landing per module
|
||
|
||
The user found their wash user could open the booth's shift (any `shift:create` could open any
|
||
till) and asked for the wash interface to be filtered off booth screens. Built: manifest
|
||
`tillPermission` + `tillsFor()`; server `accessibleTillsFor()` guards shift open/close/state
|
||
and cash movements (`403 till_forbidden`), `/api/shift/tills` returns only the role's tills;
|
||
header shift button needs `session:read`; module `landing` replaces the hard-coded merchant
|
||
landing, all guards bounce to `/`, `/booth` needs `session:read`. Diagnosed the dev
|
||
`Lavazhier` role: it holds booth permissions (`session:read`, `payment:create`,
|
||
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
|
||
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
|
||
and [[venue-modules]] §Tills → As-built.
|
||
|
||
## [2026-09-05] ingest | Permissions matrix rethink — three moves built
|
||
|
||
User: "we opened Pandora's box with this car wash module … rethink the permissions matrix";
|
||
and "the user should have websocket for live events — this does not mean it can read
|
||
/reports". Decision recorded on [[venue-modules]] §"Permissions matrix" (open-questions
|
||
#16), then built: (1) per-desk till guards — manifest `tillGuards`, new `carwash:cash`,
|
||
`requireTill(kind)` resolves the guard from the till, `tillPermission`/`session:read`
|
||
borrowing removed; (2) jobs — manifest `jobs[]` (booth-operator, booth-supervisor,
|
||
merchant, wash-operator) as one-click chips in Setup → Roles with "mixes desks" / "partial
|
||
job" lints; (3) the live feed admits any WATCH permission (event/session/device read or a
|
||
module's `feedPermission`) and filters every push per role — `report:read` is the reports
|
||
screen only. 352/352 server tests; lavazhier (event:read) now shows LIVE. Their dev role
|
||
still needs `carwash:cash` (+ create/update) and should drop the booth permissions — the
|
||
"Wash operator" chip is exactly that.
|
||
|
||
## [2026-09-05] ingest | Role reassignment now takes effect without re-login
|
||
|
||
User: a user moved to a new "Lavazh NEW" role kept getting `403` on `POST /api/carwash/orders`.
|
||
Cause: the login token pins the `roleId` current at LOGIN; `/api/auth/me` read the user row (new
|
||
role) while every guard read the token (old role). Editing a role already took effect per
|
||
request (the permission cache); reassigning one did not. Fix in `auth.ts`: `refreshRole()` after
|
||
every `jwtVerify` resolves the user's CURRENT role from the DB (cached per user, cleared by
|
||
`bumpPermsCache()`, which the user update/delete routes now call); a deleted user's session
|
||
ends with 401 on its next request; the WS cookie path uses the same. Test: moved user creates
|
||
an order on the next request with the same cookie. Recorded on [[local-jwt-auth]].
|
||
|
||
## [2026-09-06] ingest | MODULES_ENTITLED never reached the container
|
||
|
||
User set park-2 to `MODULES_ENTITLED=parking`, re-synced, destroyed + redeployed the stack —
|
||
Lavazh still there. Cause: the variable was in the Komodo stack env and `.env.example` but not
|
||
in `docker-compose.yml`'s server `environment:` block, so the container never saw it; unset =
|
||
every module → every booth on 55d6242 had Car Wash entitled. Fix: compose forwards it with
|
||
default `parking,validation`. Troubleshoot on a booth with `docker exec … env | grep MODULES`
|
||
and the boot log line `venue modules (entitled = …; effective = …)`. Recorded on
|
||
[[venue-modules]] §As-built (deploy gotcha).
|
||
|
||
## [2026-09-06] ingest | Car Wash no longer depends on the validation module
|
||
|
||
User set `MODULES_ENTITLED=parking,carwash` on park-2 — no Lavazh. Cause: the manifest said
|
||
carwash `dependsOn: ["parking","validation"]`, so the effective set dropped it as dependency-
|
||
broken, and the program compose/read routes sat behind the validation module gate. That was a
|
||
design error: the discount ENGINE (validation program rows + `applyValidation()`) is core; the
|
||
`validation` module is only the merchant's scan screen. Fixed: `dependsOn: ["parking"]`; the
|
||
program routes are plain site:read/site:update; the merchant routes (mine/lookup/apply/void)
|
||
stay module-gated. Tests updated. Recorded on [[venue-modules]] (v1 answers item 4 + As-built).
|
||
|
||
## [2026-09-06] ingest | Tills follow-ups closed: per-till activity log, wash bucket on the Z, wash-desk printer
|
||
The three "known follow-ups" of the Tills decision are built. (1) `tillOfEvent(type, payload)`
|
||
in `@parking/shared` — money events by payload `till`, other events by their owning module's
|
||
till, everything else booth — is applied by `/api/events?till=` in SQL and passed by the hub
|
||
log, the Drawer "today" panel and the booth feed; the events route now admits module-feed
|
||
roles (a wash operator's `carwash:read`) and returns only their module's types, the same rule
|
||
the live socket uses. (2) `chargesByModuleMinor` on the shift report/summary/signed payload:
|
||
module charges on the till's payments by module; the ticket bucket excludes them; printed
|
||
`Lavazh (në biletë)` on the booth slip; the wash till's slip prints `Lavazh:` for its own
|
||
takings. (3) Printer role `wash-desk`: the wash till's Z-report and vouchers print there with
|
||
failover to the booth printer; `printerRoleOf()` is the one reading of the role field so a
|
||
desk printer is never mistaken for an entry dispenser; footer label "at wash desk". Updated
|
||
[[shift]] §Tills, [[printer-roles-failover]], [[venue-modules]].
|
||
|
||
## [2026-09-06] ingest | Wash operator job could not load the desk's price list
|
||
User built a role from the "Wash operator" chip (carwash:read/create/update/cash) and the desk's
|
||
category/service pickers stayed empty. Cause: `GET /api/carwash/settings` was guarded by
|
||
`site:read` only — the price list is Setup's data AND the desk's working data. Fixed with a
|
||
new `requireAnyPermission(...)` guard (auth.ts): the read opens to `carwash:read` OR
|
||
`site:read`; the write stays `site:update`. Regression test in carwash.test.ts.
|
||
|
||
## [2026-09-06] ingest | Permissions matrix loose ends: jobs remembered + re-appliable, role edits signed
|
||
Roles now store the jobs they follow (`role_jobs`, migration 0029); `jobsBehind()` in
|
||
`@parking/shared` surfaces a followed job whose bundle grew past the role; the roles list shows a
|
||
"behind <job>" badge + "Update to job" (union, nothing removed) and the editor lints it. Not a
|
||
runtime union by decision (the grid stays explicit; an update never widens a role without a
|
||
click). Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
|
||
name + permissions + jobs, operator); a no-op resave signs nothing. The stale "should
|
||
booth-supervisor carry subscription:*" note is closed — it already does. Tests: routes/roles.test.ts.
|
||
Updated [[venue-modules]] §Permissions matrix status, [[local-jwt-auth]].
|
||
|
||
## [2026-09-06] ingest | Vision vehicle category — app plumbing built, model pending
|
||
Decisions (user): a flagged downgrade is recorded only; Setup maps vision classes onto the site's
|
||
categories. Built: `VEHICLE_CLASSES` vocabulary + `VehicleRead` (shared); `/analyze`
|
||
`vehicle.body_type`/`confidence` in the service schema and the Node client; the read stored in the
|
||
plate's `device_events` row (`vehicleForIdentity`); `carwash_categories.vision_classes`,
|
||
`carwash_config.vision_threshold`, four vision columns on orders (migration 0030); Setup chips per
|
||
category + threshold; the desk pre-selects the mapped category and shows the read + thumbnail;
|
||
a confident, pricier-mapped read with a cheaper choice signs `anomaly carwash.categoryDowngrade`
|
||
(both categories/prices, operator, snapshot) — never blocks. No recognizer emits body_type yet.
|
||
Tests in carwash.test.ts. Updated [[venue-modules]] (As built), [[opencv-anpr-service]].
|
||
|
||
## [2026-09-06] ingest | Vision vehicle stage, phase A: YOLOX-S beside the plate recognizer
|
||
`vision_service/vehicle.py` (YOLOX ONNX on onnxruntime: letterbox, grid decode, NMS, COCO
|
||
car/motorcycle/bus/truck → vocabulary, one vehicle per frame — the box holding the plate, else the
|
||
largest) + `WithVehicle` composition over any plate recognizer; `VISION_VEHICLE_MODEL_PATH` (unset =
|
||
off), input size, detector floor; Dockerfile bakes yolox_s.onnx (best-effort curl) and sets the
|
||
path; compose forwards it (empty = off). Measured on four real dev entry frames: car at 0.83–0.88,
|
||
~240–330 ms, empty lane → none. Tests: tests/test_vehicle.py (pure post-processing + composition +
|
||
missing-model health). Updated [[opencv-anpr-service]], [[venue-modules]].
|
||
|
||
## [2026-09-06] ingest | Car Wash review outbox — booth side
|
||
User: the operator cannot be fully trusted, so their category choice + the snapshot go to a
|
||
trusted remote reviewer over Netbird, with the plate blurred and no site identity. Built the booth
|
||
side: vision reads keep the vehicle and plate boxes as frame fractions (service bbox → client →
|
||
device_events); `carwash_review_outbox` (0031); `review-outbox.ts` (crop with margin ≤ 640 px,
|
||
plate blurred in place, pseudonymous booth id + keyed operator hash, multipart POST with a
|
||
per-booth bearer, backoff, permanent rejections, void/expiry abandon, image dropped once sent);
|
||
enqueue off the intake path in `createOrder`; `/api/carwash/review/status` + a Setup line; env +
|
||
compose. New concept page [[vision-review-outbox]]; [[venue-modules]] As built; index.
|
||
|
||
## [2026-09-06] ingest | Review collector skeleton — apps/collector + its own Komodo stack
|
||
Built `apps/collector` (Fastify + SQLite, shares the contract via @parking/shared): `POST /ingest`
|
||
(bearer per booth, X-Booth-Id must match, multipart meta+JPEG validated, idempotent on item id,
|
||
crop on the volume), the reviewer's screen served by the process (Basic auth; one button/key per
|
||
class + unusable + skip; per-operator agree/disagree/unusable stats), `GET /export/labels.csv`.
|
||
Delivery: `apps/collector/Dockerfile` (monorepo context), `docker-compose.collector.yml` (bind to
|
||
the overlay address; commented `trainer` profile seam for the GPU), a third build step in
|
||
build-images.yml, and a `wash-collector` stack on `art-docker-station` in komodo/resources.toml
|
||
(secret refs to fill). Booth payload now carries `operatorCategory.classes`. Tests: app.test.ts.
|
||
Updated [[vision-review-outbox]], [[fleet-deployment-komodo]].
|
||
|
||
## [2026-09-07] ingest | Entry-stream sampling for the review outbox
|
||
User asked about feeding internet pictures through the collector; assessment: licensed only,
|
||
separate folder, warm-up weight, never the evaluation set — and the stronger accelerator is the
|
||
ENTRY stream (every car, the gate view, zero domain shift). Built: `deviceEvents.emitVehicleRead`
|
||
from snapshot.ts (core announces; the module listens), `ReviewOutbox.sampleEntry()` (one in N,
|
||
in-process) + `enqueueEntry()` (crop + camera class, no order/operator/category), env
|
||
`CARWASH_REVIEW_ENTRY_SAMPLE` (compose + resources template + .env.example), packages carry
|
||
`kind`; the collector stores kind, the review screen shows entry samples as such, export has a
|
||
kind column, operator agreement is wash-only. Setup line shows "1 in N entries sampled". Also:
|
||
Setup → Car wash is a two-column grid (the master-data card was squeezed at max-w-2xl). Tests
|
||
on both sides. Updated [[vision-review-outbox]].
|
||
|
||
## [2026-09-07] decide | Phase B training path + hardware — recorded, not built
|
||
User asked "now what about the training" and then "let's talk hardware". Recorded on the new
|
||
[[bodytype-classifier-training]]: the five-step loop (train on the collector host → evaluate with a
|
||
floor → publish weights to the registry → bake into the vision image → TAG bump; a booth gets a
|
||
model the way it gets code, never a runtime fetch); ~200 reviewed crops per class before the first
|
||
run; the Quadro FX 3800 is unusable (cc 1.3), the HD P530 irrelevant, the Xeon E3-1225 v5 is enough
|
||
(feature-extraction head in minutes, full fine-tune ~1 h); trainer image = CPU-only torch, the
|
||
compose seam drops the GPU reservation; cloud GPU rejected (crops stay on premises). Linked from
|
||
[[opencv-anpr-service]], [[vision-review-outbox]], index. User: "No build just yet."
|