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
Two unrelated leftover wiki edits from earlier sessions:
- NEW concepts/vision-service-hardening.md: the prioritised to-do list from the
2026-07-02 code + security reviews of apps/vision/ (DoS gaps, unauthenticated/
operator-writable model weights, 0.0.0.0 default bind). Cross-linked from
opencv-anpr-service.md ("consult before touching this service").
- container-deployment.md: note that a boot-time migration can be a DATA SEED
(e.g. an RBAC permission granted to the operator role via INSERT OR IGNORE),
and that a built-in-role grant does not auto-apply to a custom role.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
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
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
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
- lpr-camera.md: "503 Device Busy" can be PERSISTENT (main-stream saturation on
the G3H) — the real fix is sub-stream selection, not just retry.
- device-status-monitoring.md: QR reader health was false-healthy (hardcoded
"ready") until the ICMP-ping fix; document the push-device monitoring model.
- log entries for both 2026-06-26 sessions.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
New concepts/printer-usb-transport.md (the seam, usblp char device,
reachability-only status, threat model). open-questions #14: confirm the
on-site printer is USB and bake the usblp + udev write-access rule into the
appliance image (provisioning, not app code; unverified on hardware). Updated
rongta-printer.md (USB transport note), index.md, log.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
- dingtian-relay: the "offline despite ping" gotcha (relay_pw in every binary frame,
missing form field → Test connection sent 0 → timeout) + the identity-gated secret
re-merge that stops a redirected probe exfiltrating the password.
- button-light-indicator: serialized desired-state worker (UDP is unordered → the lamp
stuck on/off) and hot-reload of the lamp config (no restart).
- log entry for the three fixes (commits 420542c / fd15988 / 830993b).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
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
Captures this session's back-and-forth as a new concept page
[[lane-presence-and-anpr-entry]] and cross-links it:
- BUILT: advisory lane busy/free booth lights (LaneStatus + WS), with the
measured camera limits behind the 30s timeout (no leave signal; movement-
driven re-fire; notificationRecurrence locked to "beginning" — ISAPI flip
silently reverts).
- PLANNED: the ANPR "bridge" — explicitly a small apps/server HANDLER (~40
lines), NOT a new service/container. On a camera vehicle event: snapshot ->
ANPR -> high-confidence match -> debounce -> emitRead{kind:"plate"}, then
the existing subscription match/dispatch/gate admits the subscriber. Both
directions, opt-in (config.anpr), plate never the sole authority.
- Records the decisions (high confidence floor, debounce-for-correctness)
and the REJECTED ideas (continuous livestream / per-car queue tracking /
make-model) with why, plus the open hardware question (booth-PC test).
Updates subscription.md (plate matching is built; the live source is this
bridge) and lpr-camera.md (the two consumers of the vehicle event).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The Hik DS-2CD1043G2-LIU was NOT defective. An earlier wiki entry wrongly
concluded it needed RMA (dead event engine) based on a silent alertStream
+ diskfull/EventScribe:except + dead RTC surviving a full factory reset.
Real cause: no detection AREA was drawn on the frame. With no region, the
camera detects nothing -> generates no event -> posts nothing. The instant
an area was drawn, the first vehicle produced a clean POST.
- Flag "draw the detection area" as the FIRST thing to check.
- Document the confirmed real payload: multipart/form-data (MoveDetection.xml),
EventNotificationAlert with eventType=VMD, eventState=active,
targetType=vehicle (vehicle/human classified on-device), targetRect bbox.
Note the dateTime is garbage (dead RTC) -> use our own receive time.
- Reframe the SSH diagnostics: diskfull/EventScribe/RTC are RED HERRINGS,
not proof of a dead camera; don't escalate to hardware fault while a basic
config precondition is unmet.
- Append a log correction (append-only) superseding the earlier conclusion.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Captures the hard-won findings from the field session: the WSL source-IP
rewrite + skipSourceIpCheck fix, the boolean-as-string setup bug, the
unreliable "Test" button, the latching httpBroken flag, and Notify-
Surveillance-Center vs HTTP-Alarm-Server.
Adds a "diagnose a non-pushing camera from its OWN state" runbook
(alertStream heartbeat silence, SSH showStatus EventScribe:except, dmesg
RTC/UBIFS, netstat outbound watch) and documents the verified-dead
DS-2CD1043G2-LIU unit (defective event engine, survives factory reset ->
RMA), with the pull+vision fallback.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
Documents the deploy-time requirement that the cookie fail-safe fix (7629d5d)
introduced: the LAN appliance serves the SPA same-origin over plain http, where a
Secure cookie is never sent — so it MUST set COOKIE_SECURE=0 or operators can't log
in. A TLS deploy leaves it unset.
- wiki/concepts/disk-os-hardening.md: new "Deploy-time server configuration (runbook)"
section listing the security-load-bearing env (JWT_SECRET, EVENT_SIGNING_KEY,
COOKIE_SECURE=0) with the why + the network-scoped justification.
- wiki/entities/local-jwt-auth.md: corrected the stale "Secure when NODE_ENV=production"
cookie line to the Secure-by-default / opt-out model.
- wiki/log.md: entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
Add apps/desktop, a thin Tauri v2 shell wrapping the SAME @parking/web SPA so
the desktop and browser UIs never drift: dev loads the Vite dev server (HMR),
prod bundles the web app's dist/. No business logic in the shell (device/auth/
ledger stay in @parking/server); deny-by-default capabilities.
apps/web (single UI source of truth):
- lib/origin.ts: centralize the backend origin (API_BASE/apiUrl/wsUrl from
VITE_API_BASE); no-op in the browser, lets the desktop build target Fastify.
- lib/kiosk.ts: block the right-click context menu in PROD only (dev keeps it +
devtools).
- lib/desktop-updater.ts: prompt-on-update auto-update (no-op in browser/offline)
→ downloadAndInstall + relaunch; i18n update.* keys (sq+en).
- .env.production: VITE_API_BASE wired to the Fastify origin for the bundle.
Desktop:
- window starts maximized (not fullscreen — operator keeps OS access).
- auto-update via tauri-plugin-updater + -process; self-hosted endpoint is a
PLACEHOLDER to fill in. Updater keypair: pubkey embedded in tauri.conf.json;
private key + password kept OUTSIDE the repo (~/.parking-updater-keys) and as
TAURI_SIGNING_* build secrets.
- Turbo build is a no-op; the real signed bundle is `pnpm --filter
@parking/desktop bundle` (verified → .deb/.rpm/.AppImage + .sig signatures).
Verified: cargo check clean; turbo run build lint 14/14 green; i18n parity holds;
no key/sig/bundle artifacts in the repo.
Wiki (security + desktop analysis recorded alongside):
- new concepts/tpm.md (TPM 2.0: how it works, sealed-LUKS auto-unlock + non-
extractable signing key, limits — live-root, bus-sniff — TPM-vs-ATECC608 by
platform).
- new decisions/desktop-shell-tauri.md (Tauri v2 over Electron; best-case Ubuntu
26.04 LTS, worst-case Windows+WSL → kiosk browser; full as-built).
- pull-the-disk attack trace on append-only-event-chain; ATECC608 not-in-a-PC
caveat; cross-links from disk-os-hardening / threat-model.
- open-questions #11 (appliance WebKitGTK), #12 (TPM hardening impl), #13
(startup verifyChain self-check); index/overview/log/standing-decisions.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).
Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.
(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).
Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.
outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).
1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
amount = span price × quantity; maxConcurrent defaults to the quantity so all
N cars can be inside. Quantity rides in the payment payload.
2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
tariff (the subscriber is a transient for that time):
- early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
the vehicle_entry payload), collected at exit;
- late exit: window-close → departure, and exit is GATED
(sub.refused.unpaidWindow) until paid at the booth.
Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
reuses computeFee + the active tariff version
(apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
business gate — the fail-open rule still governs the offline path.
3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
max(0, quantity − itsCarsInside) per active subscription, so transients see
"full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.
Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Re-model subscription pricing from per-row, operator-typed prices into an
admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now
SELLS by picking a plan over a date span; the price is LOOKED UP, never typed —
removing the fat-finger risk on a money field — and day/week/month periods make
the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span.
- Schema/migration 0010: new `subscription_plans` (immutable, effective-dated,
keyed by a stable planId; period day/week/month + per-period price + active
flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a
"Monthly" plan from the existing site default price (no data loss).
- Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period),
amount = periods × per-period price. Ceil = any started period is full (hotel
practice). `resolvePlanVersion` picks the latest active version ≤ sale instant.
- Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked
sell path derives the amount from the plan; `POST /api/subscriptions/quote`
returns a server-computed quote so the operator can't override it. The
signed-payment sale fix is unchanged — only the amount SOURCE moved; payload
now carries planId/planVersionId/periods. Updates never re-sell (price frozen).
- Frontend: SubscriptionManager sell form swaps the price field for a plan
picker + start/end dates + a live quote line. New SubscriptionPlansManager
(Setup tab) for the admin catalog. i18n (sq+en) for both.
Verified on a copy of the live DB: 0010 applies (existing subs intact), a
3-night hotel sale prices to 2,400 ALL, appends one signed payment with
planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Creating a priced subscription wrote only the mutable `subscriptions`
master row and appended NOTHING to the signed ledger — so the cash an
operator collected showed in the live feed, drawer, and shift Z-report
nowhere, leaving no signed trace. A booth operator could sell
subscriptions and pocket the money untraceably — the exact
operator-as-adversary path the append-only signed ledger exists to close.
Found live: 3 priced subscriptions (27,000 ALL) had zero payment events.
Selling a priced subscription now appends a signed `payment` event at
create time: amount = priceMinor x months (full multi-month prepay),
operator-chosen tender (cash->drawer / card->bank), payload
{ subscriptionSale: true, permitId, operator, months }. Folds into the
shift Z-report/drawer with no new summing logic; the feed badges it
"subscription sale" and resolves the holder name. The create response
returns the recorded { sale }; subscriptionRoutes now takes the EventLog
and ShiftService.
Not hard-gated on an open shift (a sale can happen outside the booth money
path) — it warns instead. The 3 historical off-book sales are not
back-fillable (append-only forbids forging dated events) — reconcile via
cash_movement or a Z-report note.
Verified against a copy of the live DB with the real signing modules:
signed payment appended, hash-chain still verifies, lands in shift cash
totals. Build + lint 12/12.
Wiki: subscription "Collecting the fee" deferred -> BUILT (+ the off-book
hole and why); shift sale-folds-in; threat-model worked example
("store the price != account for the sale").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The ANPR plate was saved (device_events kind:"read") but had no UI. Extend
GET /api/snapshots/by-identity/:identity to also return plates[] (plate, confidence,
region, direction, snapshotId, at) for that session, and render each as a cyan
"Plate: AA558EE 100%" chip in the SnapshotStrip — so it shows in both the booth
event-detail modal and the pay modal, beside the evidence photo, no separate screen.
Deduped by plate+direction; session:read gated; i18n sq+en.
Verified: by-identity returns plates[] for a seeded read (200, AA558EE 0.999 Albania
entry). Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.
The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.
Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Make the vision service genuinely configurable (was env-only).
- SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted
only when on; sq+en) — opt-in is no longer raw JSON.
- DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting
a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the
booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category
union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when
up, 0 chips when disabled.
- apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example
(Node side) + a Configuration section in opencv-anpr-service.md covering all four
layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE
.env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr
camera recognizes but every read is refused.
Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Answers "is a recognized plate saved?" — now yes, for both transient and subscriber, as
an ANPR audit trail independent of whether it matched anything.
VisionReader now stores the snapshot bytes in `snapshots` keyed by identity=PLATE — the
same identity the flow signs its anomaly/event with — so GET /api/snapshots/by-identity/:plate
(the booth event-detail modal's snapshot strip) shows the car's photo against that
anomaly with no UI changes. It also records an unsigned device_events{kind:"read"}
breadcrumb (plate, confidence, region, model, snapshotId, and the dispatch outcome) as a
queryable recognition log. Switched from emitRead to calling ReadDispatcher.dispatch
directly (like qr-reader) to capture that outcome.
Non-blocking: a refused read (no session / unpaid / unknown plate) just returns
rejected — no barrier hold — and is logged with its snapshot for investigation. Plate
stays advisory (exit demands payment; subscription matches only a bound plate).
Verified e2e: a recognized AL plate with no open session signed exit.refused.noSession
(identity=plate), stored a 555KB snapshot under that plate, recorded the read breadcrumb
(accepted:false, reason "no open session"), and by-identity returned the image — the
refused read is fully investigable with its picture. Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A VisionReader polls each opt-in camera (config.anpr===true, off by default) every
VISION_POLL_MS, captures a snapshot, recognizes via VisionClient, and on a confident
plate emits deviceEvents.emitRead({kind:"plate", value}) — the same event a physical
plate reader sends, so the existing ReadDispatcher routes it to the subscription/exit
flow unchanged (no flow rewrite).
The plate stays advisory by construction: the exit flow still demands a covering
payment, the subscription flow only matches a bound plate. Guards: low-confidence reads
dropped; debounce (VISION_DEDUPE_MS) so a parked car doesn't re-fire; per-camera
in-flight guard; idle when vision is off or no camera opts in. #recognizeOn is public
for a future on-demand (loop-edge/API) trigger.
Verified end-to-end: an in-memory anpr camera (AL plate image) + live fast_alpr service
→ VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} onto the bus; debounce
held it to 1 emit over 7 polls. Build + lint green. Updates opencv-anpr-service
(trigger-wiring + per-camera opt-in marked done).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Node-side adapter to the apps/vision ANPR microservice (localhost HTTP: POST /analyze
with snapshot bytes, GET /health), returning a normalised VisionResult or null. Enforces
"advisory, never sole authority" at the boundary: opt-in (VISION_ENABLED, default off),
fail-soft (any error/timeout/unreachable → null, never throws into the lane → ticket
fallback), and re-applies the confidence floor (VISION_MIN_CONFIDENCE) on top of the
service's own low_confidence flag. Per-request AbortController timeout so a slow call
can't hang the barrier. Constructed in server.ts.
Verified: fail-soft (disabled/unreachable → null, no throw) and live end-to-end (Node
client → running fast_alpr service → AA558EE 0.999, region=Albania). NOT yet wired into
the read bus — the opt-in snapshot→DeviceReadEvent{kind:"plate"} trigger is the next
step. Build + lint green. Updates opencv-anpr-service (adapter gap marked done).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Record the verdict: the ANPR service is worthy to consume NOW as an advisory plate
IDENTITY source (Job 1) — the flows already treat a kind:"plate" read as first-class
(exit signs source:"lpr"; subscription matches read plate vs bound plates), so it feeds
an existing input with no flow rewrite. It is NOT worthy as the sole authority to open a
transient barrier (a plate is not a payment; spoofing needs Job 2 vehicle verification,
unbuilt) — gated by the confidence floor with ticket/manual fallback. Lists the four
gaps before consumption (VisionClient adapter, opt-in trigger, field accuracy,
weight-provenance). Next step is the adapter, not more model work.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Benchmarked fast-alpr's four fast-plate-ocr models via the full pipeline on real AL
plates (AA558EE, AA687KE), CPU. All four read both correctly; the default
cct-xs-v2-global-model wins on confidence (0.999/1.000) AND speed (33-39ms) and returns
region=Albania. The "European 40+country" model is WORSE here (~0.77 confidence, one
synthetic misread) — overturning the "EU model better for AL" assumption from the prior
research. Decision: no config change. Resolves the AL-accuracy-benchmark open item
(results table + finding added to opencv-anpr-service); weight-provenance remains the
one open recognizer item. Re-benchmark on real on-site captures once cameras installed.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Settle WHERE the host-side ANPR service lives and how it joins the build: in this
monorepo at apps/vision/ (not a separate repo), still a separate OS process called
over localhost HTTP, wired into the Turbo graph via a thin package.json shim whose
scripts shell to Python tooling (uv/uvicorn/ruff/pytest). Co-located source honors the
vision-service runtime+license isolation decision (AGPL reach is a linking boundary,
not a folder); the fast-alpr MIT baseline removes most of the split-repo pressure
anyway. New page vision-service-packaging; updates vision-service, opencv-anpr-service,
the CLAUDE.md layout, index, log. Not built yet — packaging decision only.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Research note from the recognizer-options query. fast-alpr v0.4.0 (MIT) — a swappable
YOLOv9-detector + CCT-OCR pipeline on ONNX Runtime, CPU-only and offline — fits the
decided vision-service architecture and is MIT end-to-end (code + published weights),
so the ANPR path may not need the scoped AGPL exception. Flags the open caveats:
verify model-weight provenance, and benchmark AL-plate accuracy (default global vs.
the 40+ country EU model). fast-alpr is plate-only, so the vehicle-verification job
stays ours to build. Decision kept open. Updates opencv-anpr-service (new "Recognizer
evaluation" section + licensing nuance), vision-service (open/next), index, log.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Sync the two canonical reference pages with this session's features: local-jwt-auth
gains the new log resource / log:read permission in the RBAC grid (links app-logs);
first-run-setup notes the one-car-one-ticket presence-loop/cooldown guard the admin
configures on a relay (links entry-double-press).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The dynamic-RBAC management routes are themselves grantable (role:* and
user:*), so a non-admin holding them could self-escalate: edit their own
role to add a permission they lack, mint a privileged role, assign someone
the admin role, or reset/delete a more-privileged account. Found by the
commit security review (2× HIGH).
Fix — enforce the RBAC invariant "you cannot grant beyond yourself":
- roles.ts: role:create/update reject any permission not held by the caller
(escalates()). An admin holds the full set, so it stays unrestricted.
- users.ts: user:create/update reject assigning a role whose permissions
exceed the caller's; update/password-reset/delete reject acting on a user
whose current role exceeds the caller's (exceedsCaller()).
The existing no-lockout + builtin-admin protections are unchanged.
Verified: 10-assertion inject test — manager (role:* + user:* but no
tariff:update, not admin) gets 403 on self-grant, minting a privileged role,
assigning/resetting/deleting an admin; admin stays unrestricted; the manager
can still create peers + in-scope roles (not over-blocked). Full build green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".
Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().
CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.
Full build green; both receipt modes render-verified; routes live.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The rongta-printer "Deployment" section still described a single
2026-06-14 unit. The live site now runs two: entry-dispenser 10.0.10.9
(Cashino, `cashino` ping-only driver) and booth-receipt 10.0.10.10
(Rongta, full status-page monitoring). Follow-up to 3e6773a.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Rounds out subscriptions across enrollment, the barrier flow, and the booth.
- RFID credentials enabled with a "Read card" enrollment flow: the operator
arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
reader's next read is captured into the form and NOT dispatched to the access
flow — the OTHER reader keeps serving live entry/exit. Routes:
/api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
per-occurrence id (SUBSESS-<short>), not the credential value, with
permitId in the payload. Direction is decided by the barrier the reader sits
at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
pay/exit modal shows a subscription mode (snapshots + a single audited
Open-barrier action) to assist a faulty exit reader / missing card;
reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
"abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.
Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Builds out subscription credentials on top of the rename.
- Operator chooses the credential type; only QR is live (RFID shown disabled
"soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
globally-unique-checked) — the customer/operator never picks the value.
RF stays operator-entered (the physical card id). Reader output decided =
TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
(GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
create (best-effort — never fails the create; returns {printed,printError})
+ reprint via POST /api/subscriptions/:id/print and a "Print code" button.
Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.
- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
(/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
hash-chained history; renaming it would break verification of past events.
Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).
Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.
Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
File concept pages for the operator-UI architecture ([[booth-console]]: stack,
/api/ws live feed, anti-CSWSH) and [[i18n]] (per-user server-stored language;
resolves a dangling code-comment link). Qualify the stale 'plain React' note on
react-vite-spa. Backfill log entries for the live WebSocket, frontend foundation,
and i18n builds (which had none), plus a reconciliation lint entry. Catalog
booth-exit-flow + the two new pages in index; fix the concept count (27→41).
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.
Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)
Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
(v1 events won't verify under v2 — intentional, gated per-event by keyId)
Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]
Web:
- wizard: no lane selector; add controllers (relay map + entry-button
terminal) first, then bind readers/cameras/printers to a controller relay
Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
The reader sends Connection: keep-alive but only acts on the verdict (beep,
output) once the TCP socket closes. Fastify's default kept the connection alive,
so the reader waited out a ~10s keep-alive timeout before beeping — even though
the server replied in ~15ms. Every vendor demo replies Connection: close and
shuts the socket. Set reply.header('connection','close') on the QR endpoint.
Verified the header is now sent; symptom was correct accept/reject with a ~10s
lag before the beep.
The QR reader is a push device and the setup wizard assigns random-UUID ids, so
'id = serial' can't be set via the UI. Add a dedicated gee-qr-reader driver
(reader category) with a single 'serial' config field; the admin assigns it
normally and enters the device's serial (its cjihao).
The QR endpoint now resolves the lane by matching lane_devices.config.serial to
the scan's cjihao (instead of row id == cjihao), so no DB hand-editing. An
unassigned serial resolves to no lane -> status:0, gracefully.
Verified via inject through the real /api/setup/assign: assign {serial:
H05M2AFA} -> .jsp scan with a matching permit QR -> status:1 (accept) + open;
re-scan -> permit exit; unknown card -> status:0; unassigned serial -> status:0.