Debugging "is the camera event coming or not?" was painful: a rejected
push only logged a warning and recorded nothing, so "no event" was
ambiguous (never sent vs sent-and-refused), and the only durable record
was an unreadable device_events row.
- Record EVERY push, accepted or rejected: accepted -> kind:"alarm",
rejected -> kind:"alarm-rejected" with the precise reason (unknown
device / not-hikvision / push-disabled / source-IP mismatch / digest
fail). The 404 body now also returns the reason.
- New GET /api/devices/hikvision/alarms (device:read): the recent pushes
newest-first as JSON (accepted+rejected, with ip/reason/eventType/
target/plate/rawHead) so you can SEE arrivals in the browser instead of
grepping the dev log or querying SQLite.
Tests: hikvision-alarm.test.ts now 8 (rejection-recorded + read-endpoint
list + gating). server 111/111; build+lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Newer Hik firmware can PUSH events to us: Event -> Smart/VCA with
"Detection Target: Human/Vehicle" + Notify Surveillance Center + Alarm
Settings -> Alarm Server makes the camera HTTP-POST an
EventNotificationAlert on each detection.
- New POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts):
same machine-push pattern as the Dingtian Input Link — source-IP guarded
+ optional HTTP Digest, not behind the SPA cookie/CSRF.
- Discovery-first / permissive: a wildcard content-type parser accepts ANY
body as raw bytes (event XML, multipart+JPEG, or JSON — Hik varies by
firmware), records it verbatim as a kind:"alarm" device_event, and
best-effort extracts eventType/target/plate/dateTime/channelID for the
summary + a loud log line. The point is to SEE exactly what a camera
sends before wiring it further.
- hikvision driver gains alarmPushEnabled + pushUser/pushPassword config and
pushesToBackend:true (setup offers the backend push IP).
- NOT yet a barrier trigger / DeviceReadEvent — records only. A plate read
is advisory, never the sole reason a barrier opens; the read-bus/ANPR
wiring is a deliberate next step once the real payload is known.
Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw
JSON, wrong-IP 404, disabled 404, unknown-device 404). server 109/109;
build+lint 14/14. Wiki: lpr-camera.md + log.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.
Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.
Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).
Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).
Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).
Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.
Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Adds an admin Reports screen (/setup/reports, gated report:read) — an
on-demand dashboard over the signed event log.
Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates
in one call — entry/exit counts + all money summed straight from
ledger_events (same source the shift Z-report reconciles, so totals tie
out to the drawer); revenue split into ticket / subscription-sale /
out-of-window mirrors the Z-report. Duration stats come from the sessions
cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv
export of the per-bucket series. reports.ts + routes/reports.ts.
Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month
grain, KPI cards, entry/exit line, revenue bar + cash/card split,
revenue-mix pie, peak-hours histogram, numeric breakdown, subscription
stats. Charts via Recharts (MIT), lazy-loaded into its own chunk
(~111KB gz) so the booth bundle is untouched. New Setup tab + nav + i18n
(sq + en parity). asc() exported from @parking/db; formatMinutes helper.
Tests: reports.test.ts (10) pin the sums, tz bucketing, money split,
duration stats, subscription counts. server 90/90; build+lint 14/14.
Wiki: reporting-analytics.md "Built v1" section + log entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Adds a bottom-of-modal "Test ANPR" button (shown only when a camera's
Plate recognition opt-in is checked) that captures a live snapshot off
the camera and runs it through the vision service, reporting the plate
read + confidence + elapsed time, or which stage failed.
- New POST /api/setup/test-anpr: builds the camera from the unsaved
config (no DB write/device change, like /test), captures a snapshot,
runs vision.analyze. Fail-soft like the runtime path (snapshot.ts):
camera/vision failures are reported results, never a 500.
- Thread the existing VisionClient into setupRoutes; add an isCamera()
type guard to @parking/devices.
- Web: testAnpr() client + AnprTestResult; button, hint, result line.
- i18n keys in sq + en (Catalog parity).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Boots the REAL Fastify app over a fresh in-memory DB (buildServer({ db }), driven by
app.inject — no listen) to exercise the security seam end to end:
- routes.test.ts (7): /health open; login rejects bad creds and sets token+csrf
cookies on good ones; an unauthenticated GET /api/occupancy is 401; a site:read-only
role GETs occupancy but is 403 on PUT /api/site-config (the permission gate, with a
valid CSRF so the 403 is the perm check); an admin passes the same PUT; and a mutation
with the auth cookie but NO csrf header is 403 (double-submit enforced).
Adds seedUser()/login() helpers (real bcrypt + the real /api/auth/login route) and
LOG_LEVEL=silent in the vitest env so asserted 401/403 responses don't flood output.
server 75/75 green (8 suites).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A subscription froze its planVersionId at sale (reproducible pricing). There was
no way to move a sold sub onto a different VERSION of the SAME plan — needed when
an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1
"every day" → v2 "weekdays only") and wants an existing subscriber on it, or back
on v1.
Backend (PUT /api/subscriptions/:id):
- accept planVersionId; honored only with the subscription:plan permission
(stronger than subscription:update — a plan-management action). Non-privileged
caller sending a change → 403, not silently dropped.
- validated to belong to the sub's EXISTING planId (a different plan = a
different price basis = a re-sale → 400).
- price/currency/period/planId stay frozen; only planVersionId moves. The swap is
server-logged for audit (the row is mutable master data, not on the ledger).
Past signed entry/exit events keep their own windowTariffVersionId, so history
reprices identically — only future access uses the new version's windows.
Frontend (SubscriptionManager):
- pass the session user through the route (like RolesManager).
- admin-only "Versioni" picker in the edit modal: lists every version of the
sub's plan by effective date + a timeframe summary (days + window, or 24/7),
current pre-selected. The plan itself stays read-only. Sends planVersionId only
when it changed.
- i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay
in both sq + en.
Verified on a writable DB copy: version changed, price + planId frozen,
cross-plan version rejected. Live DB untouched. build+lint 14/14.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
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
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).
Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.
outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).
1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
amount = span price × quantity; maxConcurrent defaults to the quantity so all
N cars can be inside. Quantity rides in the payment payload.
2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
tariff (the subscriber is a transient for that time):
- early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
the vehicle_entry payload), collected at exit;
- late exit: window-close → departure, and exit is GATED
(sub.refused.unpaidWindow) until paid at the booth.
Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
reuses computeFee + the active tariff version
(apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
business gate — the fail-open rule still governs the offline path.
3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
max(0, quantity − itsCarsInside) per active subscription, so transients see
"full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.
Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Re-model subscription pricing from per-row, operator-typed prices into an
admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now
SELLS by picking a plan over a date span; the price is LOOKED UP, never typed —
removing the fat-finger risk on a money field — and day/week/month periods make
the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span.
- Schema/migration 0010: new `subscription_plans` (immutable, effective-dated,
keyed by a stable planId; period day/week/month + per-period price + active
flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a
"Monthly" plan from the existing site default price (no data loss).
- Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period),
amount = periods × per-period price. Ceil = any started period is full (hotel
practice). `resolvePlanVersion` picks the latest active version ≤ sale instant.
- Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked
sell path derives the amount from the plan; `POST /api/subscriptions/quote`
returns a server-computed quote so the operator can't override it. The
signed-payment sale fix is unchanged — only the amount SOURCE moved; payload
now carries planId/planVersionId/periods. Updates never re-sell (price frozen).
- Frontend: SubscriptionManager sell form swaps the price field for a plan
picker + start/end dates + a live quote line. New SubscriptionPlansManager
(Setup tab) for the admin catalog. i18n (sq+en) for both.
Verified on a copy of the live DB: 0010 applies (existing subs intact), a
3-night hotel sale prices to 2,400 ALL, appends one signed payment with
planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
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
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
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
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
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
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
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
The dynamic-RBAC management routes are themselves grantable (role:* and
user:*), so a non-admin holding them could self-escalate: edit their own
role to add a permission they lack, mint a privileged role, assign someone
the admin role, or reset/delete a more-privileged account. Found by the
commit security review (2× HIGH).
Fix — enforce the RBAC invariant "you cannot grant beyond yourself":
- roles.ts: role:create/update reject any permission not held by the caller
(escalates()). An admin holds the full set, so it stays unrestricted.
- users.ts: user:create/update reject assigning a role whose permissions
exceed the caller's; update/password-reset/delete reject acting on a user
whose current role exceeds the caller's (exceedsCaller()).
The existing no-lockout + builtin-admin protections are unchanged.
Verified: 10-assertion inject test — manager (role:* + user:* but no
tariff:update, not admin) gets 403 on self-grant, minting a privileged role,
assigning/resetting/deleting an admin; admin stays unrestricted; the manager
can still create peers + in-scope roles (not over-blocked). Full build green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".
Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().
CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.
Full build green; both receipt modes render-verified; routes live.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
Rounds out subscriptions across enrollment, the barrier flow, and the booth.
- RFID credentials enabled with a "Read card" enrollment flow: the operator
arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
reader's next read is captured into the form and NOT dispatched to the access
flow — the OTHER reader keeps serving live entry/exit. Routes:
/api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
per-occurrence id (SUBSESS-<short>), not the credential value, with
permitId in the payload. Direction is decided by the barrier the reader sits
at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
pay/exit modal shows a subscription mode (snapshots + a single audited
Open-barrier action) to assist a faulty exit reader / missing card;
reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
"abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.
Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Builds out subscription credentials on top of the rename.
- Operator chooses the credential type; only QR is live (RFID shown disabled
"soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
globally-unique-checked) — the customer/operator never picks the value.
RF stays operator-entered (the physical card id). Reader output decided =
TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
(GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
create (best-effort — never fails the create; returns {printed,printError})
+ reprint via POST /api/subscriptions/:id/print and a "Print code" button.
Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.
- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
(/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
hash-chained history; renaming it would break verification of past events.
Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).
Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.
Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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.
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).
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.
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.
- 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.
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.
Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)
Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
(v1 events won't verify under v2 — intentional, gated per-event by keyId)
Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]
Web:
- wizard: no lane selector; add controllers (relay map + entry-button
terminal) first, then bind readers/cameras/printers to a controller relay
Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
The reader sends Connection: keep-alive but only acts on the verdict (beep,
output) once the TCP socket closes. Fastify's default kept the connection alive,
so the reader waited out a ~10s keep-alive timeout before beeping — even though
the server replied in ~15ms. Every vendor demo replies Connection: close and
shuts the socket. Set reply.header('connection','close') on the QR endpoint.
Verified the header is now sent; symptom was correct accept/reject with a ~10s
lag before the beep.
The QR reader is a push device and the setup wizard assigns random-UUID ids, so
'id = serial' can't be set via the UI. Add a dedicated gee-qr-reader driver
(reader category) with a single 'serial' config field; the admin assigns it
normally and enters the device's serial (its cjihao).
The QR endpoint now resolves the lane by matching lane_devices.config.serial to
the scan's cjihao (instead of row id == cjihao), so no DB hand-editing. An
unassigned serial resolves to no lane -> status:0, gracefully.
Verified via inject through the real /api/setup/assign: assign {serial:
H05M2AFA} -> .jsp scan with a matching permit QR -> status:1 (accept) + open;
re-scan -> permit exit; unknown card -> status:0; unassigned serial -> status:0.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.