ef55d1c6a9dd1f47d0a87a6788c7821eeaf93ba4
169 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b485e9870b |
feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small Fastify + SQLite service in the monorepo (shares the payload contract and the class vocabulary via @parking/shared), delivered to art-docker-station by its own stack so nothing booth-side lands there and nothing of it on a booth. - POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row. - /review + /api/*: the reviewer's screen served by the process (Basic auth, one login): one pending crop at a time, operator's pick and camera's pick beside it, one button/key per vocabulary class + unusable + skip; stats per booth and per hashed operator (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's category). - GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host. - Booth payload now carries operatorCategory.classes so the comparison needs no site setup. - Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one secret per booth referenced from both the collector's token list and the booth's own stack (park-2 lines templated, commented, DNS name for the URL). - Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
e67f0ccef0 |
feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash order with a vehicle read queues a package for a trusted reviewer over the private overlay (Netbird); the verdict becomes the phase-B training label and the per-operator error rate. wiki/concepts/vision-review-outbox.md. - Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a downscaled copy); vehicleForIdentity() returns them. - carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 % margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped); 400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured. - Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close. - GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash. - Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in .env.example and forwarded by compose. - Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms, queue/drain/backoff/abandon, through the app). Wiki: new concept page, index, venue-modules As built, log. The collector is not built. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
20a3cb3e80 |
feat(vision): vehicle stage, phase A — YOLOX-S (Apache-2.0 ONNX) beside the plate recognizer
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO, mapped to the shared vocabulary) for the Car Wash desk's category suggestion (venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a confident downgrade is flagged, nothing is gated on it. - vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre, else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads. - recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included); a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640". - settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE (0.4, the detector's floor; the flag threshold is site config). - Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and sets the path; compose forwards it (empty = off); .env.example documents it. - Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in ~240–330 ms; empty lane with a person → none. - tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition, and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
5e1395db18 |
feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the open half (no bundled recognizer emits body_type yet, so the desk shows nothing until phase A lands in the vision service). - Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT, reason code carwash.categoryDowngrade; settings/order/lookup views carry the read. - Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node client normalises to the vocabulary and drops the rest. - Record: snapshot.ts stores the read in the plate's device_events row (or its own when the plate was unreadable); vehicleForIdentity() resolves it like the plate. - Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"), carwash_config.vision_threshold (signed config_change when it moves), four vision columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId. - Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup offers class chips per category and the threshold. Operator decides. - Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no reason prompt (user, 2026-09-06). Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
50c18405b6 |
feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix): - `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in @parking/shared surface a followed job whose bundle grew past the role in a later release; the roles list shows a "behind <job>" badge with a one-click "Update to job" (the union, nothing removed) and the editor lints it. Never a runtime union: the grid stays the explicit enforcement layer and an update never widens a role without a click. - Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value = name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes now takes the ledger. - booth-supervisor already carries subscription:*; the stale open note is closed. Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
e14e31a840 |
feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role
Closes the three known follow-ups of the Tills decision (venue-modules.md): - Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events by payload till, other events by their owning module's till, everything else booth), applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today" panel and the booth feed (history + live pushes). The events route admits a role that holds a module feed permission without event:read and returns only that module's event types — the live-socket rule. - Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by module; the ticket bucket excludes them (Bileta = parking only); printed "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:". - Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the one reading of the role field (the entry/booth loaders treated any non-booth role as an entry dispenser). Footer label "at wash desk". Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new requireAnyPermission) — the Wash operator job could not load the desk's category and service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules, log) updated. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
2aa1045ddc |
fix(modules): Car Wash depends on parking only — the discount engine is core, not the validation module
Build & push images / images (push) Successful in 2m51s
A site entitled to parking,carwash had the wash silently dropped as dependency-broken. The validation program routes (compose/read) leave the validation module gate; the merchant scan routes (mine/lookup/apply/void) stay behind it. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
9a13528611 |
fix(deploy): forward MODULES_ENTITLED into the server container (default parking,validation)
Build & push images / images (push) Successful in 3m19s
The Komodo stack env alone is compose interpolation input; only variables in the
service's environment: block reach the container. Without it every booth on
|
||
|
|
55d6242c7d |
feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix", open-questions #16) — the grid stays the enforcement layer: - Move 1: each desk's money is guarded by that desk's own permissions. Manifest tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot touch the booth by construction. Replaces the session:read borrowing (tillPermission). /api/shift/tills lists the role's readable tills with canWork; history/movements without a till filter return the union of readable tills. - Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor, merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and "partial job" lints (warnings, never blocks). - Move 3: the live WebSocket 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. Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's role applies on the next request and a deleted user's session ends with 401. Tests: till guards + look-only role, feed rules, every job's permissions exist, role reassignment without re-login. 353/353. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
a9ccf9e20c |
feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
23d6379be8 |
feat(modules): venue-module registry — entitled ∩ activated, requireModule, Setup panel
Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order
steps 1 + 3). No Car Wash code yet; validation is the first module behind the
seam, unchanged in behaviour.
- @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required;
validation dependsOn parking), parseEntitledModules / resolveModuleActivation
/ effectiveModules as pure functions.
- DB: site_config.modules_json (migration 0026, hand-written + journal;
additive, nullable = everything entitled).
- Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated
from site_config, effective set, requireModule preHandler → 403
module_disabled); modules/index.ts registers folder-based modules by
iterating the registry (modules/validation); site-config GET exposes
modules/modulesEntitled/modulesActivated, PUT takes the full desired set,
enforces entitlement + dependency rules (400 with reason) and signs one
config_change per module that actually flips; /api/auth/me carries the
effective set; validation routes guarded requireModule → requirePermission.
- Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads
WEB_MODULES into nav + route tree (validate route no longer named there);
Setup → Site "Modules" panel (required shown disabled, dependencies as
hints, server refusal shown verbatim); validation sections + programs fetch
gated on the module; App invalidates the router whenever the session
changes (route-context consumers only re-read on navigation — the nav was
stale after a flip, and after every other setUser too).
- Lavazh validation station retired (STATIONS = ["bar"]; rows untouched).
- Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks;
documented in .env.example.
- Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright
round-trip on /setup/site verified live.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
|
||
|
|
db9c3e0e31 |
docs(wiki): venue modules design — Car Wash pilot, Parking as a peer module (open decision)
Records the 2026-09-04/05 design sessions on wiki/decisions/venue-modules.md: 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, 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, Car Wash as the pilot (inside the parking, entry snapshot as identity, bay camera for the unrecorded-wash anti-fraud signals, v1 scope + build order), and vision vehicle category as an advisory anomaly flag. Name stays parking-system; validation stays for the Bar, only the Lavazh station retires with Car Wash. Open-questions #15, index, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
9c05f86c86 |
docs(desktop): updates are admin-only — keep the polkit prompt; AppImage rejected on field evidence
Decision (user, 2026-09-04) after the first successful self-update (v0.1.6 → v0.1.7): a .deb update runs pkexec dpkg -i and asks for an admin password the operator does not have — that prompt is the intended gate. The AppImage was tried as the no-root path and aborts on the 26.04 booth (bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa: EGL_BAD_PARAMETER), and it discards the distro-maintained WebKitGTK the platform decision rests on. Passwordless polkit for dpkg is root for the operator — rejected. - update.prompt (en + sq) now says the install needs the administrator password. - desktop-shell-tauri.md: decision, evidence, rejected alternatives, and the deferred fleet-grade option (root systemd timer in the .deb, minisign- verified, notify-only in-app). - standing-decisions.md: ship the .deb; runtime backend; updates admin-only. - appliance-provisioning.md: drop the stale "hardcoded to localhost" note. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
54e691a4c9 |
fix(release): latest.json entry per installer type — .deb booths could never self-update
Release desktop / bundle (push) Successful in 5m26s
tauri-plugin-updater resolves the download target as {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. Our manifest carried only the bare key, pointing at the
AppImage. A .deb install therefore downloaded the AppImage, verified its
signature, then failed install_deb()'s is_deb check with
InvalidUpdaterFormat — after the download, before any relaunch. This, not
version drift or swallowed errors, is why v0.1.0→v0.1.6 never self-updated.
latest.json now carries linux-x86_64-deb, linux-x86_64-rpm (when built) and
linux-x86_64 (AppImage), each with its own .sig. A .deb update ends in a
polkit password prompt (pkexec dpkg -i) — the intended admin gate on a
root-installed package. README + wiki updated; wiki also records the v0.1.6
LIVE field verification.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
|
||
|
|
8fa66c9911 |
fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never send. Every desktop handshake 401'd and use-live-feed reconnected every 10s (confirmed in the park-2 server log). - routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s, single-use, in-memory ticket; the WS preHandler accepts it via an x-ws-ticket header after the Origin check, then the same report:read role check. Browser cookie path unchanged; JWT stays out of JS. - platform-ws.ts: fetch a ticket before connect, send it with the Origin header; connect failures now go through logClient (rate-limited). - logger.ts: flush read the CSRF token from 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. Stash moved to a dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts. - backend-config.ts: ConnectScreen probe uses the unauthenticated /health (now also returns app: "parking-system") instead of accepting any 401. - README: local-AppImage release gate — tauri dev runs at http://localhost:5173, not tauri://localhost, so none of these origin-dependent bugs reproduce there. - wiki: new section + log entry; four citation corrections. Requires the server image with this commit deployed before the new desktop build connects (the ticket endpoint must exist). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
5c6a21e2c3 |
feat(desktop): runtime-configurable backend server address
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases, not built per-booth, but the backend origin was baked in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the same installer could never point at a different appliance without a rebuild. Adds ConnectScreen (shown before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the operator-entered URL across restarts. CSP's connect-src tightens to 'self' only — all backend traffic already routes through tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway — and the real access boundary moves to capabilities/default.json's http:default scope, wildcarded so an operator-chosen host is actually reachable. Adds a "Change server" control in Setup (desktop-only) to repoint an already-configured install. While tracing the desktop auth path for this: tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate from the webview, so document.cookie on tauri://localhost never sees the parking_csrf cookie the server sets (open upstream bug, tauri-apps/tauri#13045/#11518). This means the desktop app has likely been silently sending no CSRF header on every mutation since the shell was first built — pre-existing, independent of this change. Fixed by having sessionView() (routes/auth.ts) also echo the 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. Verified end-to-end against a real LAN-bound dev server: login returns a csrfToken matching the cookie, a mutation using the body-sourced token in X-CSRF-Token succeeds (200), and the same mutation without it still correctly 403s. |
||
|
|
56904422af |
feat(desktop): show the installed app's own version in the UI
Nothing displayed which desktop build was actually installed — debugging a stuck update meant inferring the current version backwards from the update prompt's target version. Added DesktopVersionBadge (next to the existing server-side VersionBadge) using @tauri-apps/api's getVersion(), the real running app version baked in from tauri.conf.json. No-ops in a browser. Exported inTauri() from origin.ts instead of redefining it again. |
||
|
|
7804285dec |
fix(desktop): route update-failure logging through logClient, not console
console.error/console.warn only forward to the server when the client log level is debug/trace (default: info) — the earlier error-logging fix never actually surfaced anything, and a real update failure produced zero logs anywhere. desktop-updater.ts now calls logClient() directly, unconditionally, plus download-progress events. Also documents the resource-sync-park-systems branch misconfig (pointed at dev, Stacks are stage-tier) found while chasing this — full writeup on fleet-deployment-komodo.md. |
||
|
|
7317042e8d |
fix(desktop): WS live feed offline — native plugin sends no Origin header
Login worked after the mixed-content fix, but the live feed 403'd silently: tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the webview page, so it never auto-attaches Origin the way a browser WebSocket would — routes/ws.ts's anti-CSWSH check rejects a missing Origin before auth. platform-ws.ts now sets Origin: tauri://localhost explicitly. Also fixes a second, independent gap the above alone wouldn't have caught: komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production despite .env.example documenting it as required for desktop. Needs a Komodo sync + redeploy to reach a live booth. |
||
|
|
439b11d16d |
fix(desktop): route fetch + WebSocket through native Tauri plugins (mixed-content)
Fixing VITE_API_BASE got login to build a correct absolute URL, but it still failed with WebKit's generic "Load failed" — WebKitGTK treats tauri://localhost as a secure origin, so http://127.0.0.1:3000 (and ws://) from inside it is blocked as mixed content, a WebKit limitation CSP's connect-src can't override. Added tauri-plugin-http (genuine fetch() drop-in, wired via a new platformFetch() in origin.ts, used by api.ts + logger.ts) and tauri-plugin-websocket (not a drop-in — adapted behind a native-WebSocket- shaped interface in the new platform-ws.ts so use-live-feed.ts needed no changes). Both route through Tauri's Rust side instead of the webview's own fetch/WebSocket. Capabilities scoped to 127.0.0.1:3000/localhost:3000, matching the existing CSP allowlist. |
||
|
|
276b048fa9 |
fix(desktop): sync tauri.conf.json version to the release tag, stop swallowing install failures
v0.1.1 was tagged but tauri.conf.json's own "version" field (what Tauri
bakes into the bundle filename/internal version) stayed at 0.1.0 — the
signed binary didn't match what latest.json claimed to describe, so every
update download failed signature verification. desktop-updater.ts's single
catch{} swallowed that identically to "offline", so it looked like nothing
happened at all. release.yml now syncs tauri.conf.json's version from the
git tag before building; the updater now logs a real post-accept failure
instead of silently reverting.
|
||
|
|
faa3265e49 |
fix(desktop): restore VITE_API_BASE for the desktop build
apps/web/.env.production's VITE_API_BASE went empty in
|
||
|
|
a1f3103a76 |
fix(desktop): mirror signed releases to public repo for the updater
The updater endpoint pointed at mca/parking_solution's own Gitea "latest release" redirect, but that repo is private and field appliances have no Gitea credentials — every update check was silently failing. release.yml now mirrors signed installers to mca/public_releases (public, installers only) under a fixed desktop-latest tag; tauri.conf.json points there. Rejected embedding a read token in the app instead, given the booth-operator threat model. Also: make the appliance-provisioning root_directory gotcha impossible to skim past (boxed callout + explicit next-step pointers), after it caused a second missed step on the park-2 install. |
||
|
|
0e9b9f5d82 |
fix(resources): drop stale park-lab-old Stack; docs(wiki): Periphery connect_as and upgrade gotchas
park-lab-old referenced a server removed from Komodo, breaking the resource sync. Also documents two Periphery incidents from this session: a Core-UI rename doesn't touch the agent's own connect_as, and upgrading Periphery is a config-preserving re-run of the installer. |
||
|
|
ea8fe22969 |
docs(wiki): USB printer cover-open field bug writeup; add art-docker-station lab box
Printer investigation (park-buzi): cover-open on the USB thermal printer wedges its status offline/faulty, surviving a full reboot, recoverable only via `docker restart server`. Traced sendRawUsb/ probeUsb end-to-end — no persistent handle in the app layer, so the leading theory is the container's /dev/usb directory bind-mount retaining a stale view across the printer's physical re-enumeration. Not yet confirmed on hardware; documented with repro/confirmation commands and ranked candidate fixes. Also registers a new lab bench box, "art-docker-station", as a Komodo Stack (dev tier, same shape as park-lab, its own isolated secret refs). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
3a176c5cc8 |
docs(wiki): DS-2CD1047G3H-LIU main-stream ISAPI snapshot is a firmware bug — camera line to be replaced
Full live investigation of the persistent 503 "deviceBusy" on main-stream ISAPI snapshots (10.0.10.13): ruled out config (byte-identical to a working sibling model), ruled out firmware age (reproduced on both the original V5.8.11 and current V5.11.0 builds, ~15 months apart), and ruled out real resource contention (a full channel-ID sweep shows every ID fails identically except the one hardcoded working value, including nonexistent channels) — pointing at a broken/incomplete ISAPI snapshot handler that mislabels itself as "busy," not a real encoder ceiling. RTSP main-stream frame-grab was confirmed as a working route around it, but given the bug and the sub-stream's real-world plate-read accuracy problems, the owner decided to replace the DS-2CD1047G3H-LIU units rather than carry an ffmpeg/RTSP dependency to work around vendor firmware. Ingested the vendor datasheet as a source page along the way. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
28bd838696 |
docs(wiki): merchant validations settled + as-built; scan input decided (camera paths postponed)
validation-discounts: driving cases → the settled validation-only model (all money/paper at the booth) → setup UX/storage/RBAC → full as-built record. DECIDED: merchant stations scan with a USB/HID barcode scanner on the web/desktop app (hand-keying + Luhn as fallback); POSTPONED with analysis: web getUserMedia scanning (secure-context TLS prerequisite on the LAN + Code128-via-camera weakness → QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning; Android build/sideload overhead + configurable-server-URL prerequisite). Also: wsl-dev-networking gains the mirrored-mode gotcha where a Windows-side listener makes a port EADDRINUSE inside WSL while invisible to ss — Vite auto-increments and tauri dev's fixed devUrl waits on the wrong port. Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm |
||
|
|
ba7538aeb5 |
docs(wiki): capture cloud-service SaaS requirements (postponed)
Multi-tenant SaaS layered on the offline model: link-up monitoring of the signed ledger, device status, financials; one admin → many sites; per-site secret custody; recurring fee. Records the four tensions, the confirmed secrets boundary (sync creds + device-password escrow + app identity, NOT the signing key), and the two in-discussion corrections that stand (NetBird already solves booth isolation; remote barrier-open is pulseOpen-and-signed, driven by the unmanned future). status: open, postponed. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
22544ecf63 |
docs(wiki): log-storm hardening + reset drift guard (2026-07-07 incident)
button-light-indicator: failure backoff + rate-limited logging rationale; app-logs: storm coalescing invariant + --diagnostics wipe; local-dev-workflow and appliance-provisioning §7d: new reset flag table + drift guard; log entry tying all three layers to the ENETUNREACH incident. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
5287be5278 |
docs(wiki): catch-up sweep — five pages lagging the log
rongta-printer still named the cashino driver id (→ escpos + migration 0023 note); tariff-time-tiers listed the composer price preview as deferred (→ delivered by the lab fee breakdown); tariff.md lab section gained the breakdown + composer increment-guard paragraph; i18n.md now records the "25 Qer 14:30" date standard + never-toLocaleString-for- dates rule; fleet-deployment-komodo gained the park-lab stack + tier table (the park-lab addition had also slipped the log — both fixed). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
6ceaadfbf2 |
feat(devices): camera clock sync via ISAPI — heal the 1970 power-cut reset
park-buzi field observation: after a power cut the Hikvision cameras reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there until a human logs into the web UI (which silently pushes the browser clock) — corrupting the snapshot OSD timestamps (the evidence trail) and ANPR push times meanwhile. The host is the site's time authority (offline-first, no NTP infra): - Device monitor triggers a sync at each camera's offline→ready edge — exactly the power-restored moment — plus a 24h backstop; the attempt is stamped before the async call so a failing camera retries at backstop cadence, never every poll. - HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual with the site wall-clock now WITH explicit utc offset (localIsoWithOffset), echoing the camera's timeZone verbatim — correct the clock, never fight its tz/DST config. - Jumps >1h (the power-cut signature) log warn (persisted to app_logs); small corrections info. Capability-guarded (isClockSyncable) — hikvision only; dahua's CGI has no such endpoint. - http-digest generalised to digestRequest (GET/PUT/POST + body); the handshake was already method-aware. digestGet delegates unchanged. 8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant + echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability, DST-both-sides pins on the offset formatter. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
cd3b534e51 |
feat(setup): USB printer discovery — pick a real /dev/usb device
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:
- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
compose bind-mount) and enriches each with the printer's self-reported
make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
first real device; a saved-but-unplugged path stays selectable,
flagged "saved — not present now"; zero found falls back to free text
+ a check-the-cable hint.
- Transport option label no longer hardcodes lp0.
Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
011fe5a4c4 |
fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
The chunked-write fix (
|
||
|
|
a02957034d |
fix(web): setup allows adding a printer with no controller configured
Second half of the printer/relay decoupling: the category section's
add-button gate ("add a controller first — a printer points at one of
its relays") blocked every non-access category while zero controllers
existed — hit on the lab bench (USB printer test, no relays on hand).
Printers don't bind (role + failoverRank route jobs), so the gate now
exempts them like the form's requirement already does.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
ee61c24bb9 |
docs(wiki): Periphery v2.2.0 --user installer defaults root_directory=/etc/komodo
Lab box (park-test) crash-looped: panic writing the agent key to /etc/komodo/keys/periphery.key (Permission denied). Gotcha #9 was framed as a hand-config hazard; v2.2.0's installer now writes the system-style default even with --user. §7a: verify root_directory after every install + sed fix + reset-failed/restart; user-unit vs sudo note; the onboarding key survives a pre-connect crash. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
3a186d29df |
docs(wiki): runbook §5c uses gpasswd -d — deluser rejects hyphenated users
Demoting the operator on park-buzi failed with "sanitize_string: invalid characters in 'park-operator'" — Ubuntu's perl adduser/deluser tooling rejects the hyphenated username. §5c now prescribes gpasswd -d for sudo/lxd/lpadmin (shadow-suite, no perl sanitize) and documents that group removal lands at NEXT login: the auto-login operator session keeps its old memberships until reboot/relog, so verify `groups` from inside the session afterwards. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f9887c2a76 |
fix(server): seed-admin self-heals the admin role + signs a ledger event
Field failure on park-buzi: reset-db --users wipes the roles table and points to seed-admin — which inserted the user with roleId "admin" without recreating the role row (migration 0007 never re-runs), dying on the role_id FOREIGN KEY. The script now upserts the built-in admin role first (the row alone suffices — admin permissions resolve in code). It also appends a SIGNED config_change (admin.passwordReset / admin.seeded, operator console:seed-admin) via the server's compiled EventLog + signer: a console seed/reset by the Linux admin can't be gated by the app, but it stays attributable in the chain. Best-effort — no build/signing key warns loudly and proceeds (locking an admin out to protect an audit line would invert the priority). Both paths verified against a scratch DB reproducing the post-reset state. Runbook: appliance-provisioning §7e — lost app-admin password reset via FORCE=1 (interactive preferred; sessions not revoked → rotate JWT_SECRET if theft suspected); §7d notes the FK failure + self-heal. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
ffe8c13a1c |
fix(web): setup wizard no longer forces printers to bind to a barrier
"Cilën barrierë shërben kjo pajisje?" is load-bearing for readers and cameras (which barrier a scan opens + inherited direction) but nothing consumes it on a printer — print routing is role + failoverRank (printer-routing.ts). The wizard applied the requirement to every non-controller device, so adding a printer demanded a meaningless relay pick that got stored as dead config. Printers are now exempt: no requirement, the binding panel is hidden, the binding is not persisted (a stale pre-fix one drops off on next edit), and the device list shows the printer's ROLE instead of a bogus amber "unbound". Server never validated it — no API change. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
14638c2e13 |
docs(wiki): industry survey of parking tariff systems + session log
New reference page tariff-industry-survey.md (2026-07 web research): field taxonomy — per-started-increment hourly (per-minute tried and rolled back in practice), degressive ladders, day caps, up-to matrices, day tickets, evening/overnight packages, event rates, early bird (entry-time-conditioned), day/night + weekend/holiday/seasonal windows, category pricing, contracts, merchant validations (amount/percent/ time-credit/re-rate), SFpark-style dynamic pricing. Coverage map: our engine expresses everything a staffed single lot advertises; real gaps = early bird (the pick-table-by-entry-time future design, same mechanism as weekend menus) and validation overlays; anti-features = per-minute billing + dynamic pricing. Indexed + logged. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
c5ed3f1308 |
feat(drawer): drawer hub — balance now, this-shift figure, daily activity, shift history; busy spinners
/drawer was record + review only: no current balance, no sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub: - Drawer now: the till's running balance (new GET /api/drawer/balance, shift:read — exposes the service's existing drawerBalance(); the drawer is one site-wide till, same exposure the X-report already had) with the open shift's X-report breakdown alongside (float + takings + vouchers = expected = balance) and a "This shift: ±X" figure (expected − opening float — the shift's own contribution vs what it inherited). - Today's cash activity: every cash payment + voucher since local midnight from the signed chain, live, with day totals (card never enters the till). - Record + movements/review: the 2026-07-01 flow, unchanged. - Closed shifts: drawer-focused history via the scope-aware /api/shifts (float → takings ± vouchers → expected per shift). Also: every shift open/close button (header, /shifts, pay modal, end- shift confirm) now shows an animated spinner + dims while busy — the old label-swap-only feedback read as a dead click when a shift open ran slow. The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain)) is recorded as an open item in wiki/concepts/shift.md with the fix sketch: fold from the last z-report's signed expectedDrawerMinor forward. No new ledger surface — one read-only endpoint; RBAC test added. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
fd9885e9ec |
feat(tariff-lab): DB-backed draft tariffs + named published versions
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in
|
||
|
|
c21babf293 |
feat(logging): ~2-month container rotation, ISO timestamps, level names
Operator asked for bounded container logs (~2 months of history), human- readable timestamps, and clarity on levels. Levels already existed (LOG_LEVEL env → pino, default info; warn+ teed into app_logs, queryable at /setup/logs) — the "level":30 / epoch-ms "time" in docker logs were pino defaults. - server.ts logger: stamp ISO-8601 UTC time (timestamp fn) and level NAMES (formatters.level) so `docker logs` reads human. - log-service.ts pinoDbStream: accept BOTH level encodings (name + numeric) — the label switch would otherwise have silently stopped warn+ persistence into app_logs. New log-service-stream.test.ts pins both encodings, the info-stays-stdout-only rule, and the never-throws fallback. - docker-compose.prod.yml: json-file caps resized from 10m×3 (≈30 MB — days, not months) 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 — comment says to revisit if `docker logs` holds under ~60 days. - app_logs retention default aligned 30→60 days (LOG_RETENTION_DAYS still overrides). Wiki: app-logs.md gains the container-log store section (rotation, format, LOG_LEVEL knob) + retention update; log.md entry. Suite 282 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
43c1f45e29 |
feat(reader): channel tagging (clone defense) + structural filter for phantom scans
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).
1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
channel-blind (one opaque cardid from either engine) and SubscriptionFlow
matched by value only, so printing an RF card's UID (often written on the
card face, e.g. 86A158) as a barcode cloned the card. Now:
- Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
overrides READER_QR_PREFIX / READER_CARD_PREFIX).
- routes/qr-reader.ts strips the prefix and tags the read's confirmed
channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
capture stores the BARE value. READ log lines carry ch=… (permanent
phantom attribution).
- SubscriptionFlow.match requires channel agreement: an optical decode may
not claim an rf credential (and vice versa) — refused + signed
sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
- Unprefixed reads keep the legacy untagged shape and match as before, so
enforcement only bites where prefixes are deployed. Deploy server FIRST,
then set prefixes in the vendor tool.
2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
requested, reverses the earlier "record every probe" position — red
"who is exiting?" rows for NOBODY train the operator to ignore the feed).
read-dispatch.ts drops a no-match reader value that cannot possibly be a
credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to UNSIGNED device_events telemetry
(unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
shapes, unknown physical cards, unknown SUB- codes all still sign the
normal refusal anomaly; enrolled credentials match before the filter and
can never be hidden. Works for legacy unprefixed reads too — the feed
cleans up on deploy, before any vendor-tool change.
Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).
Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
35e593ab63 |
docs(wiki): DT-008 phantom-scan diagnosis + backfill bypass/relay-test concept pages
Two independent wiki updates bundled (all docs):
1. dingtian-dt008-reader.md: phantom optical decodes on the park-buzi EXIT
reader (empty pre-opening site, low-sun afternoons). Chain of evidence:
READ log lines carry the reader's own serial (H05MA5B0) → physical device,
not a network source; snapshot shows nobody present; code shapes are the
giveaway (6-digit numerics = checksum-less Interleaved 2-of-5, lone "C" =
Code39/Codabar artifact) → 1D engine decoding sun-made stripe patterns
(striped arm, fence shadows, glare). No fraud exposure (11-digit Luhn ids
can't match); noise only. Fix on the entity page: vendor-tool symbology cut
to QR+Code128 + min decode length, BOTH readers; config lives ON the device
→ re-apply after any factory reset/swap. Deliberately NOT filtering
impossible codes server-side — probe recording is the anomaly path's job.
2. Backfilled two shipped-but-undocumented features (six code files already
linked the first page as if it existed):
- concepts/entry-presence-bypass.md — admin drops a FAULTY presence signal
(granular radar/camera by decision, not a master switch); every flip is a
signed config_change; persists till off; tickets stamped presenceBypassed;
radar-bypass cooldown tradeoff; "the admin is not the adversary, but
trusted never means invisible".
- concepts/setup-relay-test.md — 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/declared relays only;
radarAlert lamps excluded; pulseOpen only.
Cross-linked from operator-issued-entry.md, cataloged in index.md, logged.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
b4f1418858 |
fix(entry): enforce the camera press-gate + duplicate-ticket defenses
Field report (park-buzi): a BLINKING entry button still printed — the lamp encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only checked the radar, so a radar false-positive (rain, pedestrian) minted a real signed ticket. Three layered fixes: 1. CAMERA gate on the physical press: with an entry camera configured, a press is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never opens, never traps). Camera-less sites keep the radar-only gate; a faulty camera is dropped via the existing bypassPresenceCamera admin toggle. 2. Cooldown as a REAL backstop behind presence: the presence branch returned early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds the stationary-car double-ticket (a motion radar drops a motionless car → spurious loop-clear re-arms one-car-one-ticket → same car reprints). 3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected): when entry ANPR recognizes a plate already OPEN under another session entered within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE entry.duplicatePlate anomaly naming both tickets for the operator to void. ANPR stays non-blocking (rides the post-open snapshot as before). REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The camera has no leave events — "free" is a ~30s silence timeout that never lapses inside a queue, so every queued car after the first would be suppressed until an operator intervened. Blocking legit entry at peak beats nothing; the proper preventive fix is a pass-through sensor (passedInput) — recorded as open in wiki/concepts/entry-double-press.md. Also: setup.relayTest reason was missing from both web catalogs (parity is only enforced sq<->en, so the build passed) — added. Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less unaffected / bypass honored / cooldown catches the dropout re-press / residual risk documented / still-present re-press stays suppressed) + entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other plates). Suite 258 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
c142166972 |
docs(wiki): ATECC608 is upcoming — retag ledger signing to the on-host reality
No secure element is on-site: event signing runs on the software HMAC (EVENT_SIGNING_KEY, an env var on the host disk), so the ledger is tamper-EVIDENT but forgeable by anyone who owns the host. Several pages overstated it as present-tense "ATECC608-signed / unforgeable"; correct them. - NEW concepts/hardware-signer-options.md: four options for a non-extractable signing key (USB HSM / YubiKey / reuse the TPM / plain-dongle trap) + the recommendation (TPM interim → USB-HSM target; ATECC608 stays for the embedded ESP32, wrong part for a PC host). - entities/atecc608.md: UPCOMING-not-present status banner + PC-vs-embedded. - disk-os-hardening.md: fix the live-USB row (BIOS boot-order password is load-bearing, not Secure Boot — a signed live USB runs); add a physical-tamper chain (Dell 7070 CMOS-reset → live-USB → PCR-7 same-signer unseal) + accepted risks (that unseal, unsigned-initramfs evil-maid, operator-USB read TODO). - open-questions #6 reframed; standing-decisions / overview / threat-model / index de-overstated; log query entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
33c4ea1e91 |
feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.
(A) Operator-issued entry — 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.
This hands the operator-adversary a mint, so it is:
- PRESENCE-GATED like the physical button: a real car must be present (radar/
loop AND camera busy). Enforced BOTH sides — the server re-checks current
presence so a direct POST can't bypass a disabled button; no presence loop
=> feature unavailable; a no-presence attempt signs an anomaly.
- FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
companion entry.operatorIssued anomaly (the adversary path always leaves a
red-flag row).
- 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 is
factored into one shared #issueTicket (button + operator). UI: the entry
BarrierLight becomes a clickable issue-control when presence+permission+shift
meet (confirm -> issue).
(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
- BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
pay/exit modal shows a red warning + "Override & release" (override signs an
attributed exit.plateSwapOverride). Flag+override, never a silent hard block
(exit fails-open; a plate is never the sole gate).
- READER path (no operator): log-only anomaly + fail-open.
Extended BoothExitResult + /api/exit (override); 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: 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."
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
114a32e6f2 |
feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.
- New `drawer` resource: drawer:create (operator records; admin-revocable per
role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
touches the drawer balance (the correction is settled outside the app). This
is what keeps a late review from leaking into the next operator's inherited
drawer — a denial that lands after the reviewed shift closed moves no cash.
Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
(operator: record + own; admin: review queue + all). routes/drawer.ts lifted
from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
for its other job = admin-sees-all-shifts). New DrawerManager.tsx.
Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
on-site), matching the card-tender gate.
shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
018328a877 |
feat(booth): disable card tender until a P2PE POS is on-site (cash-only)
No card processor / POS terminal on any site yet. Offering "Card" would let an operator record a card payment that never cleared a terminal, corrupting the till reconciliation — a fraud/error surface on an operator-adversary system. Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both tender pickers (BoothPayModal, SubscriptionManager). With card off there's nothing to choose, so the tender row is suppressed and payment defaults to cash. UI-only gate: the Tender type, payment events, shift accounting, and reports still understand `card`, so historical card events and a future re-enable stay coherent. Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open barrier" with no tender/cash/card row. Wiki: new concepts/card-payments.md records the current cash-only state, the PCI-scope-out-of-app constraint, the future-POS device requirements, and the re-enable path (flip the flag once a bank-certified P2PE terminal is provisioned). Linked from index, parking-session, open-questions #3. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
266e9b0027 |
docs(wiki): record session findings — snapshot fix, booth rework, db reset
- entry-exit-points.md: the snapshot content-type bug + serve-side cleanType fix (Hikvision image/jpeg; charset="UTF-8" broke every legacy render). - booth-exit-flow.md: the Active-Sessions/modal rework — inline barrier button removed -> modal; closed-within-grace view; live grace countdown; actual paid amount; read-only snapshot review in the closed-session view. - local-dev-workflow.md: the gated `pnpm db:reset` training tool + flag table + the booth (docker exec, no pnpm) note. - appliance-provisioning.md: new §7d — reset on the booth via docker exec into the server container (script ships in the deploy bundle; DATABASE_URL= /data/parking.sqlite), ledger-truncation warning + the two safety gates. - index.md catalog line; log.md entries. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |