Commit Graph

46 Commits

Author SHA1 Message Date
julian 14638c2e13 docs(wiki): industry survey of parking tariff systems + session log
Build desktop / desktop (push) Successful in 4m14s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m49s
New reference page tariff-industry-survey.md (2026-07 web research):
field taxonomy — per-started-increment hourly (per-minute tried and
rolled back in practice), degressive ladders, day caps, up-to matrices,
day tickets, evening/overnight packages, event rates, early bird
(entry-time-conditioned), day/night + weekend/holiday/seasonal windows,
category pricing, contracts, merchant validations (amount/percent/
time-credit/re-rate), SFpark-style dynamic pricing. Coverage map: our
engine expresses everything a staffed single lot advertises; real gaps =
early bird (the pick-table-by-entry-time future design, same mechanism
as weekend menus) and validation overlays; anti-features = per-minute
billing + dynamic pricing. Indexed + logged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian 35e593ab63 docs(wiki): DT-008 phantom-scan diagnosis + backfill bypass/relay-test concept pages
Two independent wiki updates bundled (all docs):

1. dingtian-dt008-reader.md: phantom optical decodes on the park-buzi EXIT
   reader (empty pre-opening site, low-sun afternoons). Chain of evidence:
   READ log lines carry the reader's own serial (H05MA5B0) → physical device,
   not a network source; snapshot shows nobody present; code shapes are the
   giveaway (6-digit numerics = checksum-less Interleaved 2-of-5, lone "C" =
   Code39/Codabar artifact) → 1D engine decoding sun-made stripe patterns
   (striped arm, fence shadows, glare). No fraud exposure (11-digit Luhn ids
   can't match); noise only. Fix on the entity page: vendor-tool symbology cut
   to QR+Code128 + min decode length, BOTH readers; config lives ON the device
   → re-apply after any factory reset/swap. Deliberately NOT filtering
   impossible codes server-side — probe recording is the anomaly path's job.

2. Backfilled two shipped-but-undocumented features (six code files already
   linked the first page as if it existed):
   - concepts/entry-presence-bypass.md — admin drops a FAULTY presence signal
     (granular radar/camera by decision, not a master switch); every flip is a
     signed config_change; persists till off; tickets stamped presenceBypassed;
     radar-bypass cooldown tradeoff; "the admin is not the adversary, but
     trusted never means invisible".
   - concepts/setup-relay-test.md — admin-only commissioning pulse, signed
     barrier_open_command BEFORE the fire so a test open never reads as the
     out-of-band-open fraud signal; saved controllers/declared relays only;
     radarAlert lamps excluded; pulseOpen only.
   Cross-linked from operator-issued-entry.md, cataloged in index.md, logged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 19:00:49 +02:00
julian c142166972 docs(wiki): ATECC608 is upcoming — retag ledger signing to the on-host reality
No secure element is on-site: event signing runs on the software HMAC
(EVENT_SIGNING_KEY, an env var on the host disk), so the ledger is
tamper-EVIDENT but forgeable by anyone who owns the host. Several pages
overstated it as present-tense "ATECC608-signed / unforgeable"; correct them.

- NEW concepts/hardware-signer-options.md: four options for a non-extractable
  signing key (USB HSM / YubiKey / reuse the TPM / plain-dongle trap) + the
  recommendation (TPM interim → USB-HSM target; ATECC608 stays for the embedded
  ESP32, wrong part for a PC host).
- entities/atecc608.md: UPCOMING-not-present status banner + PC-vs-embedded.
- disk-os-hardening.md: fix the live-USB row (BIOS boot-order password is
  load-bearing, not Secure Boot — a signed live USB runs); add a physical-tamper
  chain (Dell 7070 CMOS-reset → live-USB → PCR-7 same-signer unseal) + accepted
  risks (that unseal, unsigned-initramfs evil-maid, operator-USB read TODO).
- open-questions #6 reframed; standing-decisions / overview / threat-model /
  index de-overstated; log query entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 13:41:11 +02:00
julian 33c4ea1e91 feat(entry): operator-issued entry + exit plate-swap reconciliation
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s
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
2026-07-01 12:17:52 +02:00
julian 114a32e6f2 feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.

- New `drawer` resource: drawer:create (operator records; admin-revocable per
  role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
  default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
  A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
  touches the drawer balance (the correction is settled outside the app). This
  is what keeps a late review from leaking into the next operator's inherited
  drawer — a denial that lands after the reviewed shift closed moves no cash.
  Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
  op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
  (operator: record + own; admin: review queue + all). routes/drawer.ts lifted
  from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
  for its other job = admin-sees-all-shifts). New DrawerManager.tsx.

Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
  movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
  Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
  on-site), matching the card-tender gate.

shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 11:17:20 +02:00
julian 018328a877 feat(booth): disable card tender until a P2PE POS is on-site (cash-only)
No card processor / POS terminal on any site yet. Offering "Card" would let an
operator record a card payment that never cleared a terminal, corrupting the
till reconciliation — a fraud/error surface on an operator-adversary system.

Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both
tender pickers (BoothPayModal, SubscriptionManager). With card off there's
nothing to choose, so the tender row is suppressed and payment defaults to
cash. UI-only gate: the Tender type, payment events, shift accounting, and
reports still understand `card`, so historical card events and a future
re-enable stay coherent.

Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open
barrier" with no tender/cash/card row.

Wiki: new concepts/card-payments.md records the current cash-only state, the
PCI-scope-out-of-app constraint, the future-POS device requirements, and the
re-enable path (flip the flag once a bank-certified P2PE terminal is
provisioned). Linked from index, parking-session, open-questions #3.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 09:57:03 +02:00
julian 266e9b0027 docs(wiki): record session findings — snapshot fix, booth rework, db reset
Build desktop / desktop (push) Successful in 4m34s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
- 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
2026-06-30 17:58:43 +02:00
julian 1b86750b0d docs(wiki): firmware/dbx-vs-TPM hardening + create disk-os-hardening page
Real-world park-buzi episode: a UEFI dbx update (delivered via fwupd/LVFS,
NOT apt) revoked a stale GRUB -> panic, and moved PCR 7 -> broke TPM-sealed
LUKS auto-unlock -> passphrase prompt. Recovered by re-sealing PCR 7.

- appliance-provisioning.md: extend the §4 re-seal runbook to name dbx; new
  §4a (fwupd-not-apt, GRUB-panic ordering, PCR-7 re-seal, operator lockdown:
  mask fwupd + remove firmware-updater snap + BIOS-password + passphrase
  escrow) incl. the --test-passphrase-silently-passes-via-TPM trap
  (--disable-external-tokens); gotchas #12/#13.
- disk-os-hardening.md: NEW — resolves a long-dangling wikilink referenced
  from ~18 pages. The *why* of host hardening (5 controls + firmware lockdown);
  commands stay in appliance-provisioning; reconciliation remains the primary
  anti-fraud control.
- index.md: expand the disk-os-hardening catalog line.
- log.md: two note entries.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 15:21:32 +02:00
julian 9e442586af docs(wiki): settle on-site encrypted backup + disaster-recovery design
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
2026-06-29 11:43:23 +02:00
julian f6e35bbebf fix(reader): correct the QR reader's identity — Dingtian DT-008, not "GEE"
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m46s
CI / check (push) Successful in 38s
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
2026-06-28 17:39:15 +02:00
julian 9918f278b2 feat(deploy): Komodo fleet deployment — resources.toml + decision
CI / check (push) Successful in 36s
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
2026-06-27 12:14:04 +02:00
julian 6d7682ab4a docs(wiki): printer USB transport + open-question for the provisioning
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
2026-06-24 20:32:24 +02:00
julian 2915d141aa feat(devices): radar presence input + button-light output on the controller
Model the entry button (I1) and a Hikvision radar (I2) as named children of the
access controller, and drive the button's 12V lamp on a spare relay.

- Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled
  presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input
  active-level override: relays[].presenceActiveLow -> driver inputActiveLow set,
  inverting just that terminal (pure helper inputActive()). The Dingtian has one
  board-wide resting level otherwise.
- AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian
  latch) so business logic drives a NON-barrier lamp through the interface. Barriers
  still only pulseOpen — barrier-not-a-door preserved.
- ButtonLightController: subscribes to the radar input edge + the camera lane status
  and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off.
  Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on
  its own (advisory; threat model).
- SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n.

Tests: button-light.test.ts (truth table + blink + fail-OFF + de-dupe),
access-dingtian.test.ts (active-level inversion). Workspace build+lint+test green
(158 server tests). Wiki: hikvision-radar, button-light-indicator + updates.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 11:45:22 +02:00
julian 9d65099d9b docs(wiki): appliance provisioning runbook — booth unit 1 hardened (LUKS+TPM+SecureBoot+GRUB)
CI / check (push) Successful in 45s
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
2026-06-23 15:53:56 +02:00
julian 8155ff456b feat(deploy): Docker images for server (API+SPA) and vision + branch-aware build pipeline
CI / check (push) Successful in 35s
Build & push images / images (push) Failing after 17s
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
2026-06-23 15:07:52 +02:00
julian 65328b8c11 feat(anpr): subscriber-entry bridge + admin disable toggle
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a
vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh
snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and —
matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read.
The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens
the barrier. A plate is never the sole authority: it routes through the same gate
(active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget,
subscriber-only by construction. Field-verified end to end (plate AA504LX opened the
entry barrier and appended a signed vehicle_entry).

Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site
Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and
lane busy/free are unaffected. Read live per event, so toggling takes effect with no
restart. Migration 0013 (additive ALTER ADD COLUMN, default 1).

- New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9)
- hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3)
- server.ts reorders the read flows above the hik-alarm registration
- snapshot.ts exports buildCamera for reuse
- env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000)
- site route + SiteSettings checkbox + i18n (sq/en parity)
- wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:49:18 +02:00
julian 411572511d docs(camera): lane presence + ANPR subscriber-entry bridge design
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
2026-06-22 19:12:49 +02:00
julian 7680d9a0ed feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.

Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.

Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).

Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).

Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).

Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.

Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 09:33:54 +02:00
julian d0536da3d7 feat(desktop): Tauri v2 kiosk shell — maximized window, prod right-click block, auto-update + code-signing
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
2026-06-21 12:21:49 +02:00
julian ee28b7302f docs(wiki): decide vision service packaging — apps/vision/ in the monorepo
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
2026-06-19 14:25:31 +02:00
julian e4827c9651 docs(wiki): record fast-alpr as the evaluated ANPR recognizer baseline
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
2026-06-19 14:19:16 +02:00
julian 30e7fe85de feat(booth): refusal snapshots, subscriber access medium, one-car-one-ticket entry
Three booth-integrity improvements that share the entry/exit flows and activity log.

Refusal snapshots: previously only an accepted open captured a camera image; now
every refusal/hold anomaly fires the directional camera too (a turned-away car is
exactly the evidence wanted) — entry refused-full/held, exit refused
closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription.
A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo
together. Same fire-and-forget contract; failed captures still show as tiles.

Subscriber access medium: the subscription flow already signed `via`
(qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a
cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only.

One car = one ticket: the entry button could be mashed to mint many tickets per car
(corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard
only blocked overlapping presses. Add a per-relay guard configured on the relay spec:
PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input —
one ticket per car, re-armed when the loop clears) or COOLDOWN fallback
(entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned
device_events telemetry, not a signed anomaly. SetupWizard exposes both fields.
Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is
in-memory/rebuildable, starts armed after restart.

Wiki: new entry-double-press; updated entry-exit-points, booth-console, index.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 12:54:54 +02:00
julian bfb6ab0b36 feat(logs): app log store — backend pino DB sink + frontend error collection
Add a third data stream (app_logs), distinct from the signed ledger and device
telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to
ship to, so the host is the log store.

Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay
stdout-only) with no call-site change; the DB is built before Fastify so the logger
has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn),
window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console
warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on
pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere.

POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new
log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by
age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter
level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs.

Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record;
backend warn/error persisted, info dropped; non-admin GET 403 / POST 204.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 12:54:22 +02:00
julian 0074e82a2a docs(wiki): activity-log explainability, dates/i18n, KP-300H barcode fix
Record this session's work across the affected pages + three log entries.

- ticket-encoding: id 13→11 digits (guess-resistance rationale, legacy-safe
  validation) + a barcode-geometry rule (symbol dots must fit the narrowest
  deployed printer's line — the KP-300H 72mm overflow).
- rongta-printer: KP-300H raster-garbage root cause (line overflow, not
  corruption), sendRaw graceful-close fix, Albanian human dates (formatStampSq).
- i18n: localized ledger reason codes, relative/human dates + the
  "browser ICU lacks Albanian" gotcha, toggle stale-router-context fix.
- shift: Albanian Z-report, shift-history UI + permission scoping.
- booth-console: explainable activity log (inline reasons/badges, event-detail
  modal with snapshots + audit disclosure, subscriber names, failed-snapshot
  tiles).
- index/log updated; all added wikilinks resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:41:27 +02:00
julian cf1ff5676d feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.

computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.

Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:00:13 +02:00
julian 5697137c52 feat(subscription): rename permit→subscription + monthly pricing
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
2026-06-18 13:15:04 +02:00
julian 48660d3ec8 docs(wiki): reconcile with session — booth console, i18n, live WS
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).
2026-06-18 11:50:58 +02:00
julian 062feeae2f docs(wiki): update index + log for booth console, drawer, and tariff research
Catalog the new concept/source pages and append chronological log entries for the
tariff research, live WebSocket, booth pay/exit, active sessions, and shift drawer
work.
2026-06-18 11:05:43 +02:00
julian 727c62da90 ticket: site metadata header + scannable Albanian ticket; widen barcode
- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
2026-06-17 12:17:21 +02:00
julian 1efa77bf56 devices: pool-of-spaces model — drop lane, per-relay direction
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.
2026-06-16 20:29:38 +02:00
julian f67c1ead87 wiki: ER80 protocol = HTTP GET poll + JSON verdict (from QRCode SDK)
The QRCode SDK v1.6.5 settles the reader protocol (supersedes the earlier
serial guess). On each scan the reader HTTP-GETs the host
(/qa/mcardsea.php?cardid&mjihao&cjihao&status&time); the host replies JSON
{data:[{...,status,output}],code:0}. Reply status 1=valid(beep 2x)/0=invalid
(beep 1x); output 0=Access/1=WG26/2=WG34; time syncs the clock. The GET's status
low digit is the direction (1=in/0=out).

Key: the beep/accept is decided by the SERVER REPLY, not locally -- the 'no
beep' during bring-up was a plain-text reply, not a scan failure. Host-in-the-
loop and synchronous. 'Server language' only selects the URL path; transport is
plain HTTP.

New source page qrcode-sdk; updated gee-qr-er80 (protocol resolved), index.
2026-06-16 12:05:05 +02:00
julian bf37106c5c wiki: ingest GEE-QR-ER80 QR access reader datasheet
The reader on hand is a GEE-QR-ER80 QR/DataMatrix/1D barcode access reader
(not an EM4100 prox-card reader as first guessed). Interfaces: Wiegand 26/34,
RS-232, RS-485, USB, TCP/IP; 4-15 VDC; Linux-supported. Variant on hand: -Q-W
(QR scanner, Wiegand/RS-232/485).

This is the QR-ticket scanner the design already needed: a host-side reader
whose scans become read-bus events consumed by the (already-built) exit flow
and QR-permit path. Prefer RS-232/485 over Wiegand (Wiegand can't carry a
variable-length QR string; autonomy is moot with the no-ACL Dingtian).

New source + entity pages; updated ticket-encoding, entry-exit-readers, index.
Open (blocks the adapter): the RS-232/485 frame + baud (ASCII CR/LF expected).
2026-06-16 08:22:23 +02:00
julian 648d3254d6 wiki: valet / over-capacity mode; 'full' is a soft operator policy
Capture that refusing at capacity is the default, not absolute: an operator
may opt into valet over-capacity (customer hands over keys, operator stacks
the car into custody). Manned-only, new custody/session shape. Deferred;
not built into the entry flow. Made capacity-occupancy's FULL gate a soft
policy knob.
2026-06-15 18:32:40 +02:00
julian 9a4c7ee27b wiki: split signed business ledger from device telemetry
Correction before schema work: the events table conflated the anti-fraud
business ledger with device telemetry. Decision: ledger_events (signed,
chained, reconciled) holds only business facts; device_events (unsigned,
prunable) holds relay/printer/camera/reader/input telemetry. A raw button
press is telemetry; the entry flow mints a signed vehicle_entry. Drops
input_received-as-signed-event.

New: decisions/event-streams-split, concepts/device-events; updated
append-only-event-chain, index, log.
2026-06-15 18:08:56 +02:00
julian 8a8e74561d wiki: design the business layer (session, tariff, permit, vision, shift, ops)
Pivot from the hardware/integrity layer to the parking operation. All
wiki-only; no code yet. Core principle throughout: business entities are
projections over the signed append-only event log, never mutable tables.

New concepts: parking-session, tariff (composable/versioned, FX-ready),
shift (manned-only Z-report), capacity-occupancy, validation-discounts,
reporting-analytics, clock-integrity, ticket-encoding, anti-passback.
New entities: permit, opencv-anpr-service, blocklist.
Decisions: session-model, vision-service (host-side ANPR + vehicle
verification; scoped AGPL exception for the isolated service).

Updates: append-only-event-chain (new event types + vision witness),
local-jwt-auth (drop 8h expiry -> until logout; code change pending),
lpr-camera (host-side recognition supersedes edge-AI), standing-decisions
(AGPL exception), open-questions (+FX, +pay-station money corners, backup).

Deferred + flagged: intercom/help-call, receipts/refunds/change, FX engine,
lane topology (#1).
2026-06-15 17:41:38 +02:00
julian b2a0471b08 Rongta 80mm printer: driver, role-based failover, live status monitoring
Add the rongta PrinterDevice driver (ESC/POS over raw TCP 9100) and the
device-agnostic pieces around it:

- Roles + failover: each printer declares a role (entry-dispenser/booth-
  receipt) and failoverRank; printer-routing.ts picks the best healthy printer
  and falls back outside->booth for entry tickets (never the reverse).
- Live status: MonitorableDevice.readStatus()/PrinterStatus capability. The
  Rongta driver scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper
  End/Near End/Off-Line) rather than hand-decoding DLE EOT, whose reply bytes
  on this clone don't match the canonical ESC/POS bit layout (verified on
  hardware) -- avoids a false-healthy. Maps to ready/degraded/offline, fail
  safe on an unreachable or unexpected page.
- Server PrinterMonitor polls enabled printers (PRINTER_POLL_MS, default 5s),
  caches latest, emits "printer-status" on change. Exposed via
  GET /api/printers/status and an SSE stream for the booth UI.

Verified against 10.0.10.6: ready when healthy, offline when unreachable
(no throw), bus emits on change and suppresses unchanged reads.

Wiki: new rongta-printer entity, printer-roles-failover and
printer-status-monitoring concepts; BOM/index/log updated.
2026-06-14 20:26:45 +02:00
julian 23919164ee Dingtian input HTTP-push to backend (no polling)
The device pushes button events to the backend via its Input Link URL feature;
the backend decides. No polling — the chosen entry architecture.

packages/devices:
- dingtian driver: configureInputPush() writes the device's input_link_url
  config (per-input server/port/path, en=1, active-LOW, plain HTTP) so each
  input HTTP-GETs the backend on press/release. Extracted #readConfig/#writeConfig
  (with the required command:setconfig injection + post-write reset tolerance).

