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
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
No card processor / POS terminal on any site yet. Offering "Card" would let an
operator record a card payment that never cleared a terminal, corrupting the
till reconciliation — a fraud/error surface on an operator-adversary system.
Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both
tender pickers (BoothPayModal, SubscriptionManager). With card off there's
nothing to choose, so the tender row is suppressed and payment defaults to
cash. UI-only gate: the Tender type, payment events, shift accounting, and
reports still understand `card`, so historical card events and a future
re-enable stay coherent.
Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open
barrier" with no tender/cash/card row.
Wiki: new concepts/card-payments.md records the current cash-only state, the
PCI-scope-out-of-app constraint, the future-POS device requirements, and the
re-enable path (flip the flag once a bank-certified P2PE terminal is
provisioned). Linked from index, parking-session, open-questions #3.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
- entry-exit-points.md: the snapshot content-type bug + serve-side cleanType
fix (Hikvision image/jpeg; charset="UTF-8" broke every legacy render).
- booth-exit-flow.md: the Active-Sessions/modal rework — inline barrier button
removed -> modal; closed-within-grace view; live grace countdown; actual paid
amount; read-only snapshot review in the closed-session view.
- local-dev-workflow.md: the gated `pnpm db:reset` training tool + flag table +
the booth (docker exec, no pnpm) note.
- appliance-provisioning.md: new §7d — reset on the booth via docker exec into
the server container (script ships in the deploy bundle; DATABASE_URL=
/data/parking.sqlite), ledger-truncation warning + the two safety gates.
- index.md catalog line; log.md entries.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Two lessons from the first park-buzi staging deploy, both in backup-recovery.md:
- A new server env var (BACKUP_KEY) must be added to docker-compose.yml's
server.environment: allowlist, not just the Komodo secret/Stack env — otherwise
the container never receives it (inspect shows it absent, not empty).
- The backup target must be a host path bind-mounted into the container; a desktop-
automounted USB (/run/media/...) is invisible inside the container, so Test target
reports 'does not exist'. Destinations are admin-provisioned (fstab + compose bind-
mount), not operator-pluggable — partly a threat-model feature. Acknowledged as a
flexibility limitation; USB-automount-to-container flow deferred.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Model the staging-vs-production split that fleet-deployment-komodo flagged as open.
Three tiers: dev (working, no booth) -> stage (staging booth park-buzi, real-world
test) -> main (production, manual + pinned).
- build-images.yml: trigger on [dev, stage, main]. The tag computation is already
branch-derived, so :stage / :stage-<sha> build with no other change.
- komodo/resources.toml: park-buzi now branch=stage + TAG=stage-<sha> (pinned;
no webhook even on staging). BACKUP_KEY already wired as a per-booth secret.
- komodo/README.md: a Promotion (dev->stage->main) section; per-booth secret list
now includes backup_key; hard-rule #1 generalised to pinned <branch>-<sha>.
- wiki: fleet-deployment-komodo open-item resolved + a Promotion-tiers table;
deploy-trigger choice generalised; container-deployment tag list gains :stage.
Promotion is a merge: when dev is ready, merge dev->stage, CI builds the image,
bump TAG=stage-<sha> in resources.toml, deploy from Core. stage is branched from
dev HEAD so the first real-world test carries the full current app. Per-booth
secrets must pre-exist in Core; migrations run at boot so a promotion auto-migrates
the staging ledger (where a bad migration is caught before production).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
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
New concept page backup-recovery.md resolving the design half of open-question #5.
Driving scenario: a stolen/destroyed PC whose LUKS+TPM disk is unrecoverable by
design — recovery stands up a NEW PC, restores a backup, and keeps signing the
SAME chain.
Settled: admin-driven encrypted full-DB backup (SQLite online-backup/VACUUM INTO,
snapshots included) to local/USB, SMB/NFS, or SFTP targets; manual button + an
in-process daily timer; keep-last-N + dailies retention; restore is admin-only /
out-of-band (operator-adversary surface). A restored copy must still verifyChain.
Key custody (the load-bearing decision, bears on #6): three independent keys —
EVENT_SIGNING_KEY kept an extractable, escrowed software key DECOUPLED from the
TPM so the ledger survives total hardware loss (the conscious trade: a TPM-sealed
signing key would be unforgeable but permanently unverifiable after the machine
dies); a NEW dedicated park_buzi_backup_key in Komodo for backup encryption,
separate from the signing key; the LUKS/TPM disk key, appliance-only and
deliberately non-recoverable. Keys are never inside the backup they unlock.
Updated open-questions #5 (design SETTLED) + #10 note; disk-os-hardening deploy
runbook (why the signing key is not sealed + park_buzi_backup_key); index catalog
+ concept count. Design only — not yet built.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Bring three queryable pages current with the booth-UX commit (cce99aa) whose
breadth hadn't propagated:
- booth-console: Active Sessions as a real table, dropped status column/filter,
inline live-feed rows, removed TARGE via-badge + redundant Direction filter,
plate now searchable + backfilled via plate-recognized WS push, per-user font scale.
- shift: Z-report display simplified (shitje dropped, opening cash added) while
the signed payload is untouched.
- i18n: users.font_scale recorded alongside language/theme as the matching
per-user server-stored pref (migration 0014).
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
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)
Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
(h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
font utility to rem across the web app (~230 sites in 25 files + the
.label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
layout stays put, so chrome never clips; tall content scrolls its own container.
Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.
Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
options already in the Type filter.
Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
name; overstay keeps a row tint). Removed the now-redundant status filter; only
the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).
Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
opening + cash-taken = expected reads clearly. Money values no longer line-wrap.
Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
following row by one column — it now emits a full label+value pair.
Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
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
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
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
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
Adopt Komodo Periphery (over the NetBird mesh) as the booth fleet control plane,
superseding SSH-and-booth.sh. The booth runs the SAME compose files; Komodo Core
drives them remotely. booth.sh is demoted to a break-glass local fallback.
- komodo/resources.toml mirrors the working park-buzi Stack (built by hand in the
Core UI, then exported to TOML — field names match the running v2.2). Stack-only:
servers are created by the agent onboarding OUTBOUND (one-time onboarding key →
Periphery self-registers, auto-rotating keys, booth opens no inbound port), so
there is no [[server]] block. Per-booth secrets via [[...]] refs to Core's store.
- komodo/README.md + .env.komodo.example document the flow and the hard rules
(no webhook; onboarding/outbound/mesh-only; per-booth unique secrets; never
down -v the ledger volume).
- wiki/decisions/fleet-deployment-komodo.md records the decision + threat-model
analysis (Periphery is a root agent → mesh-bound; EVENT_SIGNING_KEY-in-Core is a
fraud-root blast radius until ATECC608 signs; Core is now Tier-0; GPL-3.0 is fine
as external ops tooling). container-deployment reframed (booth.sh = fallback);
index + log updated.
Verified end-to-end against a real booth (park-buzi): onboarded OK, Stack deployed,
all containers green, admin seeded.
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
The dev box runs vision as bare `uv run uvicorn`, and a plain uv run/uv sync
re-resolves the venv to the lockfile DEFAULTS, stripping fast-alpr/onnxruntime.
So after any `pnpm dev` real ANPR silently degraded to "snapshot, no plate"
(diagnosed 2026-06-25: real reads through 06-22, venv frozen lean since 06-19,
no other env with fast_alpr). The BOOTH was never affected — it runs the Docker
image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights
pre-warmed); a booth ModuleNotFoundError is a STALE image (fix: booth.sh update).
Vision package.json dev/start/recognize now run `uv sync --extra alpr &&` first
so pnpm dev is self-healing; added a dev:stub escape hatch for a lean run.
Documented in wiki/decisions/vision-service-packaging.md ("Two runtimes, one
fragile") + a log entry.
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
actions/upload-artifact@v4's backend fails on the Gitea runner (Upload installers
step errored). Mirror release.yml's proven path instead: curl + the built-in token
to the Releases API, into a ROLLING per-branch prerelease (tag desktop-<branch>,
deleted+recreated each push). Installers renamed space-free
(parking-desktop-<branch>-<sha>.{deb,AppImage}). Signed v* releases unchanged.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
createUpdaterArtifacts:true (for release.yml's .sig signing) makes `tauri build`
demand TAURI_SIGNING_PRIVATE_KEY and fail without it — even though the .deb/.AppImage
built fine. Override it off for the unsigned per-commit build via
--config '{"bundle":{"createUpdaterArtifacts":false}}'. release.yml keeps signing.
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
New wiki/decisions/appliance-provisioning.md: the hardware-verified step-by-step for
provisioning a booth PC (Dell OptiPlex 7070, i5-8500, discrete Nuvoton TPM 2.0) from
factory Windows to a hardened Ubuntu 26.04 LTS appliance. Every command was run on the
first real unit (2026-06-23). Captures the firmware-specific gotchas: Ventoy → 0x1A under
Secure Boot (flash ISO directly); the 7070 BIOS can't view db (verify via live USB); the
installer's hardware-backed encryption fails with PCR_UNUSABLE/dbt (use passphrase LUKS +
manual systemd-cryptenroll PCR-7 seal); GRUB password must be edit-only (--unrestricted)
to keep unattended boot.
OS hardening on unit 1 is COMPLETE + verified: LUKS FDE + TPM auto-unlock (PCR 7,
unattended) + Secure Boot (Deployed) + GRUB edit-lock (closes the init=/bin/bash root-shell
hole that PCR-7 sealing does not cover). Resolves the implementation half of
open-questions #12 for unit 1.
Cross-linked from disk-os-hardening; index + log updated. Still TODO on the box: Docker +
run the stack.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
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
Moves Reports out of the Setup tab bar into a standalone top-level route
(/reports) with its own header nav link, alongside Booth/Shifts/
Subscriptions. Adds a /setup/reports → /reports legacy redirect. Same
report:read gate. Wiki note updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
The two failing apps/vision smoke tests assumed stub mode but the local .env sets
VISION_RECOGNIZER=fast_alpr (real-model work, 2026-06-19), so the app built the real
recognizer: /health reported "fast_alpr" not "stub", and /analyze on garbage bytes
422'd (real decode reject) instead of returning the empty stub contract.
Fix is test isolation: a conftest autouse fixture pins VISION_RECOGNIZER=stub for the
session (an OS env var overrides the .env in pydantic-settings), restoring it after.
vision 7/7.
Updates wiki/concepts/booth-console.md (the "no automated tests" Open note now reflects
the coverage that landed) and appends wiki/log.md.
Full workspace: shared 87, server 75, devices 18, web 17, vision 7 = 204 tests across
8 turbo test tasks, 0 failures; build/lint 14/14.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A barcode/QR scanner is an HID "keyboard wedge" — it types the id + Enter into
whatever holds focus. Previously that only worked while the ticket <input> was
focused; a scan with focus elsewhere (or nowhere) went nowhere.
New useScanner hook (apps/web/src/lib/use-scanner.ts): a document-level keydown
listener that detects the scanner's FAST keystroke burst ended by Enter and opens
the pay/exit modal via setActiveTicket — regardless of focus. A gap > 50ms resets
the buffer, so human-paced typing with nothing focused never registers as a scan
(min length 3 guards stray Enters). Keystrokes into an input/textarea/select/
contenteditable are ignored, so the manual ticket field still works by hand. The
hook is paused while a modal is already open — a scan must not abandon an
in-progress payment; the operator finishes/closes, then scans the next car.
Verified at runtime (Playwright): a fast burst with focus on BODY opens the modal;
a second scan while the modal is open is ignored; slow (120ms) human typing does
NOT open it; the manual input submit still opens it. build+lint 14/14.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Three changes to the shift hub, addressing the report:
1. Route rename /shift → /shifts (matches the plural "Turnet" label and the
section). /shift and /setup/shifts both redirect to /shifts; the header link
and the operator-landing fallback point at /shifts.
2. The activity-log rows are now CLICKABLE and open the same read-only
event-detail modal the booth live feed uses (full signed payload + entry/exit
snapshots + chain provenance) — previously they were static rows. Extracted
EVENT_STYLE, the feed row, the detail modal, and their helpers out of
BoothScreen into a shared apps/web/src/ui/event-detail.tsx imported by both the
booth and the shift log, so the two render and behave identically and can't
drift.
3. Reworked the /shifts layout to fill the viewport like /booth: a fixed
title + filters, then a two-pane area (shift list | activity log) where each
pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the
whole page growing. ShiftActivityLog is now a flex column with a fixed header
and a scrollable list.
Verified at runtime (Playwright): /shift redirects to /shifts, an activity row
opens the detail modal, the layout fills height, and the booth still works (0
console errors after the extraction). build+lint 14/14.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The header shift link used nav.shift (singular: Turni/Shift) but points at the
/shift HISTORY hub, so it now uses nav.shifts (plural: Turnet/Shifts).
The Setup "Turnet" tab was a duplicate — /setup/shifts and the standalone /shift
both rendered ShiftsHistory. Removed the Setup tab + its child route; /setup/shifts
redirects to /shift for old bookmarks, and the operator-landing fallback (a
shift:read user opening /setup) now points at /shift. The orphaned nav.shift key is
left in both catalogs (harmless).
Verified at runtime (Playwright): header reads Kabina·Turnet·Abonimet·Konfigurimi,
Setup no longer lists Turnet, /setup/shifts redirects to /shift. build+lint 14/14.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V