101 Commits

Author SHA1 Message Date
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 ae736a9e3e feat(shift): current shift in the list + modal actions; full-width layout everywhere
Shift screen:
- The standalone ShiftControl block is gone from /shift. The open/CURRENT shift now
  appears at the TOP of the shift list (CURRENT badge, live figures synthesized from
  the X-report), unified with history. Selecting it shows its live activity log.
- Shift ACTIONS moved into the current shift's detail pane, each opening a MODAL:
  End shift (confirm → signed Z-report result), drawer voucher (Mandat in/out),
  takings-so-far (X-report). When no shift is open, a Start-shift button shows.
- The current shift's log auto-refreshes (5s); a closed shift is bounded by its
  window. /setup/shifts stays read-only history (no manage props). Deleted the now-
  orphaned ShiftControl.tsx.

Layout:
- Every screen is now full-width like /booth — stripped the per-screen
  `mx-auto max-w-*` caps (Logs, Subscriptions, Plans, Tariff, Users, Roles, Setup
  layout, Shifts). The shell <main> already provides padding.

Build+lint 12/12 (i18n parity). Verified a live open shift surfaces as the CURRENT
list entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 23:44:27 +02:00
julian 1b54775b4d feat(shift): two-pane shift history — list + per-shift activity log, timeframe presets
Rework the shift screen into a master/detail view on /shift: the shift CONTROL
(open/close, drawer vouchers, X-report) on top, then a two-pane history below —
shift list on the LEFT, the selected shift's signed activity log on the RIGHT.

- Timeframe presets replace the bare from/to inputs: Yesterday / Last week /
  Last month / All / Custom (custom reveals the date pickers). Filters the shift
  list by start time.
- Activity log = every ledger event in the selected shift's [start, end] window
  (entries, exits, payments, vouchers, anomalies, the Z-report), rendered like the
  booth live feed (same EVENT_STYLE), with the shift's drawer reconciliation in the
  pane header.
- Scope unchanged + enforced SERVER-SIDE: an operator sees only their own shifts
  (no operator filter); an admin (shift:cash) sees all + the operator filter. The
  list auto-selects the newest shift.

API: /api/events gains an optional `until` (ISO) upper bound so a shift's window
can be fetched ([start,end]); fetchEvents passes it. Verified on live data: a
closed shift window returns just its 20 events out of 260.

Build+lint 12/12 (i18n parity). The same component also backs /setup/shifts.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 21:56:34 +02:00
julian f2734641b2 feat(subs): print an advisory "out-of-window" slip at early entry
A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).

Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.

(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:47:45 +02:00
julian de858e91f4 i18n: translate sub.refused.unpaidWindow reason (sq + en)
The exit-gate refusal for an unpaid out-of-window subscriber charge rendered as
the raw code `reason.sub.refused.unpaidWindow` — the code + English fallback
existed in @parking/shared but the reason.* catalogs had no entry. Add it to
both catalogs with the {{amount}}/{{currency}} params the gate passes.

EN: "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth"
SQ: "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë"

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:39:53 +02:00
julian 294ca85ded fix(subs): out-of-window charge was a phantom 12h span (4,100 ALL bug)
The tariff-bridge owed amount summed TWO charges — the early-entry gap +
a "late-exit" gap — and the exit gap (outOfWindowGap edge:"exit") always
measured back to the PREVIOUS window close, even for a subscriber still BEFORE
their window. So a car that entered ~30 min early showed ~12h owed (4,100 ALL)
the moment it was looked up, instead of ~100 ALL.

Replace the two-gap sum with a single correct primitive,
minutesOutsideWindow(timeframes, tz, from, to): the minutes within the actual
stay [entry, now] that fall outside the allowed window (covering early entry AND
late exit, bounded by the stay, weekend/off-days free). windowOwedBetween prices
those minutes once as a transient stay (so increments + daily cap apply) against
the tariff in force at entry. Both the exit gate (subscription-flow) and the
booth quote (pay-station) now use this one source of truth — they can't disagree.

Verified on the live occurrence: was 4,100 ALL, now 100 ALL (9 min outside →
one increment). 87 shared tests (6 new regression cases incl. the phantom span).
Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:37:57 +02:00
julian eafbc3ddbb feat(booth): badge subscriber out-of-window entries in the live feed
A subscriber entering outside their plan's allowed window gets a deferred
transient charge (windowOwedMinor, collected/gated at exit) — but it was
SILENT at the booth: the entry showed as a plain subscriber pass with no hint
money is owed, so the operator only discovers it at exit.

Surface it: add a "out-of-window — owes fee" badge on any entry/exit event
carrying windowOwedMinor > 0, so the operator sees immediately that this
subscriber owes a fee. Also type the window-charge fields on LedgerPayload
(were riding the open-ended index signature).

Behaviour is otherwise unchanged and correct — verified the live "Mon Kukaleshi"
entry: entered 20:29 local (before the 21:00 Mon–Sat window, grace 5m), owes
100 ALL for 18:29–18:55Z, stamped on the signed entry, still owed, gated at
exit. Subscribers get no ticket by design. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:32:12 +02:00
julian 36f30d39ff feat(plans): reactivate + delete-when-unused; card layout fixes overlap
Addresses three issues with the plan catalog screen:

1. Retired plans had NO actions (the action cell was gated on "current
   version", which a retired plan lacks) — so there was no way to make one
   in-force again. Add POST /:planId/reactivate (inverse of retire) + a
   Reactivate button on retired plans.

2. No delete. Add DELETE /:planId, allowed ONLY when zero subscriptions
   reference the planId (any version) — a referenced plan version must survive
   for reproducible repricing/audit, so an in-use delete returns 409 and the UI
   says "retire it instead". The Delete button only shows when the plan has 0
   subscribers.

3. The 6-column table overflowed max-w-3xl: action buttons overlapped and the
   status badges wrapped to a second line. Replace it with a CARD list (one card
   per planId, grouped across versions): name + status on top, price · hours ·
   effective on a wrap row, "used by N" expandable to holder names, and actions
   on their own bordered row — nothing overlaps, badges stay inline.

Build+lint 12/12 (i18n parity). Verified on a DB copy: unused plans report
deletable; retire→reactivate flips active back.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:27:36 +02:00
julian 488dcb5e4e feat(plans): show hours, period/currency, and subscriber dependencies in the plan list
Deleting versioned plans is unsafe (a plan version referenced by a subscription's
planVersionId must survive for reproducible repricing/audit) — so instead of
delete, give the admin the VISIBILITY they actually needed:

- Hours column: a compact timeframes summary ("Hën–Pre 21:00–08:00" / "24/7"),
  so two same-priced plans are distinguishable at a glance.
- Period + currency are already in the price cell; the hours column removes the
  remaining ambiguity between night/day plans.
- "Used by" column: a count of subscriptions on each (current) plan (active /
  total), expandable to the holder names — so you can see what depends on a plan
  before retiring or replacing it. Computed client-side from the existing
  subscriptions list (both screens are admin-grade; no new endpoint).

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:14:30 +02:00
julian c64457020f fix(subs): resolve the plan version at the SALE instant, not validFrom
Selling/quoting a subscription resolved the plan version against `validFrom`,
but validFrom is a DATE (midnight UTC for "starts today"). A plan published
later the same day (effectiveFrom 15:22) then failed `effectiveFrom ≤ validFrom`
(00:00), so resolvePlanVersion returned null → "no active plan for that planId",
and the form's selectedPlan went null (hiding the new count field too).

The plan/price in force is determined by WHEN THE SALE HAPPENS, not by the
coverage start — like a tariff, the customer buys today's published rate. Resolve
at new Date() in all three sites (validate, priceSale, /quote); validFrom is kept
only for span pricing. Verified the two live plans now resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 19:55:29 +02:00
julian ff04ec10be feat(subs): add a "how many periods" count that drives the end date
When subscriptions moved to the plan model the span became start + end dates,
which lost the simple "renew for N months/weeks/days" input — the operator had
to hand-compute the end date. (quantity is CARS, a separate axis, not periods.)

Add a count field to the sell form: the operator types e.g. 3, and validTo is
auto-derived as validFrom + count × the plan's period (day/week/month), with the
same month-overflow clamp the server uses (Jan 31 +1mo → Feb 28) so the preview
matches what's stored + charged. The end-date field stays directly editable for
an irregular span (the hotel checkout case), and editing it isn't overwritten by
the count effect. The count row shows the plan's unit ("× month").

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 19:46:06 +02:00
julian e0e218fa61 refactor: plan timeframes use a per-day-of-week picker (like the V2 tariff)
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).

Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.

outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:43:21 +02:00
julian 21bd0f6227 fix(db): point db:migrate at the live appliance DB by default
`db:migrate` (and drizzle-kit's config default) resolved DATABASE_URL to
`./parking.sqlite` relative to packages/db — a stray, half-empty leftover DB,
not the real store at apps/server/parking.sqlite. Running `pnpm --filter
@parking/db db:migrate` with no env therefore migrated the wrong file and
failed on its broken state, while the real DB went untouched.

Default DATABASE_URL to ../../apps/server/parking.sqlite in both the db:migrate
script and drizzle.config.ts (an explicit DATABASE_URL still overrides). The
stray packages/db/parking.sqlite was untracked + already gitignored (*.sqlite);
deleted it from disk. Now `pnpm --filter @parking/db db:migrate` targets the
appliance DB out of the box.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:32:44 +02:00
julian 53e1e7b25c feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).

1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
   amount = span price × quantity; maxConcurrent defaults to the quantity so all
   N cars can be inside. Quantity rides in the payment payload.

2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
   park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
   NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
   tariff (the subscriber is a transient for that time):
     - early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
       the vehicle_entry payload), collected at exit;
     - late exit: window-close → departure, and exit is GATED
       (sub.refused.unpaidWindow) until paid at the booth.
   Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
   reuses computeFee + the active tariff version
   (apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
   business gate — the fail-open rule still governs the offline path.

3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
   max(0, quantity − itsCarsInside) per active subscription, so transients see
   "full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
   never gated by full.

UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.

Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:22:50 +02:00
julian fd4608a8f1 feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an
admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now
SELLS by picking a plan over a date span; the price is LOOKED UP, never typed —
removing the fat-finger risk on a money field — and day/week/month periods make
the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span.

- Schema/migration 0010: new `subscription_plans` (immutable, effective-dated,
  keyed by a stable planId; period day/week/month + per-period price + active
  flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a
  "Monthly" plan from the existing site default price (no data loss).
- Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period),
  amount = periods × per-period price. Ceil = any started period is full (hotel
  practice). `resolvePlanVersion` picks the latest active version ≤ sale instant.
- Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked
  sell path derives the amount from the plan; `POST /api/subscriptions/quote`
  returns a server-computed quote so the operator can't override it. The
  signed-payment sale fix is unchanged — only the amount SOURCE moved; payload
  now carries planId/planVersionId/periods. Updates never re-sell (price frozen).
- Frontend: SubscriptionManager sell form swaps the price field for a plan
  picker + start/end dates + a live quote line. New SubscriptionPlansManager
  (Setup tab) for the admin catalog. i18n (sq+en) for both.

Verified on a copy of the live DB: 0010 applies (existing subs intact), a
3-night hotel sale prices to 2,400 ALL, appends one signed payment with
planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 17:13:42 +02:00
julian 052da8c3a7 i18n: relabel X/Z report UI to plain language (keep X/Z in code)
The "X-REPORT / Z-REPORT" labels are till-accounting jargon operators don't
recognize. Relabel the user-facing strings to plain wording in both catalogs —
SQ: "ARKËTIMET DERI TANI" / "MBYLLJA E TURNIT"; EN: "TAKINGS SO FAR" /
"SHIFT CLOSE". The X/Z naming stays in code (xReport/zReport keys, the
shift_z_report event, currentReport) and the wiki.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:51:54 +02:00
julian cb68cbafdb feat: mid-shift X-report (read-only takings-so-far)
Let the operator see, on demand during an open shift, the opening float
inherited, cash/card collected so far, pay-ins/pay-outs, and the current
expected drawer balance — without closing.

GET /api/shift/report (shift:read; 204 when no shift is open) returns the same
drawer projection the Z-report computes. Factored that math into a shared
ShiftService.#summariseWindow(open, asOf) used by BOTH the X-report (asOf=now,
read-only) and close()'s Z-report (asOf=endedAt, signed), so the two can't
drift. The X-report appends NOTHING — it's a snapshot, not an accountability
mark; the Z-report at close remains the signed record.

UI: a "Takings so far" button on the shift control reveals a cyan X-report
panel; the header still shows the live drawer total for the at-a-glance figure.

Verified against a copy of the live DB: X figures match drawerBalance(), the
drawer identity holds, zero events appended, chain still verifies.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:22:55 +02:00
julian 2835f78635 feat: re-model drawer cash as directional vouchers (Mandat Arkëtimi / Pagese)
Replace the single signed-± cash_movement with two distinct financial
documents — the direction is the event TYPE, not the sign of an amount:

  cash_in  = Mandat Arkëtimi (receipt / pay-IN,  +)  voucher AR-NNNN
  cash_out = Mandat Pagese  (disbursement / pay-OUT, −)  voucher PA-NNNN

Each carries a positive magnitude, voucher number, reason, the operator who
raised it and the admin who authorized it, and prints an Albanian slip.

Authorization changes from admin-only to operator-RAISED / admin-AUTHORIZED:
any shift:create holder raises the voucher, but POST /api/cash-voucher only
commits when authorizedBy is a real admin (shift:cash) re-entering their
password (verified server-side). Keeps the float control while letting the
operator do the booth paperwork.

Legacy cash_movement events are kept — they still verify and still fold into
the drawer (signed-±); the append-only chain is never rewritten. The drawer
fold and the Z-report window now sum all three types.

Verified against a copy of the live DB with the real signing modules:
cash_in 3000 + cash_out 5000 → drawer −2000, hash-chain verifies OK.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:18:26 +02:00
julian a20400c2c5 fix: record subscription sale as a signed payment (close off-book hole)
Creating a priced subscription wrote only the mutable `subscriptions`
master row and appended NOTHING to the signed ledger — so the cash an
operator collected showed in the live feed, drawer, and shift Z-report
nowhere, leaving no signed trace. A booth operator could sell
subscriptions and pocket the money untraceably — the exact
operator-as-adversary path the append-only signed ledger exists to close.
Found live: 3 priced subscriptions (27,000 ALL) had zero payment events.

Selling a priced subscription now appends a signed `payment` event at
create time: amount = priceMinor x months (full multi-month prepay),
operator-chosen tender (cash->drawer / card->bank), payload
{ subscriptionSale: true, permitId, operator, months }. Folds into the
shift Z-report/drawer with no new summing logic; the feed badges it
"subscription sale" and resolves the holder name. The create response
returns the recorded { sale }; subscriptionRoutes now takes the EventLog
and ShiftService.

Not hard-gated on an open shift (a sale can happen outside the booth money
path) — it warns instead. The 3 historical off-book sales are not
back-fillable (append-only forbids forging dated events) — reconcile via
cash_movement or a Z-report note.

Verified against a copy of the live DB with the real signing modules:
signed payment appended, hash-chain still verifies, lands in shift cash
totals. Build + lint 12/12.

Wiki: subscription "Collecting the fee" deferred -> BUILT (+ the off-book
hole and why); shift sale-folds-in; threat-model worked example
("store the price != account for the sale").

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:46:17 +02:00
julian cdb55a8652 feat: show recognized plate in live feed + active sessions
Surface the advisory ANPR plate (device_events kind="read", keyed by
session identity — unsigned, prunable, never an access decision) next to
entry/exit events in the live feed and on active-session rows.

Resolved at serialize time (new plate-lookup.ts; prefers an entry read;
one device_events scan per page) like subscriber-name enrichment — the
signed ledger is untouched. Adds plate? to the shared LedgerEvent and to
ActiveSession/SessionLookup; a small amber badge in the UI.

Caveat: a vehicle_entry is signed + pushed over WS before the async ANPR
read lands, so a fresh feed row may show no plate until reload; always
present on active sessions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:45:57 +02:00
julian b0c9ba0f8c docs(wiki): per-increment vs per-hour tariff gotcha + composer UX idea
priceMinorPerIncrement is per BILLING INCREMENT, not per hour. Documented
the effective-hourly formula (price x 60/incrementMin) as a callout after
recurring "Lab is wrong" confusion (weekend 3h=600 not 300 was correct),
and filed a per-hour-preview composer UX candidate under Open.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:44:12 +02:00
julian 9a1feeeb20 fix(tariff): reject stepped base combined with time/seasonal tiers
A stepped ("up-to") default card prices the whole stay as one total, so the V2
engine short-circuits to steppedFee and NEVER consults windowed cards — any
time/seasonal tiers would silently never fire. Found live: an active tariff had a
stepped base plus weekday-night + weekend tiers, and every 3h stay priced 600 ALL
regardless of hour/day because the tiers were dead.

- validateTariffV2 now rejects a stepped defaultCard combined with windowedCards,
  with an actionable message (switch the base to ladder/flat, or remove the tiers).
- Composer shows an inline red warning the moment base mode is stepped and tiers
  exist; publishing is blocked server-side regardless.
- ApiError now carries the server's problems[], so the publish error surfaces the
  SPECIFIC reason instead of a generic "invalid tariff structure".
- 2 new validation tests (55 pass).

Wiki: tariff, log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 14:03:22 +02:00
julian cc507f490f feat(tariff): stepped ("up-to") pricing mode — total-by-duration
Owners often state rates as a total-by-duration matrix (0-1h=200, 0-3h=500,
0-6h=800, 0-9h=900, 0-12h=1000) that the marginal hourly ladder can't express
(the ladder sums per-increment rates; this is cumulative totals at thresholds).
Add STEPPED as a third pricing mode alongside the ladder and flat.

- @parking/shared: TariffStep {uptoMin, totalMinor} + a `steps[]` field on V1
  structures and V2 cards (mutually exclusive with blocks/flatMinor). steppedFee():
  smallest tier with uptoMin >= duration wins (INCLUSIVE boundary), the top tier
  repeats as a per-day cap; wired into computeFeeV1 + computeFeeV2 (V2 default card
  only — a whole-stay total can't be sliced per-increment by a windowed card).
  Validation: ascending uptoMin, non-negative totals, no daily-cap-with-steps,
  steps-only-on-default. priceSession/quote/booth/Lab price it via the shared core.
- Composer UI: a "By duration (up-to)" mode with an up-to/total table (base card
  only). i18n modeStepped/steppedHint/stepUpTo/stepTotal/addStep (sq+en).
- 8 new unit tests incl. the exact owner matrix, multi-day repeat, overstay, and
  validation (53 pass). Verified end-to-end via the UI: authored + published the
  matrix, Tariff Lab prices it exactly (3h->500, 6h->800, 12h->1000, 2d->2000).

Wiki: tariff (three pricing modes + stepped semantics), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 12:33:27 +02:00
julian 3d02134711 feat(tariff): Tariff Lab — pure session-pricing simulator
Test rates "in time" (overnight windows, daily caps, overstay) in seconds against
any tariff version, instead of waiting hours/days. No real ledger writes.

- Extract priceSession() into @parking/shared: the grace/overstay wrapper over
  computeFee (unpaid -> entry..now; within-grace -> settled 0; grace-expired ->
  overstay, a fresh period from grace-expiry). PayStation.quote() now calls it so
  the booth and the lab can never diverge.
- API (tariffs.ts, tariff:read, read-only): POST /api/tariff/simulate prices a
  hypothetical session (active/any version/inline structure) and returns the
  priceSession outcome + a 30m..3d duration curve (see where the daily cap flattens);
  GET /api/tariff/simulate/session/:identity prefills from a real ledger session.
- UI TariffLab.tsx at Setup -> "Tariff Lab": version picker, entry/asOf times,
  optional payment+grace, category, and load-a-real-ticket. Admin-gated, available
  on-site (useful to quote a dispute).
- 4 new priceSession unit tests incl. the ticket-1245791632490 overstay-not-zero
  regression (40 pass). i18n lab.* + nav.tariffLab (sq+en). Verified live via the UI.

Wiki: tariff (priceSession + Tariff Lab as-built), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 12:05:30 +02:00
julian a4712774ab feat(booth): overstay sessions, top-up pricing, and session/feed filters
Rework paid-but-grace-expired sessions and add booth filters.

Overstay (was "stuck"):
- Stop silently aging out a paid transient whose walk-back grace lapsed with no
  signed exit. Keep it listed with an OVERSTAY badge — a new parking period began
  (re-parked) or the car is faulty/abandoned; it is not a system fault.
- No free exit: reopenBarrier refuses server-side once a transient's payment grace
  has expired (allow only subscription OR paid-and-within-grace); the UI hides the
  Open-barrier button on overstay rows and routes to the pay/exit modal. Closes a
  hole where a stale payment authorized a free multi-day exit (operator-as-adversary).
- Price the overstay as a NEW period from grace-expiry -> now with its own daily-cap
  ladder, NOT "full stay minus paid" (which a daily cap collapsed to 0 — ticket
  1245791632490 owed ALL 0; now owes its real overstay). quote() gains periodStart +
  overstay; SessionLookup/ActiveSession gain `overstay`. handlePayAndExit charges
  whenever the session is payable (was: only if !alreadyPaid, skipping the overstay).

Filters (new ui/FilterBar): Active Sessions — search + status
(unpaid/paid/exiting/overstay) + transient-vs-subscriber. Live feed — search +
event (entry/exit/pay/void/anomaly) + direction + source (booth=manual vs reader).
All client-side over already-fetched data; matched/total count shown.

i18n parity (sq+en). Wiki: booth-exit-flow updated (overstay model, naming history,
no-free-exit security fix, new-period pricing; open question on grace-renewal noted).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 11:48:54 +02:00
julian 918f76fbef fix(booth): make the Active Sessions list scrollable
The session list filled to its content height instead of clipping, so a long list
(29 sessions) overflowed the column instead of scrolling — unlike the live feed.

The ActiveSessions Panel sat in a plain block wrapper, so it sized to content and its
inner `h-full overflow-y-auto` had no bounded height to scroll within. Make the wrapper
a flex column and give the Panel `flex-1` so it fills the column; the inner scroll area
is then bounded and scrolls — matching the live-feed treatment.

Verified live (Playwright): the scroll container is now 437px tall over 1118px of
content → scrollable, while the live feed is unchanged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 17:30:44 +02:00
julian 9ec644811a feat(vision): surface the recognized plate in the booth UI
The ANPR plate was saved (device_events kind:"read") but had no UI. Extend
GET /api/snapshots/by-identity/:identity to also return plates[] (plate, confidence,
region, direction, snapshotId, at) for that session, and render each as a cyan
"Plate: AA558EE 100%" chip in the SnapshotStrip — so it shows in both the booth
event-detail modal and the pay modal, beside the evidence photo, no separate screen.
Deduped by plate+direction; session:read gated; i18n sq+en.

Verified: by-identity returns plates[] for a seeded read (200, AA558EE 0.999 Albania
entry). Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 17:23:32 +02:00
julian ecaaefd899 refactor(vision): ANPR rides the entry/exit snapshot, drop polling reader
Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.

The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.

Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:53:43 +02:00
julian 4af8b56dda feat(vision): configurability — SetupWizard ANPR toggle, footer health chip, env docs
Make the vision service genuinely configurable (was env-only).

- SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted
  only when on; sq+en) — opt-in is no longer raw JSON.
- DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting
  a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the
  booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category
  union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when
  up, 0 chips when disabled.
- apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example
  (Node side) + a Configuration section in opencv-anpr-service.md covering all four
  layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE
  .env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr
  camera recognizes but every read is refused.

Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:41:29 +02:00
julian 540b333b06 feat(vision): persist every recognized plate + snapshot as telemetry (non-blocking)
Answers "is a recognized plate saved?" — now yes, for both transient and subscriber, as
an ANPR audit trail independent of whether it matched anything.

VisionReader now stores the snapshot bytes in `snapshots` keyed by identity=PLATE — the
same identity the flow signs its anomaly/event with — so GET /api/snapshots/by-identity/:plate
(the booth event-detail modal's snapshot strip) shows the car's photo against that
anomaly with no UI changes. It also records an unsigned device_events{kind:"read"}
breadcrumb (plate, confidence, region, model, snapshotId, and the dispatch outcome) as a
queryable recognition log. Switched from emitRead to calling ReadDispatcher.dispatch
directly (like qr-reader) to capture that outcome.

Non-blocking: a refused read (no session / unpaid / unknown plate) just returns
rejected — no barrier hold — and is logged with its snapshot for investigation. Plate
stays advisory (exit demands payment; subscription matches only a bound plate).

Verified e2e: a recognized AL plate with no open session signed exit.refused.noSession
(identity=plate), stored a 555KB snapshot under that plate, recorded the read breadcrumb
(accepted:false, reason "no open session"), and by-identity returned the image — the
refused read is fully investigable with its picture. Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:29:10 +02:00
julian 7e086ff0d7 feat(vision): wire ANPR into the read bus via VisionReader
A VisionReader polls each opt-in camera (config.anpr===true, off by default) every
VISION_POLL_MS, captures a snapshot, recognizes via VisionClient, and on a confident
plate emits deviceEvents.emitRead({kind:"plate", value}) — the same event a physical
plate reader sends, so the existing ReadDispatcher routes it to the subscription/exit
flow unchanged (no flow rewrite).

The plate stays advisory by construction: the exit flow still demands a covering
payment, the subscription flow only matches a bound plate. Guards: low-confidence reads
dropped; debounce (VISION_DEDUPE_MS) so a parked car doesn't re-fire; per-camera
in-flight guard; idle when vision is off or no camera opts in. #recognizeOn is public
for a future on-demand (loop-edge/API) trigger.

Verified end-to-end: an in-memory anpr camera (AL plate image) + live fast_alpr service
→ VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} onto the bus; debounce
held it to 1 emit over 7 polls. Build + lint green. Updates opencv-anpr-service
(trigger-wiring + per-camera opt-in marked done).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:21:34 +02:00
julian 236cbfecab feat(vision): add VisionClient Node adapter (advisory, fail-soft, opt-in)
Node-side adapter to the apps/vision ANPR microservice (localhost HTTP: POST /analyze
with snapshot bytes, GET /health), returning a normalised VisionResult or null. Enforces
"advisory, never sole authority" at the boundary: opt-in (VISION_ENABLED, default off),
fail-soft (any error/timeout/unreachable → null, never throws into the lane → ticket
fallback), and re-applies the confidence floor (VISION_MIN_CONFIDENCE) on top of the
service's own low_confidence flag. Per-request AbortController timeout so a slow call
can't hang the barrier. Constructed in server.ts.

Verified: fail-soft (disabled/unreachable → null, no throw) and live end-to-end (Node
client → running fast_alpr service → AA558EE 0.999, region=Albania). NOT yet wired into
the read bus — the opt-in snapshot→DeviceReadEvent{kind:"plate"} trigger is the next
step. Build + lint green. Updates opencv-anpr-service (adapter gap marked done).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:58:43 +02:00
julian 17fdf3d482 docs(wiki): vision fitness assessment for entry/exit flows
Record the verdict: the ANPR service is worthy to consume NOW as an advisory plate
IDENTITY source (Job 1) — the flows already treat a kind:"plate" read as first-class
(exit signs source:"lpr"; subscription matches read plate vs bound plates), so it feeds
an existing input with no flow rewrite. It is NOT worthy as the sole authority to open a
transient barrier (a plate is not a payment; spoofing needs Job 2 vehicle verification,
unbuilt) — gated by the confidence floor with ticket/manual fallback. Lists the four
gaps before consumption (VisionClient adapter, opt-in trigger, field accuracy,
weight-provenance). Next step is the adapter, not more model work.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:55:16 +02:00
julian 4833b4373d docs(wiki): Albanian-plate OCR benchmark — keep cct-xs-v2-global default
Benchmarked fast-alpr's four fast-plate-ocr models via the full pipeline on real AL
plates (AA558EE, AA687KE), CPU. All four read both correctly; the default
cct-xs-v2-global-model wins on confidence (0.999/1.000) AND speed (33-39ms) and returns
region=Albania. The "European 40+country" model is WORSE here (~0.77 confidence, one
synthetic misread) — overturning the "EU model better for AL" assumption from the prior
research. Decision: no config change. Resolves the AL-accuracy-benchmark open item
(results table + finding added to opencv-anpr-service); weight-provenance remains the
one open recognizer item. Re-benchmark on real on-site captures once cameras installed.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:51:08 +02:00
julian 5cedcaefe1 feat(vision): add recognize CLI + verify fast-alpr end-to-end
Add a dev CLI (uv run python -m vision_service.cli <image>) that runs a recognizer on
an image file and prints the parsed plate(s) + confidence + region — fast feedback with
no HTTP. Also a package.json `recognize` script and a vision-recognize entry point.

Verified fast-alpr for real: installed the `alpr` extra, downloaded the YOLOv9 + CCT
ONNX weights (~11MB, cached offline under ~/.cache), and ran recognition on the
project's test image → "5AU5341" at 1.000 confidence, region "Czech Republic", ~40ms
on CPU, via both the CLI and POST /analyze.

Fixes result parsing against the actual fast-alpr API: ocr.confidence is a LIST of
per-character confidences (not a scalar) — reduced to one plate confidence via the MIN
(a plate is only as trustworthy as its weakest character); also surface ocr.region.
Extracted the per-result mapping into a pure plate_from_alpr_result + _reduce_confidence
and unit-tested them (no model weights needed). 7 tests pass; ruff + mypy strict clean;
full turbo build/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:46:03 +02:00
julian 6933406ae3 feat(vision): scaffold apps/vision ANPR microservice (FastAPI, stub recognizer)
Skeleton of the host-side vision service per the packaging decision: a Python/FastAPI
app at apps/vision/, uv-managed, wired into the Turbo graph via a thin package.json
shim (dev/lint/test/build → uv/uvicorn/ruff/pytest). A per-package turbo.json sets
build outputs [] so the no-op build is warning-free.

Endpoints: GET /health (readiness + model version) and POST /analyze (raw
octet-stream body, so Node POSTs Snapshot.bytes directly; empty→400, oversize→413,
recognizer-not-ready→503). The recognizer is a Protocol with a StubRecognizer (no
models, boots/tests offline — the dev/CI default) and a FastAlprRecognizer (the real
MIT YOLOv9+CCT/ONNX stack, lazily imported; missing models ⇒ ready=False, not a crash)
— the device-adapter pattern applied to the model. fast-alpr + onnxruntime are an
optional `alpr` extra, so `uv sync` needs no model download.

Verified: turbo run lint|test|build includes @parking/vision and stays green; uv run
mypy strict-clean; uvicorn boots and serves /health + /analyze live; pnpm workspace
6→7. Not built yet: the Node VisionClient adapter, a Dockerfile + model fetch, and
Job 2 (vehicle verification). Updates the packaging decision (As-scaffolded) + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 15:37:38 +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 c0a775818b docs(wiki): note log:read perm + entry presence/cooldown guard in reference pages
Sync the two canonical reference pages with this session's features: local-jwt-auth
gains the new log resource / log:read permission in the RBAC grid (links app-logs);
first-run-setup notes the one-car-one-ticket presence-loop/cooldown guard the admin
configures on a relay (links entry-double-press).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 12:59:06 +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 bbf61c48df fix(ticket): 11-digit IDs — fix KP-300H barcode line-overflow
The Cashino KP-300H printed entry tickets as raster garbage (solid black
bars / banding) while the Rongta printed the same byte stream fine. Root
cause: the barcode overflowed the print line, not data corruption.

A 13-digit Code128 at module width 3 is ~534 dots. The KP-300H prints 72mm
(512 usable dots at 203 dpi), so the symbol overran the line and the firmware
rendered the overflow as raster noise. The Rongta runs 80mm (576 dots) and had
just enough room — which is why only the Cashino failed. Confirmed on hardware:
plain text printed clean, the barcode was the trigger, and an 11-digit code at
width 3 (~468 dots) both fits and scans the full value at the exit reader.

- Ticket IDs reduced 13 → 11 digits (10 random + Luhn). Length is driven by
  guess-resistance (10^10 space, ~1-in-10^7 to hit a live OPEN ticket even with
  thousands parked — the booth-operator threat model), not volume.
- validateTicketCode is now length-agnostic (\d{10,14} + Luhn) so legacy
  13-digit tickets still in circulation keep validating; the id stays opaque.

Also: sendRaw now closes the print socket GRACEFULLY (end()+FIN, wait for
close) instead of write-then-destroy, which could RST mid-stream and truncate a
job. A separate latent bug found while diagnosing, fixed here.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:35:13 +02:00
julian 00f3d141b6 feat: human + relative dates; fix language/theme toggle stale-context
Dates were raw ISO on printed slips and time-only in the UI (a session from
two days ago showed just "10:48"). Make them human and day-relative. Also fix
a latent toggle bug surfaced while testing.

Dates:
- Printed tickets/receipts/subscription cards now show "19 Qershor 2026
  10:48:25" (Albanian month, 24h with seconds) instead of YYYY-MM-DD HH:MM.
  stamp() exported as formatStampSq so the shift Z-report shares it.
- Shift Z-report is now Albanian (Operatori/Nga/Deri/Para në dorë/Arka…),
  was English-only with ISO dates.
- Web sessions/logs/history show relative days: "Sot 10:48" / "Dje 17:33" /
  "17 Qershor 10:48" via formatRelativeDateTime(). Month names come from the
  i18n catalog (common.months), NOT Intl — the appliance browser's ICU lacks
  Albanian locale data and Intl silently falls back to English month names.

Toggle fix:
- The language + theme toggles read the active value from the TanStack Router
  context `user`, which is captured at route-resolution time and does not
  re-render on setUser. After one switch the highlight froze and the equality
  guard blocked switching back until a page refresh. Drive them off live state
  instead: language from i18n.language (useTranslation subscribes to
  languageChanged), theme from local useState. (Bug dated to 040c0ff.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:14:06 +02:00
julian f31e57b4ae feat: explainable activity log — reasons, subscriber names, snapshot gaps
The live activity feed flagged anomalies with no explanation and showed
opaque session keys. Make events self-describing and clickable.

- Clickable feed rows → read-only event-detail modal: humanized fields,
  entry/exit snapshots, and signed-chain provenance collapsed behind an
  audit disclosure (operator sees the story, auditor expands for crypto).
- Localized reason codes (backend i18n): the signed ledger now carries a
  stable REASON_CODE + params (+ English fallback) instead of free-text
  English. The UI translates via reason.<code> catalogs in sq/en, so an
  Albanian operator reads Albanian — from the same immutable event. Adding
  a language is a catalog change, no re-signing. (@parking/shared
  REASON_CODES, reasonPayload; entry/exit/subscription flows emit codes.)
- Subscriber-name resolution: a SUBSESS-… occurrence now shows the
  subscription holder's name (fallback "Abonent"/"Subscriber"). Resolved
  read-time server-side (events API + WS push) as a non-signed
  subscriberLabel; cached with invalidation on subscription edit/delete.
- Failed-snapshot visibility: a camera that was attempted but unreachable
  now shows a "⚠ camera unreachable" tile instead of a silent gap. The
  snapshots API returns failures[] from telemetry, filtered so a recovered
  capture shows no stale warning.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:57:17 +02:00
julian 040c0ff4ca feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission-
gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing
the top nav to Booth·Shift·Setup; old top-level paths redirect.

Users: add optional profile metadata (full name, phone, email, address) on
create/edit. Theme: a light palette saved to the user's profile (users.theme),
toggled in the header beside the language switch and applied on load like the
language preference. Both ride on a single additive migration (0008).

Shift history: a new GET /api/shifts folds the signed shift_z_report chain into
completed shifts, SCOPED server-side — operators see only their own; holders of
shift:cash see all with an operator + date-range filter. Surfaced as the Shifts
tab; an operator cannot read another operator's takings (param spoofing is
ignored).

These three features share the router, api client and i18n catalogs, so they
land together. Verified live: theme persists across reload, metadata round-
trips to the DB, and shift scoping holds (operator self-only, admin all+filter).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:09:18 +02:00
julian 8444bf34c3 feat(web): pop-out modal forms for setup/subscriptions/roles
Add a reusable ui/Modal (Radix Dialog + terminal chrome) and move the
add/edit forms in the Devices setup, Subscriptions and Roles screens into it,
leaving each list in the page behind the modal. The Devices wizard's per-
category device form is also fully translated (setup.* i18n keys).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:08:33 +02:00
julian 808fb26ab6 feat(web): UI component layer + dark-theme reskin
The TRM tokens were good but every screen hand-rolled inputs and buttons as
bare outlines on near-black panels, so fields, cards and buttons were
visually indistinguishable. Add a component layer (.input/.select/.textarea
as recessed slots, .btn family with a FILLED primary, .card scaffolding) and
adopt it across the booth/shift/login/tariff/site screens — several of which
were still light-theme inline styles dropped on a dark background.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:07:39 +02:00
julian ef0ecadff9 fix(auth): block privilege escalation via role/user management
The dynamic-RBAC management routes are themselves grantable (role:* and
user:*), so a non-admin holding them could self-escalate: edit their own
role to add a permission they lack, mint a privileged role, assign someone
the admin role, or reset/delete a more-privileged account. Found by the
commit security review (2× HIGH).

Fix — enforce the RBAC invariant "you cannot grant beyond yourself":
- roles.ts: role:create/update reject any permission not held by the caller
  (escalates()). An admin holds the full set, so it stays unrestricted.
- users.ts: user:create/update reject assigning a role whose permissions
  exceed the caller's; update/password-reset/delete reject acting on a user
  whose current role exceeds the caller's (exceedsCaller()).

The existing no-lockout + builtin-admin protections are unchanged.

Verified: 10-assertion inject test — manager (role:* + user:* but no
tariff:update, not admin) gets 403 on self-grant, minting a privileged role,
assigning/resetting/deleting an admin; admin stays unrestricted; the manager
can still create peers + in-scope roles (not over-blocked). Full build green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 01:27:02 +02:00
julian d0841c8601 feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.

@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).

DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).

auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.

Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).

Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).

Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 01:19:28 +02:00
julian d71ba82999 feat(booth): payment receipt / exit voucher — transparency slip + CP852 fixes
After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".

Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().

CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.

Full build green; both receipt modes render-verified; routes live.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:46:38 +02:00
julian 9c9f777784 docs(wiki): record the two configured printers (Cashino at lane, Rongta at booth)
The rongta-printer "Deployment" section still described a single
2026-06-14 unit. The live site now runs two: entry-dispenser 10.0.10.9
(Cashino, `cashino` ping-only driver) and booth-receipt 10.0.10.10
(Rongta, full status-page monitoring). Follow-up to 3e6773a.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:05:40 +02:00
julian 486f8deae6 fix(shift): update Z-report title to Albanian translation 2026-06-18 20:04:13 +02:00
julian 3e6773a6d5 fix(devices): Cashino printer — ping-only driver (no false status) + Albanian role wording
The Cashino 80mm printer reported wrong status: it ran on the `rongta`
driver, whose readStatus() scrapes the Rongta board's /prn_stat.htm status
page — which the Cashino does not serve — yielding a bogus degraded/page-
error verdict while the printer was online and printing fine. Root cause:
the Cashino is an ESC/POS PRINT clone with no trustworthy STATUS mechanism.

Fix: extract the shared ESC/POS rendering + transport (renderTicket/
renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/
qrCode) from printer-rongta into drivers/printer-escpos.ts, and add a
dedicated `cashino` driver that reuses that print path but is deliberately
NOT MonitorableDevice (no readStatus). isMonitorable() is then false, so the
device monitor falls back to healthCheck() — a plain TCP reachability ping:
reachable -> ready, unreachable -> offline, never a guessed paper/cover
state it cannot sense. Rongta driver unchanged (still scrapes its page,
still monitorable). Register + re-export cashinoDriver.

Verified at runtime (cashino registered, isMonitorable=false, no readStatus,
healthCheck->offline on unreachable) and live: /api/devices/status shows both
printers ready (lane via ping, booth via page). The live entry-dispenser at
10.0.10.9 was switched rongta->cashino in the operator DB (backed up).

Also fix the Albanian device-role chip wording, which read wrong as a
"{category} {role}" label: access mixed "i përzier" -> "hyrje/dalje"
(it means a barrier spanning both directions); printer lane "korsia" ->
"në korsi"; booth "kabina" -> "në kabinë". English tidied to match
(mixed->entry/exit, lane->at lane, booth->at booth).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:00:42 +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 91cc79b14e feat(web): adopt TRM design-system tokens (tokens only)
Linked Claude Design project "TRM — Tracking & Race Management" is a
race-timing kit, not a parking design. Adopted its TOKENS only — no TRM
components. Aligned the existing term-* accents onto TRM's exact night/
semantic values (surfaces → night scale; amber→#f2a516, green→#2e8c4a,
red→#e8412b flag, cyan→#2563c8 blue) so the whole booth UI shifts palette
with zero component edits. Exposed TRM's full vocabulary (night/ink/paper
scales, flag/amber/green/blue, viz-1..8, 4px spacing, type scale, square
radii, sharp offset shadows) as Tailwind v4 utilities for new work.

Offline appliance: dropped TRM's Google-Fonts @import (no runtime network);
Goldplay display face not self-hosted yet — falls back to a sans stack.

Web build green; login renders on the new palette.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 19:46:56 +02:00
julian dfa76346d6 feat(tariff): complete the progressive ladder — require open-ended last block, hours-based composer
The stepped-block engine already does "first N hrs x X, next N hrs x Y, ...,
24h cap" (ordered blocks, per-block rate, rolling-24h cap). No new axis; this
completes the model and removes its footgun.

- validateTariffStructure (shared) now REQUIRES the last block to be open-ended
  (uptoMin: null). A bounded final block silently inherited its own rate past
  its bound (a hidden, never-stated price — e.g. the live ALL tariff billed
  hour 4+ at the 3rd-hour rate). rateAt() still prices legacy bounded-tail
  versions; validation is publish-only, so published immutable versions are
  unaffected (no migration).
- TariffComposer edits bands as a DURATION in hours ("first 2 hours, then next
  3 hours"), accumulated into the engine's cumulative uptoMin (minutes) on
  submit. The last row is a pinned, non-removable "thereafter (open-ended)"
  band, so a published card always satisfies the open-ended-last rule.
  blocksToForm round-trips stored minutes back to band hours (legacy loads).
- i18n: replaced upToMin/egExample with bandDuration/hoursUnit/egHours (sq+en,
  catalog parity green).

Verified: validator rejects bounded-last / accepts open-ended; computeFee
correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full
build green. Wiki (tariff.md, log.md) updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:58:47 +02:00
julian c9a2ef81a9 fix(tariff): forbid backdated effectiveFrom — versioning was retroactive
Version selection is "latest tariff_version with effectiveFrom <= entry time",
but the publish handler accepted ANY effectiveFrom (defaulting to now). So an
admin could publish a version with a backdated effectiveFrom and silently
reprice sessions that had already entered — the retroactive rewrite the
versioning exists to prevent. Pricing itself was sound (quote resolves by entry
time; payment records tariffVersionId, freezing completed sessions); the leak
was the publish side only.

Reject effectiveFrom earlier than now (60s skew tolerance); future-dated
(scheduling a price change) stays allowed; bad ISO -> 400. Combined with
entry-time selection this is structural: once a car has entered, no later
publish can reprice it. Did not pin tariffVersionId onto vehicle_entry (not
needed). Verified 5/5 via inject against a copy of the live DB.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:58:36 +02:00
julian b8ddda86e7 feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.

- RFID credentials enabled with a "Read card" enrollment flow: the operator
  arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
  reader's next read is captured into the form and NOT dispatched to the access
  flow — the OTHER reader keeps serving live entry/exit. Routes:
  /api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
  per-occurrence id (SUBSESS-<short>), not the credential value, with
  permitId in the payload. Direction is decided by the barrier the reader sits
  at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
  admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
  pay/exit modal shows a subscription mode (snapshots + a single audited
  Open-barrier action) to assist a faulty exit reader / missing card;
  reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
  "abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
  verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.

Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:26:48 +02:00
julian bba988c4e8 feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card
Builds out subscription credentials on top of the rename.

- Operator chooses the credential type; only QR is live (RFID shown disabled
  "soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
  globally-unique-checked) — the customer/operator never picks the value.
  RF stays operator-entered (the physical card id). Reader output decided =
  TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
  validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
  (GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
  create (best-effort — never fails the create; returns {printed,printError})
  + reprint via POST /api/subscriptions/:id/print and a "Print code" button.

Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 14:48:38 +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 ca8c7f2fa2 fix(exit): stuck active session — paid ticket with no vehicle_exit
A paid car that left via a manual barrier re-open kept no vehicle_exit, so
activeSessions() saw it as permanently open and the grace-expiry eviction
(which only ran for exited sessions) never fired — it lingered forever
(ticket T-397815c0).

- reopenBarrier() now signs a vehicle_exit (source:manual) when the session
  is still open, closing it; still no second exit when already exited
  (phantom re-close — no double-count).
- activeSessions() ages out a PAID open session past grace even with no exit
  (unpaid open sessions never age out — a car owing money stays). Pure
  display filter; the signed log is untouched.

Verified both fixes + chain integrity on a fresh DB. A one-off corrective
vehicle_exit was appended to the live ledger to clear T-397815c0.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:45 +02:00
julian f87e4c0d6b feat(devices): live device-status footer across all categories
Generalise printer-only monitoring to every configured device. New
DeviceMonitor polls all enabled devices each tick (default 8s): printers
via rich readStatus(), relays/readers/cameras via the generic healthCheck()
reachability probe, flattened to one traffic-light (ready/degraded/offline)
+ detail, deduped (emit on change only), fail-toward-offline.

- device-status bus event + GET /api/devices/status snapshot.
- Pushed over the existing /api/ws (hello carries the initial set;
  device-status frame per change).
- Web: live-store devices map, WS handler, DeviceFooter chip-per-device
  (role label not vendor; click a degraded/offline chip for an issues panel).

Verified roleKind resolution + change-only emit on a fresh DB.

Note: the footer's UI surface (api type, router mount, i18n devices) rides
in the subsequent subscription commit due to shared-file overlap.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:36 +02:00
julian 4e2e4feedb feat(shift): site-wide single-open shift + booth money-path gate
A shift becomes a SITE-WIDE accountability period — at most one open at a
time — so every taking is unambiguously attributed to one operator. Login
stays decoupled from shifts (an operator can log in off-shift to review).

Backend:
- ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY
  shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other).
- requireShift preHandler gates /api/pay, /api/exit, /api/voucher,
  /api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open.
- GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}.
- GET /api/events?since=<iso> for per-shift log scoping (db: re-export gte).

Frontend:
- Header shift button: open / close-mine / disabled-when-another-holds-it.
- Pay/exit modal gate banner (one-click open; "held by X" when another's);
  pay/exit/voucher disabled until this operator's shift is open.
- Active-Sessions barrier re-open gated the same way.
- Live feed scoped to the open shift's window; shared useShift() Query
  invalidated over the WS on shift_open/shift_z_report/cash_movement.
- sq/en strings for the control + gate.

Wiki: shift.md (site-wide single-open + gate; superseded per-operator note),
booth-console.md (header control + gate), log entry.

Verified: site-wide invariant + heldBy + handover + chain integrity on a
fresh migrated DB (11/11); db/server/web build clean.
2026-06-18 12:13:17 +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 14c83e182a feat(web): i18n with react-i18next — Albanian default, English second
Add react-i18next with two key-parity-checked catalogs (sq default/fallback, en).
Active language driven by the logged-in user's stored preference (applied after
/me resolves); SQ/EN toggle in the header persists via PUT /api/auth/language.
Translate the booth (screen, pay/exit modal, active sessions, snapshots, status),
Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.

SetupWizard deferred (its content is server-provided; needs backend catalog i18n).
2026-06-18 11:47:39 +02:00
julian 445bca0bf6 feat(auth): per-user UI language preference (sq default, en)
Add users.language ('sq'|'en', default 'sq'; migration 0003). Returned from
/api/auth/login and /api/auth/me (read from the DB, not the JWT — so changing it
needs no re-login). New PUT /api/auth/language for self-service. Loaded on login
and restored from any booth. Printed tickets stay Albanian (customer-facing).
2026-06-18 11:47:30 +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 50a3095ef3 feat(shift): cash drawer balance carried across shifts + admin cash movements
New signed cash_movement event (admin-only): load/remove drawer float, signed +
attributed. ShiftService folds cash payments + movements by time into a drawer
balance; shift open auto-inherits the prior shift's expected closing drawer as its
opening float; the Z-report reports opening/taken/added/removed/expected (= next
shift's opening float). Card payments excluded (settle to bank). Routes: POST
/api/cash-movement, drawer in GET /api/shift/current. ShiftControl shows the live
drawer + admin load/remove form + Z-report drawer block. Wiki: shift.md.
2026-06-18 11:05:36 +02:00
julian eb3dc18e67 feat(booth): active sessions panel + audited barrier re-open
Active Sessions panel lists sessions that are open OR exited-but-within-grace
(barrier state is unconfirmed, so a paid car is presumed possibly-present until
grace expires). Row click → pay/exit modal; 'Open barrier' (paid sessions only —
no payment, no button) fires a human-intervention re-pulse signed as an attributed
anomaly, never a second vehicle_exit. Wiki: booth-exit-flow.md.

Note: the backend (PayStation.activeSessions, ExitFlow.reopenBarrier, routes,
api.ts) landed with the prior commit's shared files.
2026-06-18 11:05:26 +02:00
julian 06dab1e790 feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth
reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and
signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket
id barcode; site_config.exit_voucher_default (migration 0002) drives the default.
Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher.

Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'),
SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
2026-06-18 11:05:10 +02:00
julian 9956488fd5 chore: removed graphify 2026-06-18 11:03:45 +02:00
julian 49df2015c8 feat(web): frontend foundation — Tailwind terminal theme, Query, Router, Zustand + live booth screen
Add tailwindcss (Bloomberg-terminal theme in index.css), @tanstack/react-query +
react-router, zustand, and Radix primitives. Router with role-guarded routes;
QueryClient wrapping the existing apiFetch; a small Zustand live store fed by a
/api/ws client that invalidates Query caches. Booth screen: live occupancy gauge
+ streaming entry/exit/payment feed. Vite proxies the WS upgrade.

Note: BoothScreen references the pay/exit modal + active-sessions panel added in
following commits; final HEAD builds.
2026-06-18 11:00:42 +02:00
julian c2f06a5d2a feat(server): live booth WebSocket feed (/api/ws)
Add @fastify/websocket. EventLog fires an onAppended callback after each durable
append; device-events gains a ledger channel (emitLedger). /api/ws fans out
ledger + occupancy + printer-status to authenticated booth clients. Origin
allowlist (WS_ALLOWED_ORIGINS) replaces CSRF for the handshake (anti-CSWSH).

Note: server.ts also reflects later booth route wiring; the final HEAD builds.
2026-06-18 11:00:22 +02:00
julian 58d8f06ba0 docs(wiki): tariff research — legacy ParkSQL2017 schema, time-tiers & validation/sponsorship design
Ingest the predecessor SQL Server schema (raw + source summary) and file design
pages for time-of-day/seasonal tariff tiers and merchant validation/postpaid
sponsorship. Cross-link tariff.md and validation-discounts.md. No code.
2026-06-18 10:59:21 +02:00
julian 71aaad03b9 exit: open free within entry-grace, no pay-station visit
A quick in-and-out the tariff prices at 0 (stay <= gracePeriodEntryMin) now
exits at the gate instead of being refused as "not paid". exit-flow resolves
the active site tariff (same logic as the pay station) and, if computeFee for
entry->now is 0, mints a signed $0 payment event (reason: free entry-grace)
then signs the vehicle_exit and opens. The $0 payment keeps the append-only
ledger invariant that an exit is covered by a payment, so a grace exit stays
attributable in the audit trail. A real payment still takes precedence (the
walk-back grace path is untouched). Sign+open extracted to #signExitAndOpen,
shared by both paths.
2026-06-17 12:17:28 +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 15d3e1ba08 update .gitignore and CLAUDE.md for graphify integration; add settings.json for pre-tool hooks 2026-06-16 14:34:20 +02:00
julian ff3b011fe0 qr-reader: reply Connection: close (fixes ~10s beep delay)
The reader sends Connection: keep-alive but only acts on the verdict (beep,
output) once the TCP socket closes. Fastify's default kept the connection alive,
so the reader waited out a ~10s keep-alive timeout before beeping — even though
the server replied in ~15ms. Every vendor demo replies Connection: close and
shuts the socket. Set reply.header('connection','close') on the QR endpoint.

Verified the header is now sent; symptom was correct accept/reject with a ~10s
lag before the beep.
2026-06-16 12:56:10 +02:00
julian 5705098054 devices: stub-access driver (bench-test flows without a relay)
A live QR scan reached the app but rejected: 'reader not on an access-equipped
lane' — the dispatcher requires an access device on the reader's lane. Add a
no-op stub-access driver (access category, no config) whose pulseOpen only logs
and does no device I/O, so the QR->permit->accept flow (incl. the beep) can be
tested without the Dingtian relay connected. Not for production; registered in
the catalog.
2026-06-16 12:50:05 +02:00
julian 68d61f2d99 qr-reader: gee-qr-reader driver — assign in wizard, resolve lane by serial
The QR reader is a push device and the setup wizard assigns random-UUID ids, so
'id = serial' can't be set via the UI. Add a dedicated gee-qr-reader driver
(reader category) with a single 'serial' config field; the admin assigns it
normally and enters the device's serial (its cjihao).

The QR endpoint now resolves the lane by matching lane_devices.config.serial to
the scan's cjihao (instead of row id == cjihao), so no DB hand-editing. An
unassigned serial resolves to no lane -> status:0, gracefully.

Verified via inject through the real /api/setup/assign: assign {serial:
H05M2AFA} -> .jsp scan with a matching permit QR -> status:1 (accept) + open;
re-scan -> permit exit; unknown card -> status:0; unassigned serial -> status:0.
2026-06-16 12:36:58 +02:00
julian 04135b27cf qr-reader: register all server-language extensions (reader posts .jsp)
Hardware capture: the GEE/Fondvision reader (serial H05M2AFA) scans + sends +
beeps fine — the earlier 'no beep' was just nothing answering :3000. Real
request: GET /qa/mcardsea.jsp?cardid=...&cjihao=H05M2AFA&... — the 'server
language' setting (JSP here) selects the URL EXTENSION, so it posts .jsp, not
.php. Our route was .php-only and would have 404'd it.

Register the endpoint at php/jsp/asp/aspx/cgi so it works whatever the device is
configured to. cjihao (serial) is the lane key: assign the reader as
lane_devices.id = its serial.
2026-06-16 12:30:00 +02:00
julian 392d44d842 server: GEE/Dingtian QR reader endpoint + synchronous ReadOutcome
The reader HTTP-GETs on each scan and beeps/acts on our JSON reply (host-in-the-
loop, synchronous). New route GET/POST /qa/mcardsea.php parses the SDK query,
runs the scan through the read dispatcher (permit match -> permit flow; else
transient exit), and replies the SDK verdict: status 1=valid (beep 2x) /
0=invalid (beep 1x), output, time-sync.

Refactored the read flows to return a ReadOutcome {accepted, direction, reason}
so the reply reflects the real accept/reject decision (ReadDispatcher.dispatch,
ExitFlow.handleAt, PermitFlow.run). Fire-and-forget readers ignore it.

Reader's lane is keyed off its serial (cjihao) as lane_devices.id for now;
endpoint is public (reader has no auth, on the device subnet).

Verified via inject: valid permit QR -> status:1 + open; re-scan -> permit exit;
unknown QR -> status:0; barrier-less lane -> status:0.
2026-06-16 12:12:09 +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 e579fe5b6e server+web: capacity / FULL gate (occupancy fold + transient refuse)
Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).

FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).

Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).

Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
2026-06-16 08:13:06 +02:00
julian 644bfa1462 server+web: shifts — open/close + signed Z-report (manned mode)
A shift is two signed ledger events, no mutable table: new shift_open event
type + existing shift_z_report. The operator is the logged-in user (carried in
event identity); a shift is open iff their latest shift event is a shift_open.

ShiftService: close sums payment events in [start,end] by tender (cash/card, by
payment time), appends the signed shift_z_report (totals/counts/window), and
prints via a new generic PrinterDevice.printReport(title, lines) (Rongta ESC/POS
text) to a booth-receipt printer. Print is best-effort — a failed print does not
undo the signed close.

Routes (cashier/operator/admin): GET /api/shift/current, POST /api/shift/open
(409 if open), POST /api/shift/close (409 if none). Web ShiftControl in the
shell (non-readonly): Start/End + Z-report totals.

Verified: open -> double-open 409 -> payments (cash+card; one outside the window
excluded) -> close totals correct + signed + printed -> close-again 409 ->
re-open ok; readonly 403; verifyChain ok.
2026-06-16 08:01:59 +02:00
julian 3429642edb permits: admin CRUD (route + UI)
A permit is an aggregate (row + credentials + bound plates); create/update
treat it as one unit (child sets replaced on update). GET /api/permits (any
signed-in role, for lookup); POST/PUT/DELETE + POST /:id/revoke (admin only).
Validation: maxConcurrent positive-int-or-null (unbound); a permit must have at
least one credential OR one bound plate. Revoke is the soft common case (keeps
history, barred at the barrier); DELETE hard-removes — past ledger events that
reference it are untouched (append-only audit trail, independent of this row).

Web PermitManager in the admin shell: list + add/edit (holder, car-bound toggle,
validity, credentials, plates), revoke, delete. Makes permits usable without
hand-seeding (companion to the tariff composer).

Verified via inject: validation (empty / maxConcurrent=0 -> 400), create -> 201,
operator can LIST but not write (403), update replaces child rows, revoke ->
revoked, delete -> 204 then 404 with children cleaned.
2026-06-15 19:53:03 +02:00
julian c24d99b0f4 server: permit entry/exit branch + read dispatcher
A credential read now routes by what the credential IS: matches a permit
(card/QR credential or a bound plate) -> permit flow; else -> transient exit
flow. Lane resolved once (readerLaneWithAccess); ExitFlow.onRead -> handleAt so
the dispatcher owns lane resolution.

Permit direction is inferred from session state for that car (the read value is
the per-car session key): no open session -> ENTRY (enforce maxConcurrent, sign
vehicle_entry, open); open -> EXIT (sign vehicle_exit, open, close). Fleet
permit = one session per car; anti-passback falls out naturally.

maxConcurrent enforced as a fold over the signed ledger (null = unbound).
Validity window + status + plate-OR-card identity as designed. No ticket/fee;
every use is a signed event carrying permitId. Refusals (revoked / out-of-window
/ at-capacity) are signed anomalies, barrier stays closed.

Verified against stubs: card entry -> inferred exit; fleet cap 2 (F3 rejected
at 2/2, then admitted after F1 exits); plate-bound opens; revoked rejects;
unknown credential falls through to exit reject; verifyChain ok.
2026-06-15 19:47:01 +02:00
julian b4d0dfadd6 tariff composer: admin publishes rate-card versions (pay station now operable)
validateTariffStructure (shared): non-negative ints, ascending block bounds,
only the last block open-ended — a malformed card can't be published.

Routes: GET /api/tariff (active + history, any signed-in role), POST
/api/tariff/versions (publish an immutable, effective-dated version; admin
only). The single site tariff row is created lazily. Editing = publish a new
version; past sessions keep their pricing.

Web: TariffComposer in the admin shell — edit currency, grace windows,
increment, daily cap, lost-ticket fee, and add/remove rate blocks (major-unit
input -> minor on submit); shows active version + history.

Verified via inject: empty -> active null; invalid blocks -> 400 with problem;
valid -> 201; readonly publish -> 403; after publishing, the pay station quote
returns 404 (no session) instead of 409 (no tariff) -- it now prices against the
active card.
2026-06-15 19:35:33 +02:00
julian f18e28eeca server: pay station + fee calc — full transient loop now passes
computeFee() in @parking/shared: pure integer fee over a TariffStructure
(stepped blocks, rolling-24h cap). Two edges fixed under test: grace uses RAW
duration (not rounded-up minutes), and the block ladder resets each 24h day.

PayStation + routes (GET /api/pay/quote, POST /api/pay): look up the open
session, resolve the active tariff version (latest effectiveFrom <= entry),
computeFee, append a signed payment event (amount/currency/tender/
tariffVersionId/graceExitMin). overrideMinor handles lost-ticket/dispute. PCI
stays out of the app: tender only records cash/card.

Verified end to end: entry -> quote (300 for 90min) -> pay -> exit opens and
closes the session, verifyChain ok.
2026-06-15 19:15:53 +02:00
julian a8c6d6e714 auth: JWT valid until logout (drop 8h expiry)
Booth reality breaks a fixed clock (relief late/absent, forced double shifts),
and a shift is a separate explicit boundary. Drop expiresIn from the global jwt
config and from login; the token carries no exp. Cookie maxAge = 30 days so a
browser restart doesn't log out an active operator; logout still clears it.
2026-06-15 19:15:53 +02:00
julian 2a36830880 server: exit flow (pay-on-foot validation)
A credential read at an exit lane validates the session, then opens. Adds a
'read' channel to the device bus (DeviceReadEvent: ticket/plate/qr/card);
entry stays button-driven so reads are exit/identity events.

Flow: read -> fold the SIGNED ledger for that identity -> validate open + PAID
+ within gracePeriodExitMin -> signed vehicle_exit -> pulseOpen -> close the
session cache. Unpaid / grace-expired / unknown -> signed anomaly, barrier
stays closed (a deliberate business reject, not a fail-state; 'exit fails open'
is about host/power loss). Validation reads the ledger (authoritative), not the
cache.

No payment events exist until the pay station is built, so every transient exit
currently rejects -- the correct end-state, not yet passable. Verified against
stubs: unpaid->anomaly+no-open; paid+grace->exit+open+closed; expired->anomaly;
unknown->anomaly; verifyChain ok across entry->pay->exit.

Flagged: lane_devices has no entry/exit direction model (exit door hardcoded to
1); needs a lane-direction/role model before multi-reader lanes.
2026-06-15 18:57:14 +02:00
julian 2696d281ce server: transient entry flow (button -> ticket -> signed entry -> open)
Closes the long-dangling thread from device-input-flow. On an access device's
rising input edge: print the ticket (failover), then sign vehicle_entry, then
pulseOpen, then cache the session projection.

Two invariants enforced:
- signed BEFORE open (an open with no signed event is the fraud signal);
- HOLD on print failure — no ticket means a transient can't pay on exit, so
  sign an anomaly and do NOT open, and do NOT write a vehicle_entry for a car
  that never got in.

Subscribes the same input bus as the device-telemetry writer (independent:
telemetry always records; entry acts only on an access device's on-edge,
debounced). Verified end to end against stubs: success path signs+opens+caches
and verifyChain ok; printer-down path emits only an anomaly with no open and
no entry; release edge ignored.
2026-06-15 18:32:40 +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 8c2cf93067 db: business-layer schema — ledger/device event split, tariffs, permits, sessions
Implements the wiki design in packages/db + packages/shared.

Event split: rename events -> ledger_events (signed business ledger) and add
device_events (unsigned telemetry). ledger_events gains a signed JSON payload
(amount/tariffVersionId/sessionRef/tender…) + keyId; canonicalize() includes
the payload via sorted-key serialization so business data is tamper-evident.
Raw Dingtian input now writes device_events, not a signed input_received.

New tables: tariffs + immutable tariff_versions (composable/versioned, currency
+ FX-ready), permits (+ permit_credentials, permit_plates; maxConcurrent default
1), blocklist, sessions (rebuildable projection cache — not a source of truth).

shared: split ParkingEvent/Type into LedgerEvent/LedgerEventType + DeviceEventKind;
add LedgerPayload, Tender, TariffStructure/TariffBlock.

Regenerated a single baseline migration (no production chain data existed).
Verified: chain appends + verifyChain ok; tampering a payment payload breaks
the signature. Full repo builds (5/5).
2026-06-15 18:13:35 +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 2ab5a39a57 Permanent WSL2 dev fix for multi-subnet source-address trap
Mirrored mode re-clones the Windows NIC's addresses each boot, so the kernel
keeps picking the wrong source for stacked device subnets (10.0.10.x sourced
from 192.168.1.123) — ARP resolves but ping/TCP dies, and every runtime
ip-route fix is wiped by wsl --shutdown.

deploy/wsl-fix-route-source.sh pins each scope-link route's src to this
host's own address in that subnet (no hardcoded IPs, idempotent, preserves
metric, non-fatal per route, waits for the route at boot). deploy/parking-net
.service reapplies it on every boot.

Dev-box only; the appliance is bare-metal Linux with static networkd config.
Verified: camera pings with no -I flag; driver pulls a snapshot with no
localAddress set.
2026-06-15 16:17:59 +02:00
julian fa65b2df86 Real Hikvision/Dahua camera driver; gate Backend-push-IP on capability
Replace the camera stub with HttpCamera: Hikvision ISAPI and Dahua CGI
snapshots over client-side HTTP Digest (new drivers/http-digest.ts).
healthCheck() now pulls a real frame instead of returning ready/stub.
Snapshot carries bytes (driver fetches); storage/imageRef is the caller's
job, keeping the adapter free of storage deps.

Fix the cosmetic Backend-push-IP field: add pushesToBackend to DeviceDriver
(only Dingtian sets it), expose as pushCapable in the catalog, and gate the
wizard's backend-IP fetch + field on it so pull-only devices hide it.

Verified on hardware (Hikvision 10.0.10.121): healthCheck ready,
captureSnapshot returns a valid JPEG.
2026-06-15 16:17:49 +02:00
240 changed files with 39157 additions and 910 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"hooks": {
"PreToolUse": []
}
}
+7
View File
@@ -11,6 +11,8 @@ dist/
.env .env
.env.* .env.*
!.env.example !.env.example
# Committed (non-secret): the desktop/prod build's backend origin — see apps/web/.env.production
!.env.production
# Editor/OS # Editor/OS
.DS_Store .DS_Store
@@ -20,3 +22,8 @@ dist/
/*.png /*.png
# Vendor device SDKs (reference only — protocol captured in wiki, not committed) # Vendor device SDKs (reference only — protocol captured in wiki, not committed)
/dingtian/ /dingtian/
/QRCode_sdk*/
# Graphify knowledge-graph output (dev tool; generated, not committed)
graphify-out/
parking.sqlite*.bak-*
+2 -1
View File
@@ -17,7 +17,8 @@ parking-system/
├── turbo.json ├── turbo.json
├── apps/ ├── apps/
│ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA │ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA
│ └── web/ # React + Vite SPA (operator UI) │ ├── web/ # React + Vite SPA (operator UI)
│ └── vision/ # Python/FastAPI ANPR service (planned; separate process, Turbo shim — see wiki/decisions/vision-service-packaging.md)
├── packages/ ├── packages/
│ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target) │ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target)
│ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay) │ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay)
+10
View File
@@ -0,0 +1,10 @@
# Desktop (Tauri) build — the @parking/web SPA needs to know where Fastify is.
#
# In a BROWSER (dev via the Vite proxy, or prod where Fastify serves the SPA),
# leave VITE_API_BASE UNSET — requests stay relative/same-origin.
#
# For the DESKTOP build, the bundled SPA loads from tauri://localhost and has no
# proxy, so point it at the appliance's Fastify origin. This is read at WEB build
# time, so export it before `pnpm --filter @parking/desktop build` (or put it in
# apps/web/.env.production).
VITE_API_BASE=http://127.0.0.1:3000
+3
View File
@@ -0,0 +1,3 @@
# Rust / Tauri build artifacts
src-tauri/target/
src-tauri/gen/
+42
View File
@@ -0,0 +1,42 @@
# @parking/desktop — Tauri v2 kiosk shell
A **thin native desktop window** over the `@parking/web` SPA. It contains **no UI and no business
logic** of its own: the window renders the *same* web app the browser does, so the desktop and the
browser stay identical and never drift. Device/auth/ledger logic stays in `@parking/server`. See
`wiki/decisions/desktop-shell-tauri.md`.
## How the "same look & functionality" guarantee works
| | Source of the UI |
| --- | --- |
| **Dev** (`tauri dev`) | the window loads `http://localhost:5173` — the **`@parking/web` Vite dev server**. Edit a component in `apps/web` → HMR updates the desktop window live. |
| **Prod** (`tauri build`) | the window bundles `apps/web`'s built `dist/`. `beforeBuildCommand` rebuilds the SPA first. |
There is only one UI codebase (`apps/web`); this package just wraps it.
## Backend connection
The SPA talks to Fastify over HTTP/WS. In a browser that's same-origin (relative `/api`). In the
desktop build the bundled assets load from `tauri://localhost`, so set **`VITE_API_BASE`** (read at
web build time — see `.env.example`) to the appliance's Fastify origin, e.g.
`http://127.0.0.1:3000`. The CSP `connect-src` in `tauri.conf.json` is already allowed for that
origin, and the backend must include the Tauri origin in `WS_ALLOWED_ORIGINS` for the live feed.
## Commands
```bash
pnpm --filter @parking/desktop dev # native window over the web dev server (HMR)
pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app (.deb/.rpm/.AppImage)
```
> `build` is a **no-op** in this package so `turbo run build` stays fast — the real desktop bundle
> (compiles Rust, minutes long) is the explicit `bundle` script above.
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
window needs a display (WSLg or an X server).
## Not here (deliberately)
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
this shell connects to it.
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@parking/desktop",
"version": "0.0.0",
"private": true,
"//": "Tauri v2 desktop shell — a THIN native window over the @parking/web SPA. No business logic lives here (device/auth/ledger stay in @parking/server); see wiki/decisions/desktop-shell-tauri.md. Dev loads the web dev server (HMR); build bundles the web app's dist/, so the desktop UI and the browser UI are the SAME codebase and never drift.",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "echo 'no-op in the Turbo graph — the real desktop bundle is a deliberate `pnpm --filter @parking/desktop bundle` (compiles Rust + packages installers, minutes long)'",
"bundle": "tauri build",
"tauri": "tauri",
"lint": "echo 'no JS lint (Tauri shell; Rust checked via cargo)'"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.1"
},
"dependencies": {
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1"
}
}
+4899
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "parking-desktop"
version = "0.0.0"
description = "Parking System — desktop kiosk shell"
edition = "2021"
rust-version = "1.77"
# Thin Tauri v2 shell. Deliberately holds NO business logic — it loads the
# @parking/web SPA and lets it talk to the local Fastify server. Device/auth/
# ledger stay server-side. See wiki/decisions/desktop-shell-tauri.md.
[lib]
name = "parking_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde_json = "1"
# Auto-update: prompt the operator, download a signed update, relaunch.
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
[features]
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Minimal capability set for the kiosk shell. The window only needs to render the SPA; it is granted NOTHING that touches the filesystem, shell, or devices — those stay server-side. Add a named permission here only when a concrete need arises (deny-by-default). See wiki/decisions/desktop-shell-tauri.md.",
"windows": ["main"],
"permissions": [
"core:default",
"updater:default",
"process:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+21
View File
@@ -0,0 +1,21 @@
// Parking System desktop shell — entry point.
//
// Intentionally minimal: build the default Tauri app and run it. The window
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This
// keeps the shell a thin presentation wrapper with a deny-by-default native
// surface (see wiki/decisions/desktop-shell-tauri.md).
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// Auto-update: the JS side (apps/web) checks on launch, prompts the
// operator, and installs + relaunches on confirm. These plugins expose
// the update check/install and the relaunch to that flow. The updater
// endpoint + signing pubkey live in tauri.conf.json.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.run(tauri::generate_context!())
.expect("error while running the Parking System desktop shell");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
parking_desktop_lib::run()
}
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System",
"version": "0.0.0",
"identifier": "com.parking.desktop",
"build": {
"devUrl": "http://localhost:5173",
"frontendDist": "../../web/dist",
"beforeDevCommand": "pnpm --filter @parking/web dev",
"beforeBuildCommand": "pnpm --filter @parking/web build"
},
"app": {
"windows": [
{
"label": "main",
"title": "Parking System",
"width": 1280,
"height": 800,
"minWidth": 1024,
"minHeight": 640,
"resizable": true,
"maximized": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"endpoints": [
"https://UPDATES.EXAMPLE.invalid/parking/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"//": "Tauri shell as a first-class Turbo node. build outputs [] so `turbo run build` doesn't try to cache/compile the Rust bundle on every pass (a real desktop bundle is a deliberate `pnpm --filter @parking/desktop build`).",
"tasks": {
"build": {
"outputs": []
}
}
}
+22
View File
@@ -8,6 +8,13 @@
# Generate one with: openssl rand -hex 32 # Generate one with: openssl rand -hex 32
JWT_SECRET= JWT_SECRET=
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
# Generate with: openssl rand -hex 32
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
# dev, but set a dedicated key before production. Events store the key that signed
# them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY=
# Optional ---------------------------------------------------------------- # Optional ----------------------------------------------------------------
# PORT=3000 # PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
@@ -18,3 +25,18 @@ JWT_SECRET=
# First admin (seed once): pnpm --filter @parking/server seed-admin # First admin (seed once): pnpm --filter @parking/server seed-admin
# ADMIN_USER=admin # ADMIN_USER=admin
# ADMIN_PASS= # ADMIN_PASS=
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
WS_ALLOWED_ORIGINS=http://localhost:5173
# Vision / ANPR (optional) -------------------------------------------------
# OFF by default. The Node SERVER's view of the vision microservice (apps/vision),
# which runs as a separate process with its OWN apps/vision/.env. Both sides share the
# VISION_ prefix but are different processes — keep the two .env files separate.
# See wiki/entities/opencv-anpr-service.md "Configuration".
# ANPR rides the entry/exit snapshot (button / QR / RFID triggers it) — no polling.
# VISION_ENABLED=1 # master switch — nothing runs without it
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
# VISION_MIN_CONFIDENCE=0.5 # confidence floor; keep in sync with the service
+1
View File
@@ -16,6 +16,7 @@
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@fastify/jwt": "10.1.0", "@fastify/jwt": "10.1.0",
"@fastify/static": "9.1.3", "@fastify/static": "9.1.3",
"@fastify/websocket": "^11.2.0",
"@parking/db": "workspace:*", "@parking/db": "workspace:*",
"@parking/devices": "workspace:*", "@parking/devices": "workspace:*",
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
+2 -2
View File
@@ -62,14 +62,14 @@ if (existing && process.env.FORCE !== "1") {
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
if (existing) { if (existing) {
await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id)); await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id));
console.log(`reset password for admin "${username}"`); console.log(`reset password for admin "${username}"`);
} else { } else {
await db.insert(users).values({ await db.insert(users).values({
id: randomUUID(), id: randomUUID(),
username, username,
passwordHash, passwordHash,
role: "admin", roleId: "admin",
}); });
console.log(`created admin "${username}"`); console.log(`created admin "${username}"`);
} }
+92 -13
View File
@@ -1,16 +1,22 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify"; import type { FastifyReply, FastifyRequest } from "fastify";
import type { Role } from "@parking/shared"; import { eq, rolePermissions, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
// Local JWT auth helpers — fully local, no external identity provider // Local JWT auth helpers — fully local, no external identity provider
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it); // (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
// a separate readable CSRF cookie + matching header defends mutations // a separate readable CSRF cookie + matching header defends mutations
// (double-submit). See wiki/entities/local-jwt-auth.md. // (double-submit). See wiki/entities/local-jwt-auth.md.
//
// Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each
// guarded route resolves that role's PERMISSION SET (cached in memory) and checks
// the permission it requires. Editing a role takes effect on the next request —
// no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS.
declare module "@fastify/jwt" { declare module "@fastify/jwt" {
interface FastifyJWT { interface FastifyJWT {
payload: { sub: string; username: string; role: Role; csrf: string }; payload: { sub: string; username: string; roleId: string; csrf: string };
user: { sub: string; username: string; role: Role; csrf: string }; user: { sub: string; username: string; roleId: string; csrf: string };
} }
} }
@@ -18,9 +24,15 @@ export const TOKEN_COOKIE = "parking_token";
export const CSRF_COOKIE = "parking_csrf"; export const CSRF_COOKIE = "parking_csrf";
export const CSRF_HEADER = "x-csrf-token"; export const CSRF_HEADER = "x-csrf-token";
/** Token lifetime, also used as the cookie maxAge. */ // Session lifetime: the JWT has NO expiry — a login is valid until explicit
export const TOKEN_TTL = "8h"; // logout. Booth reality breaks any fixed clock (relief late/absent, forced double
export const TOKEN_TTL_SECONDS = 8 * 60 * 60; // shifts), and a shift is a separate explicit boundary, not the token's lifetime.
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
//
// The cookie still needs a maxAge so it survives a browser restart (a session
// cookie would log out an active operator on browser close — the opposite of
// "until logout"). Use a long fixed window; the server clears it on logout.
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
/** /**
* Resolve the JWT signing secret, refusing to start without a strong one. * Resolve the JWT signing secret, refusing to start without a strong one.
@@ -55,7 +67,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict", sameSite: "strict",
secure, secure,
path: "/", path: "/",
maxAge: TOKEN_TTL_SECONDS, maxAge: COOKIE_MAX_AGE_SECONDS,
}); });
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit). // Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
reply.setCookie(CSRF_COOKIE, csrf, { reply.setCookie(CSRF_COOKIE, csrf, {
@@ -63,7 +75,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict", sameSite: "strict",
secure, secure,
path: "/", path: "/",
maxAge: TOKEN_TTL_SECONDS, maxAge: COOKIE_MAX_AGE_SECONDS,
}); });
} }
@@ -90,17 +102,84 @@ function assertCsrf(req: FastifyRequest): void {
} }
} }
// --- Permission resolution + cache -------------------------------------------
// A role's permission set is read from `role_permissions` and cached in memory.
// SQLite is single-writer/single-process here, so a module-level Map is a correct
// cache: every role / role-permission mutation calls bumpPermsCache() to clear it,
// and the next request re-reads. The built-in `admin` role always resolves to the
// FULL permission set in code (never trusts the DB rows for it), so administration
// can't be accidentally narrowed.
const ADMIN_PERMS: ReadonlySet<Permission> = new Set(PERMISSIONS);
const permsCache = new Map<string, ReadonlySet<Permission>>();
// The DB handle the permission resolver reads from. Set ONCE at startup via
// initAuth() so route guards don't each have to thread `db` (several route
// modules only receive a monitor/service, not the db). Single-process server.
let authDb: Db | null = null;
/** Wire the permission resolver to the app's DB. Call once in buildServer(). */
export function initAuth(db: Db): void {
authDb = db;
permsCache.clear();
}
/** Clear the permission cache. Call after ANY write to roles / role_permissions
* (or a user's roleId) so the change takes effect on the next request. */
export function bumpPermsCache(): void {
permsCache.clear();
}
/** The permission set for a role id, cached. `admin` is always the full set. */
export function permissionsFor(roleId: string): ReadonlySet<Permission> {
if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS;
const hit = permsCache.get(roleId);
if (hit) return hit;
if (!authDb) throw new Error("auth not initialised (call initAuth)");
const rows = authDb
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, roleId))
.all();
const set = new Set(rows.map((r) => r.permission as Permission));
permsCache.set(roleId, set);
return set;
}
/** True if the role grants every listed permission. */
export function roleHasPermissions(
roleId: string,
required: readonly Permission[],
): boolean {
const granted = permissionsFor(roleId);
return required.every((p) => granted.has(p));
}
/** /**
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces * preHandler permission guard. Verifies the JWT (from the HttpOnly cookie),
* CSRF on mutations, then checks the role. Authorization is a simple per-route * enforces CSRF on mutations, then requires the user's role to grant ALL of the
* role check — no Casbin/RBAC engine needed at this scale. * listed permissions. Authorization is a per-route permission check against the
* dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale.
*/ */
export function requireRole(...allowed: Role[]) { export function requirePermission(...required: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => { return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify(); // reads the token cookie (configured in server.ts) await req.jwtVerify(); // reads the token cookie (configured in server.ts)
assertCsrf(req); assertCsrf(req);
if (!req.user || !allowed.includes(req.user.role)) { if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
}; };
} }
/**
* preHandler that requires a valid signed-in session but NO specific permission —
* for "about me" routes (/me, change own language) every authenticated user may
* call regardless of role. Still enforces CSRF on mutations.
*/
export async function requireAuth(
req: FastifyRequest,
_reply: FastifyReply,
): Promise<void> {
await req.jwtVerify();
assertCsrf(req);
}
+192
View File
@@ -0,0 +1,192 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import {
formatStampSq,
printWithFailover,
registry,
type PrinterDevice,
type PrinterInstance,
type ReceiptData,
type TicketHeader,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js";
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
// from the exit, the customer pays at the booth and walks a printed voucher to the
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
//
// This mirrors the entry flow's printer selection + header build, but prints on the
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
/** Park identity for the voucher header, from site_config (all fields optional). */
function ticketHeader(db: Db): TicketHeader | undefined {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
/** Build live printer instances for failover selection (entry direction covers the
* booth-receipt role too — the booth printer is configured on the entry side). */
function loadPrinters(db: Db): PrinterInstance[] {
const rows = devicesByDirection(db, "printer", "entry");
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** The receipt figures for a paid session, folded from the SIGNED ledger
* (authoritative). Null if there's no entry or no payment for this id — the
* caller should have validated paid + open before printing. */
function receiptFigures(
db: Db,
ticketId: string,
): Omit<ReceiptData, "voucher" | "header"> | null {
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, ticketId))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
let payment: (typeof rows)[number] | undefined;
for (const r of rows) if (r.type === "payment") payment = r;
if (!payment) return null;
const p = (payment.payload ?? {}) as {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
graceExitMin?: number;
};
return {
ticketId,
enteredAt: entry.occurredAt,
paidAt: payment.occurredAt,
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
currency: p.currency ?? "ALL",
tender: p.tender === "card" ? "card" : "cash",
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
};
}
/**
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
* to the entry dispenser). The receipt is the customer's transparency record:
* entry time, payment time, duration, amount + tender — folded from the signed
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
* walk-back grace, so the one slip both proves payment AND self-exits at a
* distant exit reader (this replaces the old barcode-only voucher). In standalone
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
* at the exit. Returns the id of the printer that printed it.
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
*/
export async function printPaymentReceipt(
db: Db,
ticketId: string,
opts: { voucher: boolean },
logger: FastifyBaseLogger,
): Promise<string> {
const figures = receiptFigures(db, ticketId);
if (!figures) {
throw new Error(`no paid session to receipt for ${ticketId}`);
}
const printers = loadPrinters(db);
const data: ReceiptData = {
...figures,
voucher: opts.voucher,
header: ticketHeader(db),
};
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReceipt(data),
);
logger.info(
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
);
return printedBy;
}
/**
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
* a scannable QR of the credential code + holder/validity, so the operator can hand
* it to the customer. Used on subscription creation and on a "reprint" action.
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
*/
export async function printSubscriptionCard(
db: Db,
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const data = {
code: card.code,
holderName: card.holderName ?? null,
validFrom: card.validFrom ?? null,
validTo: card.validTo ?? null,
header: ticketHeader(db),
};
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printSubscriptionCard(data),
);
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
/**
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
* the total is computed at the booth on settlement (early-entry AND any late-exit time
* combined). It just gives the subscriber paper proof that a fee is pending against this
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
* the caller swallows failures so a missing printer never blocks the barrier.
*/
export async function printWindowChargeNotice(
db: Db,
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const hhmm = (m?: number) =>
m == null ? "" : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
const lines = [
`Abonent: ${notice.holderName || "-"}`,
`${notice.edge === "entry" ? "Hyrje" : "Dalje"}: ${formatStampSq(notice.at)}`,
notice.edge === "entry"
? `Ka hyrë jashtë orarit${notice.windowOpensMin != null ? ` (orari hap ${hhmm(notice.windowOpensMin)})` : ""}`
: "Ka dalë jashtë orarit",
"",
"⚠ Detyrim do të llogaritet në dalje",
" (paguhet në kabinë para se të dilni)",
"",
`Nr: ${notice.occurrenceId}`,
];
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }),
);
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
return printedBy;
}
+89
View File
@@ -0,0 +1,89 @@
// Credential capture ("enroll a card"): lets an operator present a physical RFID
// card/chip (or a QR) to ONE chosen reader and have its value captured for a
// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the
// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is
// never affected and keeps serving the live entry/exit flow.
//
// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next
// read from that armed reader is captured (NOT dispatched to the access flow — the
// barrier must not open for a card being enrolled) and capture auto-disarms. The
// booth form polls result() until the value appears (or it times out / is cancelled).
//
// In-memory + single-site single-writer (one booth) → no DB, no cross-process
// concerns. See wiki/entities/subscription.md.
const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000);
export type CaptureState =
| { status: "idle" }
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
| { status: "expired"; deviceId: string };
export class CredentialCapture {
#armedDeviceId: string | null = null;
#expiresAt = 0;
#captured: { deviceId: string; value: string; capturedAt: number } | null = null;
#lastExpiredDeviceId: string | null = null;
/** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any
* prior arming (only one capture at a time). Clears a stale captured/expired
* result so the form starts fresh. */
arm(deviceId: string): { expiresAt: number } {
this.#armedDeviceId = deviceId;
this.#expiresAt = Date.now() + CAPTURE_TTL_MS;
this.#captured = null;
this.#lastExpiredDeviceId = null;
return { expiresAt: this.#expiresAt };
}
/** Cancel any pending arming (operator closed the form / clicked cancel). */
cancel(): void {
this.#armedDeviceId = null;
this.#expiresAt = 0;
}
/**
* Called by the reader route on EVERY read. If this reader is the armed one (and
* not expired), capture the value, disarm, and return true → the caller must NOT
* dispatch this read to the access flow. Otherwise false → dispatch normally.
*/
tryConsume(deviceId: string, value: string): boolean {
if (this.#armedDeviceId == null) return false;
if (Date.now() > this.#expiresAt) {
// Window lapsed before a card was presented — disarm, mark expired.
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return false;
}
if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader
if (!value) return false;
this.#captured = { deviceId, value, capturedAt: Date.now() };
this.#armedDeviceId = null; // single-shot
this.#expiresAt = 0;
return true;
}
/** Current state for the booth form's poll. Lazily transitions armed→expired. */
state(): CaptureState {
if (this.#captured) return { status: "captured", ...this.#captured };
if (this.#armedDeviceId != null) {
if (Date.now() > this.#expiresAt) {
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return { status: "expired", deviceId: this.#lastExpiredDeviceId };
}
return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt };
}
if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId };
return { status: "idle" };
}
/** Clear a consumed/expired result once the form has read it. */
clear(): void {
this.#captured = null;
this.#lastExpiredDeviceId = null;
}
}
+90 -3
View File
@@ -1,5 +1,6 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices"; import type { PrinterStatus } from "@parking/devices";
import type { LedgerEventRow } from "@parking/db";
// Internal event bus for device-originated events (button presses, etc.). // Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry // Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -8,22 +9,73 @@ import type { PrinterStatus } from "@parking/devices";
export interface DeviceInputEvent { export interface DeviceInputEvent {
readonly driverId: string; // e.g. "dingtian" readonly driverId: string; // e.g. "dingtian"
readonly deviceId: string; // which configured device (lane_devices id) readonly deviceId: string; // which configured device (devices id)
readonly input: number; // 1-based input/channel readonly input: number; // 1-based input/channel
readonly edge: "on" | "off"; // active / inactive readonly edge: "on" | "off"; // active / inactive
readonly at: string; // ISO-8601 (server receive time) readonly at: string; // ISO-8601 (server receive time)
readonly source: "push" | "poll"; readonly source: "push" | "poll";
} }
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
// mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent {
readonly driverId: string;
readonly deviceId: string; // devices id of the reader/scanner/camera
readonly value: string; // the ticket id / plate / card number
readonly kind: "ticket" | "plate" | "qr" | "card";
readonly at: string; // ISO-8601
}
/**
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
*/
export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
readonly accepted: boolean;
/** Which way it went, when known (subscription/exit infer this). */
readonly direction?: "entry" | "exit";
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
readonly reason?: string;
}
/** A printer's status as tracked by the live monitor (status + identity). */ /** A printer's status as tracked by the live monitor (status + identity). */
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // lane_devices id readonly deviceId: string; // devices id
readonly lane: number;
readonly driverId: string; readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt readonly role?: string; // entry-dispenser | booth-receipt
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
/**
* The unified live status of ANY configured device — what the booth footer shows.
* Every enabled device is polled: printers via their rich `readStatus()`
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
* reachability probe. `state` is the common traffic-light; `detail` carries the
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
* and wiki/concepts/device-status-monitoring.md.
*/
export interface DeviceStatusEvent {
readonly deviceId: string; // devices id
readonly driverId: string;
readonly category: "access" | "reader" | "camera" | "printer" | "vision";
/**
* The device's ROLE descriptor for the footer label — NOT the vendor. A
* direction-style token the client localises and pairs with the category, so the
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
* - undetermined: null (chip shows the category alone)
*/
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
readonly state: "ready" | "degraded" | "offline";
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
}
class DeviceEventBus extends EventEmitter { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -33,6 +85,15 @@ class DeviceEventBus extends EventEmitter {
return () => this.off("input", cb); return () => this.off("input", cb);
} }
/** A credential read (ticket scan, plate, card). */
emitRead(event: DeviceReadEvent): void {
this.emit("read", event);
}
onRead(cb: (event: DeviceReadEvent) => void): () => void {
this.on("read", cb);
return () => this.off("read", cb);
}
/** Emitted by the printer monitor whenever a printer's status CHANGES. */ /** Emitted by the printer monitor whenever a printer's status CHANGES. */
emitPrinterStatus(event: PrinterStatusEvent): void { emitPrinterStatus(event: PrinterStatusEvent): void {
this.emit("printer-status", event); this.emit("printer-status", event);
@@ -41,6 +102,32 @@ class DeviceEventBus extends EventEmitter {
this.on("printer-status", cb); this.on("printer-status", cb);
return () => this.off("printer-status", cb); return () => this.off("printer-status", cb);
} }
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
* (all categories — relays, readers, cameras, printers). Drives the booth
* device-status footer over the WS. */
emitDeviceStatus(event: DeviceStatusEvent): void {
this.emit("device-status", event);
}
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
this.on("device-status", cb);
return () => this.off("device-status", cb);
}
/**
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
* payment, void, …). The payload is the persisted row — business facts only, no
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
* This is a read-side notification ONLY: it never feeds back into append/sign/
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
*/
emitLedger(event: LedgerEventRow): void {
this.emit("ledger", event);
}
onLedger(cb: (event: LedgerEventRow) => void): () => void {
this.on("ledger", cb);
return () => this.off("ledger", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+192
View File
@@ -0,0 +1,192 @@
import type { FastifyBaseLogger } from "fastify";
import { devices, type Db, type DeviceRow } from "@parking/db";
import { isMonitorable, registry } from "@parking/devices";
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
import { directionOf, relaysOf } from "./device-resolve.js";
import type { VisionClient } from "./vision-client.js";
/** Synthetic device id for the vision service in the status footer (it's a service,
* not a device row, but shares the footer's traffic-light + WS plumbing). */
const VISION_STATUS_ID = "vision-service";
// Unified live DEVICE monitor — the source for the booth's device-status footer.
// Every enabled, configured device is probed on an interval, regardless of
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
// generic healthCheck() reachability probe every Device implements. The result is
// flattened to a common traffic-light (ready | degraded | offline) + a detail
// string, cached per device id, and emitted on the bus ONLY when it changes.
//
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
// and read-only — polling a device never drives a relay or mutates the ledger.
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
/**
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
* tokens the client localises next to the category:
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
*/
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) {
case "reader":
case "camera": {
const d = directionOf(db, row); // entry | exit | both
return d;
}
case "access": {
const dirs = new Set(relaysOf(row).map((r) => r.direction));
if (dirs.size === 0) return null;
if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both
return only ?? null;
}
case "printer": {
const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane";
return null;
}
default:
return null;
}
}
export class DeviceMonitor {
readonly #db: Db;
readonly #log: FastifyBaseLogger;
readonly #pollMs: number;
/** Latest unified status per device id. */
readonly #latest = new Map<string, DeviceStatusEvent>();
#timer: ReturnType<typeof setInterval> | null = null;
#ticking = false;
/** Optional: the vision service client. When present + enabled, the monitor probes
* its /health each tick and shows it as a "vision" chip in the footer. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#pollMs = pollMs;
this.#vision = vision;
}
/** Begin polling. Idempotent. */
start(): void {
if (this.#timer) return;
void this.#tick(); // immediate first pass so the footer fills without a wait
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
this.#timer.unref?.();
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
}
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
/** Current snapshot for the API / a freshly-connected WS client. */
snapshot(): DeviceStatusEvent[] {
return [...this.#latest.values()];
}
async #tick(): Promise<void> {
if (this.#ticking) return; // never overlap polls
this.#ticking = true;
try {
// Re-read the device set each tick so a newly-assigned/removed device is
// picked up without a restart.
const rows = await this.#db.select().from(devices).all();
const enabled = rows.filter((r) => r.enabled);
const present = new Set(enabled.map((r) => r.id));
// The vision service is a pseudo-device — keep it in the present set when enabled
// so the cleanup below doesn't evict it.
if (this.#vision?.enabled) present.add(VISION_STATUS_ID);
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
for (const id of [...this.#latest.keys()]) {
if (!present.has(id)) this.#latest.delete(id);
}
await Promise.all([...enabled.map((r) => this.#poll(r)), this.#pollVision()]);
} catch (err) {
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
} finally {
this.#ticking = false;
}
}
async #poll(row: DeviceRow): Promise<void> {
const cfg = (row.config ?? {}) as Record<string, unknown>;
const base = {
deviceId: row.id,
driverId: row.driverId,
category: row.category,
roleKind: roleKindOf(this.#db, row),
};
let next: DeviceStatusEvent;
const driver = registry.get(row.driverId);
if (!driver) {
// Configured against a driver that's no longer registered — surface it,
// don't silently hide it.
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
} else {
try {
const device = driver.create(cfg as never);
// Printers expose richer paper/cover/cutter status; everything else uses
// the generic reachability probe. Both flatten to the same traffic-light.
if (isMonitorable(device)) {
const s = await device.readStatus();
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
} else {
const h = await device.healthCheck();
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
}
} catch (err) {
// A probe that throws (build error, timeout) reads as offline — never crash
// the tick, and fail toward "there's a problem" rather than false-healthy.
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
}
}
this.#publish(row.id, next);
}
/** Probe the vision service /health and publish it as a "vision" footer chip. Skipped
* entirely when no client is wired or it's disabled (no chip then). */
async #pollVision(): Promise<void> {
if (!this.#vision?.enabled) return;
const h = await this.#vision.health();
const state: DeviceStatusEvent["state"] = h.ok && h.ready ? "ready" : h.ready ? "degraded" : "offline";
this.#publish(VISION_STATUS_ID, {
deviceId: VISION_STATUS_ID,
driverId: "vision",
category: "vision",
roleKind: null,
state,
detail: h.ready ? h.recognizer : (h.detail ?? "not ready"),
checkedAt: new Date().toISOString(),
});
}
/** Cache + emit a status, but only when it CHANGED (state or detail). */
#publish(id: string, next: DeviceStatusEvent): void {
const prev = this.#latest.get(id);
this.#latest.set(id, next);
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
this.#log.info(
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
);
deviceEvents.emitDeviceStatus(next);
}
}
}
+201
View File
@@ -0,0 +1,201 @@
import { and, eq, devices, type Db, type DeviceRow } from "@parking/db";
// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one
// pool with a flexible set of entry/exit points. Direction lives on each RELAY
// inside an access controller, and readers/cameras BIND to a (controller, relay).
// See wiki/concepts/entry-exit-points.md.
/** A flow direction. "both" = one relay/barrier serving entry AND exit. */
export type Direction = "entry" | "exit" | "both";
/** A concrete flow a credential/button drives (never "both"). */
export type FlowDirection = "entry" | "exit";
/** One relay on an access controller: which barrier it opens, in which direction,
* and (optionally) the input terminals its entry button + presence loop are wired to. */
export interface RelaySpec {
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
readonly relay: number;
readonly direction: Direction;
/** 1-based input terminal of the entry button that fires this relay (transient
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
readonly button?: number;
/**
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
* Two modes, chosen by what barrier feedback exists at this lane:
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
* 1-based input terminal of an induction loop / barrier presence signal on THIS
* controller. A press prints only while a car is present, and no second ticket
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
* makes one-car-one-ticket physical.
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
*/
readonly presenceInput?: number;
readonly entryCooldownSec?: number;
}
/** Access controller config (the `relays[]` map + connection fields). */
interface AccessConfig {
readonly relays?: RelaySpec[];
readonly [k: string]: unknown;
}
/** Reader/camera config: optional binding to a controller relay. */
interface BoundConfig {
/** The access `devices.id` this reader/camera sits at. */
readonly controllerId?: string;
/** The relay on that controller it opens. */
readonly relay?: number;
/** Fallback direction when not bound to a relay. */
readonly direction?: Direction;
readonly [k: string]: unknown;
}
/** A resolved barrier: the controller row + the specific relay to pulse. Carries the
* transient-entry anti-double-press config (presence loop / cooldown) when resolved
* from a button press, so the entry flow can enforce one-car-one-ticket. */
export interface ResolvedRelay {
readonly controller: DeviceRow;
readonly relay: number;
readonly direction: Direction;
/** 1-based presence-loop input gating this relay's entry (when wired). */
readonly presenceInput?: number;
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
readonly entryCooldownSec?: number;
}
/** All enabled access controller rows. */
function accessRows(db: Db): DeviceRow[] {
return db
.select()
.from(devices)
.where(eq(devices.category, "access"))
.all()
.filter((r) => r.enabled);
}
/** The relay specs declared on an access controller (defaults to none). */
export function relaysOf(row: DeviceRow): RelaySpec[] {
const cfg = row.config as AccessConfig;
return Array.isArray(cfg.relays) ? cfg.relays : [];
}
/**
* Resolve a button press to the relay it fires: the access controller with this
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
*/
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const spec = relaysOf(row).find((r) => r.button === terminal);
if (!spec) return null;
if (spec.direction !== "entry" && spec.direction !== "both") return null;
return {
controller: row,
relay: spec.relay,
direction: spec.direction,
presenceInput: spec.presenceInput,
entryCooldownSec: spec.entryCooldownSec,
};
}
/**
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
*/
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db
.select()
.from(devices)
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get();
if (!row || !row.enabled) return null;
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
if (!spec) return null;
if (spec.direction !== "entry" && spec.direction !== "both") return null;
return { controller: row, relay: spec.relay, direction: spec.direction };
}
/**
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
* spec. Fallback (unbound): the device's config.direction + the first relay site-
* wide matching that direction — keeps the single-barrier case trivial. Null if
* nothing resolves (no barrier to open).
*/
export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null {
const cfg = deviceRow.config as BoundConfig;
// Bound: follow controllerId + relay to the exact barrier.
if (cfg.controllerId && typeof cfg.relay === "number") {
const controller = db
.select()
.from(devices)
.where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access")))
.get();
if (controller && controller.enabled) {
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
}
return null;
}
// Unbound: fall back to the device's declared direction + first matching relay.
const want = cfg.direction;
if (want === "entry" || want === "exit" || want === "both") {
return firstRelayByDirection(db, want === "both" ? "entry" : want);
}
return null;
}
/**
* The first relay site-wide serving a direction ("both" relays match either).
* Used as the unbound fallback and where a flow only needs "an exit barrier".
*/
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
for (const controller of accessRows(db)) {
const spec = relaysOf(controller).find(
(r) => r.direction === direction || r.direction === "both",
);
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
}
return null;
}
/** Enabled devices of a category whose direction matches `want` (or is "both").
* Direction is inherited from each device's bound relay, else its config fallback.
* Used for snapshots: every entry/exit camera fires on an entry/exit. */
export function devicesByDirection(
db: Db,
category: DeviceRow["category"],
want: FlowDirection,
): DeviceRow[] {
return db
.select()
.from(devices)
.where(eq(devices.category, category))
.all()
.filter((r) => {
if (!r.enabled) return false;
const d = directionOf(db, r);
return d === want || d === "both";
});
}
/** The direction a reader/camera operates in (inherited from its bound relay, or
* its config fallback). "both" when undetermined → the flow infers. */
export function directionOf(db: Db, deviceRow: DeviceRow): Direction {
const resolved = relayForDevice(db, deviceRow);
if (resolved) return resolved.direction;
const cfg = deviceRow.config as BoundConfig;
return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both";
}
+426
View File
@@ -0,0 +1,426 @@
import { randomInt, randomUUID } from "node:crypto";
import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
import {
NoPrinterAvailableError,
printWithFailover,
registry,
type AccessControlDevice,
type PrinterDevice,
type PrinterInstance,
type TicketData,
type TicketHeader,
} from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
// → open the barrier. The button is wired into an access controller's input; the
// admin maps that input terminal to a relay (config.relays[].button), so a press
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
//
// Two invariants from the threat model + safety analysis:
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
// signal (wiki/concepts/append-only-event-chain.md).
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
// unprinted) and leave the barrier closed; the operator handles the held car.
// Crucially, NO vehicle_entry is written in that case — we never record an
// "entered" event for a car that didn't get in (decision 2026-06-15).
//
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
// (fail) sign anomaly, stop.
//
// ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held
// or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry
// (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The
// guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what
// barrier feedback exists at the lane:
// - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A
// press prints only while a car is present, and NO second ticket issues until the
// loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's
// input edges to track presence + "armed" per relay.
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
// See wiki/concepts/entry-double-press.md.
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
interface RelayGuardState {
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
lastTicketAt: number;
/** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */
present: boolean;
/** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket
* prints; re-armed when the loop CLEARS (the car drove through). */
armed: boolean;
}
export class EntryFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
/** Guard against double-fire from the same physical press (on edge only). */
readonly #inFlight = new Set<string>();
/** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */
readonly #guard = new Map<string, RelayGuardState>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/** Handle a device input edge. Two kinds of edge matter to this flow:
* (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay
* anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update
* presence state so the guard knows when a car arrives/leaves. The same physical
* input is never both, so we resolve each independently. */
async onInput(e: DeviceInputEvent): Promise<void> {
// Presence-loop edge (both directions matter): keep the per-relay state current.
const presence = relayForPresence(this.#db, e.deviceId, e.input);
if (presence) {
this.#onPresenceEdge(presence, e.edge);
return; // a loop input is not a button — nothing else to do
}
if (e.edge !== "on") return; // for buttons, the release edge is just telemetry
// The firing device must be an access controller, and the pressed input terminal
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
const resolved = relayForButton(this.#db, e.deviceId, e.input);
if (!resolved) return;
// ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown)
const suppressed = this.#suppressReason(resolved);
if (suppressed) {
this.#recordSuppressedPress(e, resolved, suppressed);
this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`);
return;
}
const key = `${e.deviceId}:${e.input}`;
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
this.#inFlight.add(key);
try {
await this.#runEntry(resolved);
} catch (err) {
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
} finally {
this.#inFlight.delete(key);
}
}
/** Stable per-relay key for the guard map. */
#relayKey(r: ResolvedRelay): string {
return `${r.controller.id}:${r.relay}`;
}
/** Lazily get (or create) the guard state for a relay. New relays start ARMED and
* with no car present, so the first press on a fresh lane works immediately. */
#guardState(r: ResolvedRelay): RelayGuardState {
const key = this.#relayKey(r);
let s = this.#guard.get(key);
if (!s) {
s = { lastTicketAt: 0, present: false, armed: true };
this.#guard.set(key, s);
}
return s;
}
/** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing;
* the car LEAVING the loop (after its entry) re-arms for the NEXT car. */
#onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void {
const s = this.#guardState(r);
if (edge === "on") {
s.present = true; // a vehicle is at the barrier
} else {
// Loop cleared: the car drove through (or backed off). Re-arm for the next car —
// this is the gate that makes a *new* car necessary before another ticket.
s.present = false;
s.armed = true;
}
}
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
#suppressReason(r: ResolvedRelay): string | null {
const s = this.#guardState(r);
if (typeof r.presenceInput === "number") {
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
// ticket already issued for this still-present car).
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
if (!s.armed) return "ticket already issued for the car at the barrier";
return null;
}
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
const elapsed = Date.now() - s.lastTicketAt;
if (elapsed < r.entryCooldownSec * 1000) {
const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000);
return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`;
}
}
return null;
}
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: e.deviceId,
category: "access",
kind: "input",
detail: {
driverId: e.driverId,
input: e.input,
edge: e.edge,
entrySuppressed: true,
relay: r.relay,
reason,
},
occurredAt: e.at,
})
.run();
} catch (err) {
this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`);
}
}
async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
// they aren't locked out. "Full" is a soft policy seam for valet over-
// capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db);
if (occ.full) {
// No ticket id exists for a refused entry, so mint a synthetic ref to key the
// anomaly + its evidence snapshot together. The operator wants the photo of WHO
// was turned away (a fraud/dispute signal), so we still fire the entry camera.
const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
await this.#log.append({
type: "anomaly",
identity: refusedRef,
payload: {
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
entryRefused: true,
full: true,
},
});
this.#fireSnapshot("entry", refusedRef);
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
return;
}
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
const printers = this.#loadPrinters();
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket),
);
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
// ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm +
// stamp the cooldown so a repeat press (held button / mashing) issues no second
// ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode
// re-allows after entryCooldownSec. Done on the print success, NOT the open.
const guard = this.#guardState(resolved);
guard.lastTicketAt = Date.now();
guard.armed = false;
} catch (err) {
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
// failed attempt is in the tamper-evident record for the operator.
const reason =
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
await this.#log.append({
type: "anomaly",
identity: ticketId,
payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
});
// Capture who is held at the barrier (evidence for the operator handling the car).
this.#fireSnapshot("entry", ticketId);
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return;
}
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
// `category` is FROZEN here (in the signed payload) so the tariff prices and
// later reprices the same way at exit. Today every transient takes the SITE
// default category (operator policy, site_config.default_vehicle_category;
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
// is the future seam — source it from `resolved` then. A V1/no-category tariff
// ignores it; only V2 category cards consult it.
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const category =
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
? cfg.defaultVehicleCategory
: DEFAULT_VEHICLE_CATEGORY;
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: "ticket",
identity: ticketId,
payload: { sessionRef: ticketId, ticketPrinted: true, category },
occurredAt: issuedAt,
});
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
// a camera failure must not delay or block the already-open barrier).
this.#fireSnapshot("entry", ticketId);
// 4. Update the session projection cache (rebuildable from the ledger; this is
// just a fast read-model, never the source of truth).
try {
this.#db
.insert(sessions)
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
.run();
} catch (err) {
// Cache miss is non-fatal — the ledger is authoritative and the projection
// can be rebuilt. Log it; don't fail the (already-open) entry.
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
}
}
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
* held car is exactly when the operator wants the photo. */
#fireSnapshot(direction: "entry", identity: string): void {
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
);
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
/** Build live ENTRY printer instances (for failover selection). */
#loadPrinters(): PrinterInstance[] {
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** Park identity for the ticket header, from site_config (all fields optional;
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
#ticketHeader(): TicketHeader | undefined {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
}
/**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
*
* Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles.
*
* Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
* Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
* The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of
* failing as "session not found".
*/
function newTicketId(): string {
let body = "";
for (let i = 0; i < 10; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body);
}
/** The Luhn (mod-10) check digit for an all-digit string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Walk right-to-left; the check digit sits at position 0 from the right, so the
// last body digit is an "even" position that gets doubled.
let double = true;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return String((10 - (sum % 10)) % 10);
}
/**
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
* Lets a manual-entry path (operator types the code off the ticket when readers are
* down) reject a typo up front. A scanned/looked-up id that predates this format
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
* never reject an id that already exists in the ledger. See ticket-encoding.md.
*/
export function validateTicketCode(code: string): boolean {
// Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
// Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
// (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
if (!/^\d{10,14}$/.test(code)) return false;
const body = code.slice(0, -1);
return luhnCheckDigit(body) === code[code.length - 1];
}
+88
View File
@@ -0,0 +1,88 @@
import { eq, subscriptions, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
// We resolve them when serializing an event for the API / WS feed — never on the
// signed record itself.
//
// Today: a subscription occurrence's identity is an opaque `SUBSESS-…` key. The human
// who matters is the subscription HOLDER, whose name lives on the subscriptions row
// (mutable master data — NOT signed into the event). We resolve payload.permitId →
// holder_name so the feed reads "Aqif Kopertoni" rather than "SUBSESS-08cd1c52e219".
/** Fallback label when a subscription has no holder name (or was deleted). Matches the
* i18n key `booth.subscriberFallback`; kept here in English for the API/log layer. */
const SUBSCRIBER_FALLBACK = "Subscriber";
/** Tiny holder-name cache. Single-writer SQLite; a subscription rename is rare and the
* feed is not security-sensitive, so a short-lived cache is plenty. Invalidate by
* process lifetime — restart picks up renames; for live correctness the lookup is
* cheap enough that we just read per miss. */
const holderCache = new Map<string, string | null>();
/** Resolve a subscription id to its holder name (or null), memoized. */
function holderName(db: Db, permitId: string): string | null {
if (holderCache.has(permitId)) return holderCache.get(permitId) ?? null;
const row = db
.select({ holderName: subscriptions.holderName })
.from(subscriptions)
.where(eq(subscriptions.id, permitId))
.get();
const name = row?.holderName?.trim() || null;
holderCache.set(permitId, name);
return name;
}
/** Drop a cached holder name (call after a subscription create/update/delete). */
export function invalidateHolder(permitId: string): void {
holderCache.delete(permitId);
}
/** Clear the whole holder cache (call on bulk subscription changes). */
export function clearHolderCache(): void {
holderCache.clear();
}
/**
* Attach read-time display fields to a raw ledger row before it goes to a client:
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
* - `plate` for an entry/exit event whose session has an advisory ANPR read.
* Idempotent and cheap; events without either pass through unchanged. Used by the WS
* feed (per event). For the bulk feed page prefer `enrichEvents` (one plate scan).
*/
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
let out: T = event;
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if ((event.type === "vehicle_entry" || event.type === "vehicle_exit") && event.identity) {
const p = plateForIdentity(db, event.identity);
if (p) out = { ...out, plate: p.plate };
}
return out;
}
/**
* Bulk variant for the feed page: enriches a list of events with subscriber labels AND
* plates using a SINGLE device_events scan for all the plates (instead of one per row).
* Order preserved.
*/
export function enrichEvents<T extends LedgerEvent>(db: Db, events: T[]): T[] {
// Collect identities of entry/exit events to resolve their plates in one scan.
const wanted = new Set<string>();
for (const e of events) {
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) wanted.add(e.identity);
}
const plates = wanted.size ? platesForIdentities(db, wanted) : new Map();
return events.map((e) => {
let out: T = e;
const permitId = e.payload && typeof e.payload.permitId === "string" ? e.payload.permitId : null;
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) {
const p = plates.get(e.identity);
if (p) out = { ...out, plate: p.plate };
}
return out;
});
}
+85 -19
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { desc, events, type Db, type EventRow } from "@parking/db"; import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared"; import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
// The append-only, hash-chained, signed event log — the system's core anti-fraud // The append-only, hash-chained, signed event log — the system's core anti-fraud
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device // primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
@@ -16,11 +16,12 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
// so we guard it with an in-process async lock as well. // so we guard it with an in-process async lock as well.
export interface AppendInput { export interface AppendInput {
readonly type: ParkingEventType; readonly type: LedgerEventType;
readonly lane: number;
readonly direction?: Direction | null; readonly direction?: Direction | null;
readonly source?: IdentitySource | null; readonly source?: IdentitySource | null;
readonly identity?: string | null; readonly identity?: string | null;
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
readonly payload?: LedgerPayload | null;
/** Event time (ISO-8601). Defaults to now. */ /** Event time (ISO-8601). Defaults to now. */
readonly occurredAt?: string; readonly occurredAt?: string;
} }
@@ -36,9 +37,9 @@ export function canonicalize(e: {
index: number; index: number;
type: string; type: string;
direction: string | null; direction: string | null;
lane: number;
source: string | null; source: string | null;
identity: string | null; identity: string | null;
payload: Record<string, unknown> | null;
occurredAt: string; occurredAt: string;
prevHash: string | null; prevHash: string | null;
}): string { }): string {
@@ -46,57 +47,107 @@ export function canonicalize(e: {
e.index, e.index,
e.type, e.type,
e.direction ?? null, e.direction ?? null,
e.lane,
e.source ?? null, e.source ?? null,
e.identity ?? null, e.identity ?? null,
// Payload is part of the signed form so business data is tamper-evident.
// Serialize with sorted keys for byte-stability (object key order must not
// change a signature). null when the event type carries no payload.
canonicalPayload(e.payload),
e.occurredAt, e.occurredAt,
e.prevHash ?? null, e.prevHash ?? null,
]); ]);
} }
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
if (p == null) return null;
const sort = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sort);
if (v && typeof v === "object") {
return Object.keys(v as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((o, k) => {
o[k] = sort((v as Record<string, unknown>)[k]);
return o;
}, {});
}
return v;
};
return sort(p);
}
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */ /** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
export function hashEvent(canonical: string): string { export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex"); return createHash("sha256").update(canonical, "utf8").digest("hex");
} }
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
* Returns undefined when the key that signed an event is not available. */
export type SignerResolver = (keyId: string) => Signer | undefined;
export class EventLog { export class EventLog {
readonly #db: Db; readonly #db: Db;
readonly #signer: Signer; readonly #signer: Signer;
/** Picks the verifying signer per event keyId; lets a chain span key rotations
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
* callers that don't pass one (single-key chains, tests). */
readonly #resolveVerifier: SignerResolver;
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
* to fan the event out to live booth clients (WS). It is best-effort and must
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
readonly #onAppended?: (row: LedgerEventRow) => void;
/** Serialize appends: each waits for the previous to finish. */ /** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve(); #tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) { constructor(
db: Db,
signer: Signer,
resolveVerifier?: SignerResolver,
onAppended?: (row: LedgerEventRow) => void,
) {
this.#db = db; this.#db = db;
this.#signer = signer; this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
this.#onAppended = onAppended;
} }
/** Append one event to the chain. Returns the persisted row. Serialized. */ /** Append one event to the chain. Returns the persisted row. Serialized. */
append(input: AppendInput): Promise<EventRow> { append(input: AppendInput): Promise<LedgerEventRow> {
const run = this.#tail.then(() => this.#appendNow(input)); const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock). // Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined); this.#tail = run.catch(() => undefined);
return run; // Read-side notification, AFTER the row is durably written. Wrapped so a
// failing sink can never reject the append or break the chain lock above.
return run.then((row) => {
try {
this.#onAppended?.(row);
} catch {
// best-effort fan-out only — swallow.
}
return row;
});
} }
#appendNow(input: AppendInput): EventRow { #appendNow(input: AppendInput): LedgerEventRow {
const prev = this.#db const prev = this.#db
.select() .select()
.from(events) .from(ledgerEvents)
.orderBy(desc(events.index)) .orderBy(desc(ledgerEvents.index))
.limit(1) .limit(1)
.get(); .get();
const index = (prev?.index ?? 0) + 1; const index = (prev?.index ?? 0) + 1;
const prevHash = prev ? hashEvent(canonicalize(prev)) : null; const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
const occurredAt = input.occurredAt ?? new Date().toISOString(); const occurredAt = input.occurredAt ?? new Date().toISOString();
const payload = input.payload ?? null;
const canonical = canonicalize({ const canonical = canonicalize({
index, index,
type: input.type, type: input.type,
direction: input.direction ?? null, direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload,
occurredAt, occurredAt,
prevHash, prevHash,
}); });
@@ -106,26 +157,33 @@ export class EventLog {
index, index,
type: input.type, type: input.type,
direction: input.direction ?? null, direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload,
occurredAt, occurredAt,
prevHash, prevHash,
signature: this.#signer.sign(canonical), signature: this.#signer.sign(canonical),
keyId: this.#signer.keyId,
}; };
this.#db.insert(events).values(row).run(); this.#db.insert(ledgerEvents).values(row).run();
return row as EventRow; return row as LedgerEventRow;
} }
/** /**
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the * Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an * first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted * integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature. * row (index gap), a forged/invalid signature, and an event signed under a key
* that is no longer configured.
*
* Each row is verified against the signer for ITS OWN `keyId`, not the current
* append signer — so a chain that spans a key rotation (e.g. early events under
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
* verifies end to end. See signer.buildVerifier.
*/ */
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(events).orderBy(events.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
let expectedIndex = 1; let expectedIndex = 1;
let prevHash: string | null = null; let prevHash: string | null = null;
for (const row of rows) { for (const row of rows) {
@@ -135,8 +193,16 @@ export class EventLog {
if ((row.prevHash ?? null) !== prevHash) { if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" }; return { ok: false, index: row.index, reason: "prevHash does not match chain" };
} }
const verifier = this.#resolveVerifier(row.keyId);
if (!verifier) {
return {
ok: false,
index: row.index,
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
};
}
const canonical = canonicalize(row); const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) { if (!verifier.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" }; return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
} }
prevHash = hashEvent(canonical); prevHash = hashEvent(canonical);
+494
View File
@@ -0,0 +1,494 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
// the session → validate it is PAID and within the walk-back grace → sign a
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
//
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
// projection cache: find the open vehicle_entry for this identity, then a covering
// payment within grace. The cache is updated after, for fast reads.
//
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
// is sent back to the pay station, the rejection is logged.
//
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
// passable once the pay-station + `payment` events land.
interface SessionView {
readonly identity: string;
readonly enteredAt: string;
readonly open: boolean; // no vehicle_exit yet
readonly paidAt: string | null; // latest payment time, if any
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
* exit / re-open without a `payment`. */
readonly subscription: boolean;
readonly graceExitMin: number | null; // from the payment's tariff context, if known
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
// active tariff resolves (then we fall back to the normal paid check).
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
}
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
* intervention was recorded (signed anomaly) but the relay did not fire. */
export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
export class ExitFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the exit image. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/**
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
* relay site-wide, pulses it, and fires the exit snapshot.
*
* Returns a discriminated result so the route can react precisely:
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
* — nothing is signed beyond the existing anomaly; the operator takes payment.
* - { ok: true, opened: true } on a clean exit.
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
const key = `booth:${id}`;
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
this.#inFlight.add(key);
try {
const view = this.#sessionFor(id);
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
// path) so a booth attempt on a bad ticket is auditable.
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
}
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
const freeGrace = view.paidAt == null && view.freeGrace != null;
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!freeGrace && (!paid || !withinGrace)) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
await this.#log.append({
type: "payment",
identity: id,
payload: {
sessionRef: id,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
}
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
// to let the car out has been made and validated. Then attempt the open.
await this.#signExit(id);
if (!resolved) {
await this.#openFailedAnomaly(id, "no exit relay configured");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
await this.#openFailedAnomaly(id, "exit controller would not build");
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
this.#fireExitSnapshot(id);
this.#closeSessionCache(id);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/**
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
* opens the barrier with a signed trace.
*
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
* the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
*
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
* second exit (that would double-count occupancy): anomaly only, as before.
* See wiki/concepts/booth-exit-flow.md.
*/
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
const id = identity.trim();
if (!id) return { ok: false, reason: "ticket id required" };
const view = this.#sessionFor(id);
if (!view) return { ok: false, reason: "no session for ticket" };
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
// the operator must assist when the exit reader / card fails) OR a transient whose
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
// authorize a free open: a car that paid once and then sat inside past grace owes a
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
// path. So we mirror the exit flow's grace check here (not just in the UI): an
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!view.subscription && (!paid || !withinGrace)) {
return {
ok: false,
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
};
}
const key = `reopen:${id}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
this.#inFlight.add(key);
try {
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
...reasonPayload("exit.manualOpen"),
source: "booth",
barrierReopen: true,
...(operator ? { operator } : {}),
},
});
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
// session leaves the active list + occupancy settles. Skip when already exited
// (no double-count). Recorded as a human-intervention exit for the audit trail.
if (view.open) {
await this.#signExit(id, "manual");
this.#closeSessionCache(id);
this.#fireExitSnapshot(id);
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
}
if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
}
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher from the reader's binding, which has ruled out a subscription match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#runExit(resolved, e);
} catch (err) {
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const view = this.#sessionFor(e.value);
// No matching open session — unknown/duplicate ticket. Reject + log.
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { ...rp, exitRefused: true },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused: no open session for ${e.value}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
// its "an exit is covered by a payment" invariant, then fall through to open.
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
if (view.paidAt == null && view.freeGrace) {
await this.#log.append({
type: "payment",
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
identity: e.value,
payload: {
sessionRef: e.value,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
...reasonPayload("exit.freeGrace"),
},
});
this.#logger.info(`exit free within entry-grace (${e.value})`);
return this.#signExitAndOpen(resolved, e);
}
// PAID + within walk-back grace?
const paid = view.paidAt != null;
const withinGrace =
paid &&
view.graceExitMin != null &&
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!paid || !withinGrace) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { ...rp, exitRefused: true, sessionRef: e.value },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
// Valid (a real payment within walk-back grace): sign + open.
return this.#signExitAndOpen(resolved, e);
}
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
* Shared by the paid-exit and free-entry-grace paths. The caller has already
* established the session is allowed out (and, for grace, minted the $0 payment). */
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
this.#fireExitSnapshot(e.value);
this.#closeSessionCache(e.value);
return { accepted: true, direction: "exit" };
}
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
* or "manual" (a human-intervention barrier re-open that closes an open session —
* see reopenBarrier). */
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity,
payload: {
sessionRef: identity,
...(source === "manual" ? reasonPayload("exit.manualOpen") : {}),
},
});
}
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
#fireExitSnapshot(identity: string): void {
void snapshotAsync({
db: this.#db,
direction: "exit",
identity,
logger: this.#logger,
vision: this.#vision,
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
}
/** Update the (rebuildable) session projection cache to closed. */
#closeSessionCache(identity: string): void {
try {
this.#db
.update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "closed" })
.where(eq(sessions.id, identity))
.run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
}
}
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
* The payment + exit STAND; this tells the operator to open manually. */
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity,
payload: { ...reasonPayload("exit.open.failed"), detail, source: "booth", exitOpenFailed: true },
});
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
}
/** Fold the signed ledger into a session view for one identity (authoritative). */
#sessionFor(identity: string): SessionView | null {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
if (rows.length === 0) return null;
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const exited = rows.some((r) => r.type === "vehicle_exit");
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
// Free entry-grace: if the tariff prices entry→now at 0 (a quick in-and-out),
// the exit may open at the gate. Resolve against the tariff in force at entry,
// same as the pay station. Null when no payment is needed yet and no tariff
// resolves — then exit falls back to the normal paid check.
let freeGrace: SessionView["freeGrace"] = null;
if (!exited && paidAt == null) {
const tv = this.#tariffVersionFor(entry.occurredAt);
if (tv) {
const structure = tv.structure as unknown as TariffStructure;
// Same frozen-at-entry category the pay station uses, so the free-grace
// check agrees with the booth quote for V2 category tariffs.
const category = (entry.payload as { category?: string } | null)?.category;
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
if (fee === 0) {
freeGrace = {
tariffVersionId: tv.id,
currency: tv.currency,
graceExitMin: structure.gracePeriodExitMin,
};
}
}
}
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
return {
identity,
enteredAt: entry.occurredAt,
open: !exited,
paidAt,
subscription,
graceExitMin,
freeGrace,
};
}
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
-30
View File
@@ -1,30 +0,0 @@
import { laneDevices, type Db } from "@parking/db";
// Resolves a device instance id (lane_devices.id) to its lane number.
//
// Device pushes/events carry the `lane_devices` id (which device fired), not a
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
// rebuilt from the DB at startup and refreshed whenever assignments change
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
// every input event, so a cached map beats a per-event DB lookup.
export class LaneMap {
readonly #db: Db;
#byDeviceId = new Map<string, number>();
constructor(db: Db) {
this.#db = db;
}
/** (Re)load the id->lane map from the lane_devices table. */
refresh(): void {
const rows = this.#db.select().from(laneDevices).all();
const next = new Map<string, number>();
for (const r of rows) next.set(r.id, r.lane);
this.#byDeviceId = next;
}
/** Lane for a device instance id, or null if the device isn't known. */
laneFor(deviceId: string): number | null {
return this.#byDeviceId.get(deviceId) ?? null;
}
}
+236
View File
@@ -0,0 +1,236 @@
import { randomUUID } from "node:crypto";
import { and, appLogs, desc, eq, sql, type Db } from "@parking/db";
import {
LOG_LEVEL_ORDER,
type AppLogRecord,
type ClientLogInput,
type LogLevel,
type LogSource,
} from "@parking/shared";
// Application/diagnostic LOG SINK — the host-side store behind the third log stream
// (app_logs), distinct from the signed ledger and device telemetry. It persists:
// - BACKEND warn/error/fatal, fed by a pino stream (see pinoDbStream) so any
// app.log.warn/error lands in the DB without changing call sites.
// - FRONTEND errors POSTed to /api/logs (failed requests, uncaught errors).
// Everything here is UNSIGNED + prunable. Pruned by age AND a row cap so an offline
// appliance with finite disk can't be filled by a log storm. See
// wiki/concepts/app-logs.md, decisions/event-streams-split.md.
/** Only warn and above are persisted from the backend (info/debug stay stdout-only). */
const BACKEND_PERSIST_MIN: LogLevel = "warn";
/** Defensive caps so one runaway log can't bloat a row (chars). */
const MAX_MESSAGE = 4_000;
const MAX_STACK = 16_000;
const MAX_CONTEXT_JSON = 16_000;
export interface LogRetention {
/** Delete logs older than this many days. */
readonly maxAgeDays: number;
/** Hard cap on total rows — the oldest beyond this are pruned. */
readonly maxRows: number;
}
export const DEFAULT_RETENTION: LogRetention = {
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
};
function clamp(s: string | null | undefined, max: number): string | null {
if (s == null) return null;
return s.length > max ? s.slice(0, max) : s;
}
/** Serialize context to JSON, bounded — never throw on a circular/huge object. */
function safeContext(ctx: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (ctx == null) return null;
try {
const json = JSON.stringify(ctx);
if (json.length <= MAX_CONTEXT_JSON) return ctx;
return { _truncated: true, preview: json.slice(0, MAX_CONTEXT_JSON) };
} catch {
return { _unserializable: true };
}
}
export class LogService {
readonly #db: Db;
readonly #retention: LogRetention;
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
#writing = false;
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
this.#db = db;
this.#retention = retention;
}
/** Low-level insert. Best-effort: a logging failure must never break a request or
* recurse (a DB error here would otherwise log → insert → error → log …). */
#insert(row: {
level: LogLevel;
source: LogSource;
message: string;
context?: Record<string, unknown> | null;
httpStatus?: number | null;
path?: string | null;
stack?: string | null;
userId?: string | null;
userAgent?: string | null;
createdAt?: string;
}): void {
if (this.#writing) return;
this.#writing = true;
try {
this.#db
.insert(appLogs)
.values({
id: randomUUID(),
level: row.level,
source: row.source,
message: clamp(row.message, MAX_MESSAGE) ?? "",
context: safeContext(row.context),
httpStatus: row.httpStatus ?? null,
path: clamp(row.path, 512),
stack: clamp(row.stack, MAX_STACK),
userId: row.userId ?? null,
userAgent: clamp(row.userAgent, 512),
createdAt: row.createdAt ?? new Date().toISOString(),
})
.run();
} catch {
// Swallow — diagnostics must never take down the path they observe. (Can't log
// it; that's the recursion we're guarding against.)
} finally {
this.#writing = false;
}
}
/** Persist a BACKEND log line (called by the pino stream). Below warn is dropped. */
recordBackend(level: LogLevel, message: string, context?: Record<string, unknown> | null): void {
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
this.#insert({ level, source: "backend", message, context });
}
/** Persist a FRONTEND-reported log (from POST /api/logs). The server stamps the
* user + receive time; the client supplies level/message/context. */
recordClient(
input: ClientLogInput,
meta: { userId?: string | null; userAgent?: string | null },
): void {
this.#insert({
level: input.level,
source: "frontend",
message: input.message,
context: input.context ?? null,
httpStatus: input.httpStatus ?? null,
path: input.path ?? null,
stack: input.stack ?? null,
userId: meta.userId ?? null,
userAgent: meta.userAgent ?? null,
// Keep the client's capture time in context for ordering; createdAt is server time.
createdAt: new Date().toISOString(),
});
}
/** Read recent logs, newest first, with optional level/source/since filters. */
query(opts: {
limit: number;
level?: LogLevel;
source?: LogSource;
since?: string;
}): AppLogRecord[] {
const conds = [];
if (opts.level) conds.push(eq(appLogs.level, opts.level));
if (opts.source) conds.push(eq(appLogs.source, opts.source));
if (opts.since) conds.push(sql`${appLogs.createdAt} >= ${opts.since}`);
const rows = this.#db
.select()
.from(appLogs)
.where(conds.length ? and(...conds) : undefined)
.orderBy(desc(appLogs.createdAt))
.limit(opts.limit)
.all();
return rows as unknown as AppLogRecord[];
}
/** Prune by age then by row cap. Returns how many rows were deleted. Safe to call
* on a timer; cheap (indexed on created_at). */
prune(): number {
let deleted = 0;
try {
const cutoff = new Date(Date.now() - this.#retention.maxAgeDays * 86_400_000).toISOString();
const byAge = this.#db.delete(appLogs).where(sql`${appLogs.createdAt} < ${cutoff}`).run();
deleted += byAge.changes ?? 0;
// Row cap: keep the newest maxRows, delete the rest. One subquery — find the
// created_at boundary of the keep-window, delete older.
const total = this.#db.select({ c: sql<number>`count(*)` }).from(appLogs).get();
const count = total?.c ?? 0;
if (count > this.#retention.maxRows) {
const boundary = this.#db
.select({ createdAt: appLogs.createdAt })
.from(appLogs)
.orderBy(desc(appLogs.createdAt))
.limit(1)
.offset(this.#retention.maxRows - 1)
.get();
if (boundary) {
const byCap = this.#db
.delete(appLogs)
.where(sql`${appLogs.createdAt} < ${boundary.createdAt}`)
.run();
deleted += byCap.changes ?? 0;
}
}
} catch {
// best-effort
}
return deleted;
}
}
/**
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
* Pino writes one JSON object per line to this stream; we parse, map the numeric level
* to a name, and persist. Returned as `{ write }` so it can be passed as pino's stream.
* stdout still receives the same line (we tee), so console logging is unchanged.
*/
export function pinoDbStream(
service: LogService,
tee: NodeJS.WritableStream,
): { write: (line: string) => void } {
const NUM_TO_LEVEL: Record<number, LogLevel> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
};
return {
write(line: string): void {
// Always tee to the original destination first (don't lose stdout logging).
try {
tee.write(line);
} catch {
/* ignore */
}
try {
const obj = JSON.parse(line) as {
level?: number;
msg?: string;
err?: { stack?: string; message?: string };
[k: string]: unknown;
};
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
// Strip pino's noisy standard fields from the persisted context.
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
service.recordBackend(level, typeof msg === "string" ? msg : "", rest);
} catch {
// A non-JSON line (shouldn't happen with pino) — ignore for persistence.
}
},
};
}
+109
View File
@@ -0,0 +1,109 @@
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
// with no matching vehicle_exit. Never a hand-maintained counter (which is
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
export interface Occupancy {
/** Cars currently inside (open sessions). */
readonly count: number;
/** Spots HELD for active subscribers who are NOT currently parked (when the
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
* `quantity` spots minus however many of its cars are already inside. */
readonly reserved: number;
/** Admin-set nominal capacity, or null = no limit. */
readonly capacity: number | null;
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
readonly free: number | null;
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
readonly effectiveFree: number | null;
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
* (always false when uncapped). Subscribers are never gated by this. */
readonly full: boolean;
}
/** Count cars inside: entries minus exits, per identity, over the ledger. */
export function occupancyCount(db: Db): number {
const rows = db
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
.from(ledgerEvents)
.all();
const balance = new Map<string, number>();
for (const r of rows) {
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
}
let open = 0;
for (const v of balance.values()) if (v > 0) open += 1;
return open;
}
/** Admin-set capacity (null = uncapped). */
export function siteCapacity(db: Db): number | null {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return row?.capacity ?? null;
}
/**
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
* subscription already inside (so we never double-count a parked subscriber). This is
* what makes a transient see "full" sooner while the subscriber's spot is held.
*/
export function reservedSubscriberSpots(db: Db): number {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!cfg?.reserveSubscriberSpots) return 0;
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
const insidePerSub = new Map<string, number>();
const net = new Map<string, number>(); // occurrence identity → entries−exits
const subOf = new Map<string, string>(); // occurrence identity → subscription id
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const pl = (r.payload ?? {}) as { permitId?: string };
if (pl.permitId == null) continue; // transient
net.set(id, (net.get(id) ?? 0) + 1);
subOf.set(id, pl.permitId);
} else if (r.type === "vehicle_exit") {
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
}
}
for (const [id, n] of net) if (n > 0) {
const sub = subOf.get(id)!;
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
}
const now = new Date().toISOString();
const subs = db.select().from(subscriptions).all();
let reserved = 0;
for (const s of subs) {
const active =
s.status === "active" &&
(s.validFrom == null || now >= s.validFrom) &&
(s.validTo == null || now <= s.validTo);
if (!active) continue;
const qty = s.quantity ?? 1;
const inside = insidePerSub.get(s.id) ?? 0;
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
}
return reserved;
}
export function getOccupancy(db: Db): Occupancy {
const count = occupancyCount(db);
const capacity = siteCapacity(db);
const reserved = reservedSubscriberSpots(db);
return {
count,
reserved,
capacity,
free: capacity == null ? null : capacity - count,
effectiveFree: capacity == null ? null : capacity - count - reserved,
// A transient is refused once physical cars + held subscriber spots reach capacity.
full: capacity != null && count + reserved >= capacity,
};
}
+550
View File
@@ -0,0 +1,550 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { windowOwedBetween } from "./subscription-window.js";
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
// car (pay-on-foot — payment is decoupled from exit). Two steps:
// 1. quote(identity) → look up the open session, price it against the tariff in
// force at entry, return the amount due (no side effect).
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
// flow can validate paid + within walk-back grace). Payment is a signed ledger
// event, never a mutable "paid" flag — an operator can't forge or delete it.
// See wiki/concepts/tariff.md, parking-session.md.
export class NoOpenSessionError extends Error {
constructor(identity: string) {
super(`no open session for ${identity}`);
this.name = "NoOpenSessionError";
}
}
export class NoTariffError extends Error {
constructor() {
super("no active tariff configured");
this.name = "NoTariffError";
}
}
export interface Quote {
readonly identity: string;
/** Vehicle entry time (the session's original entry; for display/audit). */
readonly enteredAt: string;
/** Start of the period being billed RIGHT NOW. For a first payment this is the
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
* re-parked / a new period began) it is the moment that grace expired: the overstay
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now]. */
readonly amountMinor: number;
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean;
readonly currency: string;
readonly tariffVersionId: string;
readonly graceExitMin: number;
}
/** One row in the booth Active Sessions list. A session is "active" while it is
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
* paid/exited car is presumed possibly-still-present until grace expires. The
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
export interface ActiveSession {
readonly identity: string;
readonly source: string | null;
readonly enteredAt: string;
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
readonly exitedAt: string | null;
readonly open: boolean;
readonly paidAt: string | null;
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
readonly amountMinor: number | null;
readonly currency: string | null;
readonly withinGrace: boolean;
readonly graceExpiresAt: string | null;
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
* See wiki/concepts/booth-exit-flow.md. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
readonly subscription: boolean;
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
readonly subscriptionId: string | null;
/** The subscriber's holder name (for a friendly label instead of the raw key). */
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for
* at-a-glance identification. Null when no plate was read. Never an access decision. */
readonly plate: string | null;
}
/** Booth session view: everything the pay/exit modal needs in one read. */
export interface SessionLookup {
readonly identity: string;
readonly found: boolean;
/** Open = entered, no exit yet. */
readonly open: boolean;
readonly enteredAt: string | null;
readonly exitedAt: string | null;
/** Latest payment time, if paid. */
readonly paidAt: string | null;
/** Amount owed right now (the quote). Null when no session / no active tariff. */
readonly amountMinor: number | null;
readonly currency: string | null;
/** True when paid AND still within the walk-back grace window. */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
readonly subscription: boolean;
readonly subscriptionId: string | null;
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */
readonly plate: string | null;
}
export class PayStation {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
* reflects the reality and re-accrues the fee. No side effect. */
quote(identity: string): Quote {
const entry = this.#openEntry(identity);
if (!entry) throw new NoOpenSessionError(identity);
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
// for an overstay period — the customer keeps the rate card they entered under.
const tv = this.#tariffVersionFor(entry.occurredAt);
if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure;
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
// both read it from there, so a V2 category tariff yields the same amount at the
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
const category = (entry.payload as { category?: string } | null)?.category;
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
// matters for grace/overstay; pass it through. Overstay → fresh period from
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
const last = this.#lastPayment(identity);
const p = priceSession(
entry.occurredAt,
new Date().toISOString(),
structure,
last ? [last] : [],
category,
);
return {
identity,
enteredAt: entry.occurredAt,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
overstay: p.overstay,
currency: tv.currency,
tariffVersionId: tv.id,
graceExitMin: structure.gracePeriodExitMin,
};
}
/** The latest signed `payment` for this session (time + the grace window it granted),
* or null if never paid. Folds the append-only ledger. */
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
const rows = this.#db
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
let last: { paidAt: string; graceExitMin: number | null } | null = null;
for (const r of rows) {
if (r.type !== "payment") continue;
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
}
return last;
}
/**
* Take payment for a session and append the signed `payment` event. Re-quotes at
* the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
* `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) —
* recorded as the charged amount.
*/
async pay(
identity: string,
tender: Tender,
overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> {
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
// to the occurrence so the exit gate (#windowOwed − payments) clears.
const subWindow = this.#payableSubscriptionWindow(identity);
if (subWindow) {
const amountMinor = overrideMinor ?? subWindow.dueMinor;
await this.#log.append({
type: "payment",
source: "manual",
identity,
payload: {
sessionRef: identity,
amountMinor,
currency: subWindow.currency ?? undefined,
tender,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
},
});
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
return { amountMinor, currency: subWindow.currency ?? "" };
}
const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({
type: "payment",
source: "manual",
identity,
payload: {
sessionRef: identity,
amountMinor,
currency: q.currency,
tender,
tariffVersionId: q.tariffVersionId,
// The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
},
});
// Update the projection cache (rebuildable; not the source of truth).
try {
this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run();
} catch (err) {
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
}
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
return { amountMinor, currency: q.currency };
}
/**
* One-read session view for the booth pay/exit modal: entry/exit times, paid
* state, amount owed now, and walk-back-grace status. Read-only — folds the
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
* rather than throwing, so the modal can still show the session.
*/
lookup(identity: string): SessionLookup {
const id = identity.trim();
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
const exitRow = rows.find((r) => r.type === "vehicle_exit");
const open = !exitRow;
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
const graceExpiresAt =
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
// on the entry payload + a live late-exit charge), which the booth must take so the
// exit gate clears. See wiki/entities/subscription.md.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
} else if (open && isSubscription) {
const w = this.#subscriptionWindowDue(id, subscriptionId);
if (w && w.dueMinor > 0) {
amountMinor = w.dueMinor;
currency = w.currency;
}
}
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
return {
identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
};
}
/**
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
* until grace expires). One ledger scan, grouped by identity (cheaper than N
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
* (authoritative — not the sessions projection cache, which can drift).
* See wiki/concepts/booth-exit-flow.md.
*/
activeSessions(): ActiveSession[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Group the relevant events per identity in one pass.
type Acc = {
enteredAt?: string;
source: string | null;
exitedAt?: string;
paidAt?: string;
graceExitMin?: number;
subscriptionId?: string | null;
};
const byId = new Map<string, Acc>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const a = byId.get(id) ?? { source: r.source ?? null };
a.enteredAt = r.occurredAt;
a.source = r.source ?? a.source;
// Subscription occurrence? The entry payload carries permit:true + permitId
// (the on-chain field). Mark it so the booth never tries to charge it.
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
byId.set(id, a);
} else if (r.type === "vehicle_exit") {
const a = byId.get(id);
if (a) a.exitedAt = r.occurredAt;
} else if (r.type === "payment") {
const a = byId.get(id);
if (a) {
a.paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
}
}
}
// Resolve advisory plates for all candidate identities in ONE device_events scan
// (cheaper than one lookup per row).
const plates = platesForIdentities(this.#db, byId.keys());
const now = Date.now();
const out: ActiveSession[] = [];
for (const [identity, a] of byId) {
if (!a.enteredAt) continue; // no entry → not a real session
const open = a.exitedAt == null;
const graceExpiresAt =
a.paidAt && a.graceExitMin != null
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
: null;
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
const isSubscription = a.subscriptionId !== undefined;
// ACTIVE membership:
// - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
// age these out (a silent display filter); now we KEEP them and flag `overstay`
// so the operator reconciles via a top-up. The signed log is untouched, and the
// barrier never opens for free on these. See booth-exit-flow.md.
if (!open && !withinGrace) continue;
const overstay =
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
// period's top-up). A subscription is prepaid — never quote/charge it.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription && (a.paidAt == null || overstay)) {
try {
const q = this.quote(identity);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null */
}
}
out.push({
identity,
source: a.source,
enteredAt: a.enteredAt,
exitedAt: a.exitedAt ?? null,
open,
paidAt: a.paidAt ?? null,
amountMinor,
currency,
withinGrace,
graceExpiresAt,
overstay,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
plate: plates.get(identity)?.plate ?? null,
});
}
// Newest entry first.
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
return out;
}
/**
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
* now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE
* stay `[entry, now]` (one computation — covers early entry AND late exit without
* double-counting), minus whatever they've already paid against the occurrence. null
* when the plan has no timeframes / nothing is owed. Single source of truth shared with
* the exit gate so the booth quote and the gate agree.
*/
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
if (!subscriptionId) return null;
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
if (!sub) return null;
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
const entryRow = rows.find((r) => r.type === "vehicle_entry");
if (!entryRow) return null;
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
if (!owed) return null;
let paid = 0;
for (const r of rows) {
if (r.type !== "payment") continue;
const pl = (r.payload ?? {}) as { amountMinor?: number };
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
}
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
* the due amount + currency + the tariff version that priced the late-exit charge (for
* the payment payload), or null when it's transient / nothing owed. */
#payableSubscriptionWindow(
identity: string,
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (ep.permit !== true && ep.permitId == null) return null; // transient
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
if (!due || due.dueMinor <= 0) return null;
// Tariff version for the payment payload = the one that priced the stay (resolved at
// entry inside windowOwedBetween).
const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString());
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null };
}
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
#planVersionOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.planVersionId ?? null;
}
/** The subscriber's holder name for a subscription id (for a friendly UI label),
* or null. Best-effort: a deleted subscription just yields null. */
#holderOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
try {
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.holderName ?? null;
} catch {
return null;
}
}
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
#openEntry(identity: string) {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
return entry;
}
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
//
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
// session); within a direction, the newest read wins. Returns the plate text only —
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
/** The best advisory plate observed for a session, for display. */
export interface PlateView {
readonly plate: string;
readonly confidence: number | null;
readonly direction: "entry" | "exit" | null;
}
interface ReadDetail {
identity?: string;
plate?: string;
confidence?: number;
direction?: string;
}
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
export function plateForIdentity(db: Db, identity: string): PlateView | null {
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
}
/** Resolve plates for MANY identities in one device_events scan (used by the active-
* sessions list and the feed page, which each carry tens–hundreds of rows). */
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
const want = new Set(identities);
const out = new Map<string, PlateView>();
if (want.size === 0) return out;
// Newest first so the first acceptable read per (identity,direction) is the freshest.
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const byId = new Map<string, ReadDetail[]>();
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
let list = byId.get(d.identity);
if (!list) byId.set(d.identity, (list = []));
list.push(d);
}
for (const [id, reads] of byId) {
const best = pickBest(reads, id);
if (best) out.set(id, best);
}
return out;
}
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
* exit read; otherwise the first (newest) acceptable read wins. */
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
let fallback: ReadDetail | null = null;
for (const d of reads) {
if (d.identity !== identity || !d.plate) continue;
if (d.direction === "entry") return toView(d);
if (!fallback) fallback = d;
}
return fallback ? toView(fallback) : null;
}
function toView(d: ReadDetail): PlateView {
return {
plate: d.plate!.trim().toUpperCase(),
confidence: typeof d.confidence === "number" ? d.confidence : null,
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
};
}
+4 -5
View File
@@ -1,5 +1,5 @@
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { eq, laneDevices, type Db } from "@parking/db"; import { eq, devices, type Db } from "@parking/db";
import { import {
isMonitorable, isMonitorable,
registry, registry,
@@ -66,8 +66,8 @@ export class PrinterMonitor {
async refreshDevices(): Promise<void> { async refreshDevices(): Promise<void> {
const rows = await this.#db const rows = await this.#db
.select() .select()
.from(laneDevices) .from(devices)
.where(eq(laneDevices.category, "printer")) .where(eq(devices.category, "printer"))
.all(); .all();
const seen = new Set<string>(); const seen = new Set<string>();
@@ -89,7 +89,6 @@ export class PrinterMonitor {
build: () => driver.create(cfg as never), build: () => driver.create(cfg as never),
meta: { meta: {
deviceId: row.id, deviceId: row.id,
lane: row.lane,
driverId: row.driverId, driverId: row.driverId,
role: typeof cfg.role === "string" ? cfg.role : undefined, role: typeof cfg.role === "string" ? cfg.role : undefined,
}, },
@@ -139,7 +138,7 @@ export class PrinterMonitor {
if (!prev || statusChanged(prev.status, status)) { if (!prev || statusChanged(prev.status, status)) {
this.#log.info( this.#log.info(
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`, `printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
); );
deviceEvents.emitPrinterStatus(event); deviceEvents.emitPrinterStatus(event);
} }
+56
View File
@@ -0,0 +1,56 @@
import { devices, eq, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import { relayForDevice } from "./device-resolve.js";
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
// credential is (decision 2026-06-15):
// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
//
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
// resolves to exactly the barrier it sits at, and the direction is inherited from
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
// reader the exit side; "both" defers to the flow's own inference (subscription:
// session state; transient: exit).
export class ReadDispatcher {
readonly #db: Db;
readonly #exit: ExitFlow;
readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger;
constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
this.#exit = exit;
this.#subscription = subscription;
this.#logger = logger;
}
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
if (!reader || !reader.enabled) {
return { accepted: false, reason: "read from unknown/disabled device" };
}
const resolved = relayForDevice(this.#db, reader);
if (!resolved) {
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
}
const sub = this.#subscription.match(e);
if (sub) {
return this.#subscription.run(resolved, e, sub);
}
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
// transient exit (transient entry is the button flow, not a reader), so reject+log
// rather than treat an entry scan as an exit.
if (resolved.direction === "entry") {
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
}
return this.#exit.handleAt(resolved, e);
}
}
+96 -13
View File
@@ -1,11 +1,11 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, users, type Db } from "@parking/db"; import { eq, roles, users, type Db } from "@parking/db";
import { import {
TOKEN_TTL,
clearAuthCookies, clearAuthCookies,
newCsrfToken, newCsrfToken,
requireRole, permissionsFor,
requireAuth,
setAuthCookies, setAuthCookies,
} from "../auth.js"; } from "../auth.js";
@@ -17,6 +17,46 @@ interface LoginBody {
password: string; password: string;
} }
const LANGS = ["sq", "en"] as const;
type Lang = (typeof LANGS)[number];
interface LanguageBody {
language: Lang;
}
const THEMES = ["dark", "light"] as const;
type Theme = (typeof THEMES)[number];
interface ThemeBody {
theme: Theme;
}
/** The session shape the SPA bootstraps from: identity + role + its permission
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
* permissions are the source of truth. */
function sessionView(
db: Db,
user: {
id: string;
username: string;
roleId: string;
language: string;
theme: string;
fullName?: string | null;
},
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)];
return {
id: user.id,
username: user.username,
roleId: user.roleId,
roleName: role?.name ?? user.roleId,
permissions,
language: user.language,
theme: user.theme,
fullName: user.fullName ?? null,
};
}
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => { app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
const { username, password } = req.body ?? {}; const { username, password } = req.body ?? {};
@@ -34,12 +74,19 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
const csrf = newCsrfToken(); const csrf = newCsrfToken();
const token = await reply.jwtSign( // No expiresIn: the token is valid until explicit logout (see auth.ts). The
{ sub: user.id, username: user.username, role: user.role, csrf }, // token carries roleId (not the permission list) — perms resolve per-request,
{ expiresIn: TOKEN_TTL }, // so a role edit applies immediately with no re-login.
); const token = await reply.jwtSign({
sub: user.id,
username: user.username,
roleId: user.roleId,
csrf,
});
setAuthCookies(reply, token, csrf); setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role }; // `language` is NOT in the JWT (identity/role only) — it's a mutable preference
// read from the DB, so changing it needs no token refresh.
return sessionView(db, user);
}); });
app.post("/api/auth/logout", async (_req, reply) => { app.post("/api/auth/logout", async (_req, reply) => {
@@ -47,13 +94,49 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
return { ok: true }; return { ok: true };
}); });
// Who am I — used by the SPA to bootstrap session state on load. // Who am I — used by the SPA to bootstrap session state on load. Reads the live
// `language` preference from the DB (not the token).
app.get( app.get(
"/api/auth/me", "/api/auth/me",
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") }, { preHandler: requireAuth },
async (req) => { async (req, reply) => {
const { sub, username, role } = req.user; const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
return { id: sub, username, role }; if (!row) {
// The user was deleted while their cookie was still valid — clear it.
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
return sessionView(db, row);
},
);
// Change MY own UI language preference (any signed-in user). Persisted to the
// users row so it's restored on the next login, from any booth. See i18n.md.
app.put<{ Body: LanguageBody }>(
"/api/auth/language",
{ preHandler: requireAuth },
async (req, reply) => {
const language = req.body?.language;
if (!language || !LANGS.includes(language)) {
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
}
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
return { language };
},
);
// Change MY own UI theme preference (any signed-in user). Persisted to the users
// row like `language`, so it's restored on the next login from any booth.
app.put<{ Body: ThemeBody }>(
"/api/auth/theme",
{ preHandler: requireAuth },
async (req, reply) => {
const theme = req.body?.theme;
if (!theme || !THEMES.includes(theme)) {
return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` });
}
await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run();
return { theme };
}, },
); );
} }
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from "fastify";
import { requirePermission } from "../auth.js";
import type { DeviceMonitor } from "../device-monitor.js";
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
// configured devices (relays/readers/cameras via healthCheck, printers via their
// rich readStatus) in the background; this exposes its cache. Live updates ride the
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
// fallback. Any authenticated role may read (operational, not a setup action).
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
export async function deviceStatusRoutes(
app: FastifyInstance,
monitor: DeviceMonitor,
): Promise<void> {
const guard = requirePermission("device:read");
app.get("/api/devices/status", { preHandler: guard }, async () => ({
devices: monitor.snapshot(),
}));
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq, laneDevices, type Db } from "@parking/db"; import { eq, devices, type Db } from "@parking/db";
import { deviceEvents } from "../device-events.js"; import { deviceEvents } from "../device-events.js";
import { verifyDigest } from "../digest-auth.js"; import { verifyDigest } from "../digest-auth.js";
@@ -36,7 +36,7 @@ export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void>
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => { const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
const { deviceId, n, edge } = req.params; const { deviceId, n, edge } = req.params;
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get(); const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
const cfg = row?.config as DingtianDeviceConfig | undefined; const cfg = row?.config as DingtianDeviceConfig | undefined;
// Unknown device / not a dingtian / no push creds / wrong source IP → 404. // Unknown device / not a dingtian / no push creds / wrong source IP → 404.
+31 -8
View File
@@ -1,6 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, events, type Db } from "@parking/db"; import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
import { requireRole } from "../auth.js"; import type { LedgerEvent } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { enrichEvents } from "../event-enrich.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
// Read access to the append-only signed event log. NO write/update/delete routes // Read access to the append-only signed event log. NO write/update/delete routes
@@ -13,17 +15,38 @@ export async function eventRoutes(
db: Db, db: Db,
eventLog: EventLog, eventLog: EventLog,
): Promise<void> { ): Promise<void> {
// Any authenticated role may read the log (it's the audit trail). // Reading the log (the audit trail).
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requirePermission("event:read");
// Recent events, newest first. `limit` caps the page (default 100, max 1000). // Recent events, newest first. `limit` caps the page (default 100, max 1000).
app.get<{ Querystring: { limit?: string } }>( // Optional `since` (ISO) scopes to events at/after that instant — the booth passes
// the current shift's start so the live feed shows ONLY this shift's activity. An
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
// selected shift's [start, end] to show just that shift's signed activity log.
// (logs are per-shift, not all history). See wiki/concepts/shift.md.
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>(
"/api/events", "/api/events",
{ preHandler: guard }, { preHandler: guard },
async (req) => { async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all(); const since = (req.query.since ?? "").trim();
return { events: rows }; const until = (req.query.until ?? "").trim();
const bounds = [
since ? gte(ledgerEvents.occurredAt, since) : undefined,
until ? lte(ledgerEvents.occurredAt, until) : undefined,
].filter(Boolean);
const rows = db
.select()
.from(ledgerEvents)
.where(bounds.length ? and(...bounds) : undefined)
.orderBy(desc(ledgerEvents.index))
.limit(limit)
.all();
// Attach read-time display fields (subscriber name, advisory plate) without
// touching the signed record. One plate scan for the whole page (enrichEvents).
// The cast bridges the Drizzle row to the shared LedgerEvent.
const events = enrichEvents(db, rows as unknown as LedgerEvent[]);
return { events };
}, },
); );
@@ -32,7 +55,7 @@ export async function eventRoutes(
// reconciliation job / "is the log intact?" check calls. // reconciliation job / "is the log intact?" check calls.
app.get( app.get(
"/api/events/verify", "/api/events/verify",
{ preHandler: requireRole("admin") }, { preHandler: requirePermission("event:read") },
async () => eventLog.verifyChain(), async () => eventLog.verifyChain(),
); );
} }
+66
View File
@@ -0,0 +1,66 @@
import type { FastifyInstance } from "fastify";
import type { AppLogRecord, ClientLogInput, LogLevel } from "@parking/shared";
import { requireAuth, requirePermission } from "../auth.js";
import type { LogService } from "../log-service.js";
// Application/diagnostic logs (app_logs) — see wiki/concepts/app-logs.md. Two ends:
// - POST /api/logs : the FRONTEND ships its errors here (failed requests, uncaught
// exceptions). Any signed-in user may write (it's their own
// browser's diagnostics); CSRF still applies (mutation).
// - GET /api/logs : read the store — gated by `log:read` (admin/diagnostic role).
// Writes go through the shared LogService (bounded, best-effort, reentrancy-guarded);
// the DB sink for BACKEND warn+ is wired at the pino stream, not here.
const LEVELS: ReadonlySet<string> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
/** Cap a single ingest batch so a misbehaving client can't flood the store. */
const MAX_BATCH = 50;
function isValidEntry(e: unknown): e is ClientLogInput {
if (!e || typeof e !== "object") return false;
const o = e as Record<string, unknown>;
return typeof o.message === "string" && typeof o.level === "string" && LEVELS.has(o.level);
}
export async function logRoutes(app: FastifyInstance, logService: LogService): Promise<void> {
// INGEST — accept one entry or a small batch ({ entries: [...] }). Returns 204.
// Deliberately tolerant: it never 4xx's on a malformed entry (a client erroring
// while reporting an error shouldn't get a second error) — invalid items are skipped.
app.post<{ Body: ClientLogInput | { entries?: unknown[] } }>(
"/api/logs",
{ preHandler: requireAuth },
async (req, reply) => {
const body = req.body as ClientLogInput | { entries?: unknown[] };
const raw = Array.isArray((body as { entries?: unknown[] }).entries)
? (body as { entries: unknown[] }).entries
: [body];
const userId = req.user?.sub ?? null;
const userAgent = req.headers["user-agent"] ?? null;
for (const entry of raw.slice(0, MAX_BATCH)) {
if (!isValidEntry(entry)) continue;
logService.recordClient(entry, { userId, userAgent });
}
reply.code(204).send();
},
);
// READ — newest first, with optional level/source/since filters + a limit. The
// booth Logs viewer calls this. Gated by log:read.
app.get<{ Querystring: { limit?: string; level?: string; source?: string; since?: string } }>(
"/api/logs",
{ preHandler: requirePermission("log:read") },
async (req): Promise<{ logs: AppLogRecord[] }> => {
const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 2000);
const level = (req.query.level ?? "").trim();
const source = (req.query.source ?? "").trim();
const since = (req.query.since ?? "").trim();
const logs = logService.query({
limit,
level: LEVELS.has(level) ? (level as LogLevel) : undefined,
source: source === "frontend" || source === "backend" ? source : undefined,
since: since || undefined,
});
return { logs };
},
);
}
+227
View File
@@ -0,0 +1,227 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requirePermission } from "../auth.js";
import {
NoOpenSessionError,
NoTariffError,
type PayStation,
} from "../pay-station.js";
import type { ExitFlow } from "../exit-flow.js";
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
import { printPaymentReceipt } from "../booth-print.js";
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
// when the booth is at/near the exit — open the barrier. The payment becomes a
// signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
// P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
interface QuoteQuery {
identity: string;
}
interface PayBody {
identity: string;
tender: "cash" | "card";
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
overrideMinor?: number;
}
interface ExitBody {
identity: string;
}
interface VoucherBody {
identity: string;
}
interface ReceiptBody {
identity: string;
}
export async function payRoutes(
app: FastifyInstance,
db: Db,
payStation: PayStation,
exitFlow: ExitFlow,
shift: ShiftService,
): Promise<void> {
// Reads (lookup, active sessions, quote) need session/payment read; the booth
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
// single guard covers the whole booth flow — anyone who takes payment also reads
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
const guard = requirePermission("payment:create");
const readGuard = requirePermission("session:read");
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
// re-open is processed, so every taking is attributed to a shift (one operator's
// accountability period). Read-only lookups (session/active/quote) stay ungated so
// the modal can still DISPLAY the session and prompt the operator to open a shift.
// Returns 409 { error, code: "no_shift" } so the UI can show the "open a shift"
// prompt rather than a generic failure. See wiki/concepts/shift.md.
const requireShift = async (
_req: import("fastify").FastifyRequest,
reply: import("fastify").FastifyReply,
) => {
try {
shift.requireOpenShift();
} catch (err) {
if (err instanceof NoShiftOpenError) {
return reply.code(409).send({ error: err.message, code: "no_shift" });
}
throw err;
}
};
// Active sessions for the booth list: still-open OR exited-but-within-grace
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
app.get("/api/sessions/active", { preHandler: readGuard }, async () => ({
sessions: payStation.activeSessions(),
}));
// Session lookup for the booth pay/exit modal: entry/exit times, paid state,
// amount owed now, walk-back-grace status. Read-only (no side effect).
app.get<{ Params: { identity: string } }>(
"/api/session/:identity",
{ preHandler: readGuard },
async (req, reply) => {
const identity = (req.params.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
return payStation.lookup(identity);
},
);
// Booth-driven exit: validate (paid + grace, or free entry-grace) THEN sign
// vehicle_exit + open the barrier. Maps the discriminated result to HTTP:
// - validation reject → 409 with a reason (operator takes payment first),
// - exit signed but barrier didn't open → 200 { opened:false } (payment stands;
// operator opens manually; an anomaly is already signed),
// - clean exit → 200 { opened:true }.
app.post<{ Body: ExitBody }>(
"/api/exit",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const res = await exitFlow.exitForBooth(identity);
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
return reply.code(200).send(res);
},
);
// Human-intervention barrier re-open for an ACTIVE (paid) session — damaged
// ticket / dead scanner / phantom re-close. Re-pulses the exit relay + signs an
// anomaly (attributed); NEVER a second vehicle_exit. Refused without a payment
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ExitBody }>(
"/api/barrier/reopen",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const operator = req.user?.username;
const res = await exitFlow.reopenBarrier(identity, operator);
if (!res.ok) return reply.code(409).send({ error: res.reason });
return reply.code(200).send(res);
},
);
// Quote: what does this session owe right now? (No side effect.)
app.get<{ Querystring: QuoteQuery }>(
"/api/pay/quote",
{ preHandler: readGuard },
async (req, reply) => {
const identity = (req.query.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
try {
return payStation.quote(identity);
} catch (err) {
return mapError(reply, err);
}
},
);
// Pay: take payment and append the signed `payment` event.
app.post<{ Body: PayBody }>(
"/api/pay",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const { identity, tender, overrideMinor } = req.body ?? {};
if (!identity || (tender !== "cash" && tender !== "card")) {
return reply.code(400).send({ error: "identity and tender (cash|card) required" });
}
if (overrideMinor != null && (!Number.isInteger(overrideMinor) || overrideMinor < 0)) {
return reply.code(400).send({ error: "overrideMinor must be a non-negative integer (minor units)" });
}
try {
const res = await payStation.pay(identity, tender, overrideMinor);
return reply.code(201).send(res);
} catch (err) {
return mapError(reply, err);
}
},
);
// Print an exit voucher (the paid ticket id reprinted as a barcode) on the booth
// printer. Used when the booth is far from the exit — the customer self-scans the
// voucher at the exit reader, which runs the normal validated exit. Requires the
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
app.post<{ Body: VoucherBody }>(
"/api/voucher",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found || !view.open) {
return reply.code(404).send({ error: "no open session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" });
}
try {
const printedBy = await printPaymentReceipt(db, identity, { voucher: true }, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Print a standalone PAYMENT RECEIPT (transparency: entry/paid/duration/amount,
// no barcode) on the booth printer. Used (a) auto, right after a payment when no
// voucher is issued, and (b) on-demand "reprint" if the slip jammed. Requires the
// session to be PAID. See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ReceiptBody }>(
"/api/receipt",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found) {
return reply.code(404).send({ error: "no session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — nothing to receipt" });
}
try {
const printedBy = await printPaymentReceipt(db, identity, { voucher: false }, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
}
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
if (err instanceof NoOpenSessionError) return reply.code(404).send({ error: err.message });
if (err instanceof NoTariffError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
import { deviceEvents } from "../device-events.js"; import { deviceEvents } from "../device-events.js";
import type { PrinterMonitor } from "../printer-monitor.js"; import type { PrinterMonitor } from "../printer-monitor.js";
@@ -12,7 +12,7 @@ export async function printerRoutes(
app: FastifyInstance, app: FastifyInstance,
monitor: PrinterMonitor, monitor: PrinterMonitor,
): Promise<void> { ): Promise<void> {
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requirePermission("device:read");
// Current status of every monitored printer (cached — no device round-trip). // Current status of every monitored printer (cached — no device round-trip).
app.get("/api/printers/status", { preHandler: guard }, async () => ({ app.get("/api/printers/status", { preHandler: guard }, async () => ({
+128
View File
@@ -0,0 +1,128 @@
import type { FastifyInstance } from "fastify";
import { eq, devices, type Db } from "@parking/db";
import type { DeviceReadEvent } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.js";
import type { CredentialCapture } from "../credential-capture.js";
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
//
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
// reply status: 1 = valid (beep 2×) / 0 = invalid (beep 1×)
// reply output: 0 = Access, 1 = WG26, 2 = WG34 (line driven on a valid read)
// reply time: UTC — syncs the device clock
//
// The "server language" set on the device only selects this URL path; we accept the
// SDK default path. No auth on the device side (it can't); the reader sits on the
// device subnet (network-isolation) and the signed ledger is the real guarantee.
interface ReaderQuery {
cardid?: string;
mjihao?: string; // device id
cjihao?: string; // device serial
status?: string; // 2 chars: high valid/invalid, low 1=in/0=out
time?: string;
}
export async function qrReaderRoutes(
app: FastifyInstance,
db: Db,
dispatcher: ReadDispatcher,
capture: CredentialCapture,
): Promise<void> {
// Resolve the lane_devices row whose config.serial matches the reader's reported
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
// reader is assigned for that serial. (Small device set → scan in JS.)
const readerRowIdForSerial = (serial: string): string | null => {
if (!serial) return null;
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial);
return match?.id ?? null;
};
// No auth: the reader is a machine on the isolated device subnet and offers no
// auth on its side. Public route, like the Dingtian input push.
const handler = async (req: { query: ReaderQuery }, reply: import("fastify").FastifyReply) => {
const q = req.query;
// The reader sends `Connection: keep-alive` but only ACTS on our verdict (beep,
// drive output) once the socket CLOSES — every vendor demo replies
// `Connection: close` and shuts the socket. Without it the reader waits out a
// ~10 s keep-alive timeout before beeping. So force-close the connection.
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
reply.header("connection", "close");
const cardid = (q.cardid ?? "").trim();
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
const serial = (q.cjihao ?? "").trim();
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
// resolves the lane from that row). If unassigned, deviceId stays the serial so
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
const matchedRowId = readerRowIdForSerial(serial);
const deviceId = matchedRowId ?? serial;
let accepted = false;
if (cardid) {
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
// value for the subscription form and do NOT run the access flow (we must not
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
// Reads from the OTHER reader are untouched and dispatch normally below.
if (capture.tryConsume(deviceId, cardid)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
accepted = true; // beep "ok" so the operator knows the card was read
} else {
const read: DeviceReadEvent = {
driverId: "gee-qr-reader",
deviceId,
value: cardid,
kind: "qr",
at: new Date().toISOString(),
};
try {
const outcome = await dispatcher.dispatch(read);
accepted = outcome.accepted;
// Per-read diagnostic: which reader (serial) sent it, which configured device
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
// the logs (e.g. an entry-side scan resolving to the exit relay).
app.log.info(
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
);
} catch (err) {
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
}
}
}
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
// output 0 = Access (drive the reader's access line on a valid read).
return {
data: [
{
cardid,
cjihao: q.cjihao ?? 0,
mjihao,
status: accepted ? 1 : 0,
time: String(Math.floor(Date.now() / 1000)),
output: 0,
},
],
code: 0,
message: "",
};
};
// The reader's "server language" setting (JSP/PHP/C#/ASP/CGI) selects the URL
// EXTENSION it GETs — verified on hardware: a JSP-configured unit posts
// /qa/mcardsea.jsp. Register every extension so the endpoint works whatever the
// device is set to; accept POST too in case a variant differs.
for (const ext of ["php", "jsp", "asp", "aspx", "cgi"]) {
const path = `/qa/mcardsea.${ext}`;
app.get<{ Querystring: ReaderQuery }>(path, handler);
app.post<{ Querystring: ReaderQuery }>(path, handler);
}
}
+167
View File
@@ -0,0 +1,167 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
// Role management (admin). Roles are DATA: an admin composes a role from the
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
// role. The built-in `admin` role (id ADMIN_ROLE_ID) is PROTECTED — it can't be
// edited or deleted and always resolves to every permission in code. Every write
// here bumps the in-memory permission cache so changes take effect on the next
// request. See @parking/shared PERMISSIONS and ../auth.ts.
//
// PRIVILEGE-ESCALATION GUARD: `role:update`/`role:create` must NOT let a caller
// grant a permission they don't themselves hold — otherwise a non-admin with
// `role:*` could edit their own role to add (say) `tariff:update`, or mint a role
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
// only put permissions they ALREADY hold onto a role. An admin (full set) is
// unrestricted, which is the intended behaviour.
interface RoleBody {
name: string;
permissions: string[];
}
interface UpdateBody {
name?: string;
permissions?: string[];
}
const VALID = new Set<string>(PERMISSIONS);
/** Validate + dedupe a requested permission list against the code-defined grid. */
function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | { ok: false; bad: string } {
if (!Array.isArray(input)) return { ok: false, bad: "permissions must be an array" };
const out = new Set<Permission>();
for (const p of input) {
if (typeof p !== "string" || !VALID.has(p)) return { ok: false, bad: `unknown permission: ${String(p)}` };
out.add(p as Permission);
}
return { ok: true, perms: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update");
const deleteGuard = requirePermission("role:delete");
/** A role + its permission list + how many users hold it. */
function roleView(roleId: string) {
const role = db.select().from(roles).where(eq(roles.id, roleId)).get();
if (!role) return null;
const perms = db
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, roleId))
.all()
.map((r) => r.permission);
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
// The admin role always reports the full grid (it's enforced in code).
return {
id: role.id,
name: role.name,
builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
userCount,
};
}
/** Replace a role's permission rows with `perms` (in a single pass). */
function setPermissions(roleId: string, perms: Permission[]): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
for (const p of perms) {
db.insert(rolePermissions).values({ roleId, permission: p }).run();
}
}
// The full permission grid (for the role-composer checkbox UI) + every role.
app.get("/api/roles", { preHandler: readGuard }, async () => {
const all = db.select().from(roles).all();
return {
catalog: PERMISSIONS,
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
};
});
/** Reject any permission in `perms` the caller does not themselves hold — so a
* non-admin can't grant privileges beyond their own. Returns the offending
* permission, or null if all are within the caller's set. (Admin holds the full
* set, so it never trips.) */
function escalates(callerRoleId: string, perms: Permission[]): Permission | null {
const held = permissionsFor(callerRoleId);
return perms.find((p) => !held.has(p)) ?? null;
}
// Create a composable role from a name + a permission set.
app.post<{ Body: RoleBody }>("/api/roles", { preHandler: createGuard }, async (req, reply) => {
const name = (req.body?.name ?? "").trim();
if (!name) return reply.code(400).send({ error: "name required" });
if (db.select().from(roles).where(eq(roles.name, name)).get()) {
return reply.code(409).send({ error: "a role with that name already exists" });
}
const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
const over = escalates(req.user.roleId, cleaned.perms);
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run();
setPermissions(id, cleaned.perms);
bumpPermsCache();
return reply.code(201).send(roleView(id));
});
// Edit a role's name and/or permission set. The built-in admin role is locked.
app.put<{ Params: { id: string }; Body: UpdateBody }>(
"/api/roles/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const role = db.select().from(roles).where(eq(roles.id, id)).get();
if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
}
if (req.body?.name != null) {
const name = req.body.name.trim();
if (!name) return reply.code(400).send({ error: "name cannot be empty" });
const clash = db.select().from(roles).where(eq(roles.name, name)).get();
if (clash && clash.id !== id) return reply.code(409).send({ error: "a role with that name already exists" });
db.update(roles).set({ name }).where(eq(roles.id, id)).run();
}
if (req.body?.permissions != null) {
const cleaned = cleanPermissions(req.body.permissions);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
const over = escalates(req.user.roleId, cleaned.perms);
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
setPermissions(id, cleaned.perms);
}
bumpPermsCache();
return roleView(id);
},
);
// Delete a role. Refused if it's built-in or any user still holds it.
app.delete<{ Params: { id: string } }>(
"/api/roles/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const role = db.select().from(roles).where(eq(roles.id, id)).get();
if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
}
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
}
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
db.delete(roles).where(eq(roles.id, id)).run();
bumpPermsCache();
return { ok: true };
},
);
}
+197 -110
View File
@@ -1,6 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, laneDevices, setupState, type Db } from "@parking/db"; import { eq, devices, setupState, type Db } from "@parking/db";
import { import {
hasPreconditions, hasPreconditions,
hasPushConfig, hasPushConfig,
@@ -10,18 +10,21 @@ import {
registry, registry,
setDeviceLogSink, setDeviceLogSink,
type DeviceCategory, type DeviceCategory,
type DeviceConfig,
} from "@parking/devices"; } from "@parking/devices";
import { requireRole } from "../auth.js"; import { requirePermission } from "../auth.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
// First-run setup API. The admin reads the driver catalog and assigns devices // First-run setup API. The admin reads the driver catalog and assigns devices
// per lane. See wiki/concepts/first-run-setup.md. // per lane. See wiki/concepts/first-run-setup.md.
interface AssignBody { interface AssignBody {
lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
config: Record<string, string | number | boolean>; // Driver config (opaque JSON, validated by the driver). Carries the model's
// direction/binding: access → config.relays=[{relay,direction,button?}];
// reader/camera → config.controllerId + config.relay. See entry-exit-points.md.
config: DeviceConfig;
/** Optional: the backend IP the device should push to (overrides auto-pick; /** Optional: the backend IP the device should push to (overrides auto-pick;
* matters on multi-NIC hosts). */ * matters on multi-NIC hosts). */
backendIp?: string; backendIp?: string;
@@ -48,25 +51,143 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
export async function setupRoutes( /** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome =
| { config: Record<string, unknown>; warnings: string[] }
| { error: { code: number; message: string } };
/**
* Validate + configure a device, returning the config to persist. Runs the same
* pipeline for both create and edit: validate the driver config, fix
* preconditions, harden (relay password + protocol lockdown), and set up input
* push (Digest creds + push URLs). Each step is a device write (the device
* reboots on apply). The caller owns the DB row; this never touches the DB.
*
* `id` is the assignment id (stable across an edit) — it's baked into the push
* URL, so editing in place keeps the device pushing to the same path.
* `existingConfig` carries forward secrets the client never sees on edit
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
*/
async function configureDevice(
app: FastifyInstance, app: FastifyInstance,
db: Db, args: {
// Called after the set of assignments changes (assign/unassign) so the caller id: string;
// can refresh anything derived from it — e.g. the device id->lane map. driverId: string;
onAssignmentsChanged: () => void = () => {}, config: DeviceConfig;
): Promise<void> { backendIp?: string;
existingConfig?: Record<string, unknown>;
},
): Promise<ConfigureOutcome> {
const { id, driverId, config, backendIp, existingConfig } = args;
// Start from any machine-only secrets already on the row (push/relay passwords
// are redacted out of the client's copy, so an edit would otherwise drop them),
// then layer the submitted config on top.
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return { error: { code: 400, message: (err as Error).message } };
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return {
error: {
code: 502,
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
},
};
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return {
error: {
code: 400,
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
},
};
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
}
return { config: fullConfig, warnings: hardenWarnings };
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
// Setup endpoints require an admin (cookie-based JWT — see ../auth.ts). // Device setup is site administration — it changes which hardware the site runs
const adminGuard = requireRole("admin"); // and how readers bind to relays. Gated on site:update. See ../auth.ts.
const adminGuard = requirePermission("site:update");
// Catalog of selectable drivers per category (no secrets — schema only). // Catalog of selectable drivers per category (no secrets — schema only).
// `discoverable` flags drivers that can scan the LAN. // `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
// drivers that push to the backend (and thus need a backend IP at assign time).
app.get("/api/setup/catalog", async () => { app.get("/api/setup/catalog", async () => {
const catalog = registry.catalog(); const catalog = registry.catalog();
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id); const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
return { ...catalog, discoverable }; const pushCapable = registry.pushCapable();
return { ...catalog, discoverable, pushCapable };
}); });
// Scan the LAN for devices a driver can discover (UDP broadcast, etc). // Scan the LAN for devices a driver can discover (UDP broadcast, etc).
@@ -108,7 +229,7 @@ export async function setupRoutes(
{ preHandler: adminGuard }, { preHandler: adminGuard },
async () => { async () => {
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get(); const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
const rows = await db.select().from(laneDevices).all(); const rows = await db.select().from(devices).all();
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) })); const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
return { completedAt: state?.completedAt ?? null, assignments }; return { completedAt: state?.completedAt ?? null, assignments };
}, },
@@ -152,117 +273,84 @@ export async function setupRoutes(
}, },
); );
// Assign a device to a lane. Validates the chosen driver + config, configures // Assign a device. Validates the chosen driver + config, configures the device
// the device (fix preconditions + set up Digest-authenticated input push — no // (fix preconditions + set up Digest-authenticated input push — no manual device-
// manual device-web-UI step by the admin), then persists. Fails the save if // web-UI step by the admin), then persists. Fails the save if the device can't be
// the device can't be configured. See wiki/concepts/device-input-flow.md. // configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
app.post<{ Body: AssignBody }>( app.post<{ Body: AssignBody }>(
"/api/setup/assign", "/api/setup/assign",
{ preHandler: adminGuard }, { preHandler: adminGuard },
async (req, reply) => { async (req, reply) => {
const { lane, category, driverId, config, backendIp } = req.body; const { category, driverId, config, backendIp } = req.body;
const driver = registry.get(driverId); const driver = registry.get(driverId);
if (!driver || driver.category !== category) { if (!driver || driver.category !== category) {
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` }); return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
} }
const id = randomUUID(); const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...config }; const outcome = await configureDevice(app, { id, driverId, config, backendIp });
// The web password the admin typed is a DESIRED value, not a stored fact: if ("error" in outcome) {
// it's passed to the driver (via create(config) below) as the rotation return reply.code(outcome.error.code).send({ error: outcome.error.message });
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
});
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return reply
.code(502)
.send({ error: `device configuration failed: ${(err as Error).message}` });
} }
const row = { const row = {
id, id,
lane,
category, category,
driverId, driverId,
config: fullConfig, config: outcome.config,
enabled: true, enabled: true,
}; };
await db.insert(laneDevices).values(row); await db.insert(devices).values(row);
onAssignmentsChanged(); // refresh derived state (device->lane map)
// Don't echo device secrets back (push Digest password, web-UI login, …). // Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({ return reply.code(201).send({
...row, ...row,
config: redactSecrets(fullConfig), config: redactSecrets(outcome.config),
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}), ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
// Edit an assigned device in place. Same configure pipeline as assign, but it
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
// since the id is baked into the device's input-push URL
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
// break push until reconfigured; PATCH re-runs harden/push against the same id.
// The category and driver are fixed at create time (an edit can't change what
// KIND of device a slot is); only config changes. Admin-only.
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(devices)
.where(eq(devices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
const { config, backendIp } = req.body;
const outcome = await configureDevice(app, {
id: existing.id,
driverId: existing.driverId,
config,
backendIp,
// Carry forward machine-only secrets the client never received, so an
// edit that omits them doesn't blank out push/relay passwords.
existingConfig: existing.config,
});
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
return reply.code(200).send({
id: existing.id,
category: existing.category,
driverId: existing.driverId,
config: redactSecrets(outcome.config),
enabled: existing.enabled,
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
}); });
}, },
); );
@@ -283,13 +371,12 @@ export async function setupRoutes(
async (req, reply) => { async (req, reply) => {
const existing = await db const existing = await db
.select() .select()
.from(laneDevices) .from(devices)
.where(eq(laneDevices.id, req.params.id)) .where(eq(devices.id, req.params.id))
.get(); .get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" }); if (!existing) return reply.code(404).send({ error: "no such device assignment" });
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id)); await db.delete(devices).where(eq(devices.id, req.params.id));
onAssignmentsChanged(); // refresh derived state (device->lane map) app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
return reply.code(204).send(); return reply.code(204).send();
}, },
); );
+151
View File
@@ -0,0 +1,151 @@
import bcrypt from "bcrypt";
import { eq, users, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js";
import {
InvalidCashMovementError,
NoOpenShiftError,
ShiftAlreadyOpenError,
type ShiftService,
} from "../shift-service.js";
interface CashVoucherBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
* cash_out = Mandat Pagese (pay-OUT). */
type: "cash_in" | "cash_out";
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
amountMinor: number;
reason?: string;
currency?: string;
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
authorizedBy: string;
/** That admin's password — re-entered to sign off on the drawer movement. */
authorizerPassword: string;
}
interface ShiftsQuery {
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
operator?: string;
/** ISO window over shift START time. */
from?: string;
to?: string;
}
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift.
const readGuard = requirePermission("shift:read");
const guard = requirePermission("shift:create");
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - open: the open shift { startedAt, operator } or null (site-wide)
// - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity)
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
const me = req.user.username;
const open = shift.currentOpenShift();
const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance();
return {
operator: me,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me,
drawerMinor: drawer.balanceMinor,
currency: drawer.currency,
};
});
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
// as of now. Appends nothing — it's not an accountability mark, just a projection
// (the Z-report at close is the signed record). 204 when no shift is open.
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
const report = shift.currentReport();
if (!report) return reply.code(204).send();
return report;
});
// Completed shift history. SCOPED by permission:
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
// `operator` and a `from`/`to` time window over each shift's START.
// This keeps one operator from reading another's takings while letting admins
// reconcile across the site. The data is the signed shift_z_report chain.
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
const q = req.query ?? {};
// Non-admins are hard-scoped to themselves regardless of any operator param.
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to });
return { shifts, scope: canSeeAll ? "all" : "self" };
});
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
// (`shift:cash`) who re-enters their password. This keeps the float control —
// an operator cannot move the float alone — while letting them raise the slip.
// See wiki/concepts/shift.md.
app.post<{ Body: CashVoucherBody }>(
"/api/cash-voucher",
{ preHandler: guard },
async (req, reply) => {
const b = req.body ?? ({} as CashVoucherBody);
if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
}
const authName = (b.authorizedBy ?? "").trim();
if (!authName || !b.authorizerPassword) {
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
}
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
// Always run a bcrypt compare (constant-time wrt whether the user exists).
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
if (!authUser || !passwordOk || !isAdminGrade) {
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
}
try {
return await shift.recordVoucher({
type: b.type,
operator: req.user.username, // who RAISED it
authorizedBy: authUser.username, // who signed off (canonical case)
amountMinor: b.amountMinor,
reason: b.reason ?? "",
currency: b.currency,
});
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
try {
return await shift.open(req.user.username);
} catch (err) {
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
});
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
try {
return await shift.close(req.user.username);
} catch (err) {
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
});
}
+123
View File
@@ -0,0 +1,123 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import { getOccupancy } from "../occupancy.js";
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
const TEXT_FIELDS = [
"parkName",
"operatorName",
"nius",
"address",
"phone",
"email",
// IANA timezone for tariff wall-clock windows (copied into each published version).
"timezone",
// Default vehicle/customer category frozen onto each transient entry.
"defaultVehicleCategory",
] as const;
type TextField = (typeof TEXT_FIELDS)[number];
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Nominal capacity; null = no limit. */
capacity?: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault?: boolean;
/** Site default monthly subscription price in minor units (pre-fills the form). */
subscriptionMonthlyPriceMinor?: number | null;
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
* parked — so transients see "full" sooner and the subscriber's spot is held. */
reserveSubscriberSpots?: boolean;
}
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
* + every metadata field. */
type SiteConfig = {
capacity: number | null;
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = {
capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
}
/** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null {
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
}
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("site:read");
const writeGuard = requirePermission("site:update");
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
});
// Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
// (only the fields PRESENT in the body are updated; absent fields are untouched).
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const body = req.body ?? ({} as SiteConfigBody);
const patch: Partial<typeof siteConfig.$inferInsert> = {};
if ("capacity" in body) {
const c = body.capacity;
if (c != null && (!Number.isInteger(c) || c < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
}
patch.capacity = c ?? null;
}
if ("exitVoucherDefault" in body) {
if (typeof body.exitVoucherDefault !== "boolean") {
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
}
patch.exitVoucherDefault = body.exitVoucherDefault;
}
if ("subscriptionMonthlyPriceMinor" in body) {
const p = body.subscriptionMonthlyPriceMinor;
if (p != null && (!Number.isInteger(p) || p < 0)) {
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
}
patch.subscriptionMonthlyPriceMinor = p ?? null;
}
if ("reserveSubscriberSpots" in body) {
if (typeof body.reserveSubscriberSpots !== "boolean") {
return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" });
}
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString();
if (existing) {
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
}
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
});
}
+124
View File
@@ -0,0 +1,124 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
// tied to a signed vehicle_entry/exit by `identity`; the operator reviews them
// next to the event. Read-only — images are written only by the flows (snapshot.ts),
// never via the API.
export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void> {
const guard = requirePermission("session:read");
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
// first — lets the UI show "entry/exit image" links beside an event. We also return
// FAILED capture attempts (from snapshot telemetry) so the operator can tell a
// camera that was offline from a direction that simply has no camera — otherwise a
// missing shot is a silent gap. See snapshot.ts (recordFailure).
app.get<{ Params: { identity: string } }>(
"/api/snapshots/by-identity/:identity",
{ preHandler: guard },
async (req) => {
const identity = req.params.identity;
const rows = db
.select({
id: snapshots.id,
direction: snapshots.direction,
deviceId: snapshots.deviceId,
identity: snapshots.identity,
contentType: snapshots.contentType,
capturedAt: snapshots.capturedAt,
})
.from(snapshots)
.where(eq(snapshots.identity, identity))
.orderBy(desc(snapshots.capturedAt))
.all();
// Failed attempts: kind="snapshot" telemetry whose detail.identity matches and
// detail.ok === false. There may be both a failure and (on a retry) a success
// for the same direction; we keep only failures with NO successful shot in the
// same direction, so a recovered capture doesn't show a stale warning.
const haveDir = new Set<string | null>(rows.map((r) => r.direction));
const telemetry = db
.select({ detail: deviceEvents.detail, deviceId: deviceEvents.deviceId, occurredAt: deviceEvents.occurredAt })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "snapshot")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const failures: {
direction: "entry" | "exit" | null;
deviceId: string;
error: string;
occurredAt: string;
}[] = [];
const seenFailDir = new Set<string>();
for (const row of telemetry) {
const d = (row.detail ?? {}) as { identity?: string; ok?: boolean; error?: string; direction?: string };
if (d.identity !== identity || d.ok !== false) continue;
const dir = d.direction === "entry" || d.direction === "exit" ? d.direction : null;
const dirKey = dir ?? "both";
if (haveDir.has(dir) || seenFailDir.has(dirKey)) continue; // a success exists, or already shown
seenFailDir.add(dirKey);
failures.push({
direction: dir,
deviceId: row.deviceId ?? "",
error: d.error ?? "capture failed",
occurredAt: row.occurredAt ?? "",
});
}
// Recognized PLATES for this session: kind="read" telemetry from the ANPR-on-
// snapshot path (snapshot.ts → recognizePlate). Advisory — a record of the plate
// observed for the session, shown beside the image. Newest first.
const plateRows = db
.select({ detail: deviceEvents.detail, occurredAt: deviceEvents.occurredAt })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const plates: {
plate: string;
confidence: number | null;
region: string | null;
direction: "entry" | "exit" | null;
snapshotId: string | null;
at: string;
}[] = [];
for (const row of plateRows) {
const d = (row.detail ?? {}) as {
identity?: string;
plate?: string;
confidence?: number;
region?: string | null;
direction?: string;
snapshotId?: string;
};
if (d.identity !== identity || !d.plate) continue;
plates.push({
plate: d.plate,
confidence: typeof d.confidence === "number" ? d.confidence : null,
region: d.region ?? null,
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
snapshotId: d.snapshotId ?? null,
at: row.occurredAt ?? "",
});
}
return { snapshots: rows, failures, plates };
},
);
// Stream one snapshot's image bytes by id. Returns the stored content type.
app.get<{ Params: { id: string } }>(
"/api/snapshots/:id",
{ preHandler: guard },
async (req, reply) => {
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
if (!row) return reply.code(404).send({ error: "no such snapshot" });
reply.header("content-type", row.contentType);
reply.header("cache-control", "private, max-age=31536000, immutable");
return reply.send(row.bytes);
},
);
}
@@ -0,0 +1,174 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { siteTz } from "../subscription-window.js";
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
// from (so they never type a price). Mirrors the tariff composer: plans are
// EFFECTIVE-DATED IMMUTABLE VERSIONS keyed by a stable `planId`; editing a plan
// PUBLISHES A NEW VERSION (new row, new effectiveFrom), never mutates an old one, so
// a past sale reprices identically against its recorded planVersionId. Retire =
// active=0 (soft, keeps history). Admin-only (`subscription:plan`); selling stays
// operator-grade (`subscription:create`). See wiki/entities/subscription.md.
interface PlanBody {
/** Stable identity across versions (e.g. "hotel-daily"). New on create; reused to
* publish a new version of an existing plan. Slugified server-side. */
planId?: string;
name?: string;
period?: SubscriptionPeriod;
pricePerPeriodMinor?: number;
currency?: string;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
/** Allowed-time windows (tariff bridge); null/omitted = 24/7. */
timeframes?: PlanTimeframes | null;
}
/** Validate the optional timeframes blob (minutes-of-day 0–1439, days 0–6, sane grace). */
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
if (tf == null) return null;
const okMin = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439;
if (!okMin(tf.fromMin) || !okMin(tf.toMin)) return "window times must be minutes-of-day (0–1439)";
if (tf.days != null && (!Array.isArray(tf.days) || tf.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) {
return "days must be integers 0–6 (0=Sun..6=Sat)";
}
if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0";
return null;
}
/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */
function slugify(s: string): string {
return s
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("subscription:read");
const planGuard = requirePermission("subscription:plan");
function validate(b: PlanBody): string[] {
const errs: string[] = [];
if (!b.name?.trim()) errs.push("name is required");
if (!b.period || !SUBSCRIPTION_PERIODS.includes(b.period)) {
errs.push(`period must be one of: ${SUBSCRIPTION_PERIODS.join(", ")}`);
}
if (!Number.isInteger(b.pricePerPeriodMinor) || (b.pricePerPeriodMinor ?? 0) <= 0) {
errs.push("pricePerPeriodMinor must be a positive integer (minor units)");
}
if (!b.currency?.trim()) errs.push("currency is required");
if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
}
const tfErr = validTimeframes(b.timeframes);
if (tfErr) errs.push(tfErr);
return errs;
}
// List plans. ?all=1 → every version (history); default → the CURRENT sellable plan
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
// need the current list; the admin catalog screen asks for ?all=1.
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all();
if (req.query?.all) return { plans: rows };
const now = new Date().toISOString();
// Newest-effective active version wins per planId.
const current = new Map<string, (typeof rows)[number]>();
for (const r of rows) {
if (!r.active || r.effectiveFrom > now) continue;
if (!current.has(r.planId)) current.set(r.planId, r); // rows are newest-first
}
return { plans: [...current.values()] };
});
// Publish a plan version (create a plan, or a new version of an existing planId).
app.post<{ Body: PlanBody }>("/api/subscription-plans", { preHandler: planGuard }, async (req, reply) => {
const b = req.body ?? ({} as PlanBody);
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid plan", problems });
const planId = (b.planId?.trim() ? slugify(b.planId) : slugify(b.name!)) || randomUUID();
const now = new Date().toISOString();
const effectiveFrom = b.effectiveFrom?.trim() || now;
// Backdating would retroactively reprice — refuse (mirrors tariff publish).
if (Date.parse(effectiveFrom) < Date.parse(now) - 60_000) {
return reply.code(400).send({
error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales",
});
}
// Stamp the site tz into the timeframes so the windows evaluate in the site's
// wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz).
const timeframes =
b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null;
const row = {
id: randomUUID(),
planId,
name: b.name!.trim(),
period: b.period!,
pricePerPeriodMinor: b.pricePerPeriodMinor!,
currency: b.currency!.trim(),
effectiveFrom,
timeframes,
active: true,
createdBy: req.user?.username ?? null,
};
db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run();
return reply.code(201).send(row);
});
// Retire a plan (soft): mark every version of this planId inactive so it's no longer
// sellable. History (and past sales' planVersionId) is preserved. Reactivate to revive.
app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/retire",
{ preHandler: planGuard },
async (req) => {
db.update(subscriptionPlans)
.set({ active: false })
.where(eq(subscriptionPlans.planId, req.params.planId))
.run();
return { planId: req.params.planId, retired: true };
},
);
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
// latest-effective version becomes "in force" again. (The inverse of retire.)
app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/reactivate",
{ preHandler: planGuard },
async (req) => {
db.update(subscriptionPlans)
.set({ active: true })
.where(eq(subscriptionPlans.planId, req.params.planId))
.run();
return { planId: req.params.planId, reactivated: true };
},
);
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
// version). A referenced plan version MUST survive: a subscription's planVersionId is
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
// when in use (the admin should retire instead). Removes all versions of the planId.
app.delete<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId",
{ preHandler: planGuard },
async (req, reply) => {
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
if (refs.length > 0) {
return reply.code(409).send({
error: "plan is in use and cannot be deleted",
code: "plan_in_use",
subscribers: refs.length,
});
}
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
return { planId: req.params.planId, deleted: true };
},
);
}
+510
View File
@@ -0,0 +1,510 @@
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js";
import type { EventLog } from "../event-log.js";
import type { ShiftService } from "../shift-service.js";
import { directionOf } from "../device-resolve.js";
import { priceSubscriptionSpan, resolvePlanVersion } from "../subscription-pricing.js";
// Subscription admin CRUD. A subscription is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
// them as one unit (create/update replace the child sets; delete removes all).
//
// Pricing & THE SALE. priceMinor + period ("monthly") + currency record the recurring
// plan (e.g. 10,000 ALL / month). When a subscription is SOLD (created with a price),
// the operator collects real money — so we append a SIGNED `payment` ledger event for
// the amount actually taken (priceMinor × months for a multi-month prepay), with the
// tender the operator chose. That is the ONLY accountability mechanism: without it the
// sale leaves no trace in the live feed, the drawer, or the shift Z-report, and the
// operator could pocket the cash untraceably (the exact booth-operator-as-adversary
// gap this system exists to close). The `subscriptions` row is mutable master data and
// is NOT the financial record; the signed payment event is. See wiki/concepts/shift.md.
interface Credential {
kind: "rf" | "qr";
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
value?: string;
}
interface SubscriptionBody {
holderName?: string;
contact?: string;
/** PRICED SALE: the plan the operator selected. The price is LOOKED UP from the
* plan version (periods × per-period price) — the operator never types an amount.
* Omit for a free/comp subscription (no plan, no charge). */
planId?: string | null;
/** Coverage window. For a priced sale: `validFrom` defaults to now, `validTo` is
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */
validFrom?: string | null;
validTo?: string | null;
/** How many cars this subscription covers (a family pays once for N cars). Sale =
* plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */
quantity?: number | null;
/** Car-count binding: cars inside at once. Default = quantity; null = unbound. */
maxConcurrent?: number | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
tender?: Tender;
}
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
interface QuoteBody {
planId?: string;
validFrom?: string;
validTo?: string;
quantity?: number;
}
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
function newQrCode(): string {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const bytes = randomBytes(15);
let out = "";
for (const b of bytes) out += alphabet[b % 32];
return `SUB-${out}`;
}
export async function subscriptionRoutes(
app: FastifyInstance,
db: Db,
capture: CredentialCapture,
eventLog: EventLog,
shift: ShiftService,
): Promise<void> {
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
const readGuard = requirePermission("subscription:read");
const createGuard = requirePermission("subscription:create");
const updateGuard = requirePermission("subscription:update");
const deleteGuard = requirePermission("subscription:delete");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: SubscriptionBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
// PRICED SALE: a plan is selected → the span must be valid and price > 0. The
// amount is derived from the plan (operator never types it), so there's no
// priceMinor to validate.
if (b.planId != null && b.planId.trim()) {
const from = b.validFrom?.trim() || new Date().toISOString();
const to = b.validTo?.trim();
if (!to) {
errs.push("validTo (end date) is required when selling a plan");
} else if (Number.isNaN(Date.parse(to)) || Number.isNaN(Date.parse(from))) {
errs.push("validFrom/validTo must be valid ISO-8601 dates");
} else if (Date.parse(to) <= Date.parse(from)) {
errs.push("validTo must be after validFrom");
} else {
// Resolve the plan version at the SALE instant (now) — the customer buys today's
// published plan/price. (validFrom is the coverage start, which may be midnight
// today and predate a plan published this afternoon.)
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) errs.push("no active plan found for the selected planId");
}
}
if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) {
errs.push("quantity must be a positive integer (cars covered)");
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
if (b.tender != null && b.tender !== "cash" && b.tender !== "card") {
errs.push("tender must be cash|card");
}
for (const c of b.credentials ?? []) {
if (c.kind !== "rf" && c.kind !== "qr") {
errs.push("each credential needs kind (rf|qr)");
break;
}
// RF must carry the physical card id; QR may be blank (server auto-generates).
if (c.kind === "rf" && !c.value?.trim()) {
errs.push("an RF credential needs a non-empty value (the card/tag id)");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
if (!sub) return null;
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
return {
...sub,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
/** Is this credential value already used by ANY subscription? (Global uniqueness —
* a value is the lane identity, so it must resolve to one subscription.) */
function valueTaken(value: string): boolean {
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
}
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
function mintQrCode(): string {
for (let i = 0; i < 5; i += 1) {
const code = newQrCode();
if (!valueTaken(code)) return code;
}
throw new Error("could not mint a unique QR code");
}
// Replace a subscription's child rows (credentials + plates) from the body. QR
// credentials with no value are SERVER-GENERATED here (the customer never picks the
// code). The generated value is returned via loadAggregate so the UI can print it.
function writeChildren(id: string, b: SubscriptionBody) {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
for (const c of b.credentials ?? []) {
const supplied = c.value?.trim();
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
// QR being preserved on edit).
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
if (!value) continue; // guarded by validate(); defensive
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
}
}
/** Resolve the coverage end: an explicit validTo (the span end the operator picked).
* Falls back to the existing value on an update that doesn't touch it. */
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
if (b.validTo !== undefined) return b.validTo ?? null;
return fallback;
}
/** Resolve + price a priced sale: returns the plan version, the effective span, the
* quantity (cars covered), and the server-computed quote with the amount already
* MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
* sub (no planId). validate() guards the happy path. */
function priceSale(
b: SubscriptionBody,
): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null {
if (!b.planId?.trim() || !b.validTo?.trim()) return null;
const validFrom = b.validFrom?.trim() || new Date().toISOString();
const validTo = b.validTo.trim();
// Plan version is resolved at the SALE instant (now), not validFrom (which is the
// coverage start and may predate a plan published later today).
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) return null;
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
const base = priceSubscriptionSpan(plan, validFrom, validTo);
// Price ×N: the whole sale covers N cars on one subscription.
const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity };
return { plan, validFrom, validTo, quantity, quote };
}
// List all subscriptions (with their credentials + plates).
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all();
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
});
// --- Credential capture ("enroll a card") -------------------------------
// The operator picks a reader and presents an RFID card to it; the next read on
// that reader is captured for the form instead of opening a barrier. The OTHER
// reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts.
// The readers the operator can capture on (entry/exit by their bound relay).
app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => {
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
return {
readers: rows
.filter((r) => r.enabled)
.map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })),
};
});
// Arm capture on a reader (by devices.id). Operator-or-admin (booth action).
app.post<{ Body: { deviceId?: string } }>(
"/api/subscriptions/capture/arm",
{ preHandler: readGuard },
async (req, reply) => {
const deviceId = (req.body?.deviceId ?? "").trim();
if (!deviceId) return reply.code(400).send({ error: "deviceId required" });
const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!reader || reader.category !== "reader" || !reader.enabled) {
return reply.code(404).send({ error: "no such enabled reader" });
}
return capture.arm(deviceId);
},
);
// Poll the capture state (idle | armed | captured | expired). The form polls this
// and, on "captured", reads `value` into the credential field then clears it.
app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state());
// Operator cancelled / closed the form — disarm and clear any result.
app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => {
capture.cancel();
capture.clear();
return { ok: true };
});
// Create a subscription.
// Price a span against a plan WITHOUT writing anything — the live quote the sell form
// shows ("3 nights · 2,400 ALL"). Server-computed so the operator can't fudge it.
app.post<{ Body: QuoteBody }>("/api/subscriptions/quote", { preHandler: readGuard }, async (req, reply) => {
const b = req.body ?? {};
if (!b.planId?.trim()) return reply.code(400).send({ error: "planId is required" });
const validFrom = b.validFrom?.trim() || new Date().toISOString();
const validTo = b.validTo?.trim();
if (!validTo) return reply.code(400).send({ error: "validTo is required" });
if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validTo))) {
return reply.code(400).send({ error: "validFrom/validTo must be valid ISO-8601 dates" });
}
if (Date.parse(validTo) <= Date.parse(validFrom)) {
return reply.code(400).send({ error: "validTo must be after validFrom" });
}
// Resolve at the sale instant (now), not validFrom — see priceSale.
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" });
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
const base = priceSubscriptionSpan(plan, validFrom, validTo);
// Echo the ×quantity total so the form previews the family's combined price.
return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan };
});
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
const id = randomUUID();
// Price is LOOKED UP from the chosen plan (periods × per-period price) — never typed
// by the operator. A comp sub (no plan) carries no price. Persist the plan + version
// so the sale reprices identically later.
const priced = priceSale(b);
db.insert(subscriptions)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: priced ? priced.quote.amountMinor : null,
period: priced ? priced.plan.period : "month",
currency: priced ? priced.quote.currency : null,
planId: priced ? priced.plan.planId : null,
planVersionId: priced ? priced.plan.id : null,
quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
// maxConcurrent defaults to the quantity (the family's N cars can all be inside),
// unless the operator set it explicitly (null = unbound).
maxConcurrent:
b.maxConcurrent !== undefined
? b.maxConcurrent
: priced
? priced.quantity
: (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
validTo: priced ? priced.validTo : resolveValidTo(b, null),
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
const sub = loadAggregate(id);
// THE SALE: a priced subscription means the operator collected money. Append a
// SIGNED `payment` event so the takings show up in the live feed, the drawer, and
// the shift Z-report — never an untraceable cash grab. Best-effort wrt the response,
// but the append is the whole point, so a failure is logged loudly.
const sale = await recordSale(id, priced, b.tender, req.user?.username ?? "?");
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
// a print failure NEVER fails the create (the subscription + its code are saved);
// the response carries { printed, printError } so the UI can warn + offer reprint.
const printResult = await tryPrintCard(sub);
return reply.code(201).send({ ...sub, ...sale, ...printResult });
});
/**
* Append the SIGNED `payment` ledger event for a subscription sale, so the money is
* accounted for exactly like a parking payment (live feed + drawer + Z-report). The
* amount comes from the PLAN quote (periods × per-period price) — never an
* operator-typed number. No plan → no sale → nothing appended (free/comp). The event
* carries `subscriptionSale: true` + the subscription id + the plan version so the
* feed/audit can label it and the price is reproducible. We do NOT hard-require an
* open shift (a subscription can be sold outside the booth money path), but the
* operator IS recorded and the payment folds into whichever shift window contains its
* timestamp — so it can never be silently pocketed. Returns { sale } or {}.
*/
async function recordSale(
id: string,
priced: ReturnType<typeof priceSale>,
tenderIn: Tender | undefined,
operator: string,
): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; periods: number; inShift: boolean } }> {
if (!priced || priced.quote.amountMinor <= 0) return {}; // free/comp — nothing collected
const { plan, quote } = priced;
const amountMinor = quote.amountMinor;
const tender: Tender = tenderIn ?? "cash";
const currency = quote.currency;
const inShift = shift.currentOpenShift() != null;
try {
await eventLog.append({
type: "payment",
source: "manual",
// Key the payment to the subscription so the feed can resolve the holder label
// and the audit can trace WHICH subscription was sold.
identity: id,
payload: {
sessionRef: id,
amountMinor,
currency,
tender,
operator,
// Flags this `payment` as a subscription SALE (not a parking payment) so the
// live feed / activity log can label it distinctly. plan + periods for audit
// and reproducible repricing.
subscriptionSale: true,
permitId: id,
planId: plan.planId,
planVersionId: plan.id,
periods: quote.periods,
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
},
});
app.log.info(
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` +
(inShift ? "" : " [no open shift]"),
);
} catch (err) {
// A failed append is serious — the money would be untraceable. Surface it.
app.log.error(`subscription-sale payment append FAILED for ${id}: ${(err as Error).message}`);
return {};
}
return { sale: { amountMinor, currency, tender, periods: quote.periods, inShift } };
}
/** The first QR credential's code for a subscription aggregate, or null. */
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
const cred = sub?.credentials.find((c) => c.kind === "qr");
return cred?.value ?? null;
}
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
* (never throws). No QR credential → nothing to print (printed:false, no error). */
async function tryPrintCard(
sub: ReturnType<typeof loadAggregate>,
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
const code = qrCodeOf(sub);
if (!sub || !code) return { printed: false };
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return { printed: true, printedBy };
} catch (err) {
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
return { printed: false, printError };
}
}
// Update a subscription (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
"/api/subscriptions/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "subscription not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
// An update is a MASTER-DATA edit — it never re-sells or re-prices. The price,
// plan, version and currency are FROZEN as the original sale recorded them (a new
// price means a new sale = a new subscription). Editable here: holder/contact,
// car-count, the validity window, status, and credentials/plates.
db.update(subscriptions)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
validTo: resolveValidTo(b, existing.validTo),
status: b.status ?? existing.status,
})
.where(eq(subscriptions.id, req.params.id))
.run();
writeChildren(req.params.id, b);
// The holder name may have changed — drop the feed-label cache for this sub.
invalidateHolder(req.params.id);
return loadAggregate(req.params.id);
},
);
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/print",
{ preHandler: readGuard },
async (req, reply) => {
const sub = loadAggregate(req.params.id);
if (!sub) return reply.code(404).send({ error: "subscription not found" });
const code = qrCodeOf(sub);
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Revoke (soft): the common case — keeps the subscription + its history, just bars
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
// DELETE only to fully remove one created in error.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/revoke",
{ preHandler: updateGuard },
async (req, reply) => {
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a subscription + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
invalidateHolder(req.params.id);
return reply.code(204).send();
},
);
}
+243
View File
@@ -0,0 +1,243 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
isTariffV2,
priceSession,
validateTariffStructure,
type SessionPayment,
type TariffStructure,
} from "@parking/shared";
import { requirePermission } from "../auth.js";
/** Default site timezone for wall-clock tariff windows when none is configured. */
const DEFAULT_TZ = "Europe/Tirane";
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
// mutates one; a session reprices against the version in force at its entry, and
// the `payment` event records the tariffVersionId. "One active tariff per site" for
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
interface PublishBody {
currency: string;
structure: TariffStructure;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
}
const SITE_TARIFF_NAME = "Site tariff";
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
* neither (uses the active version). */
interface SimulateBody {
enteredAt: string; // ISO-8601
asOf: string; // ISO-8601 (the "now"/exit instant being simulated)
payments?: SessionPayment[]; // hypothetical payment history (latest grants grace)
category?: string;
tariffVersionId?: string;
structure?: TariffStructure;
currency?: string;
}
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Reading the rate card (pay station / operator UI needs it).
const readGuard = requirePermission("tariff:read");
// Publishing a new version changes what customers are charged.
const writeGuard = requirePermission("tariff:update");
// The single site tariff row, created on first read/publish.
function ensureSiteTariff(): string {
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (existing) return existing.id;
const id = randomUUID();
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
return id;
}
// Current state: the active (latest-effective, ≤ now) version + the full history.
app.get("/api/tariff", { preHandler: readGuard }, async () => {
const tariffId = ensureSiteTariff();
const versions = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
const now = new Date().toISOString();
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
return { tariffId, active, versions };
});
// Publish a new immutable version. Validates the structure first — a malformed
// rate card can never be published (the fee calc + the chain depend on it).
app.post<{ Body: PublishBody }>(
"/api/tariff/versions",
{ preHandler: writeGuard },
async (req, reply) => {
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" });
}
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
// validation that requires tz passes. A V1 (bare) structure is left untouched.
let toStore: TariffStructure = structure;
if (structure && isTariffV2(structure)) {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
toStore = { ...structure, tz };
}
const problems = validateTariffStructure(toStore);
if (problems.length) {
return reply.code(400).send({ error: "invalid tariff structure", problems });
}
// effectiveFrom must NOT be in the past. A version is selected by
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
// retroactively reprice already-entered sessions — exactly the immutability
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
// backdating: a new version applies only from publish (now) forward; a future
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
// absorbs client/server clock drift + request round-trip. Once a car has
// entered, no later publish can reprice it (no effectiveFrom can predate it).
const now = Date.now();
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
let effective = new Date().toISOString();
if (effectiveFrom != null) {
const t = Date.parse(effectiveFrom);
if (Number.isNaN(t)) {
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
}
if (t < now - SKEW_MS) {
return reply.code(400).send({
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
});
}
effective = new Date(t).toISOString();
}
const tariffId = ensureSiteTariff();
const id = randomUUID();
const row = {
id,
tariffId,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
};
db.insert(tariffVersions).values(row).run();
return reply.code(201).send(row);
},
);
// --- Tariff Lab (simulator) -------------------------------------------------
// Price a HYPOTHETICAL session at arbitrary times against any tariff version —
// pure, no ledger writes. Lets an admin test rates "in time" (overnight windows,
// daily caps, overstay) in seconds instead of waiting hours. Also used to quote a
// customer dispute on-site. tariff:read (admins always have it). See tariff.md.
app.post<{ Body: SimulateBody }>("/api/tariff/simulate", { preHandler: readGuard }, async (req, reply) => {
const b = req.body ?? ({} as SimulateBody);
if (!b.enteredAt || !b.asOf) {
return reply.code(400).send({ error: "enteredAt and asOf (ISO-8601) required" });
}
if (!(Date.parse(b.enteredAt) <= Date.parse(b.asOf))) {
return reply.code(400).send({ error: "asOf must be at or after enteredAt" });
}
// Resolve the structure: an explicit version id, or the active version, or an
// inline structure (preview unpublished edits). A version carries its currency.
let structure: TariffStructure | undefined = b.structure;
let currency = b.currency ?? null;
if (b.tariffVersionId) {
const v = db.select().from(tariffVersions).where(eq(tariffVersions.id, b.tariffVersionId)).get();
if (!v) return reply.code(404).send({ error: "tariff version not found" });
structure = v.structure as unknown as TariffStructure;
currency = v.currency;
} else if (!structure) {
const tariffId = ensureSiteTariff();
const nowIso = new Date().toISOString();
const active =
db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all()
.find((v) => v.effectiveFrom <= nowIso) ?? null;
if (!active) return reply.code(404).send({ error: "no active tariff to simulate against" });
structure = active.structure as unknown as TariffStructure;
currency = active.currency;
}
const problems = validateTariffStructure(structure);
if (problems.length) return reply.code(400).send({ error: "invalid tariff structure", problems });
const payments = Array.isArray(b.payments) ? b.payments : [];
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
const enteredMs = Date.parse(b.enteredAt);
const curve = SAMPLES_MIN.map((min) => ({
minutes: min,
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
}));
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
});
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
// admin can re-evaluate an actual ticket (e.g. an overstay) at any chosen `asOf`.
app.get<{ Params: { identity: string } }>(
"/api/tariff/simulate/session/:identity",
{ preHandler: readGuard },
async (req, reply) => {
const id = (req.params.identity ?? "").trim();
if (!id) return reply.code(400).send({ error: "identity required" });
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return reply.code(404).send({ error: "no session for identity" });
const payments: { paidAt: string; graceExitMin: number | null }[] = [];
for (const r of rows) {
if (r.type !== "payment") continue;
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
payments.push({ paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null });
}
const exit = rows.find((r) => r.type === "vehicle_exit");
const category = (entry.payload as { category?: string } | null)?.category ?? null;
return {
identity: id,
enteredAt: entry.occurredAt,
exitedAt: exit?.occurredAt ?? null,
payments,
category,
// The version frozen at entry — the rate card this session actually keeps.
tariffVersionId: tariffVersionIdFor(entry.occurredAt),
};
},
);
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
function tariffVersionIdFor(whenIso: string): string | null {
const tariffId = ensureSiteTariff();
const v =
db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all()
.find((row) => row.effectiveFrom <= whenIso) ?? null;
return v?.id ?? null;
}
}
+245
View File
@@ -0,0 +1,245 @@
import { randomUUID } from "node:crypto";
import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID } from "@parking/shared";
import { permissionsFor, requirePermission } from "../auth.js";
// User management (admin). Users are created/edited at runtime here — the
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
// role (RBAC); the role resolves to a permission set at request time. Passwords
// are bcrypt-hashed (cost 12) and never returned. See @parking/shared PERMISSIONS.
//
// NO-LOCKOUT INVARIANT: the app refuses to delete, or move off the `admin` role,
// the LAST user still holding `admin`. Administration can therefore never be
// locked out of the appliance. See wiki/entities/local-jwt-auth.md.
//
// PRIVILEGE-ESCALATION GUARD: a non-admin caller with `user:*` must NOT be able to
// (a) ASSIGN a role whose permissions exceed their own (e.g. hand themselves or a
// peer the admin role, or any role broader than theirs), nor (b) MODIFY a user who
// already holds a role broader than the caller's (resetting an admin's password is
// account takeover; deleting an admin is sabotage). Both are blocked below by
// comparing permission SETS. An admin holds the full set, so it is unrestricted.
// Optional profile metadata accepted on create/update. All nullable; "" is treated
// as "clear" (→ null). Trimmed before persisting.
interface ProfileBody {
fullName?: string | null;
phone?: string | null;
email?: string | null;
address?: string | null;
}
interface CreateBody extends ProfileBody {
username: string;
password: string;
roleId: string;
}
interface UpdateBody extends ProfileBody {
username?: string;
roleId?: string;
}
interface PasswordBody {
password: string;
}
const MIN_PASSWORD = 8;
const PROFILE_FIELDS = ["fullName", "phone", "email", "address"] as const;
/** Pull the optional profile fields out of a body → a patch of trimmed values
* ("" → null). Absent keys are omitted (so an update only touches what's sent). */
function profilePatch(body: ProfileBody): Record<string, string | null> {
const out: Record<string, string | null> = {};
for (const k of PROFILE_FIELDS) {
const v = body[k];
if (v === undefined) continue;
const trimmed = typeof v === "string" ? v.trim() : "";
out[k] = trimmed === "" ? null : trimmed;
}
return out;
}
export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("user:read");
const createGuard = requirePermission("user:create");
const updateGuard = requirePermission("user:update");
const deleteGuard = requirePermission("user:delete");
/** Count users currently holding the protected admin role. */
function adminCount(): number {
return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length;
}
/** True if removing/relocating `userId` from admin would leave zero admins. */
function isLastAdmin(userId: string): boolean {
const u = db.select().from(users).where(eq(users.id, userId)).get();
return u?.roleId === ADMIN_ROLE_ID && adminCount() <= 1;
}
/** A user row safe to return — never the password hash. */
function publicUser(u: {
id: string;
username: string;
roleId: string;
language: string;
createdAt: string;
fullName?: string | null;
phone?: string | null;
email?: string | null;
address?: string | null;
}) {
return {
id: u.id,
username: u.username,
roleId: u.roleId,
language: u.language,
createdAt: u.createdAt,
fullName: u.fullName ?? null,
phone: u.phone ?? null,
email: u.email ?? null,
address: u.address ?? null,
};
}
/** True if `targetRoleId` grants any permission the caller's role does NOT hold,
* i.e. assigning or touching it would let the caller act beyond their own
* privileges. (Admin holds the full set, so it never trips.) */
function exceedsCaller(callerRoleId: string, targetRoleId: string): boolean {
if (callerRoleId === targetRoleId) return false;
const held = permissionsFor(callerRoleId);
for (const p of permissionsFor(targetRoleId)) {
if (!held.has(p)) return true;
}
return false;
}
// List all users (no password hashes) + their role names for display.
app.get("/api/users", { preHandler: readGuard }, async () => {
const rows = db.select().from(users).all();
const roleRows = db.select().from(roles).all();
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
return {
users: rows.map((u) => ({ ...publicUser(u), roleName: roleName.get(u.roleId) ?? u.roleId })),
};
});
// Create a user. Username unique; password >= 8 chars; roleId must exist.
app.post<{ Body: CreateBody }>("/api/users", { preHandler: createGuard }, async (req, reply) => {
const username = (req.body?.username ?? "").trim();
const password = req.body?.password ?? "";
const roleId = (req.body?.roleId ?? "").trim();
if (!username || !roleId) {
return reply.code(400).send({ error: "username and roleId required" });
}
if (password.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
return reply.code(400).send({ error: "unknown roleId" });
}
// No-escalation: can't create a user with a role broader than your own.
if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
}
if (db.select().from(users).where(eq(users.username, username)).get()) {
return reply.code(409).send({ error: "username already exists" });
}
const id = randomUUID();
const passwordHash = await bcrypt.hash(password, 12);
db.insert(users).values({ id, username, passwordHash, roleId, ...profilePatch(req.body) }).run();
const created = db.select().from(users).where(eq(users.id, id)).get()!;
return reply.code(201).send(publicUser(created));
});
// Update a user's username and/or role. Guarded against orphaning admin.
app.put<{ Params: { id: string }; Body: UpdateBody }>(
"/api/users/:id",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const existing = db.select().from(users).where(eq(users.id, id)).get();
if (!existing) return reply.code(404).send({ error: "user not found" });
// No-escalation: can't modify a user who already outranks you.
if (exceedsCaller(req.user.roleId, existing.roleId)) {
return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" });
}
const next: { username?: string; roleId?: string } & Record<string, string | null> = {
...profilePatch(req.body ?? {}),
};
if (req.body?.username != null) {
const username = req.body.username.trim();
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
const clash = db.select().from(users).where(eq(users.username, username)).get();
if (clash && clash.id !== id) return reply.code(409).send({ error: "username already exists" });
next.username = username;
}
if (req.body?.roleId != null) {
const roleId = req.body.roleId.trim();
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
return reply.code(400).send({ error: "unknown roleId" });
}
// No-escalation: can't promote a user into a role broader than your own.
if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
}
// No-lockout: don't move the last admin off the admin role.
if (roleId !== ADMIN_ROLE_ID && isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot change the role of the last admin" });
}
next.roleId = roleId;
}
if (Object.keys(next).length === 0) {
return reply.code(400).send({ error: "nothing to update" });
}
db.update(users).set(next).where(eq(users.id, id)).run();
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
},
);
// Reset a user's password (admin sets a new one; >= 8 chars).
app.put<{ Params: { id: string }; Body: PasswordBody }>(
"/api/users/:id/password",
{ preHandler: updateGuard },
async (req, reply) => {
const id = req.params.id;
const target = db.select().from(users).where(eq(users.id, id)).get();
if (!target) {
return reply.code(404).send({ error: "user not found" });
}
// No-escalation: can't reset the password of a user who outranks you
// (that would be account takeover of a more-privileged account).
if (exceedsCaller(req.user.roleId, target.roleId)) {
return reply.code(403).send({ error: "cannot reset the password of a user whose role exceeds your own" });
}
const password = req.body?.password ?? "";
if (password.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
const passwordHash = await bcrypt.hash(password, 12);
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
return { ok: true };
},
);
// Delete a user. Refused if it's the last admin (no-lockout).
app.delete<{ Params: { id: string } }>(
"/api/users/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const target = db.select().from(users).where(eq(users.id, id)).get();
if (!target) {
return reply.code(404).send({ error: "user not found" });
}
// No-escalation: can't delete a user who outranks you.
if (exceedsCaller(req.user.roleId, target.roleId)) {
return reply.code(403).send({ error: "cannot delete a user whose role exceeds your own" });
}
if (isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot delete the last admin" });
}
db.delete(users).where(eq(users.id, id)).run();
return { ok: true };
},
);
}
+117
View File
@@ -0,0 +1,117 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { deviceEvents } from "../device-events.js";
import { enrichEvent } from "../event-enrich.js";
import type { DeviceMonitor } from "../device-monitor.js";
import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
// server-pushed updates instead of polling: each signed ledger append (entry,
// exit, payment, void) is fanned out, and the recomputed occupancy rides along
// so the screen's count stays exact (occupancy is a fold over the same ledger,
// never a counter). Printer-status changes are forwarded too.
//
// Auth: the handshake is a normal GET through Fastify's lifecycle, so the same
// HttpOnly JWT cookie that guards the REST API guards this. We verify the JWT and
// role here. A browser's WebSocket constructor cannot set custom headers, so the
// CSRF double-submit header the REST mutations use is unavailable — which would
// leave the socket open to Cross-Site WebSocket Hijacking: a malicious page in the
// operator's browser could open ws://<booth>/api/ws, the browser would auto-attach
// the HttpOnly cookie, and the attacker would receive the live entry/exit/payment
// stream. The cookie alone is NOT a control here. So we replace the CSRF check with
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
/** Permission required to watch the live feed (a read-only stream of ledger +
* device status). Any role granted `report:read` may watch. */
const WATCH_PERMISSION = "report:read" as const;
/**
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
* (comma-separated) for a booth UI served from a different origin. A missing or
* mismatched Origin is rejected — that is the anti-CSWSH control.
*/
function isAllowedOrigin(origin: string | undefined, host: string | undefined): boolean {
if (!origin) return false; // no Origin → not a same-origin browser request
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return false; // malformed Origin
}
if (host && originHost === host) return true; // same-origin (any scheme/port match via host)
const allow = (process.env.WS_ALLOWED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return allow.includes(origin);
}
type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown };
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
app.get(
"/api/ws",
{
websocket: true,
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
// role. Reject a cross/absent origin before touching the token, so a hijack
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
preHandler: async (req) => {
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
}
await req.jwtVerify();
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
},
},
(socket) => {
const send = (msg: OutMsg) => {
// readyState 1 = OPEN; never throw out of an event-bus callback.
if (socket.readyState === 1) {
try {
socket.send(JSON.stringify(msg));
} catch {
/* drop on a broken socket */
}
}
};
// Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
// Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative.
const offLedger = deviceEvents.onLedger((event) => {
// Enrich with read-time display fields (subscriber name) before fan-out.
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
});
const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", event });
});
// Unified device status (all categories) for the booth footer — pushed on
// change; the initial set rode the hello above.
const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event });
});
socket.on("close", () => {
offLedger();
offPrinter();
offDevice();
});
},
);
}
+187 -44
View File
@@ -1,18 +1,42 @@
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt"; import jwt from "@fastify/jwt";
import websocket from "@fastify/websocket";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { createDb, type Db } from "@parking/db"; import { randomUUID } from "node:crypto";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
import { deviceEvents } from "./device-events.js"; import { deviceEvents } from "./device-events.js";
import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
import { LaneMap } from "./lane-map.js"; import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js";
import { SubscriptionFlow } from "./subscription-flow.js";
import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js";
import { CredentialCapture } from "./credential-capture.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js"; import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js";
import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js";
import { subscriptionRoutes } from "./routes/subscriptions.js";
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
import { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js";
import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
import { deviceStatusRoutes } from "./routes/device-status.js";
import { wsRoutes } from "./routes/ws.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify // The backend is Fastify (Node). Hardware drivers live as isolated Fastify
// plugins emitting onto a shared internal event bus; auth is fully local // plugins emitting onto a shared internal event bus; auth is fully local
@@ -23,14 +47,30 @@ export interface BuildOptions {
} }
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> { export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
const app = Fastify({ // DB first — the logger's DB sink needs it before Fastify is constructed.
logger: { level: process.env.LOG_LEVEL ?? "info" },
});
const db = opts.db ?? createDb(); const db = opts.db ?? createDb();
// Application-log store: a pino stream tees warn+ lines into app_logs (and still
// writes them to stdout), so backend warnings/errors are queryable from the booth
// alongside frontend errors. See log-service.ts + wiki/concepts/app-logs.md.
const logService = new LogService(db);
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? "info",
stream: pinoDbStream(logService, process.stdout),
},
});
// Wire the RBAC permission resolver to this DB (route guards resolve a user's
// role → permission set through it). See auth.ts.
initAuth(db);
await app.register(cookie); await app.register(cookie);
// WebSocket support for the live booth feed (/api/ws). Registered before the
// routes so the `{ websocket: true }` route option is available.
await app.register(websocket);
// Local JWT signing with a local secret — no external identity provider. // Local JWT signing with a local secret — no external identity provider.
// Fail fast rather than fall back to a known default: a booth machine started // Fail fast rather than fall back to a known default: a booth machine started
// without a real secret would sign tokens anyone could forge (incl. an admin // without a real secret would sign tokens anyone could forge (incl. an admin
@@ -38,7 +78,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// The token is carried in an HttpOnly cookie (not the Authorization header). // The token is carried in an HttpOnly cookie (not the Authorization header).
await app.register(jwt, { await app.register(jwt, {
secret: requireJwtSecret(), secret: requireJwtSecret(),
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire // No expiry: a login is valid until explicit logout — a shift is a separate
// boundary, not the token lifetime (see auth.ts + wiki/concepts/shift.md).
cookie: { cookieName: TOKEN_COOKIE, signed: false }, cookie: { cookieName: TOKEN_COOKIE, signed: false },
}); });
@@ -47,15 +88,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie. // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db); await authRoutes(app, db);
// device id -> lane resolver. Built from lane_devices at startup and refreshed // RBAC administration: compose roles (role:*) + manage users (user:*). The
// by setupRoutes on assign/unassign, so device events can be stamped with the // built-in admin role is protected; the last admin can't be removed. See auth.ts.
// lane the device belongs to (events carry the device id, not a lane). await userRoutes(app, db);
const laneMap = new LaneMap(db); await roleRoutes(app, db);
laneMap.refresh();
// Device-agnostic setup: the admin selects devices per lane from the driver // Device-agnostic setup: the admin adds controllers (with their relays + entry
// catalog at first-run. See wiki/concepts/first-run-setup.md. // button) and binds readers/cameras to a controller relay at first-run. There is
await setupRoutes(app, db, () => laneMap.refresh()); // no lane — a parking lot is one pool with a flexible set of entry/exit points.
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
await setupRoutes(app, db);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events), // Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from // guarded by source-IP allowlist + a shared-secret path token, both read from
@@ -70,39 +112,140 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => printerMonitor.start()); app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop()); app.addHook("onClose", async () => printerMonitor.stop());
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button // Vision (ANPR) client — built early so the device monitor can include the vision
// presses) into the hash-chained, signed `events` table — the anti-fraud audit // service's health in the footer. Opt-in (VISION_ENABLED) + fail-soft; advisory only.
// trail. The device is NOT trusted; the host record is the source of truth, and // See wiki/entities/opencv-anpr-service.md.
// a relay open with no matching signed event is itself the anomaly. We record const visionClient = new VisionClient(app.log);
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that if (visionClient.enabled) app.log.info("vision client enabled");
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
const eventLog = new EventLog(db, buildSigner(app.log)); // Unified device-status monitor: polls EVERY configured device (relays/readers/
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
// /health, and feeds the booth's device-status footer over the WS. Read-only.
// See wiki/concepts/device-status-monitoring.md.
const deviceMonitor = new DeviceMonitor(db, app.log, undefined, visionClient);
await deviceStatusRoutes(app, deviceMonitor);
app.addHook("onReady", async () => deviceMonitor.start());
app.addHook("onClose", async () => deviceMonitor.stop());
// Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// in device_events. The entry flow (TODO) turns an input into a signed
// vehicle_entry once a ticket prints + the barrier is commanded.
// See wiki/decisions/event-streams-split.md.
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
deviceEvents.emitLedger(row),
);
await eventRoutes(app, db, eventLog); await eventRoutes(app, db, eventLog);
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db);
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
// Subscribes to the SAME input bus as the telemetry writer below; the two are
// independent (telemetry always records; the entry flow acts only on an access
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
// The flows take the vision client so ANPR rides their entry/exit SNAPSHOT: a button
// press / QR / RFID triggers the open + snapshot, and the plate is recognized off that
// same image and recorded against the session (advisory; never changes the decision).
// No polling — recognition fires only on a real entry/exit. See snapshot.ts +
// wiki/entities/opencv-anpr-service.md.
const entryFlow = new EntryFlow(db, eventLog, app.log, visionClient);
const unsubscribeEntry = deviceEvents.onInput((e) => {
void entryFlow.onInput(e);
});
app.addHook("onClose", async () => unsubscribeEntry());
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
// parking-session.md.
const exitFlow = new ExitFlow(db, eventLog, app.log, visionClient);
const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log, visionClient);
const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
const unsubscribeRead = deviceEvents.onRead((e) => {
void readDispatcher.dispatch(e);
});
app.addHook("onClose", async () => unsubscribeRead());
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
// CHOSEN reader to populate a subscription credential, without blocking the other
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
const credentialCapture = new CredentialCapture();
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
// on an armed reader for enrollment; otherwise the read routes through the
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
// (sum payments by tender, print the Z-report). Constructed before the pay routes
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
// Pay station (pay-on-foot): quote an open session against the active tariff +
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
const payStation = new PayStation(db, eventLog, app.log);
await payRoutes(app, db, payStation, exitFlow, shiftService);
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
// the pay station prices against. See wiki/concepts/tariff.md.
await tariffRoutes(app, db);
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
// wiki/entities/subscription.md.
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
await subscriptionPlanRoutes(app, db);
// Shift open/close + drawer endpoints (shiftService constructed above).
await shiftRoutes(app, shiftService, db);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db);
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService);
// Periodic retention prune (age + row cap) so the log table stays bounded on the
// offline appliance. Runs hourly; unref'd so it never holds the process open.
const pruneTimer = setInterval(() => {
const n = logService.prune();
if (n > 0) app.log.debug(`pruned ${n} app_log rows`);
}, 60 * 60 * 1000);
pruneTimer.unref();
logService.prune(); // once at startup
app.addHook("onClose", async () => clearInterval(pruneTimer));
const unsubscribeInput = deviceEvents.onInput((e) => { const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't // Record every input edge as unsigned telemetry, keyed to the device that fired
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded // (provenance). No lane — the pool-of-spaces model has none. The entry flow
// faithfully (the chain is append-only) rather than silently dropped or // (above) independently decides whether this edge is an entry button.
// mis-stamped as lane 0, which is a real lane. try {
const lane = laneMap.laneFor(e.deviceId) ?? -1; db.insert(deviceEventsTable)
if (lane === -1) { .values({
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`); id: randomUUID(),
} deviceId: e.deviceId,
eventLog category: "access",
.append({ kind: "input",
type: "input_received", detail: { driverId: e.driverId, input: e.input, edge: e.edge },
lane,
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
// VEHICLE was identified. A raw input has none, so it stays null. The
// device provenance lives in `identity` instead.
source: null,
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
occurredAt: e.at, occurredAt: e.at,
}) })
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`)); .run();
} catch (err) {
app.log.error(`device-event insert failed: ${(err as Error).message}`);
}
}); });
app.addHook("onClose", async () => unsubscribeInput()); app.addHook("onClose", async () => unsubscribeInput());
// TODO: entry flow (input event → signed event → print → relay).
return app; return app;
} }
+565
View File
@@ -0,0 +1,565 @@
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
import type { LedgerPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
// Shift service (manned mode only). A shift is an operator's accountability period,
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
// `payment` events taken during the shift by tender and print a Z-report.
// See wiki/concepts/shift.md.
export class ShiftAlreadyOpenError extends Error {
/** The operator who currently holds the open shift (may be someone else). */
readonly heldBy: string;
constructor(operator: string, heldBy: string) {
super(
heldBy === operator
? `operator ${operator} already has an open shift`
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
);
this.name = "ShiftAlreadyOpenError";
this.heldBy = heldBy;
}
}
export class NoOpenShiftError extends Error {
constructor(operator: string) {
super(`operator ${operator} has no open shift`);
this.name = "NoOpenShiftError";
}
}
/** Thrown by the booth money path when NO shift is open site-wide — an operator
* must open a shift before any payment/exit can be attributed to a shift. */
export class NoShiftOpenError extends Error {
constructor() {
super("no shift is open — open a shift before processing tickets");
this.name = "NoShiftOpenError";
}
}
/** A COMPLETED shift, reconstructed from its signed `shift_z_report` (which carries
* all the figures in its payload). This is the unit of the shift-history feature.
* `id` is the z_report's ledger id (stable, for the UI list key / future deep-link). */
export interface ShiftSummary {
readonly id: string;
readonly index: number;
readonly operator: string;
readonly startedAt: string;
readonly endedAt: string;
readonly cashTotalMinor: number;
readonly cardTotalMinor: number;
readonly currency: string | null;
readonly paymentCount: number;
readonly openingFloatMinor: number;
readonly cashAddedMinor: number;
readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: number;
}
export interface ShiftReport {
readonly operator: string;
readonly startedAt: string;
readonly endedAt: string;
readonly cashTotalMinor: number;
readonly cardTotalMinor: number;
readonly currency: string | null;
readonly paymentCount: number;
// --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number;
/** Admin cash LOADED into the drawer during the shift (sum of + movements). */
readonly cashAddedMinor: number;
/** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */
readonly cashRemovedMinor: number;
/** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */
readonly expectedDrawerMinor: number;
readonly printed: boolean;
}
export class InvalidCashMovementError extends Error {
constructor(msg: string) {
super(msg);
this.name = "InvalidCashMovementError";
}
}
export class ShiftService {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Current physical drawer balance (cash payments + cash_movements, by time). For
* the UI to show "inherited / in the drawer now". */
drawerBalance(): { balanceMinor: number; currency: string | null } {
return this.#drawerBalanceAt(new Date().toISOString());
}
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
openShiftFor(operator: string) {
// Scan shift events for this operator; the shift is open if the most recent
// shift event for them is a `shift_open` (not yet closed by a z_report).
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, operator))
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null;
}
/**
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
* period: at most ONE may be open at a time (so booth takings are unambiguously
* attributed to one operator). It's open iff the most recent shift event on the
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
* appended yet). Returns that row so callers can read its operator/startedAt.
*/
currentOpenShift() {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null;
}
/**
* COMPLETED shift history, newest first. Each closed shift is one signed
* `shift_z_report` whose payload already holds every figure, so this is a simple
* read of those rows (no re-summing). Optional filters:
* - operator: only this operator's shifts (the `identity` on the z_report).
* - from/to: ISO timestamps; keep shifts whose START falls in [from, to].
* The open shift (no z_report yet) is intentionally excluded — it's not a
* completed accountability period. Use `currentOpenShift()` for the live one.
*/
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "shift_z_report"))
.orderBy(ledgerEvents.index)
.all();
const out: ShiftSummary[] = [];
for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload & {
operator?: string;
startedAt?: string;
endedAt?: string;
cashTotalMinor?: number;
cardTotalMinor?: number;
paymentCount?: number;
openingFloatMinor?: number;
cashAddedMinor?: number;
cashRemovedMinor?: number;
expectedDrawerMinor?: number;
};
const operator = pl.operator ?? r.identity ?? "?";
const startedAt = pl.startedAt ?? r.occurredAt;
if (opts.operator && operator !== opts.operator) continue;
if (opts.from && startedAt < opts.from) continue;
if (opts.to && startedAt > opts.to) continue;
out.push({
id: r.id,
index: r.index,
operator,
startedAt,
endedAt: pl.endedAt ?? r.occurredAt,
cashTotalMinor: pl.cashTotalMinor ?? 0,
cardTotalMinor: pl.cardTotalMinor ?? 0,
currency: pl.currency ?? null,
paymentCount: pl.paymentCount ?? 0,
openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
expectedDrawerMinor: pl.expectedDrawerMinor ?? 0,
});
}
// Newest first for the history list.
return out.reverse();
}
/** Require an open shift for the booth money path; returns it or throws. */
requireOpenShift() {
const open = this.currentOpenShift();
if (!open) throw new NoShiftOpenError();
return open;
}
/**
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
* by operator — a drawer voucher is the admin's, not the shift operator's). Cash
* payments add to the drawer; card payments never touch it. Drawer movements adjust
* it via three event types kept side-by-side:
* - `cash_in` (Mandat Arkëtimi): + amountMinor (positive magnitude)
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
* removal) — historical chain events that still fold in unchanged.
* This is what carries across shifts.
*/
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter(
(r) =>
r.occurredAt <= at &&
(r.type === "payment" ||
r.type === "cash_in" ||
r.type === "cash_out" ||
r.type === "cash_movement"),
);
let balanceMinor = 0;
let currency: string | null = null;
for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (r.type === "payment") {
// Only CASH enters the till; card settles to the bank.
if (pl.tender !== "card") balanceMinor += amt;
} else if (r.type === "cash_in") {
balanceMinor += Math.abs(amt); // receipt — direction is the type
} else if (r.type === "cash_out") {
balanceMinor -= Math.abs(amt); // disbursement — direction is the type
} else {
// legacy cash_movement amount is signed (+ load, − removal).
balanceMinor += amt;
}
if (pl.currency) currency = pl.currency;
}
return { balanceMinor, currency };
}
/** Next voucher number for a drawer-voucher type, e.g. `AR-0007` (cash_in) /
* `PA-0007` (cash_out). Sequential per type = count of existing events + 1. The
* number is human-facing (printed on the slip); the signed chain is the real
* record, so a small race only risks a duplicate label, never a lost voucher. */
#nextVoucherNo(type: "cash_in" | "cash_out"): string {
const prefix = type === "cash_in" ? "AR" : "PA";
const count = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.type, type)).all().length;
return `${prefix}-${String(count + 1).padStart(4, "0")}`;
}
/**
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
* an amount (a receipt and a disbursement are different financial documents):
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
* route). Returns the new drawer balance + the assigned voucher number, and prints
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
*/
async recordVoucher(args: {
type: "cash_in" | "cash_out";
operator: string;
authorizedBy: string;
amountMinor: number;
reason: string;
currency?: string;
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
const { type, operator, authorizedBy, reason } = args;
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
}
const amountMinor = args.amountMinor;
const now = new Date().toISOString();
const voucherNo = this.#nextVoucherNo(type);
await this.#log.append({
type,
source: "manual",
identity: operator, // who RAISED the voucher (the operator at the booth)
payload: {
amountMinor, // positive magnitude — direction is the type
...(reason ? { reason } : {}),
...(args.currency ? { currency: args.currency } : {}),
operator,
authorizedBy,
voucherNo,
},
occurredAt: now,
});
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
this.#logger.info(
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
);
return { type, amountMinor, voucherNo, balanceMinor, printed };
}
/** Open a shift for the operator (explicit start). The opening float is auto-
* inherited from the chain = the drawer balance at the start instant. */
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
// operator's own (double-open) or another operator's (handover not done). Only
// one accountability period at a time.
const current = this.currentOpenShift();
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
const startedAt = new Date().toISOString();
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
await this.#log.append({
type: "shift_open",
source: "manual",
identity: operator, // the shift's operator; `identity` keys the shift to them
// Record the inherited opening float on the shift_open so it's reproducible
// and the next operator's handover figure is fixed in the chain.
payload: { operator, openingFloatMinor },
occurredAt: startedAt,
});
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
return { startedAt, openingFloatMinor };
}
/**
* Project the drawer/takings figures for a shift's window `[startedAt, asOf]`.
* Pure read over the signed chain — appends NOTHING — so it backs BOTH the
* mid-shift X-report (asOf = now, shift still open) and the Z-report at close
* (asOf = endedAt). The figures are identical projections; only the persistence
* differs (X = read-only, Z = signed + carried forward).
*/
#summariseWindow(
open: typeof ledgerEvents.$inferSelect,
asOf: string,
): Omit<ShiftReport, "printed"> {
const operator = open.identity ?? "?";
const startedAt = open.occurredAt;
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
// the operator who handled the money (decision: sum by payment time).
const payments = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "payment"))
.all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
let cashTotalMinor = 0;
let cardTotalMinor = 0;
let currency: string | null = null;
for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt;
else cashTotalMinor += amt;
if (pl.currency) currency = pl.currency;
}
// --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start);
// fall back to a fresh fold if an older shift_open lacks it.
const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number };
const openingFloatMinor =
typeof openPl.openingFloatMinor === "number"
? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor;
// Drawer movements within the window, split into added (+) and removed (−).
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
const movements = this.#db
.select()
.from(ledgerEvents)
.all()
.filter(
(r) =>
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
r.occurredAt >= startedAt &&
r.occurredAt <= asOf,
);
let cashAddedMinor = 0;
let cashRemovedMinor = 0;
for (const m of movements) {
const pl = (m.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (m.type === "cash_in") cashAddedMinor += Math.abs(amt);
else if (m.type === "cash_out") cashRemovedMinor += Math.abs(amt);
else if (amt >= 0) cashAddedMinor += amt; // legacy + load
else cashRemovedMinor += -amt; // legacy − removal, store as positive magnitude
if (pl.currency) currency = pl.currency;
}
// Expected drawer = opening + cash taken + added − removed. At close this is the
// figure the NEXT shift inherits as its opening float.
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
return {
operator,
startedAt,
endedAt: asOf,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount: payments.length,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
};
}
/**
* Mid-shift X-report: a READ-ONLY "so far" snapshot of the open shift's takings +
* drawer, computed as of now. Appends nothing (it's not an accountability mark —
* the Z-report at close is). Returns null when no shift is open. The same
* projection the Z-report prints, so the operator sees exactly what their close
* will show. See wiki/concepts/shift.md.
*/
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
const open = this.currentOpenShift();
if (!open) return null;
const asOf = new Date().toISOString();
return { ...this.#summariseWindow(open, asOf), asOf };
}
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
async close(operator: string): Promise<ShiftReport> {
const open = this.openShiftFor(operator);
if (!open) throw new NoOpenShiftError(operator);
const endedAt = new Date().toISOString();
const report = this.#summariseWindow(open, endedAt);
const {
startedAt,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
} = report;
await this.#log.append({
type: "shift_z_report",
source: "manual",
identity: operator,
payload: {
operator,
startedAt,
endedAt,
cashTotalMinor,
cardTotalMinor,
currency: currency ?? undefined,
paymentCount,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
},
});
const printed = await this.#printZReport(report);
this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
);
return { ...report, printed };
}
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
* is the record — a failed print doesn't undo the close). */
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
return false;
}
const cur = r.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2);
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
const lines = [
`Operatori: ${r.operator}`,
`Nga: ${zStamp(r.startedAt)}`,
`Deri: ${zStamp(r.endedAt)}`,
"",
`Pagesa: ${r.paymentCount}`,
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"",
"-- Arka --",
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
];
try {
await printer.printReport({ title: "RAPORT TURNI", lines });
return true;
} catch (err) {
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
return false;
}
}
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
* the signed event is the record; a failed print doesn't undo the voucher.
* Albanian, like every customer/operator-facing slip (see i18n.md). */
async #printVoucher(v: {
type: "cash_in" | "cash_out";
voucherNo: string;
amountMinor: number;
reason: string;
operator: string;
authorizedBy: string;
currency: string | null;
at: string;
}): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
return false;
}
const cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
const lines = [
`Mandat Nr.: ${v.voucherNo}`,
`Data: ${zStamp(v.at)}`,
"",
`Shuma: ${money(v.amountMinor)} ${cur}`,
`Arsyeja: ${v.reason || "-"}`,
"",
`Hapur nga: ${v.operator}`,
`Autorizoi: ${v.authorizedBy}`,
];
try {
await printer.printReport({ title, lines });
return true;
} catch (err) {
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
return false;
}
}
/** First enabled booth-receipt printer, or any enabled printer. */
async #boothPrinter(): Promise<PrinterDevice | null> {
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
const enabled = rows.filter((r) => r.enabled);
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
if (!booth) return null;
const driver = registry.get(booth.driverId);
if (!driver) return null;
try {
return driver.create(booth.config as never) as PrinterDevice;
} catch {
return null;
}
}
}
+31 -1
View File
@@ -14,7 +14,10 @@ export class SoftwareSigner implements Signer {
readonly keyId: string; readonly keyId: string;
readonly #key: Buffer; readonly #key: Buffer;
constructor(secret: string, keyId = "sw-hmac-v1") { // v2 canonical form: `lane` dropped from the signed array (pool-of-spaces model,
// 2026-06-16). v1 events used a different field order and won't verify under v2 —
// that's intentional and gated by the per-event keyId. See event-log canonicalize().
constructor(secret: string, keyId = "sw-hmac-v2") {
this.#key = Buffer.from(secret, "utf8"); this.#key = Buffer.from(secret, "utf8");
this.keyId = keyId; this.keyId = keyId;
} }
@@ -55,3 +58,30 @@ export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.", "event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
); );
} }
/**
* Resolve the signer that can VERIFY an existing event, by its stored `keyId`.
* Appends always use the one signer from buildSigner(), but a chain can contain
* events signed under different keys across a rotation (e.g. the JWT_SECRET
* fallback before a dedicated EVENT_SIGNING_KEY was set, or an ATECC608 swap).
* Each event stores its own `keyId`, so verifyChain() must check each row against
* the key that produced it — not the current append-signer. Returns undefined for
* an unknown keyId (the key is gone / not configured), which verifyChain surfaces
* as a distinct failure rather than a false "tampered" alarm.
*
* TODO(atecc608): add an "atecc608-slotN" case returning a public-key verifier.
*/
export function buildVerifier(keyId: string): Signer | undefined {
switch (keyId) {
case "sw-hmac-v2": {
const k = process.env.EVENT_SIGNING_KEY;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-v2") : undefined;
}
case "sw-hmac-jwtfallback": {
const k = process.env.JWT_SECRET;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-jwtfallback") : undefined;
}
default:
return undefined;
}
}
+190
View File
@@ -0,0 +1,190 @@
import { randomUUID } from "node:crypto";
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
import { registry, type CameraDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
import type { VisionClient } from "./vision-client.js";
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
// open path (decision 2026-06-16): a snapshot is EVIDENCE, not a gate. A camera
// failure must never delay or prevent an open — the signed ledger is the decision,
// the image is an independent, prunable record stored as a BLOB in `snapshots`.
// See wiki/concepts/entry-exit-points.md and append-only-event-chain.md.
//
// Every camera serving the firing direction (entry/exit, or both) snapshots. Each
// capture is independent — one camera down doesn't stop the others. A captured image
// → a `snapshots` row + a `kind:"snapshot"` telemetry device_event; a failure → a
// telemetry device_event only. The caller passes the session `identity` so the image
// links to the signed vehicle_entry/exit.
//
// ANPR rides this snapshot (2026-06-19). A transient button-press or a subscriber
// QR/RFID read triggers the entry/exit, which fires THIS snapshot — that is exactly the
// moment to recognize the plate, off the SAME image, tied to the SAME session identity.
// So when a `vision` client is passed AND the camera opts in (config.anpr), each stored
// snapshot is sent to the vision service and the extracted plate is RECORDED against the
// session (a `kind:"read"` device_event with plate/confidence/snapshotId). ADVISORY +
// fire-and-forget: it never blocks the open and never changes the entry/exit decision —
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
interface SnapshotJob {
readonly db: Db;
readonly direction: FlowDirection;
/** Session/credential ref (ticket id, plate, subscription car key) — links to the ledger. */
readonly identity: string;
readonly logger: FastifyBaseLogger;
/** Optional vision client — when present, ANPR runs on each captured image from an
* `anpr`-enabled camera and records the plate against `identity`. Advisory only. */
readonly vision?: VisionClient | null;
}
/** Camera config flag opting it into snapshot-triggered ANPR. */
interface CameraConfig {
readonly anpr?: boolean;
readonly [k: string]: unknown;
}
/**
* Fire snapshots for the directional camera set. Returns immediately with a promise
* the caller MAY ignore (fire-and-forget) — it resolves to the captured snapshot ids.
* The caller must NOT block its open path on this.
*/
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
const { db, direction, identity, logger, vision } = job;
const rows = devicesByDirection(db, "camera", direction);
if (rows.length === 0) return Promise.resolve([]);
return Promise.all(
rows.map(async (row): Promise<string | null> => {
const camera = buildCamera(row);
if (!camera) {
recordFailure(db, direction, row.id, identity, "camera config won't build", logger);
return null;
}
try {
const shot = await camera.captureSnapshot({ direction });
const id: string = randomUUID();
db.insert(snapshots)
.values({
id,
direction,
deviceId: row.id,
identity,
contentType: shot.contentType,
bytes: shot.bytes,
capturedAt: shot.capturedAt,
})
.run();
// Telemetry breadcrumb pointing at the stored image (NOT the bytes).
recordEvent(db, direction, row.id, identity, { snapshotId: id, ok: true }, logger);
// ANPR off the SAME image, tied to the SAME session — when vision is enabled
// and this camera opts in. Fire-and-forget: never delays the open path.
if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) {
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger);
}
return id;
} catch (err) {
recordFailure(db, direction, row.id, identity, (err as Error).message, logger);
return null;
}
}),
).then((ids) => ids.filter((id): id is string => id != null));
}
/**
* Recognize the plate off a captured entry/exit image and RECORD it against the session
* `identity` — an unsigned `kind:"read"` device_event carrying the plate, confidence,
* region, and the `snapshotId` it was read from. Advisory: this records the plate
* observed for the session; it does NOT feed the access decision (the flow already
* decided). A low-confidence/no-plate result records nothing (a shaky read isn't a fact).
* Best-effort + fail-soft — a vision error never surfaces on the (already-open) path.
*/
async function recognizePlate(
db: Db,
vision: VisionClient,
deviceId: string,
direction: FlowDirection,
identity: string,
snapshotId: string,
shot: { bytes: Buffer; contentType: string },
logger: FastifyBaseLogger,
): Promise<void> {
try {
const result = await vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record
const plate = result.plate.text.trim().toUpperCase();
if (!plate) return;
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId,
category: "camera",
kind: "read",
// `identity` ties the plate to the session; `snapshotId` to the evidence image.
detail: {
identity,
direction,
plate,
confidence: result.plate.confidence,
region: result.plate.region ?? null,
modelVersion: result.modelVersion,
snapshotId,
source: "entry-exit-snapshot",
},
occurredAt: new Date().toISOString(),
})
.run();
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
} catch (err) {
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
}
}
/** Build a live camera adapter from a resolved devices row, or null. */
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as CameraDevice;
} catch {
return null;
}
}
function recordFailure(
db: Db,
direction: FlowDirection,
deviceId: string,
identity: string,
error: string,
logger: FastifyBaseLogger,
): void {
logger.warn(`snapshot failed (${direction}, ${identity}): ${error}`);
recordEvent(db, direction, deviceId, identity, { ok: false, error }, logger);
}
function recordEvent(
db: Db,
direction: FlowDirection,
deviceId: string,
identity: string,
detail: Record<string, unknown>,
logger: FastifyBaseLogger,
): void {
try {
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId,
category: "camera",
kind: "snapshot",
detail: { ...detail, direction, identity },
occurredAt: new Date().toISOString(),
})
.run();
} catch (err) {
// Telemetry is best-effort; never let it surface on the (already-open) path.
logger.error(`snapshot device-event insert failed: ${(err as Error).message}`);
}
}
+390
View File
@@ -0,0 +1,390 @@
import { randomUUID } from "node:crypto";
import {
eq,
ledgerEvents,
sessions,
subscriptionCredentials,
subscriptionPlates,
subscriptions,
type Db,
type DeviceRow,
} from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { reasonPayload, type PlanTimeframes, type ReasonCode } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import { printWindowChargeNotice } from "./booth-print.js";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import { planVersionById, windowCharge, windowOwedBetween } from "./subscription-window.js";
import type { VisionClient } from "./vision-client.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
// paying per stay (they're on a recurring plan). Reached from the read dispatcher
// when a read matches a subscription (not an open ticket). See
// wiki/entities/subscription.md.
//
// Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
// subscription's cars may be inside at once; enforced over the session projection.
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
// too (card/QR OR plate). When unset, any car may use the subscription's card/QR.
//
// Direction is inferred from the SUBSCRIPTION's open-session state, NOT the specific
// credential read — so ANY of a subscription's credentials (QR / RFID / NFC / plate)
// may open or close a session. Entry mints a fresh per-occurrence session id (the
// ledger `identity`); a read with no open occurrence → ENTRY; with ≥1 open → EXIT the
// OLDEST open occurrence (FIFO). A fleet (maxConcurrent > 1) thus has several open
// occurrences at once; each read closes one. This decouples exit from the entry
// credential (you can enter with QR and leave with the card).
//
// NB: the SIGNED ledger payload still carries `permitId` (immutable history — see the
// schema note); the per-occurrence `identity` is the session key. The mutable master
// data / code is "subscription"; the on-chain field name is left as-is so historical
// events keep verifying.
export interface SubscriptionMatch {
readonly subscriptionId: string;
/** The specific credential/plate value read (for logging/anomalies). NOT the
* session key — sessions are keyed by subscription occurrence, so a different
* credential of the same subscription can close the session it opened. */
readonly carKey: string;
readonly via: "card" | "qr" | "plate";
}
export class SubscriptionFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
/** Optional vision client — passed to snapshotAsync so ANPR runs on the subscriber image. */
readonly #vision: VisionClient | null;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
this.#db = db;
this.#log = log;
this.#logger = logger;
this.#vision = vision;
}
/** Resolve a read to a subscription (by card/QR credential, or a bound plate), or null. */
match(e: DeviceReadEvent): SubscriptionMatch | null {
// Card / QR / generic credential value.
const cred = this.#db
.select()
.from(subscriptionCredentials)
.where(eq(subscriptionCredentials.value, e.value))
.get();
if (cred) {
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
if (e.kind === "plate") {
const plate = this.#db.select().from(subscriptionPlates).where(eq(subscriptionPlates.plate, e.value)).get();
if (plate) return { subscriptionId: plate.subscriptionId, carKey: e.value, via: "plate" };
}
return null;
}
/** Run the subscription entry/exit for a matched read at a barrier. `resolved` is the
* reader's bound relay; its direction constrains, "both" defers to session state. */
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const key = `${m.subscriptionId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#run(resolved, e, m);
} catch (err) {
this.#logger.error(`subscription-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
// The physical side the reader sits at — used to fire the right camera on a refusal
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
const invalid =
sub.status !== "active" ||
(sub.validFrom != null && now < sub.validFrom) ||
(sub.validTo != null && now > sub.validTo);
if (invalid) {
const reason = await this.#reject(m, lane, "sub.refused.outOfWindow", { status: sub.status });
return { accepted: false, reason };
}
// Direction: the BARRIER the reader sits at decides the verb — an entry-lane read
// is an ENTRY, an exit-lane read is an EXIT. (The credential is decoupled from the
// session, so we can't and needn't infer from "which credential".) A "both" barrier
// has no physical side, so there we infer from state: open occurrence → exit, else
// entry. This is what lets a FLEET admit several cars (each entry-lane read is an
// entry) yet exit any of them with ANY credential (FIFO).
const open = this.#openOccurrences(m.subscriptionId);
const verb: FlowDirection =
resolved.direction === "entry"
? "entry"
: resolved.direction === "exit"
? "exit"
: open.length > 0
? "exit"
: "entry";
const source = m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand";
if (verb === "exit") {
// EXIT: close the OLDEST open occurrence (FIFO). Its occurrence id is the session
// key; the credential just read may differ from the one that opened it. If the
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
const oldest = open[0];
if (!oldest) {
const reason = await this.#reject(m, "exit", "sub.refused.noSession");
return { accepted: false, direction: "exit", reason };
}
const occurrenceId = oldest.identity;
// TARIFF BRIDGE — exit gate. Total owed = carried early-entry charge (signed on the
// entry payload) + a late-exit charge (window-close→now) computed fresh. If the
// subscriber owes money and hasn't paid it, REFUSE the exit (like the transient
// unpaid/overstay gate) — they settle at the booth (a signed `payment` keyed to the
// occurrence), then re-scan. This is a host-ONLINE business gate; the offline path
// still fails open. See wiki/entities/subscription.md ("tariff bridge").
const owed = this.#windowOwed(occurrenceId, m.subscriptionId, sub.planVersionId);
const paid = this.#windowPaidMinor(occurrenceId);
if (owed.totalMinor - paid > 0) {
const reason = await this.#reject(m, "exit", "sub.refused.unpaidWindow", {
amount: ((owed.totalMinor - paid) / 100).toFixed(2),
currency: owed.currency ?? "",
});
return { accepted: false, direction: "exit", reason };
}
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity: occurrenceId,
// `permitId` carries the subscription id; `via` records which credential left.
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, via: m.via },
});
await this.#open(resolved, "exit", occurrenceId, "subscription exit");
this.#closeCache(occurrenceId);
return { accepted: true, direction: "exit" };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
// fresh per-occurrence id so a fleet can have several open at once.
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
const reason = await this.#reject(m, "entry", "sub.refused.atCapacity", {
inUse: open.length,
max: sub.maxConcurrent,
});
return { accepted: false, direction: "entry", reason };
}
// TARIFF BRIDGE — early entry. If the plan has time windows and this scan is before
// the window opens, the subscriber owes the transient tariff for arrival→window-open.
// We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry
// payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back
// from the chain. Plans without timeframes return null → nothing owed. See
// wiki/entities/subscription.md.
const entryCharge = windowCharge(this.#db, sub.planVersionId, now, "entry");
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
// the payload's `permitId` (which every fold matches on), so the key stays compact.
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source,
identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
// `permitId`/`permit` are the on-chain field names (immutable). A deferred early-
// entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit.
payload: {
sessionRef: occurrenceId,
permitId: m.subscriptionId,
permit: true,
via: m.via,
...(entryCharge
? {
windowOwedMinor: entryCharge.amountMinor,
windowCurrency: entryCharge.currency,
windowTariffVersionId: entryCharge.tariffVersionId,
windowGapStart: entryCharge.gapStart,
windowGapEnd: entryCharge.gapEnd,
}
: {}),
},
occurredAt: now,
});
if (entryCharge) {
this.#logger.info(
`subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`,
);
}
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
try {
this.#db
.insert(sessions)
.values({
id: occurrenceId,
identity: occurrenceId,
source: m.via === "plate" ? "lpr" : "wiegand",
subscriptionId: m.subscriptionId,
enteredAt: now,
state: "open",
})
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
}
// BEST-EFFORT: print an advisory "out-of-window" slip so the subscriber has paper
// proof a fee is pending (the final amount is computed at the booth on settlement,
// combining early-entry + any late-exit time). AFTER the open + cache, and fully
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
if (entryCharge) {
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
void printWindowChargeNotice(
this.#db,
{ occurrenceId, holderName: sub.holderName, at: now, windowOpensMin: tf?.fromMin, edge: "entry" },
this.#logger,
).catch((err) => this.#logger.warn(`out-of-window slip print failed for ${occurrenceId}: ${(err as Error).message}`));
}
return { accepted: true, direction: "entry" };
}
/**
* Total out-of-window charge owed for an occurrence right now: the transient cost of the
* minutes parked OUTSIDE the plan's window over the WHOLE stay `[entry, now]` — ONE
* computation covering early entry AND late exit (not entry-gap + exit-gap, which
* double-counts and lets the exit gap reach a previous day's close). A plan without
* timeframes yields 0. Single source of truth shared with the booth quote.
*/
#windowOwed(
occurrenceId: string,
_subscriptionId: string,
planVersionId: string | null,
): { totalMinor: number; currency: string | null } {
const entryRow = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, occurrenceId))
.all()
.find((r) => r.type === "vehicle_entry");
if (!entryRow) return { totalMinor: 0, currency: null };
const owed = windowOwedBetween(this.#db, planVersionId, entryRow.occurredAt, new Date().toISOString());
return { totalMinor: owed?.amountMinor ?? 0, currency: owed?.currency ?? null };
}
/** Sum of signed `payment` events keyed to this occurrence (what the subscriber has
* already paid toward their window charge). Folds the append-only ledger. */
#windowPaidMinor(occurrenceId: string): number {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
let paid = 0;
for (const r of rows) {
if (r.type !== "payment") continue;
const pl = (r.payload ?? {}) as { amountMinor?: number };
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
return paid;
}
/**
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
* `payload.permitId` is this subscription) with no later `vehicle_exit` on the same
* `identity`. Used to (a) infer entry vs. exit for ANY credential of the
* subscription, (b) pick which occurrence a read closes, and (c) enforce
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
*/
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
const net = new Map<string, number>();
const firstIndex = new Map<string, number>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
const pl = (r.payload ?? {}) as { permitId?: string };
if (r.type === "vehicle_entry") {
if (pl.permitId !== subscriptionId) continue;
net.set(id, (net.get(id) ?? 0) + 1);
if (!firstIndex.has(id)) firstIndex.set(id, r.index);
} else if (r.type === "vehicle_exit") {
if (!net.has(id)) continue; // not one of this subscription's occurrences
net.set(id, (net.get(id) ?? 0) - 1);
}
}
const open: { identity: string; index: number }[] = [];
for (const [id, n] of net) if (n > 0) open.push({ identity: id, index: firstIndex.get(id) ?? 0 });
open.sort((a, b) => a.index - b.index); // oldest first → FIFO
return open;
}
/** Sign a refused-subscription anomaly with a localizable reason code, fire the
* directional evidence camera, and return the rendered English reason for the
* caller's ReadOutcome. `dir` is the lane the refusal happened at (entry/exit) so
* the right camera captures the turned-away subscriber. `via` records which
* credential was presented. */
async #reject(
m: SubscriptionMatch,
dir: FlowDirection,
code: ReasonCode,
params?: Record<string, string | number>,
): Promise<string> {
const rp = reasonPayload(code, params);
await this.#log.append({
type: "anomaly",
identity: m.carKey,
// `permitId`/`permitRefused` are the on-chain field names (immutable).
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true, via: m.via },
});
this.#fireSnapshot(dir, m.carKey);
this.#logger.warn(`subscription refused (${m.carKey}): ${rp.reason}`);
return rp.reason;
}
/** Fire the directional camera(s) for a refused-subscription event; never awaited
* (evidence, not a gate). The accepted entry/exit paths snapshot inside #open. */
#fireSnapshot(dir: FlowDirection, identity: string): void {
void snapshotAsync({ db: this.#db, direction: dir, identity, logger: this.#logger, vision: this.#vision }).catch(
(err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`),
);
}
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
this.#fireSnapshot(dir, carKey);
}
#closeCache(carKey: string): void {
try {
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
}
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import { and, eq, lte, desc, subscriptionPlans, type Db } from "@parking/db";
import type { SubscriptionPlan } from "@parking/shared";
// Subscription-plan pricing. The PURE span math (periodsBetween / priceSubscriptionSpan
// / addMonths) lives in @parking/shared so it's unit-tested alongside the tariff fee
// function; here we add the DB-backed plan-version resolver. A plan is admin-composed,
// versioned config (like a tariff) — the operator SELLS from it and never types a
// price. See wiki/entities/subscription.md.
export { periodsBetween, priceSubscriptionSpan, addMonths } from "@parking/shared";
/** Resolve the plan VERSION in force for `planId` at `asOf`: the latest active row
* with effectiveFrom ≤ asOf (the tariff-resolve pattern). null when none applies. */
export function resolvePlanVersion(db: Db, planId: string, asOf: string): SubscriptionPlan | null {
const row = db
.select()
.from(subscriptionPlans)
.where(
and(
eq(subscriptionPlans.planId, planId),
eq(subscriptionPlans.active, true),
lte(subscriptionPlans.effectiveFrom, asOf),
),
)
.orderBy(desc(subscriptionPlans.effectiveFrom))
.limit(1)
.get();
return row ? (row as SubscriptionPlan) : null;
}
+129
View File
@@ -0,0 +1,129 @@
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
minutesOutsideWindow,
outOfWindowGap,
type PlanTimeframes,
type SubscriptionPlan,
type TariffStructure,
} from "@parking/shared";
// Subscription TIME-WINDOW → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may be
// parked (e.g. weekday 20:00→08:00, weekend all-day). A scan OUTSIDE the window is NOT
// refused — the out-of-window minutes are charged at the normal TRANSIENT tariff:
// - early ENTRY: arrival → window-open is owed (deferred; collected at exit).
// - late EXIT: window-close → departure is owed (exit is GATED until paid).
// Only plans WITH timeframes trigger any charge; a 24/7 plan never does. The gap math is
// pure + tz-aware (outOfWindowGap in @parking/shared); pricing reuses computeFee (the same
// engine transient stays use). See wiki/entities/subscription.md ("tariff bridge").
const DEFAULT_TZ = "Europe/Tirane";
/** A computed out-of-window charge: the gap, what it costs, and the tariff version used
* (recorded so it reprices identically — like every transient payment). */
export interface WindowCharge {
readonly amountMinor: number;
readonly gapStart: string;
readonly gapEnd: string;
readonly minutes: number;
readonly currency: string;
readonly tariffVersionId: string;
}
/** The plan VERSION that priced a subscription's sale (by planVersionId), or null. The
* timeframes are read from THIS version so a later plan edit can't retroactively change
* an existing subscriber's window rules. */
export function planVersionById(db: Db, planVersionId: string | null): SubscriptionPlan | null {
if (!planVersionId) return null;
const row = db.select().from(subscriptionPlans).where(eq(subscriptionPlans.id, planVersionId)).get();
return row ? (row as unknown as SubscriptionPlan) : null;
}
/** The site IANA timezone (falls back to the project default). */
export function siteTz(db: Db): string {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
}
/** The active site tariff version in force at `at` (latest effectiveFrom ≤ at), or null. */
function tariffVersionAt(db: Db, at: string) {
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/**
* Compute the out-of-window charge for a subscriber scan at `atISO`, or null when there
* is nothing to charge (no plan timeframes, in-window, weekend all-day, within grace, or
* no tariff configured). `edge` = "entry" (early) or "exit" (late). The gap is priced as
* a fresh transient stay of that duration (computeFee over [gapStart, gapEnd]).
*/
export function windowCharge(
db: Db,
planVersionId: string | null,
atISO: string,
edge: "entry" | "exit",
): WindowCharge | null {
const plan = planVersionById(db, planVersionId);
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
if (!timeframes) return null; // 24/7 plan (or comp sub) — never a time charge.
const tz = timeframes.tz || siteTz(db);
const gap = outOfWindowGap(timeframes, tz, atISO, edge);
if (!gap) return null; // in-window / all-day / within grace.
const tv = tariffVersionAt(db, gap.start);
if (!tv) return null; // no tariff to price against — can't charge (don't trap).
const structure = tv.structure as unknown as TariffStructure;
const amountMinor = computeFee(gap.start, gap.end, structure);
if (amountMinor <= 0) return null;
return {
amountMinor,
gapStart: gap.start,
gapEnd: gap.end,
minutes: gap.minutes,
currency: tv.currency,
tariffVersionId: tv.id,
};
}
/**
* The TOTAL out-of-window charge a subscriber owes for an OPEN occurrence, computed over
* the whole stay `[enteredAt, nowISO)` in ONE shot (not entry-gap + exit-gap, which
* double-counts and lets the exit gap reach back to a previous day's close). Sums the
* minutes parked outside the plan's allowed window and prices them as a single transient
* stay of that duration — so the tariff's increments + daily cap apply correctly. Returns
* null when the plan has no timeframes / nothing is owed / no tariff to price against.
*/
export function windowOwedBetween(
db: Db,
planVersionId: string | null,
enteredAtISO: string,
nowISO: string,
): { amountMinor: number; minutes: number; currency: string; tariffVersionId: string } | null {
const plan = planVersionById(db, planVersionId);
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
if (!timeframes) return null;
const tz = timeframes.tz || siteTz(db);
const minutes = minutesOutsideWindow(timeframes, tz, enteredAtISO, nowISO);
if (minutes <= 0) return null;
// Price the out-of-window duration as a transient stay (entry→entry+minutes), against
// the tariff in force at entry — reproducible, and the daily cap applies.
const tv = tariffVersionAt(db, enteredAtISO);
if (!tv) return null;
const structure = tv.structure as unknown as TariffStructure;
const end = new Date(Date.parse(enteredAtISO) + minutes * 60_000).toISOString();
const amountMinor = computeFee(enteredAtISO, end, structure);
if (amountMinor <= 0) return null;
return { amountMinor, minutes, currency: tv.currency, tariffVersionId: tv.id };
}
+193
View File
@@ -0,0 +1,193 @@
import type { FastifyBaseLogger } from "fastify";
// Node-side client for the host vision service (apps/vision — the ANPR microservice).
// Calls it over LOCALHOST HTTP with a camera snapshot and gets back a plate read. The
// Python service is a separate process/failure domain; this client is the adapter the
// rest of the server talks to, so the recognizer is swappable without business-logic
// changes. See wiki/entities/opencv-anpr-service.md, decisions/vision-service*.md.
//
// ADVISORY, NEVER SOLE AUTHORITY. Per the vision decision + the fitness assessment, a
// plate read is an *identity hint + evidence*, never the lone reason a paid/access
// barrier opens. This client enforces two things at the boundary so callers can't
// misuse it:
// 1. It is FAIL-SOFT — any error (service down, timeout, decode fail) resolves to
// `null`, never throws into the entry/exit path. A missing vision result must
// degrade to the ticket/manual path, never strand or wrongly admit a car
// (fail-state-safety).
// 2. It applies the CONFIDENCE FLOOR — a read below the threshold is returned with
// `lowConfidence: true` (mirroring the service's own flag) so the caller treats it
// as advisory-only and falls back.
//
// NOT yet wired into the read bus — that (snapshot-before-decision on an opt-in camera →
// emit DeviceReadEvent{kind:"plate"}) is a separate, deliberate step. This is the
// transport + contract adapter only.
/** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */
export interface PlateBBox {
readonly x1: number;
readonly y1: number;
readonly x2: number;
readonly y2: number;
}
/** One plate read from the vision service. `confidence` is the MIN of the model's
* per-character confidences (a plate is only as trustworthy as its weakest char). */
export interface VisionPlate {
readonly text: string;
readonly confidence: number;
readonly bbox?: PlateBBox | null;
/** Predicted issuing region/country (advisory; the global model emits this). */
readonly region?: string | null;
}
/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for
* Job 2 (vehicle verification) — not yet produced. */
interface AnalyzeResponse {
readonly plate: VisionPlate | null;
readonly plates: VisionPlate[];
readonly vehicle: unknown | null;
readonly low_confidence: boolean;
readonly model_version: string;
readonly took_ms: number;
}
/** What the rest of the server gets back from `analyze()`. Normalised + camelCased,
* with the advisory gate already applied. Never thrown — `null` on any failure. */
export interface VisionResult {
/** The best plate, or null if none read. */
readonly plate: VisionPlate | null;
/** All plates found in the frame (a frame may hold several vehicles). */
readonly plates: VisionPlate[];
/** True when the best plate is below the confidence floor — treat as advisory only
* and fall back to the ticket/manual path. */
readonly lowConfidence: boolean;
readonly modelVersion: string;
readonly tookMs: number;
}
export interface VisionHealth {
readonly ok: boolean;
readonly recognizer: string;
readonly ready: boolean;
readonly modelVersion: string;
readonly detail?: string | null;
}
export interface VisionClientOptions {
/** Base URL of the vision service (localhost). */
readonly baseUrl?: string;
/** Per-request timeout (ms) — a slow vision call must never hang the lane. */
readonly timeoutMs?: number;
/** Confidence floor: a best-plate below this is flagged lowConfidence. Mirrors the
* service's own VISION_MIN_CONFIDENCE; kept here too so the gate holds even if the
* service is misconfigured. */
readonly minConfidence?: number;
/** Master switch — when false, `analyze()` short-circuits to null (no call). Lets the
* appliance run with no vision service configured. */
readonly enabled?: boolean;
}
export class VisionClient {
readonly #baseUrl: string;
readonly #timeoutMs: number;
readonly #minConfidence: number;
readonly #enabled: boolean;
readonly #logger: FastifyBaseLogger;
constructor(logger: FastifyBaseLogger, opts: VisionClientOptions = {}) {
this.#logger = logger;
this.#baseUrl = (opts.baseUrl ?? process.env.VISION_URL ?? "http://127.0.0.1:8089").replace(/\/$/, "");
this.#timeoutMs = opts.timeoutMs ?? Number(process.env.VISION_TIMEOUT_MS ?? 1500);
this.#minConfidence = opts.minConfidence ?? Number(process.env.VISION_MIN_CONFIDENCE ?? 0.5);
// Default OFF: vision is opt-in. Enable with VISION_ENABLED=1 (or pass enabled:true).
this.#enabled =
opts.enabled ?? ["1", "true", "yes"].includes((process.env.VISION_ENABLED ?? "").toLowerCase());
}
get enabled(): boolean {
return this.#enabled;
}
/**
* Analyse snapshot bytes → a plate read, or `null`. NEVER throws and NEVER blocks the
* caller's open path beyond `timeoutMs`: any failure (disabled, unreachable, timeout,
* non-2xx, bad body) logs and resolves to null, so the caller falls back to the
* ticket/manual path. The returned `lowConfidence` re-applies the floor on top of the
* service's own flag.
*/
async analyze(imageBytes: Buffer, contentType = "application/octet-stream"): Promise<VisionResult | null> {
if (!this.#enabled) return null;
try {
const body = await this.#post("/analyze", imageBytes, contentType);
if (!body) return null;
const res = body as AnalyzeResponse;
const best = res.plate ?? null;
const lowConfidence =
res.low_confidence || (best != null && best.confidence < this.#minConfidence);
return {
plate: best,
plates: Array.isArray(res.plates) ? res.plates : [],
lowConfidence,
modelVersion: res.model_version ?? "unknown",
tookMs: typeof res.took_ms === "number" ? res.took_ms : 0,
};
} catch (err) {
this.#logger.warn(`vision analyze failed (fallback to ticket path): ${(err as Error).message}`);
return null;
}
}
/** Liveness/readiness of the vision service. Returns ok:false (never throws) when
* disabled or unreachable, so the device-status footer can show it. */
async health(): Promise<VisionHealth> {
if (!this.#enabled) {
return { ok: false, recognizer: "disabled", ready: false, modelVersion: "-", detail: "vision disabled" };
}
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
try {
const r = await fetch(`${this.#baseUrl}/health`, { signal: controller.signal });
if (!r.ok) return { ok: false, recognizer: "?", ready: false, modelVersion: "-", detail: `HTTP ${r.status}` };
const h = (await r.json()) as {
status?: string;
recognizer?: string;
ready?: boolean;
model_version?: string;
detail?: string | null;
};
return {
ok: h.status === "ok",
recognizer: h.recognizer ?? "?",
ready: Boolean(h.ready),
modelVersion: h.model_version ?? "-",
detail: h.detail ?? null,
};
} finally {
clearTimeout(timer);
}
} catch (err) {
return { ok: false, recognizer: "?", ready: false, modelVersion: "-", detail: (err as Error).message };
}
}
/** POST raw bytes to a path, with timeout. Returns parsed JSON or throws (caught by
* the caller, which fails soft). */
async #post(path: string, bytes: Buffer, contentType: string): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
try {
const r = await fetch(`${this.#baseUrl}${path}`, {
method: "POST",
headers: { "content-type": contentType },
// Buffer is a valid BodyInit in Node's undici fetch.
body: bytes,
signal: controller.signal,
});
if (!r.ok) throw new Error(`vision ${path} → HTTP ${r.status}`);
return await r.json();
} finally {
clearTimeout(timer);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
# apps/vision — the ANPR microservice's own env (copy to apps/vision/.env).
# This is the PYTHON SERVICE's config only. The Node server has its OWN VISION_* vars
# (in apps/server/.env) — keep the two .env files SEPARATE (they share the VISION_
# prefix but are different processes). See wiki/entities/opencv-anpr-service.md "Configuration".
# Recognizer: "stub" (no models, recognizes nothing — boots anywhere, for dev/CI) or
# "fast_alpr" (the real MIT YOLOv9+CCT/ONNX stack — needs `uv sync --extra alpr`).
VISION_RECOGNIZER=fast_alpr
# Bind. On the appliance prefer 127.0.0.1 — the Node backend is the only caller, so the
# /analyze endpoint should NOT be reachable off-host. (0.0.0.0 only if you must.)
VISION_HOST=127.0.0.1
VISION_PORT=8089
# fast-alpr models (only used when recognizer=fast_alpr). The defaults won the Albanian
# benchmark; change the OCR to european-plates-mobile-vit-v2-model only to re-test.
VISION_DETECTOR_MODEL=yolo-v9-t-384-license-plate-end2end
VISION_OCR_MODEL=cct-xs-v2-global-model
# Confidence floor — a best plate below this is flagged low_confidence so the Node side
# treats it as advisory and falls back to the ticket path. Keep in sync with the server.
VISION_MIN_CONFIDENCE=0.5
+11
View File
@@ -0,0 +1,11 @@
# Python
__pycache__/
*.py[cod]
.venv/
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
models/
*.onnx
+1
View File
@@ -0,0 +1 @@
3.12
+77
View File
@@ -0,0 +1,77 @@
# @parking/vision — host-side ANPR / vehicle-verification service
A **separate process** (Python + FastAPI) the Node backend calls over **localhost HTTP** with a
camera snapshot, returning a licence-plate read (and, later, vehicle-attribute verification — the
anti-plate-spoofing witness). Recognition is **advisory, never the sole authority** to open a
barrier: if this service is down or unsure, the host falls back to the ticket path.
Lives inside the Turborepo at `apps/vision/` but is **not a JS package** — Python deps are managed by
`uv`/`pyproject.toml`; the `package.json` is a thin shim so `turbo run lint/test` includes it. See
`wiki/decisions/vision-service-packaging.md` and `wiki/entities/opencv-anpr-service.md`.
## Run
```bash
# from apps/vision/ — install the light core (boots in stub mode, no model downloads)
uv sync
# dev server with reload (or: pnpm --filter @parking/vision dev)
uv run uvicorn vision_service.app:app --reload --port 8089
# checks
uv run ruff check .
uv run pytest -q
```
### Enable the real recognizer (fast-alpr)
```bash
uv sync --extra alpr # installs fast-alpr + onnxruntime (downloads model weights)
VISION_RECOGNIZER=fast_alpr uv run uvicorn vision_service.app:app --port 8089
```
Model weights (~11 MB: a YOLOv9 detector + CCT OCR) download on first use and cache under
`~/.cache/open-image-models` + `~/.cache/fast-plate-ocr` — offline after that.
### Quick test against an image (CLI, no HTTP)
```bash
uv run python -m vision_service.cli path/to/car.jpg # or: pnpm --filter @parking/vision recognize -- car.jpg
uv run python -m vision_service.cli car.jpg --ocr cct-s-v2-global-model # try another OCR model
```
Prints the parsed plate(s) + confidence + region as JSON. Confidence is the **min** of fast-alpr's
per-character confidences (a plate is only as trustworthy as its weakest character). Example output on
the fast-alpr test image: `5AU5341 (1.000) region "Czech Republic"` in ~40 ms on CPU.
`fast-alpr` is MIT (YOLOv9 detector + CCT OCR on ONNX Runtime). Swap `VISION_OCR_MODEL` to the 40+
country European model to benchmark Albanian plates. For GPU/NPU, install `onnxruntime-gpu` /
`-openvino` / `-directml` instead of `onnxruntime`.
## API
- `GET /health` → `{ status, recognizer, ready, model_version, detail? }`
- `POST /analyze` (body = raw image bytes, `Content-Type: application/octet-stream`) →
`{ plate: {text, confidence, bbox}|null, plates[], vehicle: null, low_confidence, model_version, took_ms }`
The Node side POSTs `Snapshot.bytes` directly (no multipart). `vehicle` is scaffolded but not yet
populated — fast-alpr is plate-only; the vehicle stage (Job 2) is built later on the same runtime.
## Config (env, prefix `VISION_`) — see `.env.example`
This service's env only. The **Node server has its own `VISION_*`** (`apps/server/.env`:
`VISION_ENABLED`, `VISION_URL`, `VISION_POLL_MS`, …) — same prefix, **separate process, separate
`.env`**. Don't merge them.
| Var | Default | Meaning |
| --- | --- | --- |
| `VISION_RECOGNIZER` | `stub` | `stub` (no models) or `fast_alpr` (real) |
| `VISION_HOST` | `0.0.0.0` | bind address — prefer `127.0.0.1` on the appliance (Node is the only caller) |
| `VISION_PORT` | `8089` | listen port (must match the server's `VISION_URL`) |
| `VISION_DETECTOR_MODEL` | `yolo-v9-t-384-license-plate-end2end` | fast-alpr detector |
| `VISION_OCR_MODEL` | `cct-xs-v2-global-model` | fast-alpr OCR (won the AL benchmark) |
| `VISION_MIN_CONFIDENCE` | `0.5` | below this → `low_confidence=true` |
To use it from the booth: set `VISION_ENABLED=1` on the **server**, run this service, then tick
**ANPR** on a camera in the SetupWizard (the camera must also be bound to a barrier). The booth footer
shows a **Vision** chip when enabled. Full config guide: `wiki/entities/opencv-anpr-service.md`.
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@parking/vision",
"version": "0.0.0",
"private": true,
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
"scripts": {
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
"lint": "uv run ruff check .",
"format": "uv run ruff format .",
"typecheck": "uv run mypy vision_service",
"test": "uv run pytest -q",
"recognize": "uv run python -m vision_service.cli",
"build": "echo 'no build step (Python service; models fetched at deploy)'"
}
}
+62
View File
@@ -0,0 +1,62 @@
[project]
name = "parking-vision"
version = "0.0.0"
description = "Host-side ANPR / vehicle-verification microservice for the parking system (separate process; localhost HTTP)."
requires-python = ">=3.10,<4.0"
# Core deps are LIGHT on purpose: the service boots, serves /health, and answers
# /analyze in stub mode with ONLY these. The heavy recognizer stack (fast-alpr +
# onnxruntime + model weights) is the optional `alpr` extra, so `uv sync` and the test
# suite work offline without downloading models. See
# wiki/decisions/vision-service-packaging.md + wiki/entities/opencv-anpr-service.md.
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"pydantic>=2.9",
"pydantic-settings>=2.6",
]
[project.scripts]
vision-recognize = "vision_service.cli:main"
[project.optional-dependencies]
# The real recognizer. Install with: uv sync --extra alpr
# fast-alpr is MIT (YOLOv9 detector + CCT OCR, both MIT) on ONNX Runtime — see the
# recognizer evaluation in wiki/entities/opencv-anpr-service.md. onnxruntime is the
# CPU backend; swap for onnxruntime-gpu / -openvino / -directml on capable hardware.
alpr = [
"fast-alpr>=0.4.0",
"onnxruntime>=1.19",
]
[dependency-groups]
# Dev tooling (uv installs these by default for local work; excluded from the runtime image).
dev = [
"ruff>=0.8",
"pytest>=8.3",
"httpx>=0.27", # FastAPI TestClient transport
"mypy>=1.13",
]
[tool.ruff]
line-length = 110
target-version = "py310"
[tool.ruff.lint]
# A pragmatic default set: pyflakes, pycodestyle, isort, bugbear, pyupgrade.
select = ["E", "F", "I", "B", "UP"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.10"
strict = true
# fast-alpr / onnxruntime ship without type stubs; don't fail typecheck on the optional stack.
ignore_missing_imports = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["vision_service"]
View File
+51
View File
@@ -0,0 +1,51 @@
"""Smoke tests for the vision service in STUB mode (no model weights needed)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from vision_service.app import app
def make_client() -> TestClient:
# TestClient runs the lifespan, building the (stub) recognizer.
return TestClient(app)
def test_health_ok_in_stub_mode() -> None:
with make_client() as client:
res = client.get("/health")
assert res.status_code == 200
body = res.json()
assert body["status"] == "ok"
assert body["ready"] is True
assert body["recognizer"] == "stub"
assert body["model_version"] == "stub-0"
def test_analyze_returns_contract_shape() -> None:
with make_client() as client:
# The stub recognizes nothing, but the response must match the contract.
res = client.post(
"/analyze",
content=b"\xff\xd8\xff\xe0not-a-real-jpeg",
headers={"content-type": "application/octet-stream"},
)
assert res.status_code == 200
body = res.json()
assert body["plate"] is None
assert body["plates"] == []
assert body["vehicle"] is None
assert body["low_confidence"] is False
assert body["model_version"] == "stub-0"
assert "took_ms" in body
def test_analyze_rejects_empty_body() -> None:
with make_client() as client:
res = client.post(
"/analyze",
content=b"",
headers={"content-type": "application/octet-stream"},
)
assert res.status_code == 400
+43
View File
@@ -0,0 +1,43 @@
"""Unit tests for fast-alpr result parsing — no model weights required (the objects
are duck-typed stand-ins shaped like fast-alpr's ALPRResult)."""
from __future__ import annotations
from types import SimpleNamespace
from vision_service.recognizer import _reduce_confidence, plate_from_alpr_result
def test_reduce_confidence_takes_min_of_list() -> None:
# The weakest character governs trust in the whole plate.
assert _reduce_confidence([0.99, 0.80, 0.95]) == 0.80
def test_reduce_confidence_handles_scalar_and_junk() -> None:
assert _reduce_confidence(0.7) == 0.7
assert _reduce_confidence(None) == 0.0
assert _reduce_confidence([]) == 0.0
assert _reduce_confidence("nope") == 0.0
def _fake_result(text: str, conf: list[float], region: str | None = None) -> SimpleNamespace:
box = SimpleNamespace(x1=10, y1=20, x2=110, y2=60)
return SimpleNamespace(
ocr=SimpleNamespace(text=text, confidence=conf, region=region),
detection=SimpleNamespace(bounding_box=box),
)
def test_plate_from_result_maps_fields() -> None:
plate = plate_from_alpr_result(_fake_result("5AU5341", [0.999, 0.9995, 0.97], "Czech Republic"))
assert plate is not None
assert plate.text == "5AU5341"
assert plate.confidence == 0.97 # min of the per-character list
assert plate.region == "Czech Republic"
assert plate.bbox is not None
assert (plate.bbox.x1, plate.bbox.y1, plate.bbox.x2, plate.bbox.y2) == (10, 20, 110, 60)
def test_plate_from_result_skips_empty_text() -> None:
assert plate_from_alpr_result(_fake_result("", [0.9])) is None
assert plate_from_alpr_result(SimpleNamespace(ocr=None, detection=None)) is None
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": []
}
}
}
+1466
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
"""Host-side ANPR / vehicle-verification microservice.
A separate process (FastAPI over localhost HTTP) that the Node backend calls with a
camera snapshot and gets back a plate read (Job 1) — and, later, vehicle-attribute
verification (Job 2, the anti-spoofing witness). Recognition is ADVISORY, never the
sole authority to open a barrier. See wiki/entities/opencv-anpr-service.md.
"""
__version__ = "0.0.0"
+88
View File
@@ -0,0 +1,88 @@
"""FastAPI app: POST /analyze (snapshot → plate) + GET /health.
Called by the Node backend over localhost HTTP (the camera driver already holds the
JPEG bytes — Snapshot.bytes). This service is a SEPARATE PROCESS with its own failure
domain: if it's down or unsure, the host falls back to the ticket path — recognition is
advisory, never the sole authority. See wiki/entities/opencv-anpr-service.md.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from .recognizer import Recognizer, build_recognizer
from .schemas import AnalyzeResponse, HealthResponse
from .settings import Settings, get_settings
# Cap an upload so a malformed/huge POST can't exhaust memory (a camera JPEG is well
# under this). 413 beyond it.
MAX_IMAGE_BYTES = 12 * 1024 * 1024
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
app.state.settings = settings
# Build the recognizer once at startup (models load here, not per-request).
app.state.recognizer = build_recognizer(settings)
yield
app = FastAPI(title="parking-vision", version="0.0.0", lifespan=lifespan)
# Typed accessors over the untyped `app.state` (so mypy --strict sees the real types).
def _recognizer(request: Request) -> Recognizer:
rec: Recognizer = request.app.state.recognizer
return rec
def _settings(request: Request) -> Settings:
settings: Settings = request.app.state.settings
return settings
@app.get("/health", response_model=HealthResponse)
async def health(request: Request) -> HealthResponse:
rec = _recognizer(request)
settings = _settings(request)
ready = bool(rec.ready)
return HealthResponse(
status="ok" if ready else "degraded",
recognizer=settings.recognizer,
ready=ready,
model_version=rec.model_version,
detail=getattr(rec, "error", None),
)
@app.post("/analyze", response_model=AnalyzeResponse)
async def analyze(request: Request) -> AnalyzeResponse:
"""Analyze raw image bytes (the camera JPEG). Body is the octet-stream itself, so
the Node side POSTs Snapshot.bytes directly with Content-Type:
application/octet-stream — no multipart wrapping. We read the raw body ourselves
(rather than a required Body param) so an empty/oversize body returns our own clean
400/413 instead of FastAPI's generic 422."""
image = await request.body()
if not image:
raise HTTPException(status_code=400, detail="empty image body")
if len(image) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="image too large")
rec = _recognizer(request)
if not rec.ready:
# The real recognizer failed to load — be explicit so Node falls back rather
# than treating a silent empty result as "no plate present".
raise HTTPException(
status_code=503,
detail=f"recognizer not ready: {getattr(rec, 'error', 'unavailable')}",
)
try:
return rec.analyze(image)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 - never leak a stack to the caller
raise HTTPException(status_code=500, detail=f"analysis failed: {exc}") from exc
+72
View File
@@ -0,0 +1,72 @@
"""Dev CLI to test a recognizer against an image file — no HTTP, fast feedback.
uv run python -m vision_service.cli path/to/car.jpg
uv run python -m vision_service.cli car.jpg --recognizer stub # contract only
uv run python -m vision_service.cli car.jpg --ocr cct-s-v2-global-model
Defaults to the `fast_alpr` recognizer (the point of this tool). Prints the parsed
plate result as JSON. If the `alpr` extra isn't installed it says so and exits non-zero
rather than silently using the stub. See wiki/entities/opencv-anpr-service.md.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from .recognizer import build_recognizer
from .settings import Settings
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="vision-recognize", description="Run a recognizer on an image.")
parser.add_argument("image", type=Path, help="path to an image file (JPEG/PNG) with a plate")
parser.add_argument(
"--recognizer",
choices=["fast_alpr", "stub"],
default="fast_alpr",
help="which recognizer to use (default: fast_alpr)",
)
parser.add_argument("--detector", default=None, help="override the fast-alpr detector model name")
parser.add_argument("--ocr", default=None, help="override the fast-alpr OCR model name")
args = parser.parse_args(argv)
if not args.image.is_file():
print(f"error: no such file: {args.image}", file=sys.stderr)
return 2
settings = Settings(recognizer=args.recognizer)
if args.detector:
settings.detector_model = args.detector
if args.ocr:
settings.ocr_model = args.ocr
rec = build_recognizer(settings)
if not rec.ready:
err = getattr(rec, "error", "unavailable")
print(
f"error: recognizer '{args.recognizer}' not ready: {err}\n"
"hint: install the models with uv sync --extra alpr",
file=sys.stderr,
)
return 1
image_bytes = args.image.read_bytes()
result = rec.analyze(image_bytes)
# Pydantic v2: model_dump_json gives a clean, stable rendering.
print(result.model_dump_json(indent=2))
if result.plate is None:
print("\n(no plate detected)", file=sys.stderr)
else:
flag = " [LOW CONFIDENCE]" if result.low_confidence else ""
print(
f"\n→ {result.plate.text} ({result.plate.confidence:.3f}){flag} in {result.took_ms:.1f} ms",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+174
View File
@@ -0,0 +1,174 @@
"""The recognizer port + implementations.
The service depends on the `Recognizer` PROTOCOL, never a concrete model library — the
same swappable-behind-an-interface principle as the Node device adapters
(wiki/concepts/device-adapter-pattern.md). Two impls today:
- StubRecognizer: no model weights, deterministic placeholder. Lets the service boot
and the tests run offline with nothing downloaded (dev/CI default).
- FastAlprRecognizer: the real MIT YOLOv9-detector + CCT-OCR stack on ONNX Runtime
(the `alpr` extra). See wiki/entities/opencv-anpr-service.md "Recognizer evaluation".
Adding a recognizer (e.g. a fine-tuned YOLO + PaddleOCR) = a new class here, no app change.
"""
from __future__ import annotations
import time
from typing import Protocol
from .schemas import AnalyzeResponse, BBox, PlateResult
from .settings import Settings
class Recognizer(Protocol):
"""Reads plates from a JPEG/PNG image. Implementations must be process-local and offline."""
@property
def model_version(self) -> str: ...
@property
def ready(self) -> bool: ...
def analyze(self, image_bytes: bytes) -> AnalyzeResponse: ...
def _reduce_confidence(raw: object) -> float:
"""fast-alpr's OCR confidence is a LIST of per-character confidences. Reduce to one
plate confidence via the MIN — a plate is only as trustworthy as its weakest
character (one misread digit changes the identity). Tolerates a scalar (future
models) or junk (→ 0.0). Pure + model-free so it's unit-testable without weights."""
if isinstance(raw, (list, tuple)) and raw:
try:
return float(min(raw))
except (TypeError, ValueError):
return 0.0
if isinstance(raw, (int, float)):
return float(raw)
return 0.0
def plate_from_alpr_result(r: object) -> PlateResult | None:
"""Map ONE fast-alpr ALPRResult to our PlateResult, or None if it carries no text.
Uses getattr throughout so it's decoupled from the exact fast-alpr classes (and
testable with a duck-typed stand-in). See wiki/entities/opencv-anpr-service.md."""
ocr = getattr(r, "ocr", None)
det = getattr(r, "detection", None)
text = getattr(ocr, "text", None)
if ocr is None or not text:
return None
bbox = None
box = getattr(det, "bounding_box", None)
if box is not None:
bbox = BBox(x1=int(box.x1), y1=int(box.y1), x2=int(box.x2), y2=int(box.y2))
return PlateResult(
text=text,
confidence=_reduce_confidence(getattr(ocr, "confidence", None)),
bbox=bbox,
region=getattr(ocr, "region", None),
)
class StubRecognizer:
"""A no-model placeholder. Returns an empty (no-plate) result quickly so the whole
HTTP path — Node adapter, contract, error handling — can be exercised without the
heavy recognizer stack or any model download."""
model_version = "stub-0"
ready = True
def __init__(self, settings: Settings) -> None:
self._settings = settings
def analyze(self, image_bytes: bytes) -> AnalyzeResponse:
started = time.perf_counter()
# Deliberately recognizes nothing — it is a stub, not a fake "always finds a plate"
# (which would be dangerous: recognition must never invent an identity).
took_ms = (time.perf_counter() - started) * 1000.0
return AnalyzeResponse(
plate=None,
plates=[],
vehicle=None,
low_confidence=False,
model_version=self.model_version,
took_ms=took_ms,
)
class FastAlprRecognizer:
"""The real recognizer: fast-alpr (YOLOv9 plate detector + CCT OCR, ONNX Runtime).
Imported lazily so the service still imports/boots in stub mode when the `alpr`
extra (and its model weights) are not installed — a missing recognizer must not
crash the process; it degrades to a clear `ready=False`.
"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._alpr = None
self._error: str | None = None
try:
from fast_alpr import ALPR
self._alpr = ALPR(
detector_model=settings.detector_model,
ocr_model=settings.ocr_model,
)
except Exception as exc: # noqa: BLE001 - any failure ⇒ not-ready, surfaced via /health
self._error = f"{type(exc).__name__}: {exc}"
@property
def model_version(self) -> str:
return f"fast-alpr:{self._settings.detector_model}+{self._settings.ocr_model}"
@property
def ready(self) -> bool:
return self._alpr is not None
@property
def error(self) -> str | None:
return self._error
def analyze(self, image_bytes: bytes) -> AnalyzeResponse:
if self._alpr is None:
raise RuntimeError(f"fast-alpr not available: {self._error}")
# fast-alpr's predict() takes a BGR ndarray; decode the JPEG with cv2 (pulled in
# transitively by the alpr extra). Import locally so stub mode needs neither.
import cv2
import numpy as np # local import: only needed on the real path
started = time.perf_counter()
buf = np.frombuffer(image_bytes, dtype=np.uint8)
frame = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if frame is None:
raise ValueError("could not decode image bytes")
results = self._alpr.predict(frame)
plates: list[PlateResult] = []
for r in results:
plate = plate_from_alpr_result(r)
if plate is not None:
plates.append(plate)
plates.sort(key=lambda p: p.confidence, reverse=True)
best = plates[0] if plates else None
low = best is not None and best.confidence < self._settings.min_confidence
took_ms = (time.perf_counter() - started) * 1000.0
return AnalyzeResponse(
plate=best,
plates=plates,
vehicle=None, # Job 2 not built yet
low_confidence=low,
model_version=self.model_version,
took_ms=took_ms,
)
def build_recognizer(settings: Settings) -> Recognizer:
"""Factory: pick the recognizer from settings. Falls back to the stub if the real
one can't load, so the service always comes up (with ready=False surfaced)."""
if settings.recognizer == "fast_alpr":
rec = FastAlprRecognizer(settings)
return rec
return StubRecognizer(settings)
+60
View File
@@ -0,0 +1,60 @@
"""The /analyze response contract — the shape the Node VisionClient adapter consumes.
Mirrors the first-cut API in wiki/entities/opencv-anpr-service.md:
{ plate: {text, confidence, bbox}|null, vehicle: {...}|null, modelVersion, tookMs }
Job 2 (vehicle attributes) is scaffolded as an optional field, not yet populated —
fast-alpr is plate-only; the vehicle stage is built later on the same ONNX runtime.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class BBox(BaseModel):
"""Plate bounding box in pixels (top-left origin)."""
x1: int
y1: int
x2: int
y2: int
class PlateResult(BaseModel):
text: str
# The plate's confidence = the MIN of fast-alpr's per-character confidences (a plate
# is only as trustworthy as its weakest character). See recognizer.py.
confidence: float = Field(ge=0.0, le=1.0)
bbox: BBox | None = None
# Predicted issuing region/country (advisory; fast-alpr's global model emits this).
region: str | None = None
class VehicleResult(BaseModel):
"""Job 2 — vehicle attributes / fingerprint (anti-spoofing). Not yet produced."""
colour: str | None = None
body_type: str | None = None
make: str | None = None
model: str | None = None
class AnalyzeResponse(BaseModel):
# The single best plate, or null when none was found.
plate: PlateResult | None = None
# All plates found (a frame may contain several vehicles).
plates: list[PlateResult] = Field(default_factory=list)
vehicle: VehicleResult | None = None
# True when the best plate is below the confidence floor — Node should treat the
# read as advisory only and prefer the ticket path. See fail-state-safety.
low_confidence: bool = False
model_version: str
took_ms: float
class HealthResponse(BaseModel):
status: str
recognizer: str
ready: bool
model_version: str
detail: str | None = None
+37
View File
@@ -0,0 +1,37 @@
"""Runtime configuration, from environment (prefix VISION_).
Offline-first: every default is local and works with no network. The recognizer is
chosen by `recognizer` — "stub" (no models, deterministic placeholder) or "fast_alpr"
(the real MIT YOLOv9+CCT/ONNX stack, installed via the `alpr` extra).
"""
from __future__ import annotations
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="VISION_", env_file=".env", extra="ignore")
host: str = "0.0.0.0"
port: int = 8089
# Which recognizer to load. "stub" needs no model weights (boots anywhere, for
# dev/CI); "fast_alpr" loads the real models (requires the `alpr` extra installed).
recognizer: Literal["stub", "fast_alpr"] = "stub"
# fast-alpr model names (only used when recognizer="fast_alpr"). Defaults match the
# library defaults; swap the OCR for the 40+country EU model to benchmark Albanian
# plates. See wiki/entities/opencv-anpr-service.md "Recognizer evaluation".
detector_model: str = "yolo-v9-t-384-license-plate-end2end"
ocr_model: str = "cct-xs-v2-global-model"
# Below this OCR confidence the read is returned but flagged low_confidence, so the
# Node side can fall back to the ticket path rather than trust it.
min_confidence: float = 0.5
def get_settings() -> Settings:
return Settings()
+11
View File
@@ -0,0 +1,11 @@
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
#
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not
# same-origin), so the SPA must reach Fastify by absolute origin. This is the
# appliance's local Fastify address. Not a secret — committed for reproducible
# desktop builds. Override per-deployment if Fastify binds elsewhere.
#
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT
# want this set. If you build the SPA for that, override VITE_API_BASE="" .
VITE_API_BASE=http://127.0.0.1:3000
+14 -1
View File
@@ -12,13 +12,26 @@
}, },
"dependencies": { "dependencies": {
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"i18next": "^26.3.1",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7" "react-dom": "19.2.7",
"react-i18next": "^17.0.8",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-router-devtools": "^1.167.0",
"@types/react": "19.2.17", "@types/react": "19.2.17",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"tailwindcss": "^4.3.1",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "8.0.16" "vite": "8.0.16"
} }
+208
View File
@@ -0,0 +1,208 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
// Active Sessions panel. A session is "active" while still inside OR exited-but-
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
// possibly-present until grace runs out). Lets the operator find a stuck car —
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
// - click a row → the pay/exit modal (pay an unpaid car, or review),
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
// No payment → no Open barrier button (the no-unpaid-bypass rule).
//
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
// faulty/abandoned); occupancy lingers and the car owes a fresh top-up. The operator
// reconciles via the pay/exit modal — never a free barrier open.
// See wiki/concepts/booth-exit-flow.md.
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
type KindFilter = "transient" | "subscription";
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
if (s.subscription) return "subscription";
if (s.overstay) return "overstay";
if (!s.open && s.withinGrace) return "exiting";
if (s.paidAt) return "paid";
return "unpaid";
}
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
if (s.overstay)
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
}
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
// The audited barrier re-open is a money-path action (server-gated on an open
// shift); disable it unless this operator's shift is open.
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
const shiftReady = shiftOpen && shiftMine;
const { data, isLoading } = useQuery({
queryKey: qk.activeSessions,
queryFn: fetchActiveSessions,
// Belt-and-braces refresh in case a grace window expires with no ledger event
// to invalidate the cache (the WS only pushes on appends).
refetchInterval: 15_000,
});
const reopen = useMutation({
mutationFn: (identity: string) => reopenBarrier(identity),
onSettled: () => {
void qc.invalidateQueries({ queryKey: qk.activeSessions });
void qc.invalidateQueries({ queryKey: qk.events });
},
});
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
// Filters: free-text search, status, and transient-vs-subscriber.
const [search, setSearch] = useState("");
const [status, setStatus] = useState<StatusFilter | "">("");
const [kind, setKind] = useState<KindFilter | "">("");
const sessions = useMemo(() => data?.sessions ?? [], [data]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return sessions.filter((s) => {
if (kind === "transient" && s.subscription) return false;
if (kind === "subscription" && !s.subscription) return false;
if (status && statusOf(s) !== status) return false;
if (q) {
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}, [sessions, search, status, kind]);
const statusOpts: SegOption<StatusFilter>[] = [
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
{ value: "paid", label: t("booth.fStatusPaid") },
{ value: "exiting", label: t("booth.fStatusExiting") },
{ value: "overstay", label: t("booth.fStatusOverstay") },
];
const kindOpts: SegOption<KindFilter>[] = [
{ value: "transient", label: t("booth.fKindTransient") },
{ value: "subscription", label: t("booth.fKindSubscription") },
];
async function handleReopen(s: ActiveSession) {
setReopenMsg(null);
try {
const r = await reopen.mutateAsync(s.identity);
setReopenMsg({
id: s.identity,
ok: r.opened,
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
});
} catch (e) {
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
}
}
return (
<Panel
title={t("booth.activeSessions")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{filtered.length}
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
</span>
}
className="min-h-0 flex-1"
>
<div className="flex h-full flex-col">
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
</FilterBar>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{filtered.length === 0 ? (
<div className="text-term-muted">
{isLoading
? t("common.loading")
: sessions.length === 0
? t("booth.noActiveSessions")
: t("booth.noMatch")}
</div>
) : (
filtered.map((s) => {
const badge = statusBadge(s);
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
return (
<div
key={s.identity}
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
>
<button
type="button"
onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
title={t("booth.openPayExit")}
>
<span className="text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
</span>
{s.plate && (
<span
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{s.plate}
</span>
)}
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
<span
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
title={badge.titleKey ? t(badge.titleKey) : undefined}
>
{t(badge.key)}
</span>
</button>
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
owes a top-up — the row routes to the pay/exit modal instead (no
free overstay exit). An unpaid transient also has no button
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
{(s.paidAt && !s.overstay) || s.subscription ? (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
onClick={() => handleReopen(s)}
className="btn btn-pay btn-sm shrink-0"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
>
{t("booth.openBarrier")}
</button>
) : (
<span className="w-[88px] shrink-0" />
)}
{msg && (
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</span>
)}
</div>
);
})
)}
</div>
</div>
</Panel>
);
}
+36 -29
View File
@@ -1,11 +1,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchMe, logout, type SessionUser } from "./api.js"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { SetupWizard } from "./SetupWizard.js"; import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme } from "./lib/theme.js";
import { router } from "./router.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
// simple enough that a framework's abstractions cost more than they save. // off to TanStack Router inside the QueryClient provider. The router renders the
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me. // terminal chrome + screens; auth gating stays here (Login until signed in), and
// the signed-in user flows into the router context for role-based route guards.
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md. // See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
export function App() { export function App() {
@@ -18,31 +24,32 @@ export function App() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>; // Apply the signed-in user's preferred language + theme whenever they resolve/
if (!user) return <Login onLoggedIn={setUser} />; // change (login, bootstrap, or a toggle). Albanian + dark are the defaults before
// auth resolves; on logout, fall back to dark so the Login screen is consistent.
useEffect(() => {
if (user) {
setLanguage(user.language);
applyTheme(user.theme);
} else {
applyTheme("dark");
}
}, [user]);
if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
}
if (!user) {
return ( return (
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}> <QueryClientProvider client={queryClient}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}> <Login onLoggedIn={setUser} />
<h1 style={{ margin: 0 }}>Parking System</h1> </QueryClientProvider>
<span style={{ color: "#555" }}> );
{user.username} ({user.role}){" "} }
<button
type="button" return (
onClick={async () => { <QueryClientProvider client={queryClient}>
await logout(); <RouterProvider router={router} context={{ user, setUser }} />
setUser(null); </QueryClientProvider>
}}
>
Log out
</button>
</span>
</header>
{user.role === "admin" ? (
<SetupWizard />
) : (
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
)}
</main>
); );
} }
+428
View File
@@ -0,0 +1,428 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
boothExit,
fetchSiteConfig,
lookupSession,
openShift,
paySession,
printReceipt,
printVoucher,
reopenBarrier,
type SessionLookup,
} from "./api.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
// A shift must be open (and mine) before any pay/exit/voucher action — the booth
// money path is gated. The server enforces this too (409 no_shift); the modal
// surfaces it up front and offers a one-click open. See wiki/concepts/shift.md.
const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift();
const shiftReady = shiftOpen && shiftMine;
const [tender, setTender] = useState<"cash" | "card">("cash");
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
const [phase, setPhase] = useState<Phase>("review");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const [openingShift, setOpeningShift] = useState(false);
const [reprinting, setReprinting] = useState(false);
const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle.
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
const alreadyPaid = s?.paidAt != null;
const isSubscription = s?.subscription === true;
// OVERSTAY = paid but walk-back grace expired with no exit → a NEW period began; owes
// a fresh TOP-UP. Treat it as payable even though it's "already paid": the car must
// settle the new period's fee (s.amountMinor, priced from grace-expiry) before any
// exit. A normal within-grace paid session is NOT payable (it's settled). See
// booth-exit-flow.md / reopenBarrier server guard.
const isOverstay = s?.overstay === true;
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
// when it has an amount due. Otherwise the only action is an audited assist-open.
const subWindowDue = !!(isSubscription && (s?.amountMinor ?? 0) > 0);
// Allow pay for an unpaid transient, an overstay top-up, or a subscriber window charge.
const canPay = !!(
shiftReady &&
s?.found &&
s.open &&
((!alreadyPaid && !isSubscription) || isOverstay || subWindowDue)
);
async function handleOpenBarrier() {
if (!s) return;
setError(null);
setPhase("finishing");
try {
const r = await reopenBarrier(identity);
setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }));
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleOpenShift() {
setOpeningShift(true);
setError(null);
try {
await openShift();
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: qk.events });
} catch (e) {
setError((e as Error).message);
} finally {
setOpeningShift(false);
}
}
async function handleReprintReceipt() {
setReprinting(true);
setError(null);
try {
const r = await printReceipt(identity);
setResult(t("pay.receiptReprinted", { printer: r.printedBy }));
} catch (e) {
setError((e as Error).message);
} finally {
setReprinting(false);
}
}
async function handlePayAndExit() {
if (!s) return;
setError(null);
try {
// 1. Take payment. For a first stay this is the only charge; for an OVERSTAY the
// session is "already paid" but a new period accrued — we still charge (canPay
// is true). A settled within-grace session is not payable (canPay false) and is
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
if (canPay) {
setPhase("paying");
await paySession(identity, tender);
}
// 2. Voucher OR immediate exit.
setPhase("finishing");
if (voucher) {
// The voucher slip carries the payment detail + barcode + grace.
const r = await printVoucher(identity);
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
} else {
const r = await boothExit(identity);
// No voucher → auto-print a standalone payment receipt for transparency.
// Best-effort: a printer fault must NOT block the exit that already happened;
// the operator can reprint from the done screen.
let receiptNote = "";
try {
await printReceipt(identity);
} catch {
receiptNote = ` ${t("pay.receiptPrintFailed")}`;
}
setResult(
(r.opened
? t("pay.paidBarrierOpened")
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })) +
receiptNote,
);
}
// Refresh the live views.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
aria-describedby={undefined}
>
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{isSubscription
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
: `${t("pay.ticket")} ${identity}`}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
✕
</Dialog.Close>
</div>
<div className="flex flex-col gap-3 p-4">
{/* Shift gate — block all actions until THIS operator has a shift open.
Another operator's open shift can't be operated under (no shared
till); only an "open mine" path when no shift is open at all. */}
{!shiftReady && (
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
{blockedByOther ? (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateOtherTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
</div>
</>
) : (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
<button
type="button"
onClick={handleOpenShift}
disabled={openingShift}
className="btn btn-go btn-sm mt-2"
>
{openingShift ? t("shift.opening") : t("shift.openNow")}
</button>
</>
)}
</div>
)}
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
{s && !s.found && (
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
{t("pay.noSessionFound")}
</div>
)}
{s && s.found && !s.open && (
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
</div>
)}
{s && s.found && s.open && (
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
<Row
label={t("pay.duration")}
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
/>
<Row
label={t("pay.statusLabel")}
value={
isSubscription
? t("pay.subscription")
: isOverstay
? t("pay.overstay")
: alreadyPaid
? t("pay.paid")
: t("pay.unpaid")
}
valueClass={
isSubscription
? "text-term-cyan"
: isOverstay
? "text-term-red"
: alreadyPaid
? "text-term-green"
: "text-term-amber"
}
/>
</div>
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
{subWindowDue && s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: isSubscription
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
</span>
</div>
{/* For a subscription with a window charge, explain why it's payable. For a
plain prepaid subscription, explain the assist-open is the only action. */}
{subWindowDue ? (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.windowChargeHint")}
</div>
) : isSubscription && (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.subAssistHint")}
</div>
)}
{/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && (
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.overstayHint")}
</div>
)}
{/* Snapshots */}
<SnapshotStrip identity={identity} />
{phase !== "done" && !isSubscription && (
<>
{/* Tender */}
{canPay && (
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => (
<button
key={tn}
type="button"
onClick={() => setTender(tn)}
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{t(`pay.${tn}`)}
</button>
))}
</div>
)}
{/* Voucher checkbox (default from site config) */}
<label className="flex items-center gap-2 text-[12px]">
<input
type="checkbox"
className="accent-term-amber"
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
{t("pay.printExitVoucher")}
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
</label>
</>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
{phase === "done" ? (
<>
{/* Reprint the payment receipt (slip jammed / customer asks).
Only for a charged session — a subscription has no payment. */}
{!isSubscription && (
<button
type="button"
onClick={handleReprintReceipt}
disabled={reprinting}
className="btn btn-sm"
>
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
</button>
)}
<button
type="button"
onClick={onClose}
className="btn btn-primary btn-sm"
>
{t("common.close")}
</button>
</>
) : (
<>
<button
type="button"
onClick={onClose}
className="btn btn-ghost btn-sm"
>
{t("common.cancel")}
</button>
{isSubscription ? (
// Prepaid — the only action is the audited barrier open (assist
// a faulty exit reader / missing card). Gated on an open shift.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-pay btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : (
<button
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="btn btn-go btn-lg"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
)}
</>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return (
<div className="flex items-baseline justify-between">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<span className={`text-sm ${valueClass}`}>{value}</span>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More