apps/server:
- routes/devices.ts: public GET/POST
  /api/devices/dingtian/:deviceId/input/:n/{on,off} — translates a device push
  into an internal device event. Not behind cookie/CSRF (machine call from the
  device); trust comes from the signed event log, not this request.
- device-events.ts: internal EventEmitter bus so the entry flow subscribes to
  input events without coupling to HTTP. Wired into the server.

Verified on hardware: configured the device, then real presses on all 4 inputs
pushed to the backend (input N on+off, source = device IP). No polling.

wiki: device-input-flow concept (path + trust model for the flat/no-VLAN
network); dingtian-relay updated; index + log.
2026-06-14 15:10:50 +02:00
julian 355026dcf7 Remove UHPPOTE/ZKTeco; Dingtian is the only access driver
Neither UHPPOTE nor ZKTeco is used — the Dingtian relay controller was chosen
and verified. Remove their code and re-scope the wiki.

Code:
- delete access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32-relay stubs),
  and the three uhppote-*.mjs hardware test scripts.
- remove the `uhppoted` npm dependency from @parking/devices and @parking/server.
- unregister uhppote/zkteco/esp32-relay from the driver registry; drop their
  exports. Catalog access drivers = dingtian only. Build green (5/5).
- refresh now-stale example comments (registry/interfaces/setup/api) to use
  current examples; keep the two "UHPPOTE blocker" references that explain why
  the precondition capability exists.

Wiki (kept pages, re-scoped):
- uhppote-controller, zkteco-controller -> rejected/historical with callouts;
  uhppote-vs-esp32 -> historical (detection-vs-prevention lens still useful).
- re-point all "current device" framing (standing-decisions, bom, overview,
  open-questions, device-registry, device-discovery, index) to dingtian-relay.
- transferable concepts (network-isolation, event-log-ingestion, barrier-not-a-
  door, threat-model) untouched. Raw source immutable. Links lint clean.
2026-06-14 14:28:52 +02:00
julian 1b55e2034d Dingtian relay driver — resolves the ticket-first entry blocker
The Dingtian board's inputs are independent of its relays (configurable), so a
button on an input can report to the host WITHOUT auto-firing a relay — solving
the access-controller-button-flow blocker the UHPPOTE/ZKTeco couldn't.

packages/devices:
- access-dingtian.ts: `dingtian` access driver implementing AccessControlDevice
  (relay pulse/latch via UDP string protocol :60001), InputDevice (read inputs +
  poll-based press/release events, active-LOW), and the new PreconditionDevice.
- PreconditionDevice capability on the interface: a device can report config it
  requires for parking and optionally fix it. Dingtian checks input_link_relay
  via the HTTP config API and can disable it.
- httpPort config field — the web/config API port is separate from UDP control
  (this unit uses 8080, not the default 80).
- Register dingtian; export driver objects from the package.

Verified on real hardware (DT-R004 @ 10.0.10.172): status read, relay pulse,
input events; disabled input_link_relay via the driver, then confirmed pressing
inputs fires NO relay (0000) — host-in-the-loop entry works.

Config-write gotcha recorded: config_set.cgi requires "command":"setconfig"
injected after "status" (GET omits it) or the POST silently no-ops.

apps/server/scripts/dingtian-test.mjs: status / watch / pulse hardware test.
wiki: dingtian-relay verified; button-flow marked RESOLVED; index + log.
2026-06-14 14:15:00 +02:00
julian 4319fb86dc wiki: Dingtian relay decision, HTTP-over-MQTT, unmanned direction
- dingtian-relay: relay+input controller (4ch on hand). Inputs are decoupled
  from relays (configurable via input_link_relay) — solves the
  access-controller-button-flow blocker the UHPPOTE couldn't. Full protocol from
  the SDK (UDP string control :60001, `00` status parse, input_link_url push,
  multicast discovery). Driver + hardware test still to build.
- dingtian-vs-mqtt: use direct HTTP/UDP now; MQTT skipped (broker = extra infra
  + failure mode + overkill at one-host/few-devices scale) but kept for later
  multi-lane scale.
- autonomous-direction: record the roadmap to fully unmanned (no booth) and how
  it reshapes the threat model (operator-fraud -> unattended-machine threats),
  makes host-in-the-loop entry mandatory, and raises fail-state stakes.
- threat-model: note the unmanned shift. index + log.

gitignore the vendor SDK (dingtian/, 71MB of binaries/examples) — reference
only, protocol captured in the wiki.
2026-06-14 13:27:01 +02:00
julian dbf1fa17d7 wiki: document dev environment (WSL networking, workflow)
Capture hard-won dev knowledge that was only in commit messages:

- wsl-dev-networking: WSL2 NAT blocks UDP broadcast (device discovery can't
  reach the LAN); fix is mirrored networking (.wslconfig, Win11 22H2+), plus the
  gotchas that remained after — multiple interfaces, subnet-directed broadcast,
  localhost->IPv6 stall. Alternatives for non-mirrored setups.
- local-dev-workflow: first-time setup, pnpm dev, and the gotchas (the
  strip-types dev-server hang -> tsx, the 127.0.0.1 proxy fix, .env loading,
  seeding into the right DB).
- device-discovery: corrected the old "broadcast permission (EACCES)" note — the
  real cause was the lib not enabling SO_BROADCAST for global 255.255.255.255;
  documented the three verified broadcast gotchas + I/O serialization.
- schema: add a `reference` page type; new "Dev environment" index section; log.

Links lint clean; both new pages well-connected.
2026-06-14 13:09:35 +02:00
julian 77606da2c9 UHPPOTE hardware bring-up + entry-flow blocker
Brought up the real UHPPOTE controller (serial 225088491, fw 09120) end to end
and recorded a procurement-level blocker.

Verified on hardware:
- discovery (LAN scan), host-commanded openDoor on doors 1 & 2 (physically
  actuated; reason="remote open door"), and live button capture
  (reason="push button ok").

Driver/networking fixes (packages/devices/src/drivers/access-uhppote.ts):
- broadcast to subnet-directed address (lib doesn't enable SO_BROADCAST for the
  global 255.255.255.255 -> EACCES);
- Config broadcast must match the target's subnet for unicast reply routing
  (fixes the health-check timeout: 5s -> 24ms ready);
- discover across all local subnets, dedupe by serial;
- serialize all controller I/O (concurrent calls collided on UDP :60001).

Server/UX:
- load .env via node --env-file-if-exists (vars weren't being read before);
- SETUP_AUTH_BYPASS hardened: env-gated, dev + loopback only, fails closed
  otherwise; surfaced as catalog.authBypass so the wizard drops the token field;
- .env.example documents all vars; inline favicon stops a 404.
- apps/server/scripts/: uhppote-listen (live events, restores prior listener)
  and uhppote-relay (guarded door-open test).

BLOCKER (wiki/decisions/access-controller-button-flow.md): the controller's
push-button input auto-opens the relay in firmware with no report-without-open
mode, so ticket-first entry (button -> print -> open, fail-closed) is impossible
as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a programmable
aux input + PULL SDK but that's unverified and needs a new driver. Entry-lane
hardware decision paused to focus on the business side.

wiki: access-controller-button-flow (blocker), zkteco-controller (stub +
assessment), uhppote-controller callout, index + log.
2026-06-14 10:29:43 +02:00
julian a0e0fd9118 Add device discovery (UHPPOTE LAN scan) to setup
UHPPOTE controllers self-announce via UDP broadcast, but the frontend had no way
to find them — the admin had to type the serial blind. Add a generic discovery
capability and surface it in the setup wizard.

packages/devices:
- DiscoverableDriver capability + DiscoveredDevice type + isDiscoverable() guard
  on the registry (optional, so any driver can opt in).
- uhppote driver implements discover() via uhppoted getDevices (UDP broadcast),
  mapping each controller's serial/IP/firmware into a DiscoveredDevice; extract
  shared buildCtx().

apps/server:
- GET /api/setup/discover/:driverId (admin-only): runs discover() and
  health-checks each found device so reachability shows before assigning.
- catalog now returns a `discoverable` driver-id list.

apps/web:
- SetupWizard "Scan for controllers" button for discoverable drivers; lists found
  devices with health badges; selecting one auto-fills serial + host. api client
  gains discoverDevices().

wiki: new device-discovery concept; cross-link from registry/setup/uhppote;
note the broadcast-permission (EACCES) deployment caveat; index + log.

Verified: catalog flags uhppote discoverable; discover runs and fails gracefully
without hardware; non-discoverable driver -> 400; missing token -> 401.
2026-06-14 08:21:27 +02:00
julian 72ba4099ea Device-agnostic driver registry + first-run setup
Make the device-adapter pattern selectable so the admin chooses hardware at
install — per lane, from a catalog of supported drivers. Adding a device =
registering one more driver; no business-logic change.

packages/devices:
- interfaces.ts: AccessControlDevice / ReaderDevice / CameraDevice / PrinterDevice
  (adds CameraDevice for entry/exit snapshot-on-event; access relay stays
  intent-only per "a barrier is not a door").
- registry.ts: driver catalog with per-driver config fields + factory, config
  validation, and a catalog payload for the setup UI.
- drivers/: stub adapters — access (zkteco, esp32-relay), reader (wiegand,
  tcp-ip), camera (hikvision, dahua). Real vendor protocols TBD.

packages/db:
- lane_devices + setup_state tables (migration 0001); re-export query helpers.

apps/server:
- routes/setup.ts: GET /api/setup/catalog (public schema), and admin-only
  /assign, /state, /complete with registry validation before persisting.
- extract auth.ts (requireJwtSecret, requireRole, JWT type aug).

apps/web:
- SetupWizard scaffold + api client: pick a driver per category for a lane,
  render its config fields.

wiki: device-registry + first-run-setup concept pages; cross-link from
device-adapter-pattern; index + log updated.

Verified: full turbo build (5/5); catalog lists all drivers; admin assign
persists; missing-config and no-token requests are rejected.
2026-06-14 07:59:46 +02:00
julian 7de5c74500 wiki: record JWT key open question; defer ESP32 controller
- open-questions #7: symmetric vs. asymmetric JWT signing key (from the
  commit security review). Prefer RS256/EdDSA so verifying hosts hold only a
  public key — mirrors the ATECC608 / challenge-response "public key only"
  property. Decide before multi-host/multi-lane deployment.
- Mark esp32-custom-controller status: deferred per decision not to build
  device-level auth now; access control stays on UHPPOTE + network isolation
  (noted in open-questions #6).
- local-jwt-auth: document hardened secret handling + 8h expiry and the
  asymmetric-key pointer.
- Update index.md and append a log.md entry.
2026-06-14 07:45:14 +02:00
julian bfe64032d8 Initial scaffold: Turborepo monorepo + design wiki
Turborepo (pnpm workspaces) with all dependencies pinned to latest
mutually-compatible versions: turbo 2.9, TypeScript 6, Fastify 5,
React 19, Vite 8, better-sqlite3 12 + Drizzle ORM 0.45.

Layout:
- apps/server   Fastify backend (local JWT auth + role guard, /health)
- apps/web      React 19 + Vite 8 operator SPA
- packages/db   Drizzle schema on SQLite/WAL; append-only events + users
- packages/devices  reader/printer/relay adapter interfaces (intent-only relay)
- packages/shared   shared domain types

Architecture constraints from the design wiki are encoded in the scaffold:
append-only hash-chained + signed event log, device-agnostic adapters,
"a barrier is not a door" (relay expresses intent only), fully-local
offline-first auth.

wiki/ is an LLM-maintained Obsidian knowledge base (28 pages) ingested
from the architecture & design notes, with its own maintenance schema.

Verified: pnpm install, full turbo build (5/5), server boots and serves
/health, drizzle-kit generates the initial migration.
2026-06-14 00:34:11 +02:00