44f34d68c4527fe5058a873bc2c677fe30d8017c
128 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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 |
||
|
|
6505a4a73b |
feat(entry): admin bypass of the presence gate for faulty radar/camera
The entry button (physical press AND the operator-issued mint) requires
radar/loop presence + camera detection to confirm a real vehicle. When one
of those devices is faulty, the gate blocks legitimate transient entry. Let
the ADMIN drop a specific signal as a requirement until support fixes the
hardware — the admin is not the adversary, but weakening an anti-fraud gate
stays attributed and auditable:
- Granular: bypass radar and camera independently (Setup → controller
section). A dead camera drops only the camera check; a dead radar only
radar. Both off = normal gate; both on = press-to-print.
- Signed: a DEDICATED endpoint (PUT /api/site-config/presence-bypass,
site:update) appends a signed config_change {setting, value, prev,
operator} per actually-changed signal — new ledger type. No-op toggles
sign nothing; disabling signs too. Kept out of the generic site PUT.
- Flagged: every vehicle_entry issued (and every refusal anomaly) while
bypassed carries presenceBypassed:[...] in its signed payload.
- Persists until turned off; amber warning in Setup while active. The
booth entry light treats a bypassed signal as satisfied (server
re-checks authoritatively). Physical-button path falls through to the
cooldown backstop when radar is bypassed.
- Migration 0020: two boolean site_config columns (default off).
Fixes a latent bug surfaced by the tests: firstRelayByDirection returned no
presenceInput, so issueForOperator's radar gate always read "presence loop
unavailable" — operator-issue never actually gated on radar. The resolver
now attaches the presence input serving the relay (mirrors relayForButton).
10 new tests: 5 gate combinations (each bypass drops only its signal +
records it), 5 route tests (RBAC, signed transitions, no-op, validation).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
306d136a08 |
feat(setup): operator-tested relay pulse, signed into the ledger
Add a per-relay "Test" control on each saved controller in /setup so an admin can prove barrier wiring without a vehicle. POST /api/setup/test-relay pulses a barrier relay — but because a physical open with no matching signed command is the fraud signal, the route SIGNS a barrier_open_command (reason setup.relayTest, source manual, attributed to the acting admin) BEFORE it fires. Reconciliation then reads the open as explained, not an anomaly, and there's an audit trail. - Admin-only (site:update), CSRF-guarded; fires only against a SAVED controller (real id → clean attribution; also stops a redirected/unsaved config from opening an arbitrary host's barrier). Sign-before-fire; a pulse failure is reported, not a 500. radarAlert relays (lamps) are excluded from the UI. - New reason code setup.relayTest in @parking/shared (+ EN template); sq/en keys. - EventLog constructed before setupRoutes so the route can sign. - Integration test (stub controller, no hardware): RBAC 403, CSRF 403, signed barrier_open_command on success, 400 unknown relay w/ no ledger row, 404 unknown controller, 400 bad relay value. 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
|
||
|
|
61de1fe772 |
feat(booth): rework Active Sessions + pay/exit modal around barrier re-open
Move the audited barrier re-open out of the inline Active-Sessions row button and into the modal, and turn the modal's dead-ends into useful views. - Remove the inline per-row "Open barrier" button. Clicking a row opens the modal, which carries the action. - Modal recognizes a closed-within-grace transient (found && !open && withinGrace) and shows the session view + Open barrier instead of dead-ending on "already closed" — the exact case (paid, barrier unconfirmed) that needs a re-pulse. Server reopenBarrier guard unchanged. - Active-Sessions rows show a live grace-remaining countdown badge (exited - M:SS, 1s tick off graceExpiresAt) via new formatCountdown helper. - Settled sessions show the ACTUAL sum paid (new SessionLookup.paidMinor, summed across payment events) instead of a flat "PAID" badge. - 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/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
cfac14e09e |
fix(snapshots): normalize content-type on serve so stored images render
Cameras (Hikvision) return `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is malformed, and browsers refuse to decode an <img> declared that way. Old capture code persisted that raw header into snapshots.content_type (100/101 dev-DB rows); GET /api/snapshots/:id re-emitted it verbatim, so every legacy snapshot rendered blank in the booth modal. Capture was already hardened (encodeForStorage re-encodes to a clean image/jpeg, fail-soft via cleanType), but the serve route trusted the stored value. Export cleanType and apply it when setting the response header, so a bare image/jpeg is sent regardless of what was stored — un-breaks all legacy rows with no data migration. A stored value from an untrusted device is itself input; normalize on capture AND on serve. Adds cleanType unit tests. Verified: a previously-unrenderable 2560x1440 row now decodes in-browser. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
84f00db48b |
feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Retention (keep-last / keep-daily-days) is operational policy the on-site admin should tune, not a server env var requiring a redeploy -- same reasoning that moved the target directory to the UI. - Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable; null = code default 7 / 30 per field). - BackupService reads retention fresh each run; status() exposes keepLast + keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone). - PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null to reset to default; 400 on negative). - UI: two retention fields on the Backup config card; one Save covers target + retention. i18n sq + en. BACKUP_KEY wired into Komodo: - komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret, alongside JWT / signing keys). - komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY. Also carries the small in-progress setup-intro i18n copy trim. Tests: 218 server tests green, incl. retention persist / reset-to-default / reject- negative and the updated status shape. Migration applies cleanly (needed a statement-breakpoint between the two ALTERs). Wiki backup-recovery updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
d5e41500a8 |
feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup -> Backup), not a server env var. An env-pinned target defeats the purpose: the admin can't point backups at a freshly-plugged USB or a NAS mount without editing .env and restarting. The encryption key stays a server secret. Target storage: - New site_config.backup_target_dir (migration 0016, nullable; null = not configured). BackupService reads it fresh each run, so a UI change takes effect with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it backs up. Routes: - PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1). - POST /api/backup/test -- probe a candidate path server-side (exists / is a directory / writable) so the admin gets feedback before relying on it. - status() now exposes targetDir + keyPresent, so the UI distinguishes 'no target set' from 'BACKUP_KEY missing'. UI (apps/web/src/BackupSettings.tsx): - A Setup -> Backup tab (gated backup:read): an editable target-path field with a Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status panel (config state, last-run size/pruned/error, a distinct amber missing-key warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en); nav.backup. - API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup. Also includes a small in-progress copy trim to the setup-intro i18n strings. Verified live with Playwright: typed a path -> Test reported writable -> Save persisted it -> status reflected it and showed the key-missing warning. Whole monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
0c218179c4 |
feat(backup): encrypted on-site DB backup engine + local target
The SQLite DB is the signed append-only ledger, so a disk failure / stolen or destroyed PC means total revenue-history loss (open-question #5). This is the first slice of the backup-recovery design: the engine + a local/mounted target + a daily timer + a manual route. Engine (apps/server/src/backup.ts): - Consistent online copy of the live WAL DB via better-sqlite3's native .backup() (not a raw file copy, which can capture a torn WAL) — the restored copy is a byte-identical, queryable DB. - AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header (magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file. Zero new dependencies (Node crypto). - The plaintext intermediate is kept in scratch (not the removable/network target) and wiped in a finally, success or fail. - Retention: keep-last-N + one-per-day within N days. Wiring: - BackupService (env config, single in-flight guard, last-success/last-error). - routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run (backup:create), 409 when unconfigured. No restore route — restore is an out-of-band runbook action on a fresh appliance, not a console call. - New permission resource in @parking/shared. - server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY are set, deliberately not run at startup (a just-power-cut booth shouldn't write to a possibly-unmounted disk). - openRawDb() added to @parking/db/testing (open a file without migrating, for restore-verification tests). BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation; backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP + admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical, GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC + 409. build/lint/test green (212 server tests). Wiki + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f6e35bbebf |
fix(reader): correct the QR reader's identity — Dingtian DT-008, not "GEE"
An early wrong assumption named the QR/RFID access reader "GEE" / "GEE/Fondvision" / "GEE-QR-ER80" (and summarized a raw GEE PDF as its datasheet). There is no GEE device — it's the Dingtian DT-008 (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. Code: - Driver symbol geeQrReaderDriver → dingtianQrReaderDriver; label → "Dingtian DT-008 QR/RFID reader (HTTP push)"; comments/description rewritten to the real DT-008 facts (Wiegand 26/34, TCP/IP, USB, RS485 — not RS-232; QR/barcode + ID/IC/NFC — not DataMatrix/1D). - Persisted driverId "gee-qr-reader" → "dingtian-qr-reader" (the registry lookup key + the row created on assign in qr-reader.ts). - Migration 0015 rewrites existing devices.driver_id rows so configured readers keep resolving (applied to the dev DB — 2 rows; the booth applies it on boot). Behaviour is unchanged: naming + the persisted id only. Wiki + memory: - Renamed entities/gee-qr-er80.md → dingtian-dt008-reader.md and sources/gee-qr-er80.md → dingtian-dt008.md; rewrote both to the real DT-008 product-page specs while KEEPING all the verified-on-hardware protocol facts (cjihao serial, .jsp path, Connection: close). Fixed every cross-reference + "GEE" mention in 6 other pages. Memory gee-reader-serial-binding → dingtian-reader-serial-binding. The only surviving "GEE" mentions are deliberate naming-correction notes, the raw PDF filename, and the append-only log history. Full workspace build/lint/test green; dev DB readers verified resolving to the registered dingtian-qr-reader driver. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
96acd6b662 |
feat(snapshot): re-encode captures + disk-pressure retention
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). They dominated the appliance's single backed-up DB file. Re-encode on capture (snapshot.ts): - Downscale each frame to SNAPSHOT_MAX_EDGE (1280px long edge) + recompress at SNAPSHOT_JPEG_QUALITY (80) via sharp (libvips, Apache-2.0) before storage — ~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 (downscaling 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. Disk-pressure retention (snapshot-retention.ts) — a SAFETY VALVE, not the daily mechanism (the re-encode does that). 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 an estimated SNAPSHOT_DISK_FREE_TARGET_PCT (10%) of disk is freed — never below SNAPSHOT_MIN_KEEP (500) — then VACUUM once to return space to the OS. A DELETE only frees SQLite pages (disk 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 owns the DB-locking VACUUM, run daily off-peak. diskUsage is injectable for tests. 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 (no-op below mark / delete-oldest-to-target + VACUUM / MIN_KEEP floor / skip-VACUUM-when-empty). All four snapshot env knobs documented in the komodo env reference. Full workspace build/lint/test green; the prune smoke-verified on a scratch DB copy (file shrank after VACUUM). Existing ~81.7 MB of raw snapshots are unchanged (a one-off re-encode backfill is a separate optional follow-up). Updated entry-exit-points + technology-stack wiki. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
cce99aadfd |
fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in
|
||
|
|
f706726eeb |
feat(prefs): per-user UI font scale (A−/A+), saved to the profile
A header A−/value/A+ control scales the whole UI, persisted per user and restored on login from any booth — cloning the theme-pref pattern end to end. - DB: users.font_scale (migration 0014; percent, 100 = base, NOT NULL default). - Server: PUT /api/auth/font-scale (auth-guarded; clamps to 80–160, snaps to a 10-step); fontScale flows through sessionView → login + /me. - Client: setFontScalePref + applyFontScale; applied in App alongside theme; FontScaleToggle in the header; i18n sq+en. Scaling uses CSS `zoom` on the root, NOT root font-size: the app's type is pinned in px (text-[12px] etc., ~230 spots), which a font-size change would not scale — so the dense Active-sessions / Live-feed logs stayed tiny. `zoom` scales everything uniformly (text, spacing, icons) like the browser's Ctrl+/−, which is the readability win for operators who need larger text. Tests: 4 font-scale auth-route cases (persist + /me, clamp/snap, 400, default-100). Full workspace build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
6734e9815e |
fix(booth): backfill the live-feed plate + make plate search work
Two booth feed fixes: - Plate not showing until refresh. Plate recognition is async/advisory (snapshot.ts recognizePlate → a kind:"read" device_event keyed by the session identity), so it lands AFTER the entry/exit event already shipped over the WS without a plate; a refresh re-fetched via the bulk enrich path and showed it. 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. Both the live-feed (BoothScreen) and active-sessions (ActiveSessions) search haystacks matched the wrong field — the displayed plate is the ENRICHED top-level e.plate/s.plate (set by enrichEvent), not payload.plate (the plate is unsigned, never in the signed payload). Switched the haystacks to the displayed field. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
38481f105f |
feat(booth): blink the Entry/Exit lights 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.
Now they mirror the lamp's 3-state rule per lane:
radar present + camera not busy → BLINK green↔red (~1 Hz)
camera busy → SOLID red
otherwise → SOLID green
End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
each presence edge to its lane via the new direction-agnostic presenceLaneOf()
(device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
keyframe (index.css), which holds solid-red under prefers-reduced-motion.
Same input + same rule as the lamp, so the screen and the post never disagree.
A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.
Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
4418594af0 |
refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).
Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
barrier; 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 just a relays[] row
with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
so several alert lamps on one controller run independently. Every barrier
resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).
Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
"+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
legacy relays[].button/presenceInput/... fields, so relayForButton /
relayForPresence resolve identically from either shape — zero-downtime, no
migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
relays[].presenceActiveLow, and the inputActiveLow escape hatch.
UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.
Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).
Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
25a72ff20a |
feat(anpr): per-camera auto-open toggle (anprAutoTrigger) for shared lanes
A shared entry/exit lane has both an entry and an exit camera on ONE lane: a subscriber driving IN is admitted by the entry cam, but the exit cam sees the same car leaving its frame and phantom-EXITs the occurrence just opened (its back plate). Separate RECOGNITION from AUTO-OPEN per camera: - config.anpr (unchanged) = run snapshots through the recognizer, record the plate (evidence), BOTH directions — stays on. - config.anprAutoTrigger (new, absent ⇒ on when anpr is on) = may THIS camera auto-open the barrier. Set false on the shared-lane exit cam: it still recognises plates but never auto-triggers. The bridge gates on it (anpr-entry.ts), before the poll loop. UI: a "Auto open/close on subscriber plate" checkbox under ANPR in the camera setup (shown when anpr is on); persisted true/false so a park can explicitly disable it. i18n sq+en (also corrected the now-stale anprHint "never opens a barrier" wording — it does, via the bridge). +1 server test (anprAutoTrigger=false → no snapshot, no read); 172 green. Documented the two toggle levels (site-wide + per-camera) in lane-presence-and-anpr-entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
c2a861208f |
fix(anpr): sliding poll window so a car arriving mid-loop isn't lost
A loop started by a far/early car would (a) give up before the REAL car settled at the barrier, and (b) swallow the real car's pushes (the #polling guard dropped them). So a confident-but-wrong far-car plate could win, or the intended car get debounced out after the loop ended — wrong car acted on, right car blocked. Fix: a push that JOINS a running loop now EXTENDS the deadline (lastPush + ANPR_POLL_WINDOW_MS) instead of being dropped, capped at start + ANPR_POLL_MAX_MS (30s) so a continuously-busy lane can't slide forever. Each tick still pulls a FRESH frame, so the loop tracks whoever is at the barrier NOW, not the car that started it. Per-camera sliding deadline in #pollDeadline (cleared with #polling in finally). +1 test (push mid-poll keeps the loop alive past the initial deadline); 171 server tests green. New knob ANPR_POLL_MAX_MS documented in the komodo env reference + the two concurrency guards written up in lane-presence-and-anpr-entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
2a13b95da6 |
fix(anpr): abort the poll loop if the subscriber transacts by card/QR mid-poll
The poll-until-confident loop (prev commit) opened a race: during its ~8s window a subscriber could scan their card/QR at the reader and exit immediately — but the ANPR loop kept polling and would ALSO emit a confident read a moment later, exiting the NEXT open occurrence (a phantom double-exit, worst for a fleet sub with several open). Guard it with the subscriber's open-occurrence count: the bridge identifies the subscription as soon as a frame reads the bound plate (identity needs no confidence), baselines openOccurrenceCount, then each tick AND before emit checks if it moved. If a credential closed/opened an occurrence mid-poll, the subscriber already transacted → abort, don't emit. New public SubscriptionFlow.openOccurrenceCount(). Bounded loop is unchanged (ANPR_POLL_WINDOW_MS=8000 cap; never infinite). +1 test (credential transacts mid-poll → no double-act); 170 server tests green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f77ed11782 |
feat(anpr): poll snapshots until a confident plate, so auto-exit works
The ANPR bridge took ONE snapshot at the camera's vehicle-alarm instant — but the
alarm fires as the car APPROACHES, so that frame's plate is small/blurry/half-in-
frame and ANPR returns a low-confidence misread ('111'@0.20). The manual test reads
the SAME car at ~100% because by then it's STOPPED at the barrier, well-framed. So
subscriber auto-exit silently never fired (read below the 0.85 floor → ignored).
Fix (the car-stops-at-the-barrier insight): the bridge now PULLS A FRESH FRAME every
ANPR_POLL_MS (1000) and re-runs ANPR until one clears VISION_ENTRY_MIN_CONFIDENCE, or
ANPR_POLL_WINDOW_MS (8000) elapses (drove off / non-subscriber → give up cleanly).
- One loop per camera (#polling set) — the camera's ~1Hz alarm re-fires JOIN the
running loop instead of spawning N concurrent loops.
- Fresh camera.captureSnapshot each tick, NOT captureSnapshotShared (its 1.5s TTL
would re-serve the same bad approach frame).
- Camera-level debounce stamp moved to AFTER a successful emit (suppresses re-fires
for ANPR_DEBOUNCE_MS once we've acted), not before the loop.
VERIFIED on hardware (DS-2CD1047G3H-LIU exit lane): 7 garbage approach frames →
AA890XX@0.999 at the barrier → signed vehicle_exit. Still advisory + fail-soft; a
barrier never opens on a low-confidence read. anpr-entry.test.ts +1 (poll
escalation low→low→high); 169 server tests green. Documented in
lane-presence-and-anpr-entry + the lpr-camera camera-fault writeup.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
3a60367232 |
feat(setup): print a real test slip from the printer "Test connection" modal
healthCheck only opens the transport (TCP connect / USB open) — it proves the printer is REACHABLE, not that paper feeds and the head fires. Add a "Print test slip" action so the admin can physically confirm a printer is live (the new host-net USB /dev/usb/lpN path, or a network printer). - server: POST /api/setup/test-print — printer-only, re-merges stored secrets like /test (so an edited network printer authenticates), creates the device, and pushes a short slip via the device-agnostic printReport(). Fail-soft: a print error (paper out, head fault, transport drop) is reported, never a 500. Mirrors the test-anpr pattern. - web: testPrint() client + PrintTestResult; a button in the device modal shown for category=printer, with ok/fail rendering. i18n keys in sq + en (parity holds). Server 168 tests pass; web + server typecheck clean. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
b3cb67188e |
fix(anpr): share one camera snapshot across bridge + advisory paths
On a vehicle entry, two paths captured the SAME Hikvision camera within ~1s — the ANPR bridge (barrier-driving) and the advisory snapshotAsync (evidence/ telemetry) — each from a separate adapter instance. Hikvision serves snapshots single-threaded, so the second concurrent GET returned HTTP 503; the bridge then fail-softed and burned its 12s debounce, producing a ~74s "slow" subscriber entry (observed 2026-06-25, Qazim Mulleti / AB816NN — plate read was instant at conf 1.000; the delay was the 503/debounce churn, not recognition). Add captureSnapshotShared() in snapshot.ts: a module-level, deviceId-keyed cache that both paths call. It coalesces in-flight captures (the 2nd caller awaits the 1st's pull → no concurrent 503), serves a brief freshness window (1500ms) so the bridge→advisory sequence for one vehicle reuses one frame, never caches a failure (next caller retries), and keys by deviceId (no cross-camera/stale-vehicle reuse). Wired into anpr-entry.ts (bridge) and snapshot.ts (advisory). Tests: snapshot.test.ts (concurrent coalescing, TTL reuse, TTL-lapse re-pull, failure-not-cached, per-camera keying); anpr-entry.test.ts mock updated. 168 server tests green. NOTE: this removes the latency (the 503 collision). The separate double-entry (two signed vehicle_entry for one car) — debounce-too-short / stamp-before- success — is still open; less likely now but not eliminated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
830993bcb8 |
fix(button-light): serialize relay sends + hot-reload the lamp config
Two bugs in the button-light controller: 1. Stuck relay (random on/off). The blink fired fire-and-forget setAux every 500ms over UNORDERED UDP with no serialization — concurrent on/off packets reordered/overlapped, so the relay latched on whichever packet the device processed last. Replace with a desired-state + serialized worker (#pump): the blink timer only flips desiredOn; a single in-flight send per lamp is guaranteed, and on completion it re-converges to the latest desired state — so the final state is always authoritative and a lost/stale packet self-corrects. 2. Lamp ignored until restart. The lamp map was built once at start(); a button light added/changed via the UI never took effect without a server restart. #reconcile now re-reads the device config (at start and before each event, like DeviceMonitor), adding/updating/dropping lamps live — so a just-saved lamp blinks on the next radar edge. Tests assert confirmedOf() (the device's latched state); +1 reconcile-after-start case. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
420542ce10 |
fix(setup): add Dingtian relay-password field + secure secret re-merge on test
The relay control password (relay_pw) was read by the driver but had NO form field, so Test connection sent it as 0 → the device ignored the probe → a controller showed "offline" even though it pinged. Add a "Relay control password" config field (secret; blank keeps the stored value). Because relayPassword is redacted from the client, the edit form can't resend it — so the test endpoint now re-merges the stored secret by device id (mirroring save). It is re-merged ONLY when the submitted config addresses the SAME device: matching driverId and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A redirected host/port or mismatched driver yields NO secret, so a probe can't exfiltrate the password to an attacker host (the booth operator is the threat-model adversary). testDevice() now passes the device id; setup-secrets.test.ts covers the identity guard. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
2915d141aa |
feat(devices): radar presence input + button-light output on the controller
Model the entry button (I1) and a Hikvision radar (I2) as named children of the access controller, and drive the button's 12V lamp on a spare relay. - Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input active-level override: relays[].presenceActiveLow -> driver inputActiveLow set, inverting just that terminal (pure helper inputActive()). The Dingtian has one board-wide resting level otherwise. - AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian latch) so business logic drives a NON-barrier lamp through the interface. Barriers still only pulseOpen — barrier-not-a-door preserved. - ButtonLightController: subscribes to the radar input edge + the camera lane status and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off. Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on its own (advisory; threat model). - SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n. 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). Wiki: hikvision-radar, button-light-indicator + updates. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
8129b63a8c |
feat(profile): self-service name/email/password + desktop installers in CI
Self-service profile: any signed-in user edits their OWN fullName/email and changes their OWN password (proving the current one), without any user:* permission. New routes PUT /api/auth/profile + /api/auth/password act only on req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile reachable from the header username chip. email added to the session view + SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green. 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). Signed/versioned release stays on release.yml (tag v*). Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI split), log entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
8155ff456b |
feat(deploy): Docker images for server (API+SPA) and vision + branch-aware build pipeline
Containerize the two non-desktop apps for the booth appliance. The desktop app stays on its own tag-only release.yml. - apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod` (NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune) yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit- free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a devDep, pruned from prod. - apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never shadows the backend; a no-op in dev (no dist). Registered last in server.ts. - apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env- selected (VISION_RECOGNIZER stub|fast_alpr). - Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) + .prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from env so a branch deploy pulls that branch's image. - .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+ test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED). - .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked. Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, / + /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr with 0 runtime downloads; compose stack healthy with server→vision over the private network. Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
8a437d0c4b |
feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
CI / check (push) Failing after 56s
Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.
Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.
CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.
- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
65328b8c11 |
feat(anpr): subscriber-entry bridge + admin disable toggle
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and — matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read. The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens the barrier. A plate is never the sole authority: it routes through the same gate (active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget, subscriber-only by construction. Field-verified end to end (plate AA504LX opened the entry barrier and appended a signed vehicle_entry). Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and lane busy/free are unaffected. Read live per event, so toggling takes effect with no restart. Migration 0013 (additive ALTER ADD COLUMN, default 1). - New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9) - hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3) - server.ts reorders the read flows above the hik-alarm registration - snapshot.ts exports buildCamera for reuse - env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000) - site route + SiteSettings checkbox + i18n (sq/en parity) - wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
a2bdf99db2 |
fix(lane-status): TTL 5s -> 30s after measuring the real re-fire pattern
Controlled in/out test on the camera: the `active` re-fire rate is MOVEMENT-driven, not steady — ~1-3s apart while the car moves, but up to ~15-25s when it sits MOTIONLESS in the zone. A 5s TTL would flicker a parked car free; the TTL must exceed the still-car gap. The camera has ~no dwell lag (goes silent within ~1s of the car leaving — measured: last event 16:15:17 vs car-left ~16:15:30), so 30s keeps a motionless car busy while clearing promptly after departure. This also confirms vision-based tracking isn't warranted: the camera's leave signal is already tight. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
89542d4ab6 |
fix(lane-status): drop busy TTL 90s -> 5s (camera re-fires ~1s)
Measured the real re-fire rate on the camera: while a vehicle is in the zone it POSTs `active` about every ~1 second (not the ~30-80s I'd guessed). The camera sends no leave signal, so "free" is timeout-driven — but with a ~1s re-fire, 90s made the lane stay red for a minute and a half after the car left. 5s of silence reliably means the car is gone; the light now clears within seconds. Still override-able via LANE_BUSY_TTL_MS. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
e0b9442acc |
feat(booth): live lane busy/free barrier lights from camera vehicle detection
A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a
camera bound to entry/exit now marks that lane "busy" and shows it as a
barrier light beside the scan input on the booth (green=free, red=busy).
Advisory only — it gates nothing (never blocks a ticket or opens a barrier).
- Parse eventState (active/inactive) from the Hik payload.
- LaneStatus tracker: a vehicle `active` event marks the camera's bound lane
busy + arms an auto-clear timer. This camera class sends no leave/`inactive`
signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the
camera re-fires `active` while a car sits there, refreshing the timer). A
"both"-direction camera marks both lanes.
- Push lane-status over the existing booth WS (+ in the hello snapshot);
live-store holds { entry, exit }; two BarrierLight icons render it.
- i18n booth.laneEntry/laneExit (sq + en).
Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm,
no re-emit while busy, both/exit direction, unknown device). server 120/120;
web + server build/lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
b7300ec080 |
fix(hik-alarm): listen on all methods + skippable source-IP guard (WSL)
Diagnosed why no camera push ever landed: (1) the route only registered POST/GET, so a probe with another method got a generic 404 the camera reads as "service available" while our handler never ran; (2) more fundamentally, WSL mirrored mode REWRITES the inbound source IP to the host's own address (10.0.10.203), so the camera's real IP (10.0.10.12) never survives and the source-IP guard rejected every push as a mismatch. - Register the event route on POST/GET/PUT/PATCH/DELETE/OPTIONS (HEAD comes with GET) so ANYTHING hitting the path reaches the handler and is recorded. - Log + store the HTTP method of each hit; log every hit on arrival, before any guard, so even a rejected probe is visible immediately. - Add per-device skipSourceIpCheck (a Setup checkbox) to bypass the source-IP guard where the network rewrites the source (WSL). Digest auth + the signed ledger remain the real guards. Tests: hik-alarm 10 (skip-IP accept + method capture). server green; web build green (new checkbox renderer + this field). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
461275521d |
fix(setup): render boolean config fields as a checkbox (not a text box)
The generic config-field loop had no boolean branch, so a type:"boolean" field (e.g. the camera's alarmPushEnabled) fell through to a TEXT input and saved the STRING "true" instead of a real boolean. Downstream checks use === true, so the feature read as disabled even when the admin ticked it. - Web: render type:"boolean" config fields as a real checkbox; store/merge a true/false boolean (and persist false on edit so toggling off sticks); normalize a legacy string "true"/"false" on load. - Server: isOn() coerces the flag when reading config (accepts true/"true"/ 1/"yes"/"on") so an existing row saved as the string "true" still works without a re-save, and no other boolean field hits the same trap. Tests: hik-alarm accepts string "true" for alarmPushEnabled. server 112/112; web typecheck + build green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
3db8f517d3 |
feat(hik-alarm): record rejected pushes + a read endpoint to see arrivals
Debugging "is the camera event coming or not?" was painful: a rejected push only logged a warning and recorded nothing, so "no event" was ambiguous (never sent vs sent-and-refused), and the only durable record was an unreadable device_events row. - Record EVERY push, accepted or rejected: accepted -> kind:"alarm", rejected -> kind:"alarm-rejected" with the precise reason (unknown device / not-hikvision / push-disabled / source-IP mismatch / digest fail). The 404 body now also returns the reason. - New GET /api/devices/hikvision/alarms (device:read): the recent pushes newest-first as JSON (accepted+rejected, with ip/reason/eventType/ target/plate/rawHead) so you can SEE arrivals in the browser instead of grepping the dev log or querying SQLite. Tests: hikvision-alarm.test.ts now 8 (rejection-recorded + read-endpoint list + gating). server 111/111; build+lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
6133923094 |
feat(camera): Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware can PUSH events to us: Event -> Smart/VCA with "Detection Target: Human/Vehicle" + Notify Surveillance Center + Alarm Settings -> Alarm Server makes the camera HTTP-POST an EventNotificationAlert on each detection. - New POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts): same machine-push pattern as the Dingtian Input Link — source-IP guarded + optional HTTP Digest, not behind the SPA cookie/CSRF. - Discovery-first / permissive: a wildcard content-type parser accepts ANY body as raw bytes (event XML, multipart+JPEG, or JSON — Hik varies by firmware), records it verbatim as a kind:"alarm" device_event, and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + a loud log line. The point is to SEE exactly what a camera sends before wiring it further. - hikvision driver gains alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (setup offers the backend push IP). - NOT yet a barrier trigger / DeviceReadEvent — records only. A plate read is advisory, never the sole reason a barrier opens; the read-bus/ANPR wiring is a deliberate next step once the real payload is known. 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. Wiki: lpr-camera.md + log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
7680d9a0ed |
feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were hard and unrecoverable. Now they soft-delete into a recycle bin. Schema (migration 0012): nullable deleted_at + deleted_by on users, roles, subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified against a copy of the live DB. Backend: each resource's DELETE route STAMPS instead of removing; every catalog list filters deleted_at IS NULL. New recycle-bin module + routes (GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a new recyclebin:read/update/delete permission. A 6-hourly + startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever). 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 versions 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 to empty perms). The signed append-only ledger is OUT of scope (no delete path). Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge + purge confirm; api client + i18n (sq + en parity). 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+test 19/19. Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
5a5f5c554b |
feat(reports): admin Reports dashboard — ledger-first charts
Adds an admin Reports screen (/setup/reports, gated report:read) — an on-demand dashboard over the signed event log. Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates in one call — entry/exit counts + all money summed straight from ledger_events (same source the shift Z-report reconciles, so totals tie out to the drawer); revenue split into ticket / subscription-sale / out-of-window mirrors the Z-report. Duration stats come from the sessions cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv export of the per-bucket series. reports.ts + routes/reports.ts. Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month grain, 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. New Setup tab + nav + i18n (sq + en parity). asc() exported from @parking/db; formatMinutes helper. Tests: reports.test.ts (10) pin the sums, tz bucketing, money split, duration stats, subscription counts. server 90/90; build+lint 14/14. Wiki: reporting-analytics.md "Built v1" section + log entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
742653aefb |
feat(setup): "Test ANPR" probe on ANPR-enabled cameras
Adds a bottom-of-modal "Test ANPR" button (shown only when a camera's Plate recognition opt-in is checked) that captures a live snapshot off the camera and runs it through the vision service, reporting the plate read + confidence + elapsed time, or which stage failed. - New POST /api/setup/test-anpr: builds the camera from the unsaved config (no DB write/device change, like /test), captures a snapshot, runs vision.analyze. Fail-soft like the runtime path (snapshot.ts): camera/vision failures are reported results, never a 500. - Thread the existing VisionClient into setupRoutes; add an isCamera() type guard to @parking/devices. - Web: testAnpr() client + AnprTestResult; button, hint, result line. - i18n keys in sq + en (Catalog parity). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
7629d5d7b1 |
fix(auth): make the Secure cookie flag fail-safe (default on)
secureCookies() keyed off NODE_ENV === "production", so an appliance deployed without that var silently sent the auth + CSRF cookies WITHOUT the Secure flag — the review's one Medium finding. Now Secure is the DEFAULT and you only ever opt OUT: a misconfigured/forgotten env can only make cookies more restrictive, never drop the flag. Dropped only on a deliberate COOKIE_SECURE=0/false/no/off (or an explicit NODE_ENV=development as a dev fallback). The LAN appliance that serves the SPA over plain http sets COOKIE_SECURE=0 on purpose (a Secure cookie would never be sent over its http origin and would lock operators out); a TLS deploy leaves it unset and gets Secure. - auth.test.ts (5): pins the matrix — default Secure, production Secure, dev opt-out, COOKIE_SECURE falsey opts out, any other value opts in. - .env.example documents COOKIE_SECURE (replaces the stale NODE_ENV cookie note). - dev .env sets COOKIE_SECURE=0 (local http://localhost login keeps working). server 80/80; build+lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
7e912e193b |
test(server): Phase 3 — HTTP route integration (auth + RBAC guards)
Boots the REAL Fastify app over a fresh in-memory DB (buildServer({ db }), driven by
app.inject — no listen) to exercise the security seam end to end:
- routes.test.ts (7): /health open; login rejects bad creds and sets token+csrf
cookies on good ones; an unauthenticated GET /api/occupancy is 401; a site:read-only
role GETs occupancy but is 403 on PUT /api/site-config (the permission gate, with a
valid CSRF so the 403 is the perm check); an admin passes the same PUT; and a mutation
with the auth cookie but NO csrf header is 403 (double-submit enforced).
Adds seedUser()/login() helpers (real bcrypt + the real /api/auth/login route) and
LOG_LEVEL=silent in the vitest env so asserted 401/403 responses don't flood output.
server 75/75 green (8 suites).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
5e9be16f65 |
test(server): Phase 1 — server-core suites (occupancy, pay, exit, shift)
Completes the anti-fraud/safety core coverage on a fresh in-memory DB: - occupancy.test.ts (12): the ledger-fold count, the capacity/full gate, and the reserved-subscriber-spots model — never double-count a parked subscriber, reserve tightens only the TRANSIENT gate. - pay-station.test.ts (12): quote math against the frozen tariff, the signed-payment side effect (+ chain verify), no-session / no-tariff errors, the booth lookup view, active-session listing. - exit-flow.test.ts (9): the GATE — refuse unknown / unpaid / grace-expired (no exit signed); a paid-within-grace session signs the exit; the booth transient path has NO subscription bypass; a prepaid subscriber leaves via the assist (reopenBarrier) path. - shift-service.test.ts (14): site-wide single-open invariant, the takings SPLIT by source (subscription sales vs out-of-window vs transient tickets), drawer carry- forward + cash_in/out vouchers, Z-report sign + listShifts read-back. - entry-flow.test.ts (5): the exported validateTicketCode Luhn typo-guard. (The capacity-gate/print-hold/sign-before-open paths need device fakes — covered in the device + route phases.) Adds test-helpers.ts (real EventLog, silent logger, tariff seeder). server 68/68 green. Note: apps/vision has 2 PRE-EXISTING failures (test_app.py) — environment drift now that fast_alpr + the ONNX model are installed (the "stub mode" assertions are stale). Untouched here; to be fixed in the vision phase. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
0985b86fa7 |
test(server): add fresh-SQLite test harness + anti-fraud core suites
Foundation for testing every service. Adds @parking/db/testing — createTestDb() spins a fresh in-memory SQLite and applies the real Drizzle migrations, so server tests run against the production schema with zero live-DB risk. Wires Vitest into apps/server (test script + config; test signing keys via env) and adds the first Phase-1 suites against the anti-fraud core: - signer.test.ts (10): sign/verify round-trip, tamper + forgery rejection, malformed-signature guard, determinism, keyId rotation (buildVerifier). - event-log.test.ts (12): monotonic index, prevHash linkage, payload-in-signature, append serialization, and verifyChain() catching every tamper class — edited payload, deleted row (index gap), broken prevHash, unknown keyId — plus canonicalize byte-stability. Also stops *.test.ts leaking into shipped dist/ (tsconfig exclude in server + shared; shared had been emitting compiled tests all along). server 22/22, shared 87/87 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
eb47016ae3 |
feat(shift): confirm-before-close with X-report + split tickets vs subscriptions; fix dark <select>
CI / check (push) Failing after 31s
Three changes: 1. Confirm-before-close. The header shift button closed the shift directly — a stray click would sign the irreversible Z-report. It now opens a confirm modal showing the live X-report (takings split by source + expected drawer) with Cancel / End-shift. Opening a shift stays immediate (no such risk). 2. Split takings by SOURCE. The report separates Tickets (transient) from Subscriptions (monthly sales + a subscriber's out-of-window charge), so the operator sees subscriber money apart from ticket money. Buckets are derived from the signed payment payload flags (subscriptionSale / subscriptionWindowCharge) and always reconcile to cash + card (a payment with neither flag is a ticket). Computed in #summariseWindow, carried on the signed shift_z_report payload, and shown in the X-report, the close modal, the shift history detail, and the printed Z-report. Reports predating the fields default subscription to 0 (ticket absorbs the whole take), so old shifts still reconcile. 3. Fix dark-theme native <select> popups rendering WHITE on WebKitGTK (the Tauri Linux WebView): set color-scheme dark/light on <html> per theme + explicit <option> colours, so the OS-drawn dropdown list follows the theme. Verified the split on a read-only DB copy: tickets 0, subscriptions 10,200 (10,000 sale + 200 out-of-window), reconciles to cash+card. build+lint 14/14, i18n parity (sq+en). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
78d1f6808a |
feat(subs): admin can correct a subscription's plan VERSION
A subscription froze its planVersionId at sale (reproducible pricing). There was no way to move a sold sub onto a different VERSION of the SAME plan — needed when an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1 "every day" → v2 "weekdays only") and wants an existing subscriber on it, or back on v1. Backend (PUT /api/subscriptions/:id): - accept planVersionId; honored only with the subscription:plan permission (stronger than subscription:update — a plan-management action). Non-privileged caller sending a change → 403, not silently dropped. - validated to belong to the sub's EXISTING planId (a different plan = a different price basis = a re-sale → 400). - price/currency/period/planId stay frozen; only planVersionId moves. The swap is server-logged for audit (the row is mutable master data, not on the ledger). Past signed entry/exit events keep their own windowTariffVersionId, so history reprices identically — only future access uses the new version's windows. Frontend (SubscriptionManager): - pass the session user through the route (like RolesManager). - admin-only "Versioni" picker in the edit modal: lists every version of the sub's plan by effective date + a timeframe summary (days + window, or 24/7), current pre-selected. The plan itself stays read-only. Sends planVersionId only when it changed. - i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay in both sq + en. Verified on a writable DB copy: version changed, price + planId frozen, cross-plan version rejected. Live DB untouched. build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
8acef0464c |
fix(subs): price out-of-window charge from minutes actually parked, not a fixed entry stamp
An out-of-window subscriber entry 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 to exit. That over-charged anyone who left before the window opened — a 1-hour visit was billed as 6.5 hours. The amount isn't knowable at entry: a subscriber may enter early, leave after an hour, come and go several times before the window opens, and linger past window-close. They should pay only for the minutes actually parked outside the window (capped at the window edges) — exactly what minutesOutsideWindow already computes. So the entry now stamps a MARKER only (outOfWindow: true + windowTariffVersionId for reproducible pricing), no fixed amount. The exit gate and booth quote price it live via windowOwedBetween(entry → settle-time), which already caps at the window edges (early entry stops accruing at window-open; the in-window portion of a crossing stay is free; the late-exit tail keeps accruing until payment). Both already called that one function, so they agree. - subscription-flow: entry stamps outOfWindow marker; the advisory slip is now a scannable out-of-window TICKET (Code128 + QR of the occurrence id). - shared LedgerPayload: add outOfWindow; mark windowOwedMinor/windowGap*/ windowCurrency deprecated read-only (historic signed events still type-check). - BoothScreen: window-charge badge keys on outOfWindow (or the old stamp). - ActiveSessions: drop the always-on "Open barrier" for subscribers — the assist-open / window-charge payment live in the pay modal, so the list can't one-click past an unpaid out-of-window charge. Verified the live model on a DB copy: 13:21→14:30 = 200 ALL; 19:55(in grace)→ 23:00 = 0; 19:00→21:30 (crosses into window) = 100 ALL. Existing signed occurrences left untouched (immutable). build+lint 14/14, shared 87/87. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
df5caf8d87 |
feat(subs): scannable out-of-window slip + two-step booth flow
The advisory out-of-window slip for a subscriber had two problems:
1. Faulty character codes. It rendered via the generic text printReport,
which has no CP852 mapping for the em dash, ellipsis, or warning sign in
the composed strings — so they printed as "?" ("PARKIM ? JASHTE ORARIT").
Added ASCII transliterations for that typographic punctuation in the
ESC/POS encoder (— → -, ⚠ → !, … → ..., curly quotes/bullet), so they
degrade to a readable glyph instead of "?".
2. Not scannable. The slip printed only "Nr: SUBSESS-…" as plain text, so
the operator had to hand-key it. Gave the notice its own render function
(renderWindowChargeNotice) + a printWindowChargeNotice device method that
prints the occurrence id as a Code128 AND a QR — the same scan path as a
transient ticket, so the operator scans it straight into the booth pay
modal, which then quotes the combined window charge. Implemented on both
the rongta and cashino drivers.
Also fixed the booth pay modal: "Open barrier" no longer shows by default
for a subscriber. A prepaid subscriber with nothing owed sees only a small
"assist open" reveal (the audited manual open for a faulty reader / lost
card stays available, just not the default). A subscriber owing an
out-of-window charge is now two steps — take payment first, then "Open
barrier" appears — instead of an always-on open button.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
1b54775b4d |
feat(shift): two-pane shift history — list + per-shift activity log, timeframe presets
Rework the shift screen into a master/detail view on /shift: the shift CONTROL (open/close, drawer vouchers, X-report) on top, then a two-pane history below — shift list on the LEFT, the selected shift's signed activity log on the RIGHT. - Timeframe presets replace the bare from/to inputs: Yesterday / Last week / Last month / All / Custom (custom reveals the date pickers). Filters the shift list by start time. - Activity log = every ledger event in the selected shift's [start, end] window (entries, exits, payments, vouchers, anomalies, the Z-report), rendered like the booth live feed (same EVENT_STYLE), with the shift's drawer reconciliation in the pane header. - Scope unchanged + enforced SERVER-SIDE: an operator sees only their own shifts (no operator filter); an admin (shift:cash) sees all + the operator filter. The list auto-selects the newest shift. API: /api/events gains an optional `until` (ISO) upper bound so a shift's window can be fetched ([start,end]); fetchEvents passes it. Verified on live data: a closed shift window returns just its 20 events out of 260. Build+lint 12/12 (i18n parity). The same component also backs /setup/shifts. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |