24 Commits

Author SHA1 Message Date
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
95 changed files with 6976 additions and 624 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
}
]
}
]
}
}
+4
View File
@@ -20,3 +20,7 @@ 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/
+10
View File
@@ -86,3 +86,13 @@ For the full reasoning behind each, follow the links from `wiki/overview.md`.
- TypeScript throughout. Match the style of surrounding code. - TypeScript throughout. Match the style of surrounding code.
- Confirm before destructive or outward-facing actions. Commit/push only when asked. - Confirm before destructive or outward-facing actions. Commit/push only when asked.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+11 -5
View File
@@ -18,9 +18,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 +61,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 +69,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,
}); });
} }
+36 -3
View File
@@ -8,17 +8,41 @@ 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, permits, 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 (permit/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;
@@ -33,6 +57,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);
+155
View File
@@ -0,0 +1,155 @@
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 terminal its entry button is 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;
}
/** 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. */
export interface ResolvedRelay {
readonly controller: DeviceRow;
readonly relay: number;
readonly direction: Direction;
}
/** 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 };
}
/**
* 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";
}
+190
View File
@@ -0,0 +1,190 @@
import { randomUUID } from "node:crypto";
import { sessions, type Db, type DeviceRow } from "@parking/db";
import {
NoPrinterAvailableError,
printWithFailover,
registry,
type AccessControlDevice,
type PrinterDevice,
type PrinterInstance,
type TicketData,
} from "@parking/devices";
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, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.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.
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>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
* button — an input terminal mapped to an entry relay on its controller. */
async onInput(e: DeviceInputEvent): Promise<void> {
if (e.edge !== "on") return; // 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;
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);
}
}
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. Permit holders are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
// subscribers 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) {
await this.#log.append({
type: "anomaly",
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
});
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 };
try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket),
);
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
} 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: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false },
});
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return;
}
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: "ticket",
identity: ticketId,
payload: { sessionRef: ticketId, ticketPrinted: true },
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).
void snapshotAsync({
db: this.#db,
direction: "entry",
identity: ticketId,
logger: this.#logger,
}).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`));
// 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}`);
}
}
/** 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;
}
}
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */
function newTicketId(): string {
return `T-${randomUUID()}`;
}
+39 -15
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,14 +47,35 @@ 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");
@@ -71,32 +93,33 @@ export class EventLog {
} }
/** 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; return run;
} }
#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,16 +129,17 @@ 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;
} }
/** /**
@@ -125,7 +149,7 @@ export class EventLog {
* row (index gap), and a forged/invalid signature. * row (index gap), and a forged/invalid signature.
*/ */
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) {
+175
View File
@@ -0,0 +1,175 @@
import { eq, ledgerEvents, sessions, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import type { ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { LedgerPayload } 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
readonly graceExitMin: number | null; // from the payment's tariff context, if known
}
export class ExitFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** 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 permit 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 reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential";
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { reason, exitRefused: true },
});
this.#logger.warn(`exit refused: no open session for ${e.value}`);
return { accepted: false, direction: "exit", reason };
}
// 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 reason = !paid
? "exit refused — not paid (pay at the station)"
: "exit refused — walk-back grace expired (top-up required)";
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: { reason, exitRefused: true, sessionRef: e.value },
});
this.#logger.warn(`exit refused (${e.value}): ${reason}`);
return { accepted: false, direction: "exit", reason };
}
// Valid: sign the exit BEFORE opening, then open, then update the cache.
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source: e.kind === "plate" ? "lpr" : "ticket",
identity: e.value,
payload: { sessionRef: e.value },
});
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`);
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate).
void snapshotAsync({
db: this.#db,
direction: "exit",
identity: e.value,
logger: this.#logger,
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
try {
this.#db
.update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "closed" })
.where(eq(sessions.id, e.value))
.run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
}
return { accepted: true, direction: "exit" };
}
/** 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;
}
}
return {
identity,
enteredAt: entry.occurredAt,
open: !exited,
paidAt,
graceExitMin,
};
}
/** 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;
}
}
+49
View File
@@ -0,0 +1,49 @@
import { eq, ledgerEvents, siteConfig, 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;
/** 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;
/** True when count ≥ capacity (always false when uncapped). */
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;
}
export function getOccupancy(db: Db): Occupancy {
const count = occupancyCount(db);
const capacity = siteCapacity(db);
return {
count,
capacity,
free: capacity == null ? null : capacity - count,
full: capacity != null && count >= capacity,
};
}
+139
View File
@@ -0,0 +1,139 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db";
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.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;
readonly enteredAt: string;
readonly amountMinor: number;
readonly currency: string;
readonly tariffVersionId: string;
readonly graceExitMin: number;
}
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 against the tariff in force at its entry. No side effect. */
quote(identity: string): Quote {
const entry = this.#openEntry(identity);
if (!entry) throw new NoOpenSessionError(identity);
const tv = this.#tariffVersionFor(entry.occurredAt);
if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure;
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
return {
identity,
enteredAt: entry.occurredAt,
amountMinor,
currency: tv.currency,
tariffVersionId: tv.id,
graceExitMin: structure.gracePeriodExitMin,
};
}
/**
* 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 top-up the same call re-prices entry→now and the exit flow's
* grace-window restarts from this payment. `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 }> {
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 };
}
/** 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;
}
}
+222
View File
@@ -0,0 +1,222 @@
import { eq, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
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";
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
// See wiki/entities/permit.md.
//
// Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
// permit'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 permit's card/QR.
//
// Direction is inferred from session state for THAT car (the read credential value
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
// fleet permit can have several cars in at once, each its own session, and
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
export interface PermitMatch {
readonly permitId: string;
/** The specific credential/plate value read — the per-car session key. */
readonly carKey: string;
readonly via: "card" | "qr" | "plate";
}
export class PermitFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */
match(e: DeviceReadEvent): PermitMatch | null {
// Card / QR / generic credential value.
const cred = this.#db
.select()
.from(permitCredentials)
.where(eq(permitCredentials.value, e.value))
.get();
if (cred) {
return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a permit's bound plate is an identity.
if (e.kind === "plate") {
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get();
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" };
}
return null;
}
/** Run the permit 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: PermitMatch): Promise<ReadOutcome> {
const key = `${m.permitId}:${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(`permit-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: PermitMatch): Promise<ReadOutcome> {
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
if (!permit) return { accepted: false, reason: "permit not found" };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
const invalid =
permit.status !== "active" ||
(permit.validFrom != null && now < permit.validFrom) ||
(permit.validTo != null && now > permit.validTo);
if (invalid) {
const reason = `permit ${permit.status}/out-of-window`;
await this.#reject(m, reason);
return { accepted: false, reason };
}
// Direction: the car's open-session state is the natural verb (in→exit, out→entry).
// The barrier the car is at (resolved.direction) must AGREE — a car at an exit
// barrier that isn't inside (or at an entry barrier while already in) is a
// wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows
// the session state.
const carOpen = this.#carHasOpenSession(m.carKey);
const inferred: FlowDirection = carOpen ? "exit" : "entry";
if (resolved.direction !== "both" && resolved.direction !== inferred) {
const reason = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
await this.#reject(m, reason);
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
}
if (carOpen) {
// EXIT: this car is already inside → the read is its exit.
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
payload: { sessionRef: m.carKey, permitId: m.permitId },
});
await this.#open(resolved, "exit", m.carKey, "permit exit");
this.#closeCache(m.carKey);
return { accepted: true, direction: "exit" };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
if (permit.maxConcurrent != null) {
const open = this.#permitOpenCount(m.permitId);
if (open >= permit.maxConcurrent) {
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`;
await this.#reject(m, reason);
return { accepted: false, direction: "entry", reason };
}
}
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
// No ticket, no fee — the permit IS the authorization. Recorded for audit.
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
occurredAt: now,
});
await this.#open(resolved, "entry", m.carKey, "permit entry");
try {
this.#db
.insert(sessions)
.values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
}
return { accepted: true, direction: "entry" };
}
/** Does this specific car (credential value) have an open session right now? */
#carHasOpenSession(carKey: string): boolean {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, carKey))
.orderBy(ledgerEvents.index)
.all();
const entries = rows.filter((r) => r.type === "vehicle_entry").length;
const exits = rows.filter((r) => r.type === "vehicle_exit").length;
return entries > exits;
}
/** How many of this permit's cars are inside right now (fold over the ledger). */
#permitOpenCount(permitId: string): number {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "vehicle_entry"))
.all()
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
let open = 0;
for (const entry of rows) {
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
open += 1;
}
return open;
}
async #reject(m: PermitMatch, reason: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity: m.carKey,
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
});
this.#logger.warn(`permit refused (${m.carKey}): ${reason}`);
}
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).
void snapshotAsync({
db: this.#db,
direction: dir,
identity: carKey,
logger: this.#logger,
}).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`));
}
#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;
}
}
}
+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 { PermitFlow } from "./permit-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 permit entry/exit OR a transient exit, so we dispatch by WHAT the
// credential is (decision 2026-06-15):
// - matches a permit (card/QR/bound plate) → PERMIT 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 (permit: session
// state; transient: exit).
export class ReadDispatcher {
readonly #db: Db;
readonly #exit: ExitFlow;
readonly #permit: PermitFlow;
readonly #logger: FastifyBaseLogger;
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) {
this.#db = db;
this.#exit = exit;
this.#permit = permit;
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 permit = this.#permit.match(e);
if (permit) {
return this.#permit.run(resolved, e, permit);
}
// Not a permit → 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);
}
}
+7 -5
View File
@@ -2,7 +2,6 @@ import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, users, type Db } from "@parking/db"; import { eq, users, type Db } from "@parking/db";
import { import {
TOKEN_TTL,
clearAuthCookies, clearAuthCookies,
newCsrfToken, newCsrfToken,
requireRole, requireRole,
@@ -34,10 +33,13 @@ 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).
{ sub: user.id, username: user.username, role: user.role, csrf }, const token = await reply.jwtSign({
{ expiresIn: TOKEN_TTL }, sub: user.id,
); username: user.username,
role: user.role,
csrf,
});
setAuthCookies(reply, token, csrf); setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role }; return { id: user.id, username: user.username, role: user.role };
}); });
+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.
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, events, type Db } from "@parking/db"; import { desc, ledgerEvents, type Db } from "@parking/db";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
@@ -22,7 +22,7 @@ export async function eventRoutes(
{ 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 rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all();
return { events: rows }; return { events: rows };
}, },
); );
+69
View File
@@ -0,0 +1,69 @@
import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js";
import {
NoOpenSessionError,
NoTariffError,
type PayStation,
} from "../pay-station.js";
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session
// then takes payment; the payment becomes a signed ledger event. PCI scope stays
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender`
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.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;
}
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> {
// Cashier/operator/admin operate the pay station; readonly may not.
const guard = requireRole("admin", "operator", "cashier");
// Quote: what does this session owe right now? (No side effect.)
app.get<{ Querystring: QuoteQuery }>(
"/api/pay/quote",
{ preHandler: guard },
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 },
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);
}
},
);
}
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 });
}
+160
View File
@@ -0,0 +1,160 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db";
import { requireRole } from "../auth.js";
// Permit (subscription) admin CRUD. A permit 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/permit.md). A permit is an aggregate:
// the permit row + its credentials (card/QR) + its bound plates. The API treats them
// as one unit (create/update replace the child sets; delete removes all).
interface Credential {
kind: "rf" | "qr";
value: string;
}
interface PermitBody {
holderName?: string;
contact?: string;
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
maxConcurrent?: number | null;
validFrom?: string | null;
validTo?: string | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
}
export async function permitRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Admin manages permits; operator/cashier/readonly may LIST (to look one up).
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
const writeGuard = requireRole("admin");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: PermitBody): 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");
}
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
for (const c of b.credentials ?? []) {
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
errs.push("each credential needs kind (rf|qr) and a non-empty value");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const permit = db.select().from(permits).where(eq(permits.id, id)).get();
if (!permit) return null;
const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all();
const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all();
return {
...permit,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
// Replace a permit's child rows (credentials + plates) from the body.
function writeChildren(id: string, b: PermitBody) {
db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run();
db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run();
for (const c of b.credentials ?? []) {
db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run();
}
}
// List all permits (with their credentials + plates).
app.get("/api/permits", { preHandler: readGuard }, async () => {
const rows = db.select().from(permits).all();
return { permits: rows.map((r) => loadAggregate(r.id)) };
});
// Create a permit.
app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
const id = randomUUID();
db.insert(permits)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null,
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
return reply.code(201).send(loadAggregate(id));
});
// Update a permit (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: PermitBody }>(
"/api/permits/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "permit not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
db.update(permits)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null,
status: b.status ?? existing.status,
})
.where(eq(permits.id, req.params.id))
.run();
writeChildren(req.params.id, b);
return loadAggregate(req.params.id);
},
);
// Revoke (soft): the common case — keeps the permit + its history, just bars it.
// A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to
// fully remove a permit created in error.
app.post<{ Params: { id: string } }>(
"/api/permits/:id/revoke",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a permit + 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/permits/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.delete(permits).where(eq(permits.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run();
db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run();
return reply.code(204).send();
},
);
}
+109
View File
@@ -0,0 +1,109 @@
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";
// 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,
): 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 deviceId = readerRowIdForSerial(serial) ?? serial;
let accepted = false;
if (cardid) {
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;
if (!accepted) app.log.info(`QR ${cardid} rejected: ${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);
}
}
+22 -26
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,6 +10,7 @@ import {
registry, registry,
setDeviceLogSink, setDeviceLogSink,
type DeviceCategory, type DeviceCategory,
type DeviceConfig,
} from "@parking/devices"; } from "@parking/devices";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
@@ -18,10 +19,12 @@ import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"
// 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,13 +51,7 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
export async function setupRoutes( export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
app: FastifyInstance,
db: Db,
// Called after the set of assignments changes (assign/unassign) so the caller
// can refresh anything derived from it — e.g. the device id->lane map.
onAssignmentsChanged: () => void = () => {},
): Promise<void> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
@@ -62,11 +59,13 @@ export async function setupRoutes(
const adminGuard = requireRole("admin"); const adminGuard = requireRole("admin");
// 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 +107,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,15 +151,15 @@ 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}` });
@@ -250,14 +249,12 @@ export async function setupRoutes(
const row = { const row = {
id, id,
lane,
category, category,
driverId, driverId,
config: fullConfig, config: fullConfig,
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,
@@ -283,13 +280,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();
}, },
); );
+41
View File
@@ -0,0 +1,41 @@
import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js";
import {
NoOpenShiftError,
ShiftAlreadyOpenError,
type ShiftService,
} from "../shift-service.js";
// 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): Promise<void> {
// Cashier/operator/admin run shifts; readonly can't.
const guard = requireRole("admin", "operator", "cashier");
// Is the current operator's shift open? (For the UI to show Start vs. End.)
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
const operator = req.user.username;
const open = shift.openShiftFor(operator);
return { operator, open: open ? { startedAt: open.occurredAt } : null };
});
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 });
}
});
}
+43
View File
@@ -0,0 +1,43 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { requireRole } 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.
interface SiteConfigBody {
/** Nominal capacity; null = no limit. */
capacity: number | null;
}
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
const writeGuard = requireRole("admin");
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Read site config (capacity).
app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { capacity: row?.capacity ?? null };
});
// Set capacity (admin). null or 0+ integer.
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const { capacity } = req.body ?? ({} as SiteConfigBody);
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString();
if (existing) {
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
}
return { capacity: capacity ?? null };
});
}
+49
View File
@@ -0,0 +1,49 @@
import type { FastifyInstance } from "fastify";
import { desc, eq, snapshots, type Db } from "@parking/db";
import { requireRole } 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 = requireRole("admin", "operator", "cashier", "readonly");
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
// first — lets the UI show "entry/exit image" links beside an event.
app.get<{ Params: { identity: string } }>(
"/api/snapshots/by-identity/:identity",
{ preHandler: guard },
async (req) => {
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, req.params.identity))
.orderBy(desc(snapshots.capturedAt))
.all();
return { snapshots: rows };
},
);
// 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);
},
);
}
+79
View File
@@ -0,0 +1,79 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db";
import { validateTariffStructure, type TariffStructure } from "@parking/shared";
import { requireRole } from "../auth.js";
// 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";
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Any signed-in role may READ the tariff (the pay station / operator UI needs it).
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
// Only an admin may PUBLISH a new version (it changes what customers are charged).
const writeGuard = requireRole("admin");
// 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" });
}
const problems = validateTariffStructure(structure);
if (problems.length) {
return reply.code(400).send({ error: "invalid tariff structure", problems });
}
const tariffId = ensureSiteTariff();
const id = randomUUID();
const row = {
id,
tariffId,
effectiveFrom: effectiveFrom ?? new Date().toISOString(),
currency,
structure: structure as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
};
db.insert(tariffVersions).values(row).run();
return reply.code(201).send(row);
},
);
}
+94 -38
View File
@@ -1,16 +1,29 @@
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt"; import jwt from "@fastify/jwt";
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 { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; import { TOKEN_COOKIE, requireJwtSecret } 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 { PermitFlow } from "./permit-flow.js";
import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js"; import { buildSigner } from "./signer.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.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 { permitRoutes } from "./routes/permits.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";
@@ -38,7 +51,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 +61,11 @@ 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 // Device-agnostic setup: the admin adds controllers (with their relays + entry
// by setupRoutes on assign/unassign, so device events can be stamped with the // button) and binds readers/cameras to a controller relay at first-run. There is
// lane the device belongs to (events carry the device id, not a lane). // no lane — a parking lot is one pool with a flexible set of entry/exit points.
const laneMap = new LaneMap(db); // See wiki/concepts/first-run-setup.md, entry-exit-points.md.
laneMap.refresh(); await setupRoutes(app, db);
// Device-agnostic setup: the admin selects devices per lane from the driver
// catalog at first-run. See wiki/concepts/first-run-setup.md.
await setupRoutes(app, db, () => laneMap.refresh());
// 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 +80,85 @@ 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 // Append-only signed business LEDGER (ledger_events). Holds only business facts
// presses) into the hash-chained, signed `events` table — the anti-fraud audit // (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// trail. The device is NOT trusted; the host record is the source of truth, and // button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// a relay open with no matching signed event is itself the anomaly. We record // in device_events. The entry flow (TODO) turns an input into a signed
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that // vehicle_entry once a ticket prints + the barrier is commanded.
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md. // See wiki/decisions/event-streams-split.md.
const eventLog = new EventLog(db, buildSigner(app.log)); const eventLog = new EventLog(db, buildSigner(app.log));
await eventRoutes(app, db, eventLog); await eventRoutes(app, db, eventLog);
// 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.
const entryFlow = new EntryFlow(db, eventLog, app.log);
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 PERMIT flow (if it matches a permit) or the transient
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
const exitFlow = new ExitFlow(db, eventLog, app.log);
const permitFlow = new PermitFlow(db, eventLog, app.log);
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log);
const unsubscribeRead = deviceEvents.onRead((e) => {
void readDispatcher.dispatch(e);
});
app.addHook("onClose", async () => unsubscribeRead());
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
// verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
await qrReaderRoutes(app, db, readDispatcher);
// Pay station (pay-on-foot): quote an open session against the active tariff +
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
const payStation = new PayStation(db, eventLog, app.log);
await payRoutes(app, payStation);
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
// the pay station prices against. See wiki/concepts/tariff.md.
await tariffRoutes(app, db);
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
await permitRoutes(app, db);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
await shiftRoutes(app, shiftService);
// 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);
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;
} }
+178
View File
@@ -0,0 +1,178 @@
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
import { registry, 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 {
constructor(operator: string) {
super(`operator ${operator} already has an open shift`);
this.name = "ShiftAlreadyOpenError";
}
}
export class NoOpenShiftError extends Error {
constructor(operator: string) {
super(`operator ${operator} has no open shift`);
this.name = "NoOpenShiftError";
}
}
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;
readonly printed: boolean;
}
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;
}
/** 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;
}
/** Open a shift for the operator (explicit start). */
async open(operator: string): Promise<{ startedAt: string }> {
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
const startedAt = new Date().toISOString();
await this.#log.append({
type: "shift_open",
source: "manual",
identity: operator, // the shift's operator; `identity` keys the shift to them
payload: { operator },
occurredAt: startedAt,
});
this.#logger.info(`shift opened for ${operator}`);
return { startedAt };
}
/** 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 startedAt = open.occurredAt;
const endedAt = new Date().toISOString();
// All payments taken in [startedAt, endedAt], 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 <= endedAt);
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;
}
await this.#log.append({
type: "shift_z_report",
source: "manual",
identity: operator,
payload: {
operator,
startedAt,
endedAt,
cashTotalMinor,
cardTotalMinor,
currency: currency ?? undefined,
paymentCount: payments.length,
},
});
const printed = await this.#printZReport({
operator,
startedAt,
endedAt,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount: payments.length,
});
this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`,
);
return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, 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);
const lines = [
`Operator: ${r.operator}`,
`From: ${r.startedAt}`,
`To: ${r.endedAt}`,
"",
`Payments: ${r.paymentCount}`,
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
`Card: ${money(r.cardTotalMinor)} ${cur}`,
];
try {
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
return true;
} catch (err) {
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(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;
}
}
}
+4 -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;
} }
+115
View File
@@ -0,0 +1,115 @@
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";
// 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.
interface SnapshotJob {
readonly db: Db;
readonly direction: FlowDirection;
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */
readonly identity: string;
readonly logger: FastifyBaseLogger;
}
/**
* 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 } = 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);
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));
}
/** 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}`);
}
}
+10
View File
@@ -1,7 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchMe, logout, type SessionUser } from "./api.js"; import { fetchMe, logout, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { PermitManager } from "./PermitManager.js";
import { SetupWizard } from "./SetupWizard.js"; import { SetupWizard } from "./SetupWizard.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
import { TariffComposer } from "./TariffComposer.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is // Operator UI shell. Plain React (no admin framework) — the operator UI is
// simple enough that a framework's abstractions cost more than they save. // simple enough that a framework's abstractions cost more than they save.
@@ -38,8 +42,14 @@ export function App() {
</button> </button>
</span> </span>
</header> </header>
<SiteSettings canEdit={user.role === "admin"} />
{user.role !== "readonly" && <ShiftControl />}
{user.role === "admin" ? ( {user.role === "admin" ? (
<>
<SetupWizard /> <SetupWizard />
<TariffComposer />
<PermitManager />
</>
) : ( ) : (
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p> <p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
)} )}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import {
ApiError,
createPermit,
deletePermit,
fetchPermits,
revokePermit,
updatePermit,
type Permit,
type PermitCredential,
type PermitInput,
} from "./api.js";
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
// credentials (card/QR) and bound plates. A permit is mutable master data; every
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
interface FormState {
holderName: string;
contact: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: PermitCredential[];
platesText: string; // comma/space separated
}
function emptyForm(): FormState {
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
}
function formFrom(p: Permit): FormState {
return {
holderName: p.holderName ?? "",
contact: p.contact ?? "",
carBound: p.maxConcurrent != null,
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
validFrom: p.validFrom ?? "",
validTo: p.validTo ?? "",
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
platesText: p.plates.join(", "),
};
}
function toInput(f: FormState): PermitInput {
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
export function PermitManager() {
const [permits, setPermits] = useState<Permit[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
fetchPermits()
.then((r) => setPermits(r.permits))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(reload, []);
function startNew() {
setForm(emptyForm());
setEditing("new");
setMsg(null);
}
function startEdit(p: Permit) {
setForm(formFrom(p));
setEditing(p.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createPermit(toInput(form));
else if (editing) await updatePermit(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: "Permit saved." });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doRevoke(p: Permit) {
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(p: Permit) {
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<PermitCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!permits) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>Permits</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{permits.map((p) => (
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{p.holderName ?? "(unnamed)"}</strong>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
<span style={{ color: "#666" }}>
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
{p.credentials.length} cred · {p.plates.length} plate(s)
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(p)}>Edit</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
<button type="button" onClick={() => doDelete(p)}>Delete</button>
</li>
))}
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>+ Add permit</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>Holder name</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>Contact</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>Car limit</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>Valid from</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
<label>Valid to</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
<label>Bound plates</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">RF card/tag</option>
<option value="qr">QR</option>
</select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
A permit needs at least one credential OR one bound plate.
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>Save</button>
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+310 -66
View File
@@ -12,28 +12,41 @@ import {
type Catalog, type Catalog,
type CatalogEntry, type CatalogEntry,
type DeviceCategory, type DeviceCategory,
type DeviceConfig,
type Direction,
type DiscoveredDevice, type DiscoveredDevice,
type RelaySpec,
type TestResult, type TestResult,
} from "./api.js"; } from "./api.js";
// First-run setup wizard (scaffold). The admin assigns devices per lane from the // First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
// driver catalog. The data model is multi-instance — one lane_devices row per // a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
// instance — so EVERY category supports more than one device: each section lists // declares its relays = entry/exit/both + which input terminal the entry button is
// the already-assigned instances (with Remove) and an "Add" form. Drivers that // on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
// support LAN discovery get a "Scan" button. Auth is via the admin's session // Direction is a property of the relay, inherited by bound devices. The data model
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md. // is multi-instance — one `devices` row per instance. See entry-exit-points.md.
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [ const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
{ key: "access", title: "Access controllers", noun: "access controller" }, key: "access",
{ key: "reader", title: "Readers", noun: "reader" }, title: "Controllers (barriers + entry button)",
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" }, noun: "controller",
{ key: "printer", title: "Printers", noun: "printer" }, };
// Categories that BIND to a controller relay (direction inherited from the relay).
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" },
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" },
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" },
]; ];
const DIRECTION_LABELS: Record<Direction, string> = {
entry: "Entry",
exit: "Exit",
both: "Both (entry + exit)",
};
export function SetupWizard() { export function SetupWizard() {
const [catalog, setCatalog] = useState<Catalog | null>(null); const [catalog, setCatalog] = useState<Catalog | null>(null);
const [assignments, setAssignments] = useState<Assignment[] | null>(null); const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [lane, setLane] = useState(1);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const reloadState = useCallback(() => { const reloadState = useCallback(() => {
@@ -50,35 +63,41 @@ export function SetupWizard() {
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>; if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
if (!catalog || !assignments) return <p>Loading device catalog…</p>; if (!catalog || !assignments) return <p>Loading device catalog…</p>;
// Controllers are needed before binding readers/cameras (they pick a controller relay).
const controllers = assignments.filter((a) => a.category === "access");
return ( return (
<section> <section>
<h2>First-run setup</h2> <h2>First-run setup</h2>
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}> <p style={{ color: "#666", fontSize: "0.9em" }}>
<label> Add your barrier controllers first — set which relay is entry/exit and which
Lane{" "} terminal the entry button is wired to. Then add readers, cameras and printers
<input and point each at the barrier it serves.
type="number" </p>
min={1}
value={lane}
onChange={(e) => setLane(Number(e.target.value))}
style={{ width: "4rem" }}
/>
</label>
<span style={{ color: "#666", fontSize: "0.85em" }}>
Devices are added per lane. Switch lanes to configure another.
</span>
</div>
{CATEGORIES.map(({ key, title, noun }) => ( <CategorySection
category={CONTROLLER.key}
title={CONTROLLER.title}
noun={CONTROLLER.noun}
entries={catalog[CONTROLLER.key]}
discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable}
controllers={controllers}
assignments={controllers}
onChanged={reloadState}
/>
{BOUND.map(({ key, title, noun }) => (
<CategorySection <CategorySection
key={key} key={key}
lane={lane}
category={key} category={key}
title={title} title={title}
noun={noun} noun={noun}
entries={catalog[key]} entries={catalog[key]}
discoverableIds={catalog.discoverable} discoverableIds={catalog.discoverable}
assignments={assignments.filter((a) => a.category === key && a.lane === lane)} pushCapableIds={catalog.pushCapable}
controllers={controllers}
assignments={assignments.filter((a) => a.category === key)}
onChanged={reloadState} onChanged={reloadState}
/> />
))} ))}
@@ -87,37 +106,37 @@ export function SetupWizard() {
} }
function CategorySection({ function CategorySection({
lane,
category, category,
title, title,
noun, noun,
entries, entries,
discoverableIds, discoverableIds,
pushCapableIds,
controllers,
assignments, assignments,
onChanged, onChanged,
}: { }: {
lane: number;
category: DeviceCategory; category: DeviceCategory;
title: string; title: string;
noun: string; noun: string;
entries: CatalogEntry[]; entries: CatalogEntry[];
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[];
controllers: Assignment[];
assignments: Assignment[]; assignments: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
}) { }) {
// Show the add-form automatically when nothing is assigned yet; otherwise it's
// collapsed behind "Add another" so the list stays the focus.
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
// Warnings from the most recent save (e.g. "string protocol could not be
// disabled — finish in the device web UI"). Persist after the form closes.
const [warnings, setWarnings] = useState<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0; const showForm = adding || assignments.length === 0;
// Binding categories need a controller to point at first.
const isBound = category !== "access";
const blockedNoController = isBound && controllers.length === 0;
return ( return (
<fieldset style={{ marginTop: "1rem" }}> <fieldset style={{ marginTop: "1rem" }}>
<legend> <legend>{title}</legend>
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
</legend>
{warnings.length > 0 && ( {warnings.length > 0 && (
<div <div
@@ -144,17 +163,20 @@ function CategorySection({
{assignments.length > 0 && ( {assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}> <ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => ( {assignments.map((a) => (
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} /> <AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
))} ))}
</ul> </ul>
)} )}
{showForm ? ( {blockedNoController ? (
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
) : showForm ? (
<DeviceForm <DeviceForm
lane={lane}
category={category} category={category}
entries={entries} entries={entries}
discoverableIds={discoverableIds} discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
controllers={controllers}
onSaved={async (w) => { onSaved={async (w) => {
setWarnings(w); setWarnings(w);
await onChanged(); await onChanged();
@@ -173,17 +195,17 @@ function CategorySection({
function AssignmentRow({ function AssignmentRow({
assignment, assignment,
controllers,
onChanged, onChanged,
}: { }: {
assignment: Assignment; assignment: Assignment;
controllers: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
}) { }) {
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// A short, human summary of the instance: role (if any) + host. const cfg = assignment.config as Record<string, unknown>;
const cfg = assignment.config;
const role = typeof cfg.role === "string" ? cfg.role : null;
const host = typeof cfg.host === "string" ? cfg.host : null; const host = typeof cfg.host === "string" ? cfg.host : null;
async function remove() { async function remove() {
@@ -210,8 +232,8 @@ function AssignmentRow({
}} }}
> >
<strong>{assignment.driverId}</strong> <strong>{assignment.driverId}</strong>
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
{host && <span style={{ color: "#666" }}>{host}</span>} {host && <span style={{ color: "#666" }}>{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} />
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>} {!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{error && <span style={{ color: "crimson" }}>{error}</span>} {error && <span style={{ color: "crimson" }}>{error}</span>}
@@ -222,27 +244,66 @@ function AssignmentRow({
); );
} }
/** Inline summary of an assignment's direction/binding for the list. */
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
const cfg = assignment.config as Record<string, unknown>;
if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>;
return (
<span style={{ display: "flex", gap: "0.35rem" }}>
{relays.map((r) => (
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
))}
</span>
);
}
// Bound device: show controller + relay it points at, with inherited direction.
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>;
const controller = controllers.find((c) => c.id === controllerId);
const spec = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
: undefined;
return (
<DirectionBadge
direction={spec?.direction ?? "both"}
label={`${controller ? controller.driverId : "?"} · R${relay}`}
/>
);
}
function DeviceForm({ function DeviceForm({
lane,
category, category,
entries, entries,
discoverableIds, discoverableIds,
pushCapableIds,
controllers,
onSaved, onSaved,
onCancel, onCancel,
}: { }: {
lane: number;
category: DeviceCategory; category: DeviceCategory;
entries: CatalogEntry[]; entries: CatalogEntry[];
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[];
controllers: Assignment[];
onSaved: (warnings: string[]) => Promise<void> | void; onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void; onCancel?: () => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string>(""); const [selectedId, setSelectedId] = useState<string>("");
const selected = entries.find((e) => e.id === selectedId); const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id); const canDiscover = selected != null && discoverableIds.includes(selected.id);
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access";
// Config values (auto-filled by discovery, editable by hand).
const [config, setConfig] = useState<Record<string, string | number>>({}); const [config, setConfig] = useState<Record<string, string | number>>({});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]);
// Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>("");
const [boundRelay, setBoundRelay] = useState<number | "">("");
const [tested, setTested] = useState<TestResult | null>(null); const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null); const [testError, setTestError] = useState<string | null>(null);
@@ -252,18 +313,12 @@ function DeviceForm({
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null); const [scanError, setScanError] = useState<string | null>(null);
// Backend push IP: which of OUR addresses the device should call back on. We
// auto-pick the NIC on the device's subnet, but surface it editable here so a
// multi-NIC host can be corrected (the chosen IP is baked into the device on
// save). Only relevant for drivers that push (the field hides if no candidates).
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null); const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>(""); const [backendIp, setBackendIp] = useState<string>("");
// (Re)load backend-IP candidates whenever the device host changes after a const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
// successful test (the test confirms the host is real + reachable).
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
useEffect(() => { useEffect(() => {
if (!testedHost) { if (!testedHost || !pushesToBackend) {
setBackendIps(null); setBackendIps(null);
return; return;
} }
@@ -281,7 +336,7 @@ function DeviceForm({
live = false; live = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [testedHost]); }, [testedHost, pushesToBackend]);
function selectDriver(id: string) { function selectDriver(id: string) {
setSelectedId(id); setSelectedId(id);
@@ -308,8 +363,8 @@ function DeviceForm({
resetStatus(); resetStatus();
} }
// Config the user actually entered, merged over driver defaults. /** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
function mergedConfig(): Record<string, string | number> { function mergedScalarConfig(): Record<string, string | number> {
const out: Record<string, string | number> = {}; const out: Record<string, string | number> = {};
for (const f of selected?.configFields ?? []) { for (const f of selected?.configFields ?? []) {
const v = config[f.key] ?? (f.default as string | number | undefined); const v = config[f.key] ?? (f.default as string | number | undefined);
@@ -318,7 +373,22 @@ function DeviceForm({
return out; return out;
} }
// Editing config invalidates a prior test. /** Full config to persist: scalars + the model's direction/binding fields. */
function mergedConfig(): DeviceConfig {
const out: DeviceConfig = { ...mergedScalarConfig() };
if (isController) {
out.relays = relays.map((r) => ({
relay: r.relay,
direction: r.direction,
...(r.button ? { button: r.button } : {}),
}));
} else if (controllerId && boundRelay !== "") {
out.controllerId = controllerId;
out.relay = boundRelay;
}
return out;
}
function resetStatus() { function resetStatus() {
setTested(null); setTested(null);
setTestError(null); setTestError(null);
@@ -331,7 +401,7 @@ function DeviceForm({
setTestError(null); setTestError(null);
setTested(null); setTested(null);
try { try {
setTested(await testDevice(selected.id, mergedConfig())); setTested(await testDevice(selected.id, mergedScalarConfig()));
} catch (e) { } catch (e) {
setTestError((e as Error).message); setTestError((e as Error).message);
} finally { } finally {
@@ -341,17 +411,21 @@ function DeviceForm({
async function save() { async function save() {
if (!selected) return; if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the
// model with a fallback, but the wizard guides the admin to bind explicitly).
if (!isController && (!controllerId || boundRelay === "")) {
setSaveError("Pick the controller and relay this device sits at.");
return;
}
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
const result = await assignDevice({ const result = await assignDevice({
lane,
category, category,
driverId: selected.id, driverId: selected.id,
config: mergedConfig(), config: mergedConfig(),
...(backendIp ? { backendIp } : {}), ...(backendIp ? { backendIp } : {}),
}); });
// Hand warnings to the parent so they persist after this form unmounts.
await onSaved(result.warnings ?? []); await onSaved(result.warnings ?? []);
} catch (e) { } catch (e) {
setSaveError((e as Error).message); setSaveError((e as Error).message);
@@ -441,6 +515,23 @@ function DeviceForm({
</div> </div>
))} ))}
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
{/* BOUND device: which controller + relay it sits at. */}
{!isController && (
<BindingPicker
controllers={controllers}
controllerId={controllerId}
relay={boundRelay}
onControllerChange={(id) => {
setControllerId(id);
setBoundRelay("");
}}
onRelayChange={setBoundRelay}
/>
)}
{/* Test (no save/no device change) then Save (configures + persists). */} {/* Test (no save/no device change) then Save (configures + persists). */}
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}> <div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="button" onClick={test} disabled={testing}> <button type="button" onClick={test} disabled={testing}>
@@ -476,8 +567,6 @@ function DeviceForm({
</div> </div>
)} )}
{/* Backend push IP — only for push-capable devices (candidates present).
Pre-filled with the auto-pick; editable for multi-NIC hosts. */}
{backendIps && backendIps.length > 0 && ( {backendIps && backendIps.length > 0 && (
<div style={{ margin: "0.5rem 0 0" }}> <div style={{ margin: "0.5rem 0 0" }}>
<label> <label>
@@ -512,6 +601,161 @@ function DeviceForm({
); );
} }
/** Controller relay map editor: each row = a relay + its direction + (optional)
* the input terminal its entry button is wired to. */
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function add() {
const nextRelay = (relays.reduce((m, r) => Math.max(m, r.relay), 0) || 0) + 1;
onChange([...relays, { relay: nextRelay, direction: "both" }]);
}
function remove(i: number) {
onChange(relays.filter((_, idx) => idx !== i));
}
return (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong>
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}>
Each relay opens one barrier. Set its direction; for transient entry, set which input
terminal the entry button is wired to.
</p>
{relays.map((r, i) => (
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}>
<label>
Relay{" "}
<input
type="number"
min={1}
value={r.relay}
style={{ width: "3.5rem" }}
onChange={(e) => update(i, { relay: Number(e.target.value) })}
/>
</label>
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
{(["entry", "exit", "both"] as Direction[]).map((d) => (
<option key={d} value={d}>
{DIRECTION_LABELS[d]}
</option>
))}
</select>
{(r.direction === "entry" || r.direction === "both") && (
<label>
Entry button on terminal{" "}
<input
type="number"
min={1}
value={r.button ?? ""}
placeholder="—"
style={{ width: "3.5rem" }}
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
)}
{relays.length > 1 && (
<button type="button" onClick={() => remove(i)}>
✕
</button>
)}
</div>
))}
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}>
+ Add relay
</button>
</div>
);
}
/** Binding picker for readers/cameras/printers: choose the controller + relay this
* device sits at. Direction is inherited from the chosen relay (shown). */
function BindingPicker({
controllers,
controllerId,
relay,
onControllerChange,
onRelayChange,
}: {
controllers: Assignment[];
controllerId: string;
relay: number | "";
onControllerChange: (id: string) => void;
onRelayChange: (relay: number) => void;
}) {
const controller = controllers.find((c) => c.id === controllerId);
const relays: RelaySpec[] = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
: [];
const chosen = relays.find((r) => r.relay === relay);
return (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}>
<label>
Controller{" "}
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled>
Choose…
</option>
{controllers.map((c) => {
const host = (c.config as Record<string, unknown>).host;
return (
<option key={c.id} value={c.id}>
{c.driverId}
{typeof host === "string" ? ` (${host})` : ""}
</option>
);
})}
</select>
</label>
<label>
Relay{" "}
<select
value={relay === "" ? "" : String(relay)}
disabled={!controller}
onChange={(e) => onRelayChange(Number(e.target.value))}
>
<option value="" disabled>
Choose…
</option>
{relays.map((r) => (
<option key={r.relay} value={r.relay}>
Relay {r.relay} ({DIRECTION_LABELS[r.direction]})
</option>
))}
</select>
</label>
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />}
</div>
{controller && relays.length === 0 && (
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}>
This controller has no relays configured.
</p>
)}
</div>
);
}
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280";
return (
<span
style={{
color,
border: `1px solid ${color}`,
borderRadius: 4,
padding: "0 0.35rem",
fontSize: "0.75em",
fontWeight: 600,
}}
>
{label ?? direction}
</span>
);
}
function HealthBadge({ status }: { status: string }) { function HealthBadge({ status }: { status: string }) {
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626"; const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
return <span style={{ color, fontWeight: 600 }}>● {status}</span>; return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js";
// Manned-mode shift control. Start/End are explicit (not time-based — see
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
// totals. Available to cashier/operator/admin (readonly has no shift).
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
export function ShiftControl() {
const [startedAt, setStartedAt] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [report, setReport] = useState<ShiftReport | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
fetchShift()
.then((s) => setStartedAt(s.open?.startedAt ?? null))
.catch(() => {
/* readonly / not permitted — hide control */
});
}, []);
async function start() {
setBusy(true);
setErr(null);
setReport(null);
try {
const { startedAt } = await openShift();
setStartedAt(startedAt);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
async function end() {
setBusy(true);
setErr(null);
try {
const z = await closeShift();
setReport(z);
setStartedAt(null);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Shift:</strong>{" "}
{startedAt ? (
<>
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
<button type="button" onClick={end} disabled={busy}>
{busy ? "Ending…" : "End shift"}
</button>
</>
) : (
<>
<span style={{ color: "#777" }}>not started</span>{" "}
<button type="button" onClick={start} disabled={busy}>
{busy ? "Starting…" : "Start shift"}
</button>
</>
)}
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
{report && (
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
<div>Payments: {report.paymentCount}</div>
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
</div>
</div>
)}
</section>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js";
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
// transient entry at capacity) is enforced server-side in the entry flow.
// See wiki/concepts/capacity-occupancy.md.
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState("");
const [msg, setMsg] = useState<string | null>(null);
function reload() {
fetchOccupancy().then(setOcc).catch(() => {});
}
useEffect(() => {
reload();
fetchSiteConfig()
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity)))
.catch(() => {});
}, []);
async function save() {
setMsg(null);
const raw = capInput.trim();
const capacity = raw === "" ? null : Math.round(Number(raw));
try {
await setCapacity(capacity);
reload();
setMsg("Capacity saved.");
} catch (e) {
setMsg((e as Error).message);
}
}
return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Occupancy:</strong>{" "}
{occ == null ? (
"…"
) : (
<>
<span style={{ fontWeight: 600 }}>{occ.count}</span>
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
{occ.capacity != null && (
<span style={{ color: "#666" }}> · {occ.free} free</span>
)}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
</>
)}
{canEdit && (
<div style={{ marginTop: "0.6rem" }}>
<label>
Capacity (blank = no limit):{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
</label>{" "}
<button type="button" onClick={save}>Save</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div>
)}
</section>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import {
ApiError,
fetchTariff,
publishTariffVersion,
type TariffBlock,
type TariffStructure,
type TariffState,
} from "./api.js";
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
// creates a new IMMUTABLE version (the active card); old versions are kept so past
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
// Editable form mirror of TariffStructure, but money in major-unit strings.
interface BlockForm {
uptoMin: string; // "" = open-ended (last block)
price: string; // major units, e.g. "2.00"
}
interface FormState {
currency: string;
gracePeriodEntryMin: string;
incrementMin: string;
dailyCap: string; // "" = no cap
lostTicket: string;
gracePeriodExitMin: string;
blocks: BlockForm[];
}
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
function emptyForm(): FormState {
return {
currency: "EUR",
gracePeriodEntryMin: "15",
incrementMin: "60",
dailyCap: "",
lostTicket: "20.00",
gracePeriodExitMin: "15",
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }],
};
}
function formFromActive(s: TariffState): FormState {
const v = s.active;
if (!v) return emptyForm();
const st = v.structure;
return {
currency: v.currency,
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
incrementMin: String(st.incrementMin),
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
lostTicket: toMajor(st.lostTicketMinor),
gracePeriodExitMin: String(st.gracePeriodExitMin),
blocks: st.blocks.map((b) => ({
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
price: toMajor(b.priceMinorPerIncrement),
})),
};
}
function toStructure(f: FormState): TariffStructure {
const blocks: TariffBlock[] = f.blocks.map((b) => ({
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
priceMinorPerIncrement: toMinor(b.price),
}));
return {
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
incrementMin: Math.round(Number(f.incrementMin)),
blocks,
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
lostTicketMinor: toMinor(f.lostTicket),
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
overstay: "reprice",
};
}
export function TariffComposer() {
const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
useEffect(() => {
fetchTariff()
.then((s) => {
setState(s);
setForm(formFromActive(s));
})
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}, []);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function setBlock(i: number, patch: Partial<BlockForm>) {
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
}
function addBlock() {
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] }));
}
function removeBlock(i: number) {
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) }));
}
async function publish() {
setSaving(true);
setMsg(null);
try {
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
const fresh = await fetchTariff();
setState(fresh);
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." });
} catch (e) {
const text =
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
? `${e.message}: ${((e as ApiError & { problems?: string[] }).problems ?? []).join("; ")}`
: (e as Error).message;
setMsg({ kind: "err", text });
} finally {
setSaving(false);
}
}
return (
<section style={{ marginTop: "2rem" }}>
<h2>Tariff</h2>
{!state?.active ? (
<p style={{ color: "#b45309" }}>
No rate card published yet — the pay station can't charge until you publish one.
</p>
) : (
<p style={{ color: "#555" }}>
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
{state.versions.length} version(s) in history. Publishing creates a new version; past
sessions keep their original pricing.
</p>
)}
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
<label>Currency</label>
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
<label>Free entry grace (min)</label>
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label>Billing increment (min)</label>
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label>Daily cap (blank = none)</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
<label>Lost-ticket fee</label>
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label>Exit walk-back grace (min)</label>
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last
block's bound blank for "thereafter". Price is per billing increment.
</p>
<table style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "#555" }}>
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
<th />
</tr>
</thead>
<tbody>
{form.blocks.map((b, i) => (
<tr key={i}>
<td style={{ padding: "0.15rem 0.5rem" }}>
<input
value={b.uptoMin}
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
style={{ width: 110 }}
/>
</td>
<td style={{ padding: "0.15rem 0.5rem" }}>
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
</td>
<td>
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
+ Add block
</button>
<div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}>
{saving ? "Publishing…" : "Publish new version"}
</button>
</div>
{msg && (
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
)}
</section>
);
}
+150 -3
View File
@@ -96,6 +96,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & { export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
/** Driver ids that support LAN discovery. */ /** Driver ids that support LAN discovery. */
discoverable: string[]; discoverable: string[];
/** Driver ids that push to the backend (need a backend IP at assign time). */
pushCapable: string[];
}; };
export function fetchCatalog(): Promise<Catalog> { export function fetchCatalog(): Promise<Catalog> {
@@ -118,7 +120,26 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
return body.devices; return body.devices;
} }
export type DeviceConfig = Record<string, string | number | boolean>; export type ConfigValue =
| string
| number
| boolean
| null
| ConfigValue[]
| { [k: string]: ConfigValue };
export type DeviceConfig = Record<string, ConfigValue>;
/** Direction a barrier/relay (or a device bound to it) serves. */
export type Direction = "entry" | "exit" | "both";
/** One relay on an access controller: which barrier it opens, in which direction,
* and (optionally) the input terminal its entry button is wired to. */
export interface RelaySpec {
relay: number;
direction: Direction;
/** Input terminal of the entry button that fires this relay (transient entry). */
button?: number;
}
export interface TestResult { export interface TestResult {
health: { status: string; detail?: string }; health: { status: string; detail?: string };
@@ -151,9 +172,10 @@ export function fetchBackendIps(
} }
export interface AssignBody { export interface AssignBody {
lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
// reader/camera → config.controllerId + config.relay.
config: DeviceConfig; config: DeviceConfig;
/** Backend IP the device should push to (overrides auto-pick). */ /** Backend IP the device should push to (overrides auto-pick). */
backendIp?: string; backendIp?: string;
@@ -167,7 +189,6 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
/** A persisted device assignment (one per instance; machine-only secrets stripped). */ /** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment { export interface Assignment {
id: string; id: string;
lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
config: DeviceConfig; config: DeviceConfig;
@@ -195,3 +216,129 @@ export function fetchState(): Promise<SetupState> {
export function unassignDevice(id: string): Promise<void> { export function unassignDevice(id: string): Promise<void> {
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" }); return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
} }
// --- Tariff composer ------------------------------------------------------
export interface TariffBlock {
uptoMin: number | null;
priceMinorPerIncrement: number;
}
export interface TariffStructure {
gracePeriodEntryMin: number;
incrementMin: number;
blocks: TariffBlock[];
dailyCapMinor: number | null;
lostTicketMinor: number;
gracePeriodExitMin: number;
overstay: "reprice";
}
export interface TariffVersion {
id: string;
tariffId: string;
effectiveFrom: string;
currency: string;
structure: TariffStructure;
createdBy?: string | null;
createdAt?: string;
}
export interface TariffState {
tariffId: string;
active: TariffVersion | null;
versions: TariffVersion[];
}
export function fetchTariff(): Promise<TariffState> {
return apiFetch<TariffState>("/api/tariff");
}
/** Publish a new immutable tariff version (becomes the active rate card). */
export function publishTariffVersion(body: {
currency: string;
structure: TariffStructure;
effectiveFrom?: string;
}): Promise<TariffVersion> {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
}
// --- Permits --------------------------------------------------------------
export interface PermitCredential {
kind: "rf" | "qr";
value: string;
}
export interface Permit {
id: string;
holderName: string | null;
contact: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
status: "active" | "suspended" | "revoked";
credentials: PermitCredential[];
plates: string[];
}
export type PermitInput = Omit<Permit, "id" | "status"> & {
status?: Permit["status"];
};
export function fetchPermits(): Promise<{ permits: Permit[] }> {
return apiFetch("/api/permits");
}
export function createPermit(body: PermitInput): Promise<Permit> {
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
}
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function revokePermit(id: string): Promise<Permit> {
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
}
export function deletePermit(id: string): Promise<void> {
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
}
// --- Shifts ---------------------------------------------------------------
export interface ShiftStatus {
operator: string;
open: { startedAt: string } | null;
}
export interface ShiftReport {
operator: string;
startedAt: string;
endedAt: string;
cashTotalMinor: number;
cardTotalMinor: number;
currency: string | null;
paymentCount: number;
printed: boolean;
}
export function fetchShift(): Promise<ShiftStatus> {
return apiFetch("/api/shift/current");
}
export function openShift(): Promise<{ startedAt: string }> {
return apiFetch("/api/shift/open", { method: "POST" });
}
export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" });
}
// --- Site config / occupancy ----------------------------------------------
export interface Occupancy {
count: number;
capacity: number | null;
free: number | null;
full: boolean;
}
export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy");
}
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
return apiFetch("/api/site-config");
}
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
}
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Parking dev: pin route source addresses (WSL2 mirrored-mode fix)
# Run after WSL has populated the mirrored interfaces/addresses.
After=network.target wsl-pro.service
Wants=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
# Idempotent; safe to re-run. Path is the repo checkout on this dev box.
ExecStart=/home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1
# Mirrored-mode addresses can land slightly after boot; one retry covers the race.
ExecStartPost=/bin/sh -c 'sleep 3; /home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1 || true'
[Install]
WantedBy=multi-user.target
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# WSL2 mirrored-mode source-address fix (dev box only).
#
# Problem: in WSL2 mirrored networking the Windows host's interfaces — and ALL
# their IPs — are cloned into Linux on every boot. When two device subnets land
# on one NIC (e.g. 192.168.1.x AND 10.0.10.x on eth1), the kernel's connected
# routes come up `scope link` with NO preferred source, and source selection can
# pick the WRONG address (sourcing 10.0.10.x traffic from 192.168.1.123). ARP
# still resolves (L2), so the device looks REACHABLE while every ping/TCP times
# out. See wiki/concepts/wsl-dev-networking.md.
#
# Fix: for each connected `scope link` route, pin its preferred `src` to THIS
# host's own address in that same subnet. No hardcoded IPs — derived at runtime,
# so it also covers future device subnets. Idempotent; a no-op when nothing needs
# fixing. Runs at boot via parking-net.service.
#
# Production note: the real appliance is bare-metal Linux, not WSL — there this
# is just static networkd/netplan config. This script exists only for the dev box.
# NB: intentionally NOT `set -e`. This is a best-effort boot fixer; an individual
# `ip` call failing (e.g. a route not up yet) must not abort the rest.
set -uo pipefail
fix_iface() {
local iface="$1"
# Each connected /N route on this iface that the kernel manages (proto kernel,
# scope link) — i.e. the directly-attached subnets. Capture the full line so we
# can preserve attributes (notably `metric`) when we replace the route.
ip -4 route show dev "$iface" proto kernel scope link | while read -r line; do
local subnet="${line%% *}" # e.g. "10.0.10.0/24"
local prefix="${subnet%/*}"
# Preserve a metric if the route has one (mirrored-mode routes carry e.g. 281);
# replacing without it would change the route's priority.
local metric=""
case "$line" in *" metric "*) metric="metric ${line##* metric }";; esac
# Find THIS host's own address inside the same subnet — the correct src.
local hostip=""
local cidr
for cidr in $(ip -4 -o addr show dev "$iface" | awk '{print $4}'); do
if ipcalc_net "$cidr" "$subnet"; then hostip="${cidr%/*}"; break; fi
done
[ -n "$hostip" ] || continue
local current
current=$(ip -4 route get "$prefix" 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)
[ "$current" = "$hostip" ] && continue # already correct — no-op
# `replace` creates-or-updates, so it works whether or not the route is
# present yet (avoids the boot-race RTNETLINK "No such file" that `change` hits).
# Non-fatal: a single failure must not abort the whole boot fixer.
if ip route replace "$subnet" dev "$iface" proto kernel scope link src "$hostip" $metric; then
echo "pinned $subnet -> src $hostip (was ${current:-none})"
else
echo "warn: could not pin $subnet -> src $hostip" >&2
fi
done
return 0
}
# True if address $1 (a.b.c.d/p) is inside subnet $2 (n.n.n.0/p), same prefix len.
ipcalc_net() {
local addr="${1%/*}" alen="${1#*/}"
local net="${2%/*}" nlen="${2#*/}"
[ "$alen" = "$nlen" ] || return 1
# Compare the network part by masking both to /nlen.
local a n
a=$(mask_to_net "$addr" "$nlen")
n=$(mask_to_net "$net" "$nlen")
[ "$a" = "$n" ]
}
# Mask an IPv4 dotted-quad to its /len network address.
mask_to_net() {
local ip="$1" len="$2"
local IFS=. ; read -r o1 o2 o3 o4 <<<"$ip"
local int=$(( (o1<<24) + (o2<<16) + (o3<<8) + o4 ))
local mask=$(( len == 0 ? 0 : (0xFFFFFFFF << (32 - len)) & 0xFFFFFFFF ))
local net=$(( int & mask ))
echo "$(( (net>>24)&255 )).$(( (net>>16)&255 )).$(( (net>>8)&255 )).$(( net&255 ))"
}
main() {
# Default to eth1 (the mirrored LAN NIC here); accept overrides as args.
local ifaces=("${@:-eth1}")
# Boot race: WSL mirrored mode can populate the interface's addresses/routes a
# beat after the unit starts. Wait (bounded) for at least one connected route
# to appear on the first interface before pinning.
local i tries=0
for i in "${ifaces[@]}"; do
while [ "$tries" -lt 15 ] \
&& [ -z "$(ip -4 route show dev "$i" proto kernel scope link 2>/dev/null)" ]; do
sleep 1; tries=$((tries + 1))
done
break
done
for i in "${ifaces[@]}"; do
ip link show "$i" >/dev/null 2>&1 && fix_iface "$i"
done
}
main "$@"
@@ -1,23 +0,0 @@
CREATE TABLE `events` (
`id` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`type` text NOT NULL,
`direction` text,
`lane` integer NOT NULL,
`source` text,
`identity` text,
`occurred_at` text NOT NULL,
`prev_hash` text,
`signature` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `events_index_unique` ON `events` (`index`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+125
View File
@@ -0,0 +1,125 @@
CREATE TABLE `blocklist` (
`id` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`value` text NOT NULL,
`reason` text,
`active` integer DEFAULT true NOT NULL,
`added_by` text,
`added_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `device_events` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text,
`category` text,
`kind` text NOT NULL,
`detail` text,
`occurred_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `devices` (
`id` text PRIMARY KEY NOT NULL,
`category` text NOT NULL,
`driver_id` text NOT NULL,
`config` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `ledger_events` (
`id` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`type` text NOT NULL,
`direction` text,
`source` text,
`identity` text,
`payload` text,
`occurred_at` text NOT NULL,
`prev_hash` text,
`signature` text NOT NULL,
`key_id` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `ledger_events_index_unique` ON `ledger_events` (`index`);--> statement-breakpoint
CREATE TABLE `permit_credentials` (
`id` text PRIMARY KEY NOT NULL,
`permit_id` text NOT NULL,
`kind` text NOT NULL,
`value` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `permit_plates` (
`id` text PRIMARY KEY NOT NULL,
`permit_id` text NOT NULL,
`plate` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `permits` (
`id` text PRIMARY KEY NOT NULL,
`holder_name` text,
`contact` text,
`max_concurrent` integer DEFAULT 1,
`valid_from` text,
`valid_to` text,
`status` text DEFAULT 'active' NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`identity` text,
`source` text,
`permit_id` text,
`entered_at` text NOT NULL,
`exited_at` text,
`state` text DEFAULT 'open' NOT NULL,
`last_event_index` integer
);
--> statement-breakpoint
CREATE TABLE `setup_state` (
`id` integer PRIMARY KEY NOT NULL,
`completed_at` text
);
--> statement-breakpoint
CREATE TABLE `site_config` (
`id` integer PRIMARY KEY NOT NULL,
`capacity` integer,
`updated_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `snapshots` (
`id` text PRIMARY KEY NOT NULL,
`direction` text NOT NULL,
`device_id` text,
`identity` text,
`content_type` text NOT NULL,
`bytes` blob NOT NULL,
`captured_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tariff_versions` (
`id` text PRIMARY KEY NOT NULL,
`tariff_id` text NOT NULL,
`effective_from` text NOT NULL,
`currency` text NOT NULL,
`structure` text NOT NULL,
`created_by` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tariffs` (
`id` text PRIMARY KEY NOT NULL,
`scope` text DEFAULT 'site' NOT NULL,
`name` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
@@ -1,14 +0,0 @@
CREATE TABLE `lane_devices` (
`id` text PRIMARY KEY NOT NULL,
`lane` integer NOT NULL,
`category` text NOT NULL,
`driver_id` text NOT NULL,
`config` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `setup_state` (
`id` integer PRIMARY KEY NOT NULL,
`completed_at` text
);
+607 -12
View File
@@ -1,11 +1,179 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "721bbb8f-b929-4018-9420-0ae75b03ff93", "id": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"events": { "blocklist": {
"name": "events", "name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
@@ -35,13 +203,6 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": { "source": {
"name": "source", "name": "source",
"type": "text", "type": "text",
@@ -56,6 +217,13 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": { "occurred_at": {
"name": "occurred_at", "name": "occurred_at",
"type": "text", "type": "text",
@@ -76,11 +244,18 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
} }
}, },
"indexes": { "indexes": {
"events_index_unique": { "ledger_events_index_unique": {
"name": "events_index_unique", "name": "ledger_events_index_unique",
"columns": [ "columns": [
"index" "index"
], ],
@@ -92,6 +267,426 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": { "users": {
"name": "users", "name": "users",
"columns": { "columns": {
-245
View File
@@ -1,245 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1073123c-0df9-4109-84bf-7f23b95ec5bd",
"prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93",
"tables": {
"events": {
"name": "events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"events_index_unique": {
"name": "events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lane_devices": {
"name": "lane_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+2 -9
View File
@@ -5,15 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1781389618205, "when": 1781632874398,
"tag": "0000_absent_rocket_raccoon", "tag": "0000_baseline",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781416636098,
"tag": "0001_cuddly_maria_hill",
"breakpoints": true "breakpoints": true
} }
] ]
+209 -16
View File
@@ -1,11 +1,17 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Schema notes: // Schema notes:
// - `events` is APPEND-ONLY. Never expose UPDATE/DELETE on it. A correction or // - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
// void is a new row of type 'void'. Each row chains to the previous via // • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
// `prevHash` and is signed by the ATECC608 (`signature`). This is the core // Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
// anti-fraud integrity mechanism. See wiki/concepts/append-only-event-chain.md. // chains via `prevHash` and is signed (`signature`). The anti-fraud record; sessions,
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first). // - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
// See wiki/entities/local-jwt-auth.md. // See wiki/entities/local-jwt-auth.md.
@@ -21,30 +27,88 @@ export const users = sqliteTable("users", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
export const events = sqliteTable("events", { // --- The signed business ledger (formerly `events`) ----------------------
// Holds ONLY business/accountability facts: vehicle_entry, vehicle_exit, payment,
// void, shift_z_report, plus witness-grade barrier_open_command/observed, anomaly.
// `payload` carries type-specific data (amount, tariffVersionId, sessionRef, tender,
// plate confidence…) and is part of the SIGNED canonical form, so it is tamper-evident
// like the rest of the row. See packages/shared ParkingEventType + LedgerPayload.
export const ledgerEvents = sqliteTable("ledger_events", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
// Monotonic chain index. Gaps are alarms (see event-log-ingestion). // Monotonic chain index. Gaps are alarms (see event-log-ingestion).
index: integer("index").notNull().unique(), index: integer("index").notNull().unique(),
type: text("type").notNull(), type: text("type").notNull(),
direction: text("direction", { enum: ["entry", "exit"] }), direction: text("direction", { enum: ["entry", "exit"] }),
lane: integer("lane").notNull(),
source: text("source"), source: text("source"),
identity: text("identity"), identity: text("identity"),
// Type-specific business payload (JSON). Signed as part of the canonical form.
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>(),
occurredAt: text("occurred_at").notNull(), occurredAt: text("occurred_at").notNull(),
// Hash of the previous event (hex). Null only for the genesis event. // Hash of the previous event (hex). Null only for the genesis event.
prevHash: text("prev_hash"), prevHash: text("prev_hash"),
// ATECC608 signature over the canonical event payload (hex). // ATECC608 signature over the canonical event payload (hex).
signature: text("signature").notNull(), signature: text("signature").notNull(),
// Which signer/key produced `signature` (e.g. "sw-hmac-v1", "atecc608-slot0"),
// so old events stay verifiable across a signer swap. See packages/shared Signer.
keyId: text("key_id").notNull(),
}); });
// Per-lane device assignments chosen by the admin during first-run setup. // --- Device telemetry (unsigned, prunable) -------------------------------
// One row per (lane, category, instance). `driverId` references a driver in the // Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
// @parking/devices registry; `config` is that driver's JSON config (host, port, // offline, reader read, raw input edges. Keyed to a `devices` instance. No
// credentials…). Lets the system stay device-agnostic and admin-configurable. // prevHash/signature — this stream may rotate/prune.
// See wiki/concepts/device-registry.md and first-run-setup.md. export const deviceEvents = sqliteTable("device_events", {
export const laneDevices = sqliteTable("lane_devices", { id: text("id").primaryKey(),
// The `devices` instance that produced it (raw provenance).
deviceId: text("device_id"),
category: text("category", {
enum: ["access", "reader", "camera", "printer"],
}),
// e.g. "input", "relay", "status", "read", "snapshot".
kind: text("kind").notNull(),
// Free-form telemetry detail (input number + edge, status flags, error…).
detail: text("detail", { mode: "json" }).$type<Record<string, unknown>>(),
occurredAt: text("occurred_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Camera snapshots (unsigned, prunable, blob-in-DB) -------------------
// An entry/exit snapshot captured asynchronously AFTER the barrier opens — evidence,
// not a gate (camera failure never blocks an open; see entry/exit flows). Stored as a
// BLOB so the appliance keeps a single backed-up file with nothing scattered on disk.
// Kept in its own table (not inline in device_events) so the hot telemetry scans don't
// drag image bytes, and so images can be pruned independently. The signed
// vehicle_entry/exit references a snapshot by `id` in its payload — the image is an
// independent record (anti-fraud), unsigned and prunable. Retention policy is an open
// question — see wiki/concepts/entry-exit-points.md. Served via GET /api/snapshots/:id.
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
// The camera `devices` instance that captured it (raw provenance).
deviceId: text("device_id"),
// The session/credential ref (ticket id, plate, permit) — links to the ledger event.
identity: text("identity"),
contentType: text("content_type").notNull(),
bytes: blob("bytes").notNull().$type<Buffer>(),
capturedAt: text("captured_at").notNull(),
});
// --- Device assignments (first-run setup) --------------------------------
// One row per device instance. `driverId` references a driver in the @parking/devices
// registry; `config` is that driver's JSON config. There is NO lane: a parking lot is
// one pool of spaces with a flexible set of entry/exit points. Direction lives INSIDE
// the config, per the hardware:
// - access controller: config.relays = [{ relay, direction: entry|exit|both, button? }]
// — one physical board has several relays; each relay opens one barrier in one
// direction (or both). `button` = the input terminal the entry button is wired to
// (transient entry trigger; absent = no button at that barrier).
// - reader / camera: config.controllerId + config.relay BIND it to the barrier it sits
// at; its direction is INHERITED from that relay. Unbound → falls back to a
// direction picked in config.
// See device-registry.md, first-run-setup.md, wiki/concepts/entry-exit-points.md.
export const devices = sqliteTable("devices", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
lane: integer("lane").notNull(),
category: text("category", { category: text("category", {
enum: ["access", "reader", "camera", "printer"], enum: ["access", "reader", "camera", "printer"],
}).notNull(), }).notNull(),
@@ -64,7 +128,136 @@ export const setupState = sqliteTable("setup_state", {
completedAt: text("completed_at"), completedAt: text("completed_at"),
}); });
// Single-row site settings (admin-configurable). The home for site-wide knobs;
// `capacity` is the nominal space count the FULL gate refuses transient entry at
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
export const siteConfig = sqliteTable("site_config", {
id: integer("id").primaryKey(), // always 1
capacity: integer("capacity"), // null = no capacity limit
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Tariffs (composable, versioned) -------------------------------------
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
// A session reprices against the version in force at its entry time; the `payment`
// ledger event records the tariffVersionId used. "One active tariff per site" today;
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
export const tariffs = sqliteTable("tariffs", {
id: text("id").primaryKey(),
// Only "site" used now; "zone" reserved for multi-tariff later.
scope: text("scope", { enum: ["site", "zone"] }).notNull().default("site"),
name: text("name").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
export const tariffVersions = sqliteTable("tariff_versions", {
id: text("id").primaryKey(),
tariffId: text("tariff_id").notNull(),
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
effectiveFrom: text("effective_from").notNull(),
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
currency: text("currency").notNull(),
// The composable rate card (stepped blocks + caps/grace). Shape: TariffStructure
// in packages/shared. Immutable once published.
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Permits (subscriptions) ---------------------------------------------
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
export const permits = sqliteTable("permits", {
id: text("id").primaryKey(),
holderName: text("holder_name"),
contact: text("contact"),
// Car-count binding: how many of the permit's cars may be inside at once.
// null = unbound. Default 1.
maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"),
validTo: text("valid_to"),
status: text("status", { enum: ["active", "suspended", "revoked"] })
.notNull()
.default("active"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
export const permitCredentials = sqliteTable("permit_credentials", {
id: text("id").primaryKey(),
permitId: text("permit_id").notNull(),
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
value: text("value").notNull(),
});
// Plate binding (optional). When a permit has plate rows, a matching plate read is
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
export const permitPlates = sqliteTable("permit_plates", {
id: text("id").primaryKey(),
permitId: text("permit_id").notNull(),
plate: text("plate").notNull(),
});
// --- Blocklist (banlist) -------------------------------------------------
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
export const blocklist = sqliteTable("blocklist", {
id: text("id").primaryKey(),
kind: text("kind", { enum: ["plate", "card", "qr"] }).notNull(),
value: text("value").notNull(),
reason: text("reason"),
active: integer("active", { mode: "boolean" }).notNull().default(true),
addedBy: text("added_by"),
addedAt: text("added_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Sessions (PROJECTION cache) -----------------------------------------
// NOT a source of truth — a rebuildable fold over ledger_events for fast queries
// (occupancy, pay-station lookup, anti-passback, plate search). Always reconstructable
// from the signed chain; never the authority for "paid". See wiki/concepts/parking-session.md.
export const sessions = sqliteTable("sessions", {
// The session key = the entry's identity (ticket id or plate).
id: text("id").primaryKey(),
// Identity that opened the session, and how it was read.
identity: text("identity"),
source: text("source"),
// null while transient; set when matched to a permit.
permitId: text("permit_id"),
enteredAt: text("entered_at").notNull(),
// null until exit; presence = CLOSED.
exitedAt: text("exited_at"),
// Derived state for quick filtering: open | paid | closed | voided.
state: text("state", { enum: ["open", "paid", "closed", "voided"] })
.notNull()
.default("open"),
// Index of the last ledger event folded into this row (cache freshness / rebuild).
lastEventIndex: integer("last_event_index"),
});
export type UserRow = typeof users.$inferSelect; export type UserRow = typeof users.$inferSelect;
export type EventRow = typeof events.$inferSelect; export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
export type LaneDeviceRow = typeof laneDevices.$inferSelect; export type DeviceEventRow = typeof deviceEvents.$inferSelect;
export type SnapshotRow = typeof snapshots.$inferSelect;
export type DeviceRow = typeof devices.$inferSelect;
export type SetupStateRow = typeof setupState.$inferSelect; export type SetupStateRow = typeof setupState.$inferSelect;
export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type PermitRow = typeof permits.$inferSelect;
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
export type PermitPlateRow = typeof permitPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
@@ -721,6 +721,7 @@ export const dingtianDriver: AccessDriver = {
description: description:
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.", "Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
transports: ["udp"], transports: ["udp"],
pushesToBackend: true, // HTTP-pushes input/button events to the backend (Input Link URL)
configFields: [ configFields: [
hostField, hostField,
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." }, { ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
@@ -0,0 +1,35 @@
import type { AccessControlDevice, DeviceHealth } from "../interfaces.js";
import type { AccessDriver, DeviceConfig } from "../registry.js";
import { stubLog } from "./common.js";
// Stub access controller — a no-op barrier for BENCH TESTING the entry/exit/permit
// flows without real relay hardware. `pulseOpen` just logs "intent to open"; it
// performs no device I/O, so it can stand in on a lane while the real
// [[dingtian-relay]] isn't connected. NOT for production. See first-run-setup.md.
class StubAccess implements AccessControlDevice {
readonly driverId = "stub-access";
constructor(_config: DeviceConfig) {}
async connect(): Promise<void> {}
async disconnect(): Promise<void> {}
async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub (no real barrier)" };
}
async pulseOpen(doorId: number): Promise<void> {
stubLog(this.driverId, `pulseOpen door ${doorId} (stub — no relay fired)`);
}
async getDoorStatus(): Promise<"open" | "closed"> {
return "closed";
}
}
export const stubAccessDriver: AccessDriver = {
id: "stub-access",
category: "access",
label: "Stub barrier (bench testing — no relay)",
description:
"A no-op access controller for testing the flows without hardware. pulseOpen only logs; no relay is fired. Not for production.",
transports: ["tcp-ip"],
configFields: [],
create: (c) => new StubAccess(c),
};
+88 -25
View File
@@ -1,57 +1,120 @@
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js"; import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
import type { CameraDriver, DeviceConfig } from "../registry.js"; import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js"; import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
import { digestGet } from "./http-digest.js";
// Camera drivers — entry/exit snapshot-on-event. The image is stored and // Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
// referenced from the signed event as an independent fraud-control record. // HTTP when an event fires; the bytes are stored and referenced from the signed
// Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL. STUBS only. // event as an independent fraud-control record (the camera PULLS, it never pushes
// to us). Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL and
// channel encoding. Both use HTTP Digest auth (see ./http-digest.ts).
//
// VERIFIED on hardware (2026-06-15): a Hikvision unit at 10.0.10.121 returns a
// 2688×1520 JPEG from /ISAPI/Streaming/channels/101/picture with Digest auth.
// See wiki/entities/lpr-camera.md.
const DEFAULT_TIMEOUT_MS = 8000;
class HttpCamera implements CameraDevice {
readonly #host: string;
readonly #port: number;
readonly #user: string;
readonly #password: string;
readonly #channel: number;
readonly #timeout: number;
// Source outbound from the device-facing NIC on a multi-homed host (the
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
readonly #localAddress: string | undefined;
class StubCamera implements CameraDevice {
constructor( constructor(
readonly driverId: string, readonly driverId: string,
protected readonly config: DeviceConfig, config: DeviceConfig,
protected readonly snapshotPath: string, /** Builds the snapshot path from the configured channel. */
) {} private readonly snapshotPath: (channel: number) => string,
async connect(): Promise<void> { ) {
stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`); this.#host = String(config.host);
} this.#port = Number(config.port ?? 80);
async disconnect(): Promise<void> { this.#user = String(config.username ?? "");
stubLog(this.driverId, "disconnect"); this.#password = String(config.password ?? "");
this.#channel = Number(config.channel ?? 1);
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
} }
async connect(): Promise<void> {}
async disconnect(): Promise<void> {}
async healthCheck(): Promise<DeviceHealth> { async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" }; // The only honest liveness probe for a snapshot camera is to actually pull a
// frame: it exercises reachability + auth + the path/channel in one shot.
try {
const res = await this.#get();
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
return { status: "degraded", detail: `HTTP ${res.status}` };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
} }
}
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> { async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
// Real driver: GET http(s)://host{snapshotPath}, store bytes, return ref. const res = await this.#get();
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction}`); if (res.status !== 200) {
throw new Error(
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}`,
);
}
stubLog(this.driverId, `captureSnapshot ${ctx.direction} (${res.body.length} bytes)`);
return { return {
imageRef: `stub://${this.driverId}/lane${ctx.lane}/${ctx.direction}/${Date.now()}`, bytes: res.body,
contentType: "image/jpeg", contentType: res.contentType || "image/jpeg",
capturedAt: new Date().toISOString(), capturedAt: new Date().toISOString(),
}; };
} }
#get() {
return digestGet({
host: this.#host,
port: this.#port,
path: this.snapshotPath(this.#channel),
user: this.#user,
password: this.#password,
timeoutMs: this.#timeout,
localAddress: this.#localAddress,
});
}
} }
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, { key: "channel", label: "Channel", type: "number" as const, required: false, default: 1 }]; const channelField: ConfigField = {
key: "channel",
label: "Channel",
type: "number",
required: false,
default: 1,
};
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
export const hikvisionDriver: CameraDriver = { export const hikvisionDriver: CameraDriver = {
id: "hikvision", id: "hikvision",
category: "camera", category: "camera",
label: "Hikvision camera", label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI.", description: "Hikvision snapshot via ISAPI (HTTP Digest).",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: cameraConfigFields, configFields: cameraConfigFields,
// /ISAPI/Streaming/channels/<id>/picture // ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"), create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
}; };
export const dahuaDriver: CameraDriver = { export const dahuaDriver: CameraDriver = {
id: "dahua", id: "dahua",
category: "camera", category: "camera",
label: "Dahua camera", label: "Dahua camera",
description: "Dahua snapshot via CGI.", description: "Dahua snapshot via CGI (HTTP Digest).",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: cameraConfigFields, configFields: cameraConfigFields,
// /cgi-bin/snapshot.cgi?channel=<n> // Dahua channels are 0-based on the CGI; the admin enters 1-based.
create: (c) => new StubCamera("dahua", c, "/cgi-bin/snapshot.cgi"), create: (c) =>
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
}; };
+139
View File
@@ -0,0 +1,139 @@
import { createHash, randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http";
import type { IncomingMessage } from "node:http";
// Client-side HTTP Digest auth (RFC 2617, MD5, qop=auth) for talking TO devices
// that challenge with `WWW-Authenticate: Digest` — e.g. Hikvision ISAPI cameras.
// (The server-side counterpart, which VERIFIES device→backend pushes, lives in
// apps/server/src/digest-auth.ts.) Devices on the isolated VLAN can't present a
// trusted TLS cert, so plain-HTTP Digest is the available auth: the password is
// never on the wire, only a nonce-keyed hash. See wiki/concepts/network-isolation.md.
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
/** Parse a `WWW-Authenticate: Digest …` header into its k=v fields. */
function parseChallenge(header: string): Record<string, string> {
const out: Record<string, string> = {};
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
let m: RegExpExecArray | null;
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
return out;
}
/** Build the `Authorization: Digest …` response value for a challenge. */
function buildAuthHeader(
c: Record<string, string>,
user: string,
password: string,
method: string,
uri: string,
): string {
const realm = c.realm ?? "";
const nonce = c.nonce ?? "";
const qop = c.qop?.split(",")[0]?.trim(); // server may offer "auth,auth-int"
const ha1 = md5(`${user}:${realm}:${password}`);
const ha2 = md5(`${method}:${uri}`);
const parts: string[] = [
`username="${user}"`,
`realm="${realm}"`,
`nonce="${nonce}"`,
`uri="${uri}"`,
];
let response: string;
if (qop === "auth") {
const cnonce = randomBytes(8).toString("hex");
const nc = "00000001";
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
} else {
// Legacy RFC 2069 (no qop) — Hikvision uses qop=auth, but be tolerant.
response = md5(`${ha1}:${nonce}:${ha2}`);
}
parts.push(`response="${response}"`);
if (c.opaque) parts.push(`opaque="${c.opaque}"`);
return `Digest ${parts.join(", ")}`;
}
export interface DigestGetResult {
readonly status: number;
readonly contentType: string;
readonly body: Buffer;
}
export interface DigestGetOptions {
readonly host: string;
readonly port: number;
readonly path: string;
readonly user: string;
readonly password: string;
readonly timeoutMs: number;
/** Bind outbound to the device-facing NIC on a multi-homed host. */
readonly localAddress?: string;
}
function getOnce(
o: DigestGetOptions,
authHeader?: string,
): Promise<{ res: IncomingMessage; body: Buffer }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
if (authHeader) headers["authorization"] = authHeader;
const req = httpRequest(
{
host: o.host,
port: o.port,
path: o.path,
method: "GET",
timeout: o.timeoutMs,
localAddress: o.localAddress,
headers,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c as Buffer));
res.on("end", () => resolve({ res, body: Buffer.concat(chunks) }));
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
req.end();
});
}
/**
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
* the first request (no Authorization) draws a 401 + challenge, the second
* carries the computed response. If the server doesn't challenge (200 straight
* away, or no auth required), the first response is returned as-is.
*/
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
const first = await getOnce(o);
if (first.res.statusCode !== 401) {
return {
status: first.res.statusCode ?? 0,
contentType: String(first.res.headers["content-type"] ?? ""),
body: first.body,
};
}
const challengeHeader = String(first.res.headers["www-authenticate"] ?? "");
if (!/^digest/i.test(challengeHeader)) {
// 401 but not Digest (e.g. Basic-only) — surface it; caller decides.
return {
status: 401,
contentType: String(first.res.headers["content-type"] ?? ""),
body: first.body,
};
}
const challenge = parseChallenge(challengeHeader);
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
const second = await getOnce(o, auth);
return {
status: second.res.statusCode ?? 0,
contentType: String(second.res.headers["content-type"] ?? ""),
body: second.body,
};
}
+6 -1
View File
@@ -3,9 +3,10 @@
import { registry } from "../registry.js"; import { registry } from "../registry.js";
import { dingtianDriver } from "./access-dingtian.js"; import { dingtianDriver } from "./access-dingtian.js";
import { stubAccessDriver } from "./access-stub.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { rongtaDriver } from "./printer-rongta.js"; import { rongtaDriver } from "./printer-rongta.js";
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
let registered = false; let registered = false;
@@ -14,8 +15,10 @@ export function registerBuiltinDrivers(): void {
if (registered) return; if (registered) return;
registered = true; registered = true;
registry.register(dingtianDriver); registry.register(dingtianDriver);
registry.register(stubAccessDriver);
registry.register(wiegandReaderDriver); registry.register(wiegandReaderDriver);
registry.register(tcpipReaderDriver); registry.register(tcpipReaderDriver);
registry.register(geeQrReaderDriver);
registry.register(hikvisionDriver); registry.register(hikvisionDriver);
registry.register(dahuaDriver); registry.register(dahuaDriver);
registry.register(rongtaDriver); registry.register(rongtaDriver);
@@ -23,8 +26,10 @@ export function registerBuiltinDrivers(): void {
export { export {
dingtianDriver, dingtianDriver,
stubAccessDriver,
wiegandReaderDriver, wiegandReaderDriver,
tcpipReaderDriver, tcpipReaderDriver,
geeQrReaderDriver,
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
+22 -3
View File
@@ -6,6 +6,7 @@ import type {
MonitorableDevice, MonitorableDevice,
PrinterDevice, PrinterDevice,
PrinterStatus, PrinterStatus,
PrintReport,
TicketData, TicketData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
@@ -44,6 +45,21 @@ function line(text = ""): Buffer {
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]); return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
} }
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
function renderReport(report: PrintReport): Buffer {
return Buffer.concat([
INIT,
ALIGN_CENTER,
BOLD_ON,
line(report.title),
BOLD_OFF,
ALIGN_LEFT,
line(),
...report.lines.map((l) => line(l)),
FEED_AND_CUT,
]);
}
/** Build the full ESC/POS byte stream for an entry ticket. */ /** Build the full ESC/POS byte stream for an entry ticket. */
function renderTicket(data: TicketData): Buffer { function renderTicket(data: TicketData): Buffer {
return Buffer.concat([ return Buffer.concat([
@@ -55,8 +71,6 @@ function renderTicket(data: TicketData): Buffer {
DOUBLE_OFF, DOUBLE_OFF,
BOLD_OFF, BOLD_OFF,
line(), line(),
line(`Lane ${data.lane}`),
line(),
BOLD_ON, BOLD_ON,
line(data.ticketId), line(data.ticketId),
BOLD_OFF, BOLD_OFF,
@@ -201,7 +215,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
async printTicket(data: TicketData): Promise<void> { async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`); stubLog(this.driverId, `printed ticket ${data.ticketId}`);
}
async printReport(report: PrintReport): Promise<void> {
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
} }
/** /**
+25
View File
@@ -58,3 +58,28 @@ export const tcpipReaderDriver: ReaderDriver = {
configFields: [hostField, portField(9000)], configFields: [hostField, portField(9000)],
create: (c) => new StubReader("tcpip-reader", c), create: (c) => new StubReader("tcpip-reader", c),
}; };
// GEE/Fondvision QR access reader (e.g. GEE-QR-ER80). A PUSH device: on each scan
// it HTTP-GETs our backend (/qa/mcardsea.<ext>) carrying its serial (cjihao); the
// backend resolves the lane by matching that serial to this device's `serial`
// config, decides, and replies the verdict (drives the beep). No host-side
// connection — the adapter is a stub; the real integration is the HTTP endpoint
// (apps/server routes/qr-reader.ts). See wiki/entities/gee-qr-er80.md.
export const geeQrReaderDriver: ReaderDriver = {
id: "gee-qr-reader",
category: "reader",
label: "GEE/Fondvision QR reader (HTTP push)",
description:
"QR/barcode access reader that HTTP-pushes each scan to the backend. Set its server IP/port to this host in the vendor tool; enter its serial here so scans resolve to this lane.",
transports: ["tcp-ip"],
configFields: [
{
key: "serial",
label: "Device serial (cjihao)",
type: "string",
required: true,
help: "The reader's serial as it reports in each scan (the `cjihao` field). Used to map scans to this lane.",
},
],
create: (c) => new StubReader("gee-qr-reader", c),
};
+2
View File
@@ -11,8 +11,10 @@ export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js";
export { export {
registerBuiltinDrivers, registerBuiltinDrivers,
dingtianDriver, dingtianDriver,
stubAccessDriver,
wiegandReaderDriver, wiegandReaderDriver,
tcpipReaderDriver, tcpipReaderDriver,
geeQrReaderDriver,
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
+16 -4
View File
@@ -174,26 +174,38 @@ export interface CameraDevice extends Device {
} }
export interface SnapshotContext { export interface SnapshotContext {
readonly lane: number;
readonly direction: "entry" | "exit"; readonly direction: "entry" | "exit";
} }
export interface Snapshot { export interface Snapshot {
/** Storage reference for the captured image (file path / blob id). */ /** The captured image bytes. The DRIVER fetches them over the network; the
readonly imageRef: string; * CALLER (entry/exit flow) owns storage and minting a durable reference —
* keeping the device adapter free of any filesystem/blob-store dependency. */
readonly bytes: Buffer;
readonly contentType: string; readonly contentType: string;
readonly capturedAt: string; // ISO-8601 readonly capturedAt: string; // ISO-8601
/** Storage reference (file path / blob id), set once the caller has stored
* the bytes. Absent on the value the driver returns. */
readonly imageRef?: string;
} }
// --- Printers (ticket dispenser / booth printer) ------------------------- // --- Printers (ticket dispenser / booth printer) -------------------------
export interface TicketData { export interface TicketData {
readonly ticketId: string; readonly ticketId: string;
readonly lane: number;
readonly issuedAt: string; // ISO-8601 readonly issuedAt: string; // ISO-8601
} }
export interface PrinterDevice extends Device { export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>; printTicket(data: TicketData): Promise<void>;
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are
* printed as-is; the driver adds a header/cut. Kept generic so the business
* layer composes the content. See wiki/concepts/shift.md. */
printReport(report: PrintReport): Promise<void>;
}
export interface PrintReport {
readonly title: string;
readonly lines: readonly string[];
} }
// --- Live printer status (consumable / mechanical faults) ---------------- // --- Live printer status (consumable / mechanical faults) ----------------
+24 -1
View File
@@ -27,8 +27,19 @@ export interface ConfigField {
readonly help?: string; readonly help?: string;
} }
/** A JSON-serializable config value. Mostly flat scalars (host, port, credentials),
* but some configs carry nested structure — e.g. an access controller's
* `relays: [{ relay, direction, button? }]` map. See entry-exit-points.md. */
export type ConfigValue =
| string
| number
| boolean
| null
| ConfigValue[]
| { [k: string]: ConfigValue };
/** Opaque per-instance config the admin fills in (host, port, credentials…). */ /** Opaque per-instance config the admin fills in (host, port, credentials…). */
export type DeviceConfig = Record<string, string | number | boolean>; export type DeviceConfig = Record<string, ConfigValue>;
/** /**
* A driver: metadata describing a supported device model/family, the config * A driver: metadata describing a supported device model/family, the config
@@ -42,6 +53,13 @@ export interface DeviceDriver<T extends Device = Device> {
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */ /** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
readonly transports: readonly string[]; readonly transports: readonly string[];
readonly configFields: readonly ConfigField[]; readonly configFields: readonly ConfigField[];
/**
* True if the device calls BACK to our backend (HTTP push) and therefore needs
* a backend IP configured at assign time. Pull-only devices (cameras poll a
* snapshot, the relay is commanded) leave this false so the setup wizard hides
* the "Backend push IP" field. See wiki/concepts/device-input-flow.md.
*/
readonly pushesToBackend?: boolean;
/** Build a live adapter instance from validated config. */ /** Build a live adapter instance from validated config. */
create(config: DeviceConfig): T; create(config: DeviceConfig): T;
} }
@@ -130,6 +148,11 @@ class DeviceRegistry {
} }
return byCategory; return byCategory;
} }
/** Driver ids that push to the backend (need a backend IP at assign time). */
pushCapable(): string[] {
return [...this.#drivers.values()].filter((d) => d.pushesToBackend).map((d) => d.id);
}
} }
export interface CatalogEntry { export interface CatalogEntry {
+176 -9
View File
@@ -13,39 +13,206 @@ export type Direction = "entry" | "exit";
export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual"; export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual";
/** /**
* An append-only parking event. Records are never mutated; corrections are new * A signed business-LEDGER event. Records are never mutated; corrections are new
* events. `prevHash` chains each event to the previous one; `signature` is the * events. `prevHash` chains each event to the previous one; `signature` is the
* ATECC608 signature over the event contents. See wiki/append-only-event-chain. * ATECC608 signature over the canonical contents (which INCLUDE `payload`).
* Distinct from device telemetry — see wiki/decisions/event-streams-split.md.
*/ */
export interface ParkingEvent { export interface LedgerEvent {
readonly id: string; readonly id: string;
readonly index: number; readonly index: number;
readonly type: ParkingEventType; readonly type: LedgerEventType;
readonly direction: Direction | null; readonly direction: Direction | null;
readonly lane: number; readonly lane: number;
readonly source: IdentitySource | null; readonly source: IdentitySource | null;
/** Card number, plate, ticket id, etc. — depends on `source`. */ /** Card number, plate, ticket id, etc. — depends on `source`. */
readonly identity: string | null; readonly identity: string | null;
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
readonly payload: LedgerPayload | null;
readonly occurredAt: string; // ISO-8601 readonly occurredAt: string; // ISO-8601
/** Hash of the previous event in the chain (hex). Null only for genesis. */ /** Hash of the previous event in the chain (hex). Null only for genesis. */
readonly prevHash: string | null; readonly prevHash: string | null;
/** ATECC608 signature over the canonical event payload (hex). */ /** ATECC608 signature over the canonical event payload (hex). */
readonly signature: string; readonly signature: string;
/** Which signer/key produced `signature` (verifiable across a signer swap). */
readonly keyId: string;
} }
export type ParkingEventType = /** Business/accountability events that live in the SIGNED, hash-chained ledger. */
// A raw device input (e.g. a Dingtian button press) was received and recorded. export type LedgerEventType =
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
// entry flow once a ticket prints and the barrier is commanded.
| "input_received"
| "vehicle_entry" | "vehicle_entry"
| "vehicle_exit" | "vehicle_exit"
| "payment"
| "void" | "void"
// Witness-grade: a host-commanded open, and an independently-observed open
// (loop/sensor) — reconciled against each other.
| "barrier_open_command" | "barrier_open_command"
| "barrier_open_observed" | "barrier_open_observed"
// Manned-mode shift boundary: an operator takes over (shift_open) / hands over
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
| "shift_open"
| "shift_z_report" | "shift_z_report"
| "anomaly"; | "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */
export type Tender = "cash" | "card";
/**
* Type-specific data carried on a ledger event's `payload`. All amounts are
* integer minor units in the named currency — never floats. Fields are optional
* because they're event-type-specific; the producer fills what applies.
*/
export interface LedgerPayload {
/** The parking_session this event concerns (entry/exit/payment/void). */
readonly sessionRef?: string;
/** payment: amount in minor units, its currency, and how it was tendered. */
readonly amountMinor?: number;
readonly currency?: string;
readonly tender?: Tender;
/** payment: which tariff_version priced it (reproducible repricing). */
readonly tariffVersionId?: string;
/** payment: gross/discount/net split when a validation applied. */
readonly grossMinor?: number;
readonly discountMinor?: number;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null;
/** void / anomaly / override: a human/machine reason code. */
readonly reason?: string;
/** plate/vehicle from the vision service (advisory). */
readonly plate?: string;
readonly plateConfidence?: number;
/** Free-form for forward-compat without a schema change. */
readonly [k: string]: unknown;
}
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
/**
* The composable rate card stored in a tariff_version.structure. Pure data the
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
* a flat rate is just one block. See wiki/concepts/tariff.md.
*/
export interface TariffStructure {
/** Free if exited within this (drop-off/turnaround). */
readonly gracePeriodEntryMin: number;
/** Billing granularity; partial increments round UP. */
readonly incrementMin: number;
/** Consumed in order as duration accrues; last block may be open-ended. */
readonly blocks: readonly TariffBlock[];
/** Cap per rolling 24h (null = no cap). */
readonly dailyCapMinor: number | null;
/** Flat charge when there's no entry id (admin may override at the moment). */
readonly lostTicketMinor: number;
/** Pay-on-foot walk-back window: minutes after payment to reach the car. */
readonly gracePeriodExitMin: number;
/** How an overstay top-up is charged. "reprice" = recompute(entry→now) − paid. */
readonly overstay: "reprice";
}
export interface TariffBlock {
/** Upper bound of this block in minutes; null = open-ended (thereafter). */
readonly uptoMin: number | null;
readonly priceMinorPerIncrement: number;
}
/**
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
* result is fixed into a signed `payment` event, so it must be reproducible.
*
* Algorithm (wiki/concepts/tariff.md): round duration UP to incrementMin; free if
* within entry grace; else walk the stay one rolling-24h segment at a time, charging
* each increment at its block's rate (blocks consumed in order by cumulative minutes),
* capping each segment at dailyCapMinor. Times are ISO-8601; bad input → 0 (caller
* validates the tariff exists first).
*/
export function computeFee(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
): number {
const ms = Date.parse(asOf) - Date.parse(enteredAt);
if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000;
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
// 60 min — otherwise rounding-up would defeat the grace window).
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
const DAY = 24 * 60;
let total = 0;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
for (let within = 0; segStart + within < segEnd; within += inc) {
segFee += rateAt(tariff.blocks, within);
}
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
total += segFee;
}
return total;
}
/**
* Validate an admin-authored tariff structure. Returns [] if valid, else a list
* of human-readable problems. Pure — used by the composer route (and any caller)
* so a malformed rate card can never be published. See wiki/concepts/tariff.md.
*/
export function validateTariffStructure(s: unknown): string[] {
const errs: string[] = [];
if (!s || typeof s !== "object") return ["structure must be an object"];
const t = s as Partial<TariffStructure>;
const nonNegInt = (v: unknown, label: string) => {
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
};
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
errs.push("incrementMin must be a positive integer");
}
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
if (!Array.isArray(t.blocks) || t.blocks.length === 0) {
errs.push("blocks must be a non-empty array");
} else {
let prevBound = 0;
t.blocks.forEach((b, i) => {
const last = i === t.blocks!.length - 1;
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
if (b?.uptoMin == null) {
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`);
} else {
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
} else {
prevBound = b.uptoMin;
}
}
});
}
return errs;
}
/** Price of the increment that starts at `cumulativeMin` — the block whose range
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
let prev = 0;
for (const b of blocks) {
if (b.uptoMin == null || cumulativeMin < b.uptoMin) return b.priceMinorPerIncrement;
prev = b.uptoMin;
void prev;
}
// No open-ended block and past the last bound: charge the last block's rate.
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
}
export const ROLES: readonly Role[] = [ export const ROLES: readonly Role[] = [
"admin", "admin",
"operator", "operator",
+57
View File
@@ -0,0 +1,57 @@
---
type: concept
tags: [parking, domain, business, anti-fraud, access-control]
sources: []
updated: 2026-06-15
status: open
---
# Anti-Passback
Stop one credential/ticket from getting **two cars in** without an exit between — the classic
"pass the card/ticket back over the fence" abuse. A control on the entry validation, leaning on the
session projection.
## The rule
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an
OPEN [[parking-session|session]].** At entry:
```
identify vehicle → is there already an OPEN session for this id?
no → proceed (mint vehicle_entry, open)
yes → passback violation → refuse or flag (see policy)
```
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`.
## Interaction with the limits already designed
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
id is always a violation (or a re-print/duplication attempt).
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a
multi-car permit legitimately has several open sessions, but **the same car/credential** entering
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
## Policy (operator choice)
- **Hard** — refuse the second entry (strict; risks stranding a legitimate car after a *missed
exit*, which is common — tailgated out, sensor missed).
- **Soft** — allow but **flag an `anomaly`** (the type exists) for review. Safer against
false-positives from missed exits, consistent with the append-only "record + flag, don't block"
ethos elsewhere.
- Likely **soft by default**, hard as an opt-in for high-control sites.
## Honest limits
- Depends on **reliable exit detection** — if exits are routinely missed (no exit loop/plate read),
passback produces false positives; tune to the site's exit fidelity.
- A spoofed/duplicated ticket QR is caught here (same id already open) — complements
[[ticket-encoding]]'s opaque-id requirement.
## Open
- Default policy (soft/hard) and per-site override.
- Grace for legitimate quick re-entry vs. the missed-exit false-positive.
+44 -20
View File
@@ -21,9 +21,25 @@ Three layered properties:
self-consistent — someone who owns the machine still cannot forge a valid entry. self-consistent — someone who owns the machine still cannot forge a valid entry.
It only becomes trustworthy as an external fraud control when paired with [[reconciliation]] It only becomes trustworthy as an external fraud control when paired with [[reconciliation]]
against an authority the operator can't alter. Every device event — including those ingested against an authority the operator can't alter.
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
chain. ## Two event streams — the signed ledger vs. device telemetry (decision 2026-06-15)
These are **different concerns and live in different tables**:
- **`ledger_events`** — this signed, hash-chained, [[atecc608]]-signed **business ledger**:
`vehicle_entry` / `vehicle_exit` / `payment` / `void` / `shift_z_report`, plus the witness-grade
`barrier_open_command` / `barrier_open_observed` and `anomaly`. This is the anti-fraud record that
[[reconciliation]] runs against; sessions/[[tariff]]/occupancy are projections over it. (This is
the table formerly called `events`.)
- **`device_events`** — **unsigned operational telemetry**: relay fired, printer paper-out, camera
offline, reader read, raw input edges. High-volume, churny, **not** anti-fraud; may rotate/prune.
Keeping it out of the signed chain keeps the ledger small and high-value.
> A raw button press is **device telemetry**, not a business fact. It lands in `device_events`; the
> entry flow then mints a **signed `vehicle_entry`** in the ledger once a ticket prints and the
> barrier is commanded. (This supersedes the earlier "every device event lands in the chain" framing
> and the `input_received`-as-signed-event approach — see [[device-input-flow]].)
## Implementation (apps/server) ## Implementation (apps/server)
@@ -58,22 +74,28 @@ so old events stay verifiable.
> *accidental* corruption, but an operator with the signing key + DB access could re-sign a > *accidental* corruption, but an operator with the signing key + DB access could re-sign a
> forged chain. This is the central reason #6 matters. > forged chain. This is the central reason #6 matters.
### What currently feeds the log ### Business-layer event types (the ledger)
Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]], The [[parking-session]] domain folds over these **signed ledger** events:
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
the richer entry event waits for the entry flow (ticket print + barrier command).
- **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`) - `vehicle_entry` / `vehicle_exit` — a stay's endpoints; `identity` carries the ticket id or plate.
caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every - `payment` — a settled fee at the pay station, referencing the session it pays for (amount in
assign/unassign. Device events carry the device instance id, not a lane; the handler looks it integer minor units; see [[tariff]]). Making "paid" a signed event — not a mutable row — is the
up. A device with no mapping (assigned without a lane, or a stale id) logs **`lane: -1`** and a whole point: an operator can't forge it or silently delete it.
warning — never `0`, which is a real lane — and is still recorded (the chain is append-only; - `void` — a correction / lost-ticket write-off; like every other void here it is an **appended
nothing is dropped). event, never an erasure**.
- **`source` stays `null`** for `input_received`, and deliberately so: `source` is an - `shift_z_report` — the signed per-[[shift]] takings summary.
`IdentitySource` (`wiegand | lpr | qr | ticket | manual`) — *how a vehicle was identified* — not
a device/IP field. A raw button push has no vehicle identity. The device provenance lives in A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
**`identity`** (e.g. `dingtian:<id> input:1/on`). the chain exists. See [[parking-session]].
### As-built (table split done)
The split above is implemented: raw Dingtian **input (button) pushes** are **device telemetry** in
**`device_events`** (unsigned, prunable), keyed to the firing `devices` instance. Only the business
`vehicle_entry` the press drives is signed into **`ledger_events`**. The signed events carry **no
`lane`** — the pool-of-spaces model has none (dropped 2026-06-16; see [[entry-exit-points]]), and
the canonical form bumped `sw-hmac-v1` → `sw-hmac-v2` accordingly.
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only ### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
@@ -91,7 +113,9 @@ host. **Proven on hardware**: a binary relay command sent directly to the device
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** — So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input **independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching → which DOES push + log; the [[opencv-anpr-service|vision service]]'s plate **and vehicle** read;
signed command is the fraud signal.** Both the witness sources and the reconciliation logic are payment/Z-report). **A physical open with no matching signed command is the fraud signal** — and,
**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't with vehicle verification, **a plate that enters/exits on a different car** is too (the
plate-spoofing case). Both the witness sources and the reconciliation logic are **NOT yet built** —
this is the main open gap. Prevention (VLAN isolation so the attacker can't
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop. reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
+67
View File
@@ -0,0 +1,67 @@
---
type: concept
tags: [parking, domain, business, occupancy]
sources: []
updated: 2026-06-15
status: open
---
# Capacity & Occupancy
How many vehicles are inside, how many spaces remain, and what happens when the lot is full.
## Occupancy is a projection (like everything else)
`occupancy = count(open [[parking-session|sessions]])` — an entry with no matching exit. It is a
**fold over the signed [[append-only-event-chain]]**, never a hand-maintained counter (a counter is
editable and drifts; the chain is the truth). Spaces-free = `capacity − occupancy`.
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one
permit's open sessions.
## Full → refuse entry + FULL sign
- When `occupancy ≥ capacity`, the entry flow **refuses** (no `vehicle_entry`, no barrier open) and
can drive a **"FULL" sign** (a relay/output, via the device adapter layer).
- **Safety/policy nuance:** "full" blocks *entry* only — **exit always works** ([[fail-state-safety]]:
exit fails open; never trap a vehicle). Permit holders may be allowed in past a "transient full"
threshold (reserve spaces for subscribers) — an optional policy knob.
- **Counting drift is real:** tailgating (two cars, one entry) and missed reads make the live count
diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
not silently corrected.
## "Full" is a soft, operator-configurable policy
Refusing at capacity is the **default**, not an absolute. An operator may opt into
**[[valet-overcapacity|valet over-capacity]]** — accept the car into operator custody (keys handed
over, stacked beyond the marked count) instead of refusing. So the FULL gate is a policy knob
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
own custody/session shape — see [[valet-overcapacity]] (deferred).
## As-built (2026-06-16)
- **Occupancy** = `occupancyCount` (`apps/server/src/occupancy.ts`): a fold over the ledger —
entries minus exits per identity, count those `> 0`. `getOccupancy` returns `{count, capacity,
free, full}`.
- **Capacity** is a single-row `site_config` table (admin-set; `null` = uncapped). Routes
(`routes/site.ts`): `GET /api/occupancy` + `GET /api/site-config` (any role), `PUT /api/site-config`
(admin; non-negative int or null).
- **FULL gate** is in the **transient entry flow**: `occupancy.full` → refuse (no ticket, no
`vehicle_entry`, no open) + signed `anomaly`. **Permit entry is NOT gated** here — subscribers are
admitted past transient-full (their own `maxConcurrent` still applies); occupancy can read
over-capacity (`free` negative) when permits enter a full lot, as intended.
- **UI** `SiteSettings`: live occupancy + FULL badge (everyone); capacity editor (admin).
- Verified: fill to cap → 3rd transient refused; permit still admitted past full; exit frees a
slot; RBAC (operator can't set capacity); verifyChain ok. Physical FULL-sign relay output is
**deferred** (needs a sign device).
## Open
- Zone/level granularity at launch vs. single capacity number.
- Reserve-for-permits **threshold** (a soft transient cap below the hard capacity) — currently
permits are simply ungated; a tunable threshold is the richer version.
- Physical FULL-sign relay output (a sign-device role).
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).
+48
View File
@@ -0,0 +1,48 @@
---
type: concept
tags: [parking, security, integrity, offline-first, anti-fraud]
sources: []
updated: 2026-06-15
status: open
---
# Clock Integrity
Fees are a function of **time** ([[tariff]]: `fee = f(enteredAt, asOf)`), and the event chain is
ordered/timestamped. So **the host clock is part of the trust model** — and on an offline appliance
([[offline-first]], no NTP guarantee) it's a real attack surface, fitting the
[[threat-model|operator-as-adversary]] frame:
- **Backdating to cut a fee** — wind the clock back so a long stay computes as short, or so an exit
timestamps before its entry.
- **Forward/backward jumps** that corrupt durations, the rolling-24h cap, or shift boundaries
([[shift]]).
- An operator with host access changing the system time deliberately.
## What protects it
- **Monotonic chain order is independent of wall-clock.** The [[append-only-event-chain]] `index`
is strictly increasing regardless of timestamps, so **reordering** is caught even if timestamps
are forged. But the *durations* used for pricing still rely on the wall clock — so:
- **Detect clock anomalies and record them as events.** A timestamp that goes **backwards** between
consecutive chain events, or jumps implausibly, is an `anomaly` (the type already exists) — signed
and surfaced to [[reconciliation]], not silently accepted.
- **Hardware-backed time where possible.** A battery-backed RTC on the appliance; the
[[atecc608]]/secure element and [[disk-os-hardening]] reduce casual tampering. An operator
changing time should require privilege the booth login doesn't have.
- **Opportunistic trusted sync** when a [[reconciliation]] channel is briefly online (the same
USB/hotspot path) — set/check the clock against an external authority, log any correction as an
event.
## Stance
Like the rest of the system: **prevention (hardened host, privileged-only time change) first,
detection (anomaly on clock regression, reconciliation) as the backstop.** The clock can't be made
unforgeable on an offline box, but a forged clock can be made **visible**.
## Open
- RTC / time source on the chosen appliance ([[bom]]).
- Tolerance thresholds for "implausible" jumps before flagging.
- Whether to hard-refuse an event on a backwards clock vs. record-and-flag (record-and-flag matches
the append-only ethos — never drop).
+1 -1
View File
@@ -33,6 +33,6 @@ principle. The choice of *which* adapter to trust is the [[trust-boundary]] deci
> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the > **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the
> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras), > supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras),
> and the admin assigns one per lane during [[first-run-setup]]. Adding hardware support = one > and the admin assigns instances during [[first-run-setup]]. Adding hardware support = one
> more registered driver, no business-logic change. (The implemented interfaces add a > more registered driver, no business-logic change. (The implemented interfaces add a
> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.) > `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.)
+1 -1
View File
@@ -60,7 +60,7 @@ replies), so the driver **serializes** all controller I/O. Override the broadcas
2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only). 2. Admin clicks **Scan** → `GET /api/setup/discover/:driverId` (admin-only).
3. The server runs `discover()` and **health-checks each found device** so the admin sees 3. The server runs `discover()` and **health-checks each found device** so the admin sees
reachability before assigning. reachability before assigning.
4. Selecting a result **auto-fills serial + host**; the admin then assigns it to a lane. 4. Selecting a result **auto-fills serial + host**; the admin then assigns + binds it.
## Deployment notes ## Deployment notes
+45
View File
@@ -0,0 +1,45 @@
---
type: concept
tags: [parking, devices, monitoring, telemetry]
sources: []
updated: 2026-06-15
status: open
---
# Device Events (telemetry)
The **unsigned** operational record of what the hardware did and reported — distinct from the
signed business [[append-only-event-chain|ledger]] (see [[event-streams-split]]). For monitoring,
diagnostics, and live booth status — **not** anti-fraud.
## What lands here
- **Relays/barriers:** relay fired/released, pulseOpen issued (the *device-side* echo; the
authoritative `barrier_open_command` is a signed ledger event).
- **Printers:** paper-out / near-end / cover-open / cutter / offline (already polled —
[[printer-status-monitoring]]).
- **Cameras:** reachable/offline, snapshot success/failure ([[lpr-camera]]).
- **Readers / inputs:** a raw read, raw input edges (Dingtian button `input N on/off` —
[[device-input-flow]]).
## Properties
- **Unsigned, not chained** — no `prevHash`/`signature`. It's telemetry, so it carries none of the
ledger's integrity machinery.
- **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger
never does).
- **Device-keyed** — references the `devices` instance (raw device provenance). No `lane`
(pool-of-spaces model — see [[entry-exit-points]]).
## The boundary that matters
A device event is *evidence the host saw something happen*; it does **not** by itself authorize or
record a business fact. A button press here becomes a **signed `vehicle_entry`** in the ledger only
after the entry flow runs (ticket + barrier command). This keeps device chatter on the device side
of the [[device-adapter-pattern|adapter boundary]] and the signed ledger focused on money/access.
## Open
- Retention/rotation policy (size- or age-based).
- Whether any witness-grade device fact (e.g. a loop-sensor `barrier_open_observed`) should *also*
write a signed ledger entry for [[reconciliation]] — see [[append-only-event-chain]].
+3 -3
View File
@@ -39,7 +39,7 @@ neither is the real boundary:
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a - **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the `relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
device + stored in `lane_devices` by the harden step (below). device + stored in `devices` by the harden step (below).
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**. - **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a - **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
@@ -55,7 +55,7 @@ fix preconditions (disable `input_link_relay`) → **harden** → set up input p
capability ([[device-registry|HardenableDevice]]): capability ([[device-registry|HardenableDevice]]):
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in - **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
`lane_devices` so the backend can keep commanding the relay. `devices` so the backend can keep commanding the relay.
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1 - **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
binary (relay control) + UDP2 string (status read) — fewer open doors. binary (relay control) + UDP2 string (status read) — fewer open doors.
@@ -81,7 +81,7 @@ the clear. We **empirically tested the device** to pick the strongest achievable
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces → **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
generated by the backend on **device assign**, written to the device's `input_link_url` config, generated by the backend on **device assign**, written to the device's `input_link_url` config,
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but and stored in `devices` — the admin never types a URL or secret. HTTPS would be stronger but
the device can't do it here; Digest + the signed log is the practical answer on a flat network. the device can't do it here; Digest + the signed log is the practical answer on a flat network.
See `apps/server/src/digest-auth.ts`. See `apps/server/src/digest-auth.ts`.
+5 -4
View File
@@ -10,7 +10,7 @@ updated: 2026-06-15
How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to
"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported "**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported
**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an **drivers**, grouped by category; the [[first-run-setup]] UI reads it so an
operator can choose a device per lane and fill in its connection config. operator can choose a device and fill in its connection config.
> Implementation-derived (from `packages/devices`), not the source doc. > Implementation-derived (from `packages/devices`), not the source doc.
@@ -33,10 +33,11 @@ driver; **no business-logic change** — this is the [[device-adapter-pattern]]
## Why a registry (not hard-coded wiring) ## Why a registry (not hard-coded wiring)
- The admin chooses between **multiple devices per category** at install time, per lane - The admin chooses between **multiple devices per category** at install time
(mirrors the "mixable per lane" principle — see [[trust-boundary]], [[entry-exit-readers]]). (a controller's relays mix entry/exit; readers bind to them — see [[entry-exit-points]],
[[trust-boundary]], [[entry-exit-readers]]).
- Config is **validated against the driver's declared fields** before persisting. - Config is **validated against the driver's declared fields** before persisting.
- Selections persist in the `lane_devices` table and drive runtime adapter construction. - Selections persist in the `devices` table and drive runtime adapter construction.
- Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan - Drivers may optionally implement **[[device-discovery]]** (`discover()`), so the admin can scan
the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did, the LAN instead of typing connection details — no current driver uses it (the UHPPOTE did,
before removal; the [[dingtian-relay]] uses a fixed IP). before removal; the [[dingtian-relay]] uses a fixed IP).
+110
View File
@@ -0,0 +1,110 @@
---
type: concept
tags: [parking, architecture, devices, setup]
sources: []
updated: 2026-06-16
---
# Entry / Exit Points (pool-of-spaces model)
A parking lot is **one pool of spaces** with a flexible set of **entry points** and **exit
points** — any number of each, in any combination (1 in + 1 out, 1 in + 2 out, 2 in + 1 out, …).
There is **no "lane"** concept anywhere in the system (dropped 2026-06-16 — see below).
## Direction lives on the relay, not the controller
An access controller (e.g. a [[dingtian-relay]] board) has **several relays** — each relay opens
one barrier. Direction is a property of **each relay**, declared in the controller's config:
```jsonc
// access `devices` row — one Dingtian board
config: {
host: "192.168.1.100",
relays: [
{ relay: 1, direction: "entry", button: 1 }, // entry barrier; entry button on input 1
{ relay: 2, direction: "exit" } // exit barrier; opened by a reader, no button
]
}
```
- `direction`: `entry` | `exit` | `both` (`both` = one barrier/relay serving in and out).
- `button`: the **input terminal** the transient **entry button** is wired to. Only entry/both
relays have one. Absent = no button at that barrier (subscriber/reader-driven only).
The four real layouts all fall out of this:
| Layout | Controllers | Relays |
| --- | --- | --- |
| 1 barrier, both directions | 1 | `{relay:1, both, button:1}` |
| 2 barriers, 1 board | 1 | `{relay:1, entry, button:1}`, `{relay:2, exit}` |
| 2 barriers far apart | 2 | board A `{relay:1, entry}`, board B `{relay:1, exit}` |
| 1 entry + 2 exit | 3 | A entry; B, C each exit |
## Readers / cameras BIND to a relay
A reader or camera points at the barrier it physically sits at, via its config:
```jsonc
config: { ...readerConfig, controllerId: "<access devices.id>", relay: 2 }
```
Its **direction is inherited** from that relay. So an exit read opens **exactly that relay** —
no ambiguity even with multiple exit barriers ("the relay at that reader", decided 2026-06-16).
Binding is optional: an unbound device falls back to a `config.direction` + the first relay
site-wide of that direction (keeps the single-barrier case trivial). LPR is a snapshot sink —
an ANPR service ([[opencv-anpr-service]]) POSTs the plate as a `plate` read to the reader
endpoint, flowing through the same dispatcher.
## Resolution (one module: `apps/server/src/device-resolve.ts`)
- **Button press** → `relayForButton(controllerId, terminal)` → the entry relay whose `button`
matches → entry flow → `pulseOpen(relay)`.
- **Reader/permit/LPR read** → `relayForDevice(reader)` → the bound relay → `pulseOpen(relay)`;
direction inherited.
- **Snapshots** → `devicesByDirection("camera", dir)` → every camera serving that direction.
A directional barrier that contradicts the car's open-session state (an exit barrier scanned by a
car not inside, or an entry barrier by a car already in) is a wrong-barrier / [[anti-passback]]
refusal. A `both` relay defers to session state.
## The flows
| Flow | Trigger | Opens |
| --- | --- | --- |
| Transient entry | entry **button** press | the entry relay (button-mapped) → ticket prints |
| Transient exit | voucher scan at exit reader | the exit relay (reader-bound), if paid+grace |
| Subscriber entry | QR/RFID/plate at entry reader | the entry relay (reader-bound), if permit valid |
| Subscriber exit | QR/RFID/plate at exit reader | the exit relay (reader-bound), if permit valid |
Every open also fires a [[camera snapshot|append-only-event-chain]] (async, never blocks the open).
## Why no lane
"Lane" was a leftover from a rows-of-gates mental model. It added nothing here:
- **Occupancy** is a site-wide fold over the ledger (entries − exits); it never grouped by lane.
- **Device grouping** is now done by the reader→relay binding, far more precisely than a lane key.
- **Anti-fraud** doesn't use it — the signed chain, the "open must match a signed event" check,
and [[reconciliation]] all work on *what happened*, not *which gate*. The relay's direction
already catches an exit firing an entry barrier, better than a lane number would.
Dropping it removed `lane` from `ledger_events`, `device_events`, `sessions`, and the device
table (renamed `lane_devices` → `devices`). Because `lane` was part of the **signed canonical
form**, this is a versioned change: the canonical array no longer includes lane, and the signer
keyId bumped `sw-hmac-v1` → `sw-hmac-v2`. v1 events won't verify under v2 — intentional, gated by
each event's stored `keyId` (done pre-deployment, on throwaway data, so zero real cost). See
[[append-only-event-chain]].
## Camera snapshots (evidence, not a gate)
Captured **after** the barrier opens, **never awaited** — a camera failure can't delay or block an
open (the signed ledger is the decision). Stored as a **BLOB in the `snapshots` table** (single
backed-up DB, nothing scattered on disk), in its own table so hot telemetry scans don't drag image
bytes and images prune independently. Linked to the signed `vehicle_entry/exit` by `identity`.
Served read-only via `GET /api/snapshots/:id`. **Retention is unresolved** — see [[open-questions]].
## Related
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
[[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] ·
[[dingtian-relay]] · [[first-run-setup]]
+10
View File
@@ -22,6 +22,12 @@ There are **two populations** of users, and they map to **two integration paths*
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down | | [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path | | Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No | | [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
| **[[gee-qr-er80]] QR reader (serial)** | Host only | Host (reads serial → `read` bus) | No |
> Concrete host-side reader on hand: the **[[gee-qr-er80]]** (QR over RS-232/RS-485). Note autonomy
> is moot here anyway — the current relay ([[dingtian-relay]]) has **no onboard card list**, so even
> a Wiegand reader would be host-decided. So we take the serial/QR path straight to the host's
> `read` bus.
## Key points ## Key points
@@ -32,6 +38,10 @@ There are **two populations** of users, and they map to **two integration paths*
keeps autonomy + native event log. keeps autonomy + native event log.
- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled" - **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled"
mode), so one lane serves permit + casual. mode), so one lane serves permit + casual.
- **Each reader BINDS to a controller relay** (`config.controllerId` + `relay`) — the barrier it
sits at — and inherits that relay's direction (entry/exit/both). An exit read opens exactly that
relay; an entry read the entry relay. This is how separate in/out readers are disambiguated, with
no "lane". See [[entry-exit-points]].
- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed - **Host-in-the-loop is good for fraud detection** — two independent records (host's signed
[[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any [[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any
mismatch is an anomaly. mismatch is an anomaly.
+18 -16
View File
@@ -8,8 +8,10 @@ updated: 2026-06-15
# First-Run Setup (device selection) # First-Run Setup (device selection)
The admin install flow that makes the system **device-agnostic in practice**: on first run, an The admin install flow that makes the system **device-agnostic in practice**: on first run, an
admin assigns devices **per lane** by choosing from the [[device-registry]] catalog and entering admin adds **controllers** (each declaring its relays — entry/exit/both — and the entry-button
each device's connection config. terminal) and then **readers/cameras/printers** bound to a controller relay, choosing from the
[[device-registry]] catalog and entering each device's connection config. There is **no lane** —
the pool-of-spaces model; see [[entry-exit-points]].
> Implementation-derived (from `apps/server` + `apps/web`), not the source doc. > Implementation-derived (from `apps/server` + `apps/web`), not the source doc.
@@ -26,7 +28,7 @@ each device's connection config.
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest- device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
orphan/half-configured rows. On success persists to `lane_devices`. orphan/half-configured rows. On success persists to `devices`.
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our 4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
row is removed; the device itself is not un-hardened/un-configured (a stale push from an row is removed; the device itself is not un-hardened/un-configured (a stale push from an
unknown device id is already rejected, and re-assigning reconfigures it). unknown device id is already rejected, and re-assigning reconfigures it).
@@ -34,25 +36,25 @@ each device's connection config.
## Config granularity — multi-instance per category ## Config granularity — multi-instance per category
The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a The data model is **multi-instance**: `devices` holds **one row per instance**, keyed by a
generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of generated `id`. So the site can have **more than one of every category** — multiple controllers,
every category** — e.g. two printers (an entry dispenser + a booth printer; see readers, cameras, and printers (e.g. an entry dispenser + a booth printer; see
[[printer-roles-failover]]), multiple readers, multiple cameras. `assign` always inserts a new row [[printer-roles-failover]]). `assign` always inserts a new row (never an upsert), and `state`
(never an upsert), and `state` returns the full list. returns the full list.
The `SetupWizard` reflects this: each category shows the **list of assigned instances** for the The `SetupWizard` reflects this: each category shows the **list of assigned instances** (with
current lane (with **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type config fields
config fields (e.g. a printer's role) render as dropdowns. (e.g. a printer's role) render as dropdowns.
Organized **per lane** — each lane gets its access controller(s), reader(s), camera(s), and There is **no lane**. Direction lives on each access **relay**; readers/cameras **bind** to a
printer(s), each with its own connection settings. Matches the architecture's "mixable per lane" controller relay (`config.controllerId` + `relay`) — the barrier they serve — and inherit its
reality (a lane can serve permit holders via [[wiegand]] and casual via host-side reads on one direction. The wizard adds controllers first, then binds the other devices to a relay. See
relay — see [[entry-exit-readers]]). [[entry-exit-points]], [[entry-exit-readers]].
## Security notes ## Security notes
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]). - The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
- Device **credentials are stored in `lane_devices.config`** — protect at rest - Device **credentials are stored in `devices.config`** — protect at rest
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]). ([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`, - **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never `webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
+129
View File
@@ -0,0 +1,129 @@
---
type: concept
tags: [parking, domain, business, anti-fraud]
sources: []
updated: 2026-06-15
status: open
---
# Parking Session
The core business-domain entity: one vehicle's stay, from entry to exit, plus the money owed and
paid for it. Everything on the business side — [[tariff|tariffs]], payment, [[reconciliation]],
revenue reporting — hangs off the session. This page defines what a session **is** and, just as
importantly, what it is **not**.
> Scope decision (2026-06-15): build the **transient** (casual, pay-for-duration) session first;
> layer **permit holders** on top as a second identity source that short-circuits payment. Mixed
> site, transient-first — see [[entry-exit-readers]] ("two populations, one shared relay") and
> [[session-model]].
## A session is a PROJECTION over the signed event log — not a mutable table
This is the single most important rule, and it falls straight out of the [[threat-model]] (the
adversary is the insider who can edit the database) and the [[append-only-event-chain]]:
- The **events** table is the ledger and the **only** source of truth. `vehicle_entry`,
`vehicle_exit`, `payment`, `void` are all **appended + signed**, never updated or deleted.
- A **session** is a **read-model folded from those events** — open when an entry has no matching
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
from the chain and never authoritative** ([[append-only-event-chain]]), scaled to the business
domain.
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
can't forge or silently delete — a deletion breaks the chain visibly. See [[session-model]] for
the rejected mutable-table alternative.
## Identity — how an entry is tied to its exit
A session needs a key that survives from entry to exit. Two populations, two keys
([[entry-exit-readers]]):
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid
permit means the session owes nothing — the PAY step is skipped (see below).
## Lifecycle (pay-on-foot / pay station model)
Payment is **decoupled from exit** (decision 2026-06-15, matching the [[autonomous-direction|
unmanned]] roadmap): the customer pays at a central station before walking back to the car; the
exit lane only *validates* that the session is settled.
```
ENTRY (lane) vehicle_entry event → session OPEN
(ticket printed / plate read; barrier opens)
PAY (pay station) payment event {sessionRef, fee, paidAt}
→ session PAID (grace window starts)
EXIT (lane) validate: PAID && now ≤ paidAt + graceMinutes ?
yes → vehicle_exit event → session CLOSED → pulseOpen
no → reject → re-pay overstay top-up at station, then exit
```
States, as derived from events:
| State | Condition (over the event chain) |
| --- | --- |
| **OPEN** | a `vehicle_entry` with no later matching `vehicle_exit` |
| **PAID** | OPEN + a `payment` event covering the fee due, within its grace window |
| **CLOSED** | a matching `vehicle_exit` event exists |
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close.
## Edge cases the model must name (not yet designed in full)
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
stateful rule; handled as a second `payment` event, fee = f(time since paid).
- **Lost ticket** — no entry id to match. A default flat "lost ticket" fee (see [[tariff]]), **or
an amount the admin sets at the moment** (operator judgement — e.g. they can establish entry time
from [[opencv-anpr-service|plate]] capture or CCTV and charge accordingly, or apply a fixed
penalty). Recorded as a `payment` (with the chosen amount + a reason) + a `void`/annotation so it
reconciles; the admin-set amount is captured in the signed event, attributed.
- **Manual override** — an operator/admin opens the barrier for a stuck or disputed car, or writes
off a session, as a deliberate act. Each is a **signed, reason-coded event**
(`barrier_open_command` / a void with reason) — so an override is *authorized and logged*, while
an open with **no** such signed event remains the fraud signal ([[append-only-event-chain]]). The
override is the legitimate counterpart to the out-of-band-open anomaly.
- **Forced / fail-open exit** — barrier failed open ([[fail-state-safety]]): the vehicle leaves with
**no `vehicle_exit`**. This is an open session that never closes — a **reconciliation anomaly by
design** ([[append-only-event-chain]]'s "physical open with no signed command"), not something to
paper over. (A *manual* override above is the signed, non-anomalous version.)
- **Re-entry / never-exited** — stale open sessions (drove out tailgating, sensor missed). Surface
as anomalies; never auto-close silently.
## What this unblocks (build order)
The device layer left the entry flow dangling — the session domain is that next step. Schema + code
follow this page and [[tariff]]; the decision is recorded in [[session-model]].
### As-built (2026-06-15)
- **Entry flow** (`apps/server/src/entry-flow.ts`): access-device input edge → print ticket
(failover) → signed `vehicle_entry` → `pulseOpen`. Holds (anomaly, no open, no entry) if printing
fails. See [[device-input-flow]].
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built.
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
stays closed. Validation folds the **ledger** (authoritative), then updates the `sessions` cache.
- **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to
the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
to decide (host/power loss), not an unpaid car.
- **Pay station** (`apps/server/src/pay-station.ts`, 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`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
session closed, `verifyChain` ok.
> **Resolved (2026-06-16):** the earlier "no entry/exit direction" gap is closed by the
> [[entry-exit-points]] model. Direction lives on each access **relay**; readers/cameras bind to a
> relay and inherit it. The "lane" concept was dropped entirely (pool-of-spaces) — separate in/out
> readers are distinguished by their relay binding, not a lane.
+1 -1
View File
@@ -13,7 +13,7 @@ still print when the outside dispenser jams or drops off the network.
## Roles ## Roles
Each printer instance (a `lane_devices` row, category `printer`) declares a **role** in its Each printer instance (a `devices` row, category `printer`) declares a **role** in its
config: config:
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes. - **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
+1 -1
View File
@@ -45,7 +45,7 @@ interface.
`PrinterMonitor` (`apps/server/src/printer-monitor.ts`): `PrinterMonitor` (`apps/server/src/printer-monitor.ts`):
- reloads the monitored set from `lane_devices` each tick (so a newly-assigned printer is picked - reloads the monitored set from `devices` each tick (so a newly-assigned printer is picked
up without a restart), keeping only enabled, monitorable printers; up without a restart), keeping only enabled, monitorable printers;
- polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks; - polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks;
- caches the latest status per device id; - caches the latest status per device id;
+48
View File
@@ -0,0 +1,48 @@
---
type: concept
tags: [parking, domain, business, reporting]
sources: []
updated: 2026-06-15
status: open
---
# Reporting & Analytics
Turning the signed event log into the numbers an owner runs the business on. All reports are
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
derived and rebuildable, never a separate ledger.
## Reports (driven by the events already designed)
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split.
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
the `anomaly` events + reconciliation findings ([[reconciliation]]).
## Plate / entry search (admin lookup) — user-requested 2026-06-15
The admin can **search for an entry/session by licence plate** — *if the plate was captured* (by
the [[opencv-anpr-service|vision service]] or an LPR read; a pure-ticket transient has no plate).
Returns the matching session(s): entry/exit times, fee, payment, snapshot image. Useful for
disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and incident review.
- Search keys: plate (when captured), ticket id, session id, time range.
- Read-only over the chain; surfaces the linked snapshot ([[lpr-camera]] `imageRef`) as evidence.
- Honest limit: **no plate → no plate-search hit.** The UI must say "not captured", not "no such
car", so the absence isn't mistaken for a missing record.
## Properties
- **Offline** ([[offline-first]]): all computed locally from the local DB; no cloud BI dependency.
- **Reproducible**: a report run twice over the same chain gives the same answer; figures trace to
signed events.
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
([[open-questions]] #4).
## Open
- Which reports matter at launch vs. later; the export format/cadence.
- Dashboard (live) vs. on-demand reports.
+95
View File
@@ -0,0 +1,95 @@
---
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-06-15
status: open
---
# Shift (manned mode) & the Z-Report
A **shift** is one operator's accountability period at a manned booth: from the moment they take
over to the moment they hand over, however long that is. At the end, the system signs and **prints
a Z-report** — the cash and POS totals taken during the shift. (Decisions 2026-06-15.)
## Shifts exist ONLY in manned mode
A shift is fundamentally a **human accountability boundary** — "this person was responsible for the
takings from here to here." In the [[autonomous-direction|fully-automated / unmanned]] system there
is **no operator and no shift**; what replaces it is the pay station's **cash-collection cycle**
(who emptied the vault, when, how much vs. what the signed log expected) plus ongoing
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
don't force one model across both.
## A shift is NOT time-based
It is delimited by **explicit operator action**, never by a clock:
- Booth reality: relief comes late, doesn't show, or one operator is **forced to work two shifts in
a row**. A fixed 8h boundary (or an 8h token expiry) would be wrong — it could strand an active
operator. So the [[local-jwt-auth|login token has no time expiry]] (valid until logout).
- **Start Shift / End Shift are explicit, and independent of login.** One login can span many
shifts; a back-to-back double is simply *End Shift → Start Shift again*, no re-login. The
operator (the same person or the next) marks the boundary.
```
login ——————————————————————————————————————————————→ (until logout)
[Start shift] … takings … [End shift→sign+print Z] [Start shift] … [End shift] …
```
## What End Shift does
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
[[append-only-event-chain]]) between this shift's start mark and now.
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
(the card line is omitted when there's no terminal).
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
startedAt, endedAt, cashTotal, cardTotal?, paymentCount, eventRange, prevZHash }` — chained to
the prior Z so a missing/out-of-order Z-report is itself visible.
4. **Print the Z-report** (cash total, POS total if any, counts, shift window, operator) on the
booth printer.
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
no variance gate, no manager override.
### As-built (2026-06-16)
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
event `identity`; a shift is **open** iff that operator's most recent shift event is a
`shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`).
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
printer. Printing is best-effort — a failed print does **not** undo the signed close (the event is
the record; `printed:false` is returned).
- **Routes** (`routes/shift.ts`, cashier/operator/admin): `GET /api/shift/current`,
`POST /api/shift/open` (409 if already open), `POST /api/shift/close` (409 if none open).
**UI** `ShiftControl` in the app shell (non-readonly): Start/End + the Z-report totals.
- Verified: open → double-open 409 → payments (cash+card, one dated outside the window excluded) →
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
verifyChain ok.
## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
append-only chain**, the printed cash figure *is* the system's tamper-evident truth. A manager
reconciles the signed Z-report against the actual drawer and the bank/POS batch **later** — that's
[[reconciliation]], the real control (deferred). The tradeoff vs. a heavier control is purely
*when* a skim is caught (after the fact, by a human), not *whether*.
> **Optional enhancement (not building now): blind cash count.** Have the operator enter the
> counted cash *before* the system reveals the expected figure, and record the variance into the
> `shift_z_report`. Blindness removes the operator's ability to back-fill their declaration to match
> expectation, catching a skim **at close** rather than later. Explicitly out of scope per
> 2026-06-15; documented as a clean add-on if ever wanted.
## Open
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
the money. Confirm that's the intended accountability (vs. by entry).
- **Mid-shift report / X-report** (read-only "so far" total without closing) — add if booths want
it; the sum is the same projection.
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
(relates to [[open-questions]] #1 lane topology).
+180
View File
@@ -0,0 +1,180 @@
---
type: concept
tags: [parking, domain, business, pricing]
sources: []
updated: 2026-06-15
status: open
---
# Tariff (Fee Model)
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
not code** — the park owner builds and constantly edits the rate card at runtime (like a
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host).
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
> over time), modelled with an id/scope so multiple rate cards can be added later without migration;
> (3) **currency is selectable** (ISO 4217) and the money model is **FX-ready but FX is deferred**.
## Design principles
- **Pure function of (entry time, charge time, tariff).** `fee = f(enteredAt, asOf, tariff)`. No
side effects, deterministic, unit-testable. The pay station calls it with `asOf = now`; the exit
lane re-checks against the recorded payment.
- **Data-driven.** The tariff lives as a config record (its own table or seeded config), versioned,
so a historical session always reprices against the tariff in force when it was incurred. Never
hard-code rates (this is an [[open-questions|open-question]]-adjacent procurement input — sites
differ).
- **Integer minor units.** Money is integer cents (or the site currency's minor unit) — never
floats. Avoids rounding drift across a revenue ledger.
- **The fee, once paid, is a signed `payment` event** ([[parking-session]]) — the computation is
reproducible, but the *charged* amount is fixed in the chain.
## The composable structure — stepped blocks + daily cap
The admin composes a **rate card** the fee function interprets. The general model is an **ordered
list of duration blocks** (flat rate is just one block) plus a daily cap — chosen because it
expresses every common operator shape (first-hour pricing, tapering, caps) with no special cases in
code. All amounts are **integer minor units** in the tariff's currency.
```jsonc
{
"currency": "EUR", // ISO 4217; selectable per tariff version
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
"incrementMin": 60, // billing granularity; partial increments round UP
"blocks": [ // consumed in order as duration accrues
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter
],
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
"lostTicketMinor": 2000, // flat charge when there's no entry id
"gracePeriodExitMin": 15, // pay-on-foot walk-back window
"overstay": "reprice" // top-up = recompute(entry→now) − alreadyPaid (decided)
}
```
> **The numbers above are illustrative, not defaults to ship.** "No one knows the pricing and it
> changes constantly" — so the admin authors all of it; the system ships with **no rate card** and
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
> operator policy, see Open).
**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary
amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]]
capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
default; the chosen amount is recorded in the signed `payment` event ([[parking-session]]).
## The fee algorithm (pure, integer, offline)
```
fee(enteredAt, asOf, tariff):
minutes = roundUp(asOf − enteredAt, incrementMin)
if minutes ≤ gracePeriodEntryMin: return 0
total = 0
for each rolling 24h segment of the stay:
segMinutes = minutes within this segment
segFee = walk `blocks` in order, charging priceMinorPerIncrement for each
incrementMin that falls in each block's [prevUpto, uptoMin) range
if dailyCapMinor: segFee = min(segFee, dailyCapMinor)
total += segFee
return total
```
Deterministic, side-effect-free, unit-testable; the daily cap is applied **per rolling 24h** (so an
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
because the chain + reconciliation depend on the result being reproducible.
**Settled edges (2026-06-15, with tests):**
- **Grace uses RAW duration** — a stay within `gracePeriodEntryMin` is free even though the
increment would round it up (else rounding defeats the grace window).
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
across grace, block steps, daily cap, and multi-day reset.
### Composer (as-built 2026-06-15)
The admin authors the rate card at runtime — no hand-seeding:
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be
published. The single site `tariffs` row is created lazily on first read/publish.
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted
to integer minor units on submit. Shows the active version + history; "Publish" creates a new
version (past sessions keep their pricing).
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
## The pay-on-foot consequence
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
time references**, not one:
1. At the **pay station**: `fee = f(enteredAt, now, tariff)` — charge for time parked so far.
2. At the **exit lane**: the session is valid to leave iff `now ≤ paidAt + gracePeriodExit`.
Past that, an **overstay top-up** = `f(paidAt, now, tariff.overstayRate)` is due before exit.
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
who paid; too long gives free parking between pay and exit.
## Permit holders
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
the uncovered time — an edge case to design with [[permit]].
## Versioning — edits publish immutable, effective-dated versions
Prices change constantly, **and** a historical [[parking-session]] must reprice against the rate
that was in force when it was incurred — never today's. So a tariff is **never edited in place**:
- Each save **publishes a new version** with an `effectiveFrom` timestamp; prior versions are
**immutable**. Picking the version for a session = "the latest version with `effectiveFrom ≤
session entry time`".
- The session's **`payment` event records the `tariffVersionId`** it was priced under
([[parking-session]], [[append-only-event-chain]]). The charged amount is then both reproducible
*and* fixed in the signed chain — an admin can't retroactively rewrite prices to alter what a past
session "should have" paid without it being visible.
- An **in-progress** session that crosses a version boundary uses the version in force at **entry**
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
## Data model (first cut — with [[session-model]])
| Table / field | Notes |
| --- | --- |
| `tariffs` | a logical rate card: `id`, `scope` (site/lane/zone — only "site" used now), `name`. |
| `tariff_versions` | `id`, `tariffId`, `effectiveFrom`, `currency`, `structure` (the JSON above), `createdBy`, `createdAt`. **Immutable.** |
| (active) | "one active tariff per site" = one `tariffs` row; multiple `tariff_versions` over time. The `scope`/`id` exist so multiple rate cards can be added later **without migration**. |
Unlike the event log, tariff data is **mutable master data** in the sense that new versions are
*added*; but each version row, once published, is never changed — close to append-only, and the
*use* of it is fixed in the signed `payment` event.
## Currency & FX — selectable now, FX deferred
- Each `tariff_version` names its **`currency`** (ISO 4217), admin-selectable. Amounts everywhere
are `{ minorUnits, currency }` — never a bare number, never a float.
- A `payment` event stores its **`currency`** and a reserved **`fxRate` (null for now)** + optional
`baseCurrency`. So when an exchange-rate system is added later, historical payments stay
reproducible (you know the currency charged and, once FX exists, the rate applied) — **no
migration** of stored amounts.
- **FX engine is NOT built now.** When it is, it needs an *offline* rate source (rates can't depend
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
[[open-questions]].
## Open
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site
needs day/night/weekend cards (deferred until asked).
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
+57
View File
@@ -0,0 +1,57 @@
---
type: concept
tags: [parking, domain, business, devices, entry-flow]
sources: []
updated: 2026-06-15
status: open
---
# Ticket Encoding & Scanning
How a transient [[parking-session]]'s **ticket id** is printed, carried by the customer, and read
back at the pay station and exit. This is the **physical backbone of the transient flow** — the
thing that links entry → pay → exit when there's no plate.
## The ticket id is the session key
At entry the system mints a `vehicle_entry` event with a **ticket id** (`identity`) and prints a
ticket the customer keeps. That same id is read back later to find the session. Properties the id
must have:
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
someone else's cheaper session). Sequential **physical** stock numbering is a separate
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
- **Single logical session** — scanning it at the pay station finds the open session; after payment
it's the proof-of-paid the exit checks.
## Encoding: QR (preferred) — printed by the booth dispenser
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
camera/imager to read.
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
payment ([[tariff]], pay-on-foot) → appends `payment`.
- **Exit lane** — customer scans the (now paid) ticket → host validates paid + within
`gracePeriodExit` → `vehicle_exit` → `pulseOpen`.
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
- **On hand:** the **[[gee-qr-er80]]** QR access reader (`-Q-W`: QR scanner, Wiegand/RS-232/RS-485,
Linux-supported) — the concrete scanner for this path. A serial `ReaderDevice` adapter feeds the
`read` bus; pending the reader's RS-232 frame/baud (see [[gee-qr-er80]] open questions).
## Ticketless alternative (plate as the ticket)
Where the [[opencv-anpr-service|vision service]]/LPR captures the plate, the **plate can be the
session key** instead of a printed ticket — drive in, plate read, drive to pay station and enter
plate (or it's looked up), pay, exit by plate. No paper. The two can coexist per lane
([[entry-exit-readers]] "both share a relay"); a printed QR ticket is the fallback when a plate
isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-service]]).
## Open
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number).
- Scanner hardware (imager model; same unit at pay station and exit?).
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
amount).
+47
View File
@@ -0,0 +1,47 @@
---
type: concept
tags: [parking, domain, business, capacity, manned]
sources: []
updated: 2026-06-15
status: open
---
# Valet / Over-Capacity Mode
"Full" is **not** necessarily a hard stop. If the operator opts in, a lot at nominal capacity can
still accept cars via **valet**: the customer hands over the keys and leaves, and the operator
stacks/double-parks the vehicle beyond the marked space count. (User direction, 2026-06-15.)
## "Full" is a soft, operator-configurable policy
The [[capacity-occupancy]] FULL gate is therefore a **policy knob**, not a physical absolute:
- **Refuse** — hard stop at nominal capacity (the default/strict behaviour).
- **Valet over-capacity** — accept beyond capacity into operator custody.
The choice is the operator's, per site (and possibly per time/condition).
## Valet is a manned-mode feature with a different session shape
Valet only exists when there's an operator (cf. [[shift]] — manned-only). It adds a **custody**
dimension the normal [[parking-session]] doesn't have:
- The **operator takes custody** of the car — identity is a **claim/valet ticket**, and the
operator (not the driver) is accountable for the vehicle between handover and return.
- New facts to record (as signed [[append-only-event-chain]] events when built): **key handover**,
where/when parked, and **return** to the customer. The operator's accountability ties into the
[[shift]] Z-report and [[reconciliation]] (a valet car with no return record is an anomaly).
- Payment still flows through the normal [[tariff]] (duration-based) unless a separate valet fee
applies.
## Status — deferred
Captured now so the [[capacity-occupancy]] design treats "full" as soft and the entry flow leaves a
clean seam. **Not** built into the current transient entry flow (decision 2026-06-15). Full design —
the valet session/custody model, the claim ticket, the over-capacity accept path — is future work.
## Open
- Valet session/custody data model (claim ticket, parked location, return event).
- Whether a distinct valet fee/tariff applies, or normal duration pricing.
- Operator UI for handover/return; how it ties to the [[shift]] accountability record.
+46
View File
@@ -0,0 +1,46 @@
---
type: concept
tags: [parking, domain, business, pricing, revenue]
sources: []
updated: 2026-06-15
status: open
---
# Validation & Discounts
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
a common revenue/retention feature that modifies what a [[parking-session]] owes.
## Model: a discount is a signed event, applied at fee time
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
as everything else ([[threat-model]]: an operator/merchant could otherwise fake free parking). It's
recorded so the fee computation and the audit both see it:
- A **discount/validation event** references the session: `{ sessionRef, kind, value, issuedBy,
ts }` — e.g. *2 hours free*, *€5 off*, *flat €1*, *100% off*. Appended + signed
([[append-only-event-chain]]).
- The [[tariff]] fee function applies eligible validations when computing what's due at the pay
station: `due = max(0, tariff_fee − discounts)` (or time-based: subtract validated minutes before
pricing). Pure + reproducible, like the base fee.
- The `payment` event then records gross fee, discount total, and net paid — so revenue reporting
([[reporting-analytics]]) can show **discount leakage** (how much was given away, by whom).
## How a validation is presented
- **Merchant terminal / portal** stamps the customer's ticket id (or plate) — issues the validation
event for that session.
- Or a **validation code** the customer enters at the pay station.
- Either way it ties to the session by **ticket id or plate** ([[parking-session]] identity).
## Anti-abuse
Because each validation is signed and attributed (`issuedBy`), over-validation by a colluding
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
an anomaly), rather than invisible free parking.
## Open
- Validation types the site needs (free hours / fixed amount / percentage / flat rate).
- Whether merchants self-serve (portal/terminal) or the operator applies it.
- Caps (max discount, max per merchant/day).
+53
View File
@@ -56,6 +56,59 @@ Mirrored networking is necessary but **not sufficient** — these still bit us:
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the (`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].) proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
Field devices arrive **statically configured on assorted `/24`s** by whoever installed them last
(e.g. a camera on `10.0.10.121`, a printer on `10.0.10.6`, others on `192.168.1.x`). The host
copes by carrying **one IP per device subnet on a single NIC** (this is correct — you do **not**
need a NIC per subnet). But stacking subnets on one interface exposes a Linux source-selection
trap:
- Connected routes come up as `proto kernel scope link` **with no preferred source**. With two
such subnets on one NIC, the kernel may pick the **wrong source address** — e.g. sourcing
traffic to `10.0.10.121` from `192.168.1.123`.
- Symptom is baffling: **ARP resolves and the neighbor shows `REACHABLE`** (L2 is fine, source
address is irrelevant to ARP) while **every ping and TCP connect times out** (replies have a
wrong/unroutable source → dropped, possibly by uRPF). Looks like "the device is down / the whole
subnet is unreachable" when nothing is actually broken.
- **Diagnose:** `ip route get <device-ip>` shows the chosen `src` — if it's an address on a
*different* subnet, that's the bug. Confirm by forcing the right source:
`ping -I <correct-src> <device-ip>` (or `curl --interface <correct-src> …`) — instant replies.
- **Fix (runtime):** pin the preferred source on the connected route, per subnet:
`sudo ip route replace <subnet>/24 dev <nic> proto kernel scope link src <correct-host-ip> metric <m>`
(use `replace`, not `change` — `change` errors `RTNETLINK: No such file` if the route isn't up
yet). Do **not** delete the other subnet's address unless it's genuinely unwanted — you need all
of them to reach all the devices.
- **Fix (permanent, this box):** `deploy/wsl-fix-route-source.sh` + `deploy/parking-net.service`.
The script walks each `proto kernel scope link` route on the NIC and pins `src` to THIS host's own
address in that same subnet — **no hardcoded IPs**, so it also covers future device subnets; it's
idempotent, preserves the route metric, and tolerates a missing route. The systemd unit (oneshot,
`enabled`) reapplies it on every WSL boot — which is the point, since `wsl --shutdown` otherwise
wipes the runtime fix (mirrored mode re-clones the Windows addresses fresh each boot, see below).
Install once: copy the unit to `/etc/systemd/system/`, `systemctl enable --now parking-net`.
Gotchas hit while building it: `network.target` is too early for mirrored-mode addresses (the
script waits up to 15s for a route to appear); and it must NOT `set -e` or one failed `ip` call
aborts the whole boot fixer.
> **Root cause is on the Windows side.** Mirrored mode clones the Windows host NIC's addresses into
> Linux at every boot, so the stray `192.168.1.x` lives on Windows — the truly permanent fix is to
> remove/reconfigure it there (or set `SkipAsSource`/interface metric). The systemd hook is the
> self-contained Linux-side answer that needs no Windows changes.
Verified on hardware (2026-06-15): after the hook, `10.0.10.121` pings and the real [[lpr-camera]]
Hikvision driver pulls a snapshot with **no** source-forcing (`localAddress` becomes optional).
## On the real appliance: multi-subnet is a deployment config, not a WSL hack
Production is a **dedicated hardened Linux appliance** ([[disk-os-hardening]]), so the WSL story
above is dev-only. The device-subnet problem persists, though, and is solved the same way at the
OS level: the appliance NIC carries **one address per device subnet**, each connected route with a
pinned `src`, made persistent (systemd-networkd / netplan). Per the threat model this still rides
on **[[network-isolation]]** — device subnets are isolated segments reachable only by the host.
The long-term clean answer is to **re-IP the devices onto one planned parking-system subnet** at
install so the host needs only one address; the multi-subnet config is what you run until then.
## Alternative if you can't use mirrored mode ## Alternative if you can't use mirrored mode
Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows** Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows**
+57
View File
@@ -0,0 +1,57 @@
---
type: decision
tags: [parking, decisions, integrity, devices, schema]
sources: []
updated: 2026-06-15
status: open
---
# Decision: Split the signed business ledger from device telemetry
Taken 2026-06-15, at the start of the business-layer schema work.
## The problem
The existing `events` table (signed, hash-chained — [[append-only-event-chain]]) had grown to carry
**two unrelated concerns**: the financial/accountability ledger *and* raw device telemetry (button
pushes recorded as `input_received`). They have opposite requirements — the ledger must be small,
signed, and reconciled; telemetry is high-volume, churny, and disposable.
## Decision — two tables
- **`ledger_events`** — the signed, hash-chained, [[atecc608]]-signed **business ledger** (rename of
`events`). Holds only business/accountability facts: `vehicle_entry`, `vehicle_exit`, `payment`,
`void`, `shift_z_report`, and the witness-grade `barrier_open_command` / `barrier_open_observed` /
`anomaly`. [[reconciliation]] runs against this; sessions/[[tariff]]/occupancy are projections of
it.
- **`device_events`** — **unsigned** operational telemetry (see [[device-events]]): relay fired,
printer paper-out, camera offline, reader read, raw input edges. May rotate/prune. Never signed,
never reconciled.
A raw button press is **telemetry** → `device_events`. The entry flow then mints a **signed
`vehicle_entry`** in the ledger once a ticket prints + the barrier is commanded. So
`input_received`-as-a-signed-event is **dropped** (it was transitional).
## Why
- Keeps the **signed ledger small and high-value** — fewer rows to sign, hash, verify, reconcile,
and export; signal isn't drowned in device noise.
- Right **durability semantics per stream**: the ledger is precious + append-only forever; telemetry
can age out.
- Clean separation matches the [[device-adapter-pattern]] philosophy — device chatter stays on the
device side of the boundary.
## Consequences / migration (no production data yet)
- No `.sqlite` with real chain data exists, so renaming + restructuring is safe now (no signatures
to invalidate). This is the moment to do it.
- Code: rename `events` → `ledger_events`; `EventLog`/`canonicalize`/`verifyChain` and the
`/api/events` routes follow the rename; add an unsigned `device_events` writer; move the Dingtian
input-push handler to emit `device_events` (+ the entry flow signs `vehicle_entry`).
- `ParkingEventType` in `packages/shared` splits into ledger types vs. a device-event type set.
## Open
- `device_events` retention/rotation policy.
- Which device facts (if any) are witness-grade enough to *also* warrant a signed ledger entry
(e.g. `barrier_open_observed` from a loop sensor) — see [[append-only-event-chain]] witness gap.
+24 -1
View File
@@ -24,7 +24,12 @@ status: open
manager visit) to reconcile the signed log against an external authority — the real anti-fraud manager visit) to reconcile the signed log against an external authority — the real anti-fraud
control. See [[reconciliation]]. control. See [[reconciliation]].
5. **Durability / backup.** Backup strategy for the [[sqlite]] database + recovery plan; "sync 5. **Durability / backup.** Backup strategy for the [[sqlite]] database + recovery plan; "sync
later" currently leaves a disk failure as **total revenue-history loss**. later" currently leaves a disk failure as **total revenue-history loss**. _(Confirmed in-scope
to design, 2026-06-15.)_ Because the DB is the signed [[append-only-event-chain]], a backup must
preserve the chain intact (a restored copy must still `verifyChain`); options include SQLite
WAL/online-backup snapshots to a second disk/USB + the periodic external export that doubles as
the [[reconciliation]] channel (#4). Encryption at rest already applies ([[disk-os-hardening]]).
Design TBD.
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event 6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
being implemented for now** (access control is the [[dingtian-relay]] behind being implemented for now** (access control is the [[dingtian-relay]] behind
@@ -39,3 +44,21 @@ status: open
compromising a verifying host yields nothing that can forge a token. Decide before compromising a verifying host yields nothing that can forge a token. Decide before
multi-host / multi-lane deployment (see #1 lane topology), since that's when shared-secret multi-host / multi-lane deployment (see #1 lane topology), since that's when shared-secret
distribution becomes the liability. distribution becomes the liability.
8. **Exchange-rate (FX) system.** _(Raised by the [[tariff]] design, 2026-06-15.)_ Currency is
selectable per tariff version and the money model is FX-ready (`payment` stores currency + a
reserved `fxRate`), but **no conversion is built**. If multi-currency pricing/charging is ever
needed, it requires an **offline** rate source (rates can't depend on the network —
[[offline-first]]), a base currency, and a rounding policy. Deferred; nothing blocks adding it
later without migrating stored amounts.
9. **Pay-station money corners — receipts & refunds/change.** _(Raised by the scope sweep,
2026-06-15; deferred until pay-station hardware is chosen.)_ Not yet designed: **receipts / VAT
invoices** (fiscal receipt with tax number + sequential numbering may be legally required — could
change what the `payment` event must store) and **refunds / overpayment / change** (cash change,
"exact change only", a refund as a signed reversal event). Both depend on the unmanned-vs-manned
payment subsystem (#3) and the note/coin/card acceptor hardware. Revisit at procurement.
10. **Snapshot retention.** _(Raised by the [[entry-exit-points]] camera-snapshot build, 2026-06-16.)_
Entry/exit snapshots are stored as BLOBs in the [[sqlite]] `snapshots` table. This grows the
single DB file fast (~100–300 KB per image × every entry **and** exit), and SQLite doesn't
reclaim deleted-blob pages without `VACUUM`. **Undecided:** pruning policy (age-based vs.
total-size cap), VACUUM cadence, and how this interacts with the #5 backup strategy (blobs
bloat every backup). Until decided, snapshots accumulate unbounded. See [[entry-exit-points]].
+55
View File
@@ -0,0 +1,55 @@
---
type: decision
tags: [parking, decisions, domain, business]
sources: []
updated: 2026-06-15
status: open
---
# Decision: Parking Session Model
The starting decision for the **business layer**, taken 2026-06-15 as the project pivots from the
(now hardware-verified) device/integrity layer to the parking *operation*.
## Decisions
1. **A session is a projection over the signed event log, not a mutable table.** The
[[append-only-event-chain]] `events` table stays the only source of truth; a
[[parking-session]] is folded from `vehicle_entry` / `vehicle_exit` / `payment` / `void`
events. A cache table is allowed for query speed but is always rebuildable and never
authoritative.
2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first;
layer [[permit]] holders on top as a second identity source that short-circuits payment
([[entry-exit-readers]]).
3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a
central station; the exit lane only validates the session is paid and within the walk-back
grace window before opening ([[parking-session]] lifecycle). Matches the
[[autonomous-direction|unmanned]] roadmap and sharpens [[open-questions]] #3 toward an unmanned
pay station (PCI scope still kept out of the app via a certified terminal).
4. **New signed event types:** `vehicle_entry`, `vehicle_exit`, `payment`, `void` — extend the
existing `input_received`. Recorded in [[append-only-event-chain]].
## Why (rejected alternative)
A **mutable `sessions` table** carrying `amountOwed` / `paidStatus` as the source of truth was
rejected: it reopens the exact fraud vector the system exists to close ([[threat-model]] — the
insider edits the row, marks it paid, pockets the cash). Making "paid" a **signed `payment`
event** means it can't be forged and can't be silently deleted (a deletion breaks the chain). The
projection approach costs a fold/cache but keeps the anti-fraud guarantee intact end-to-end.
## What this unblocks
Closes the dangling thread from [[device-input-flow]] ("the entry flow itself is the next
build"): `input_received` → signed `vehicle_entry` → ticket print → `pulseOpen`, then the
pay-station and exit-validation flows. Schema (`packages/db`) + shared types follow the
[[parking-session]] + [[tariff]] design pages.
## Open / next
- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]).
- Tariff versioning (effective-dated) for historical repricing.
- [[permit]] data model + lapsed-mid-stay handling.
- Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract
(payment = an independent signed event referencing a session) until procurement settles.
- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4
+ the unbuilt witness/reconciliation gap in [[append-only-event-chain]].
+4
View File
@@ -14,6 +14,10 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch
- **Stack:** [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · [[sqlite]] + - **Stack:** [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · [[sqlite]] +
[[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — **no vendor lock, no rug-pull [[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — **no vendor lock, no rug-pull
risk** (see [[payload-cms]]). Full table in [[technology-stack]]. risk** (see [[payload-cms]]). Full table in [[technology-stack]].
- **Scoped exception (2026-06-15):** the [[opencv-anpr-service]] — a **separate local process**,
not linked into the app — **may use AGPL** components (plate/vehicle models). The exception is
bounded to that process; the Node/React app stays strictly MIT/Apache/BSD. See
[[vision-service]].
- **Platform:** a **dedicated, hardened Linux appliance** (LUKS + GRUB password + Secure Boot), - **Platform:** a **dedicated, hardened Linux appliance** (LUKS + GRUB password + Secure Boot),
**not Windows/WSL** — see [[disk-os-hardening]]. **not Windows/WSL** — see [[disk-os-hardening]].
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log - **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
+61
View File
@@ -0,0 +1,61 @@
---
type: decision
tags: [parking, decisions, vision, anpr, anti-fraud]
sources: []
updated: 2026-06-15
status: open
---
# Decision: Host-side Vision Service (ANPR + vehicle verification)
Taken 2026-06-15, as part of the business-layer build ([[session-model]]).
## Decisions
1. **Build a host-side vision service** ([[opencv-anpr-service]]) that does ANPR (plate → identity)
**and** vehicle-attribute verification (anti-spoofing witness) on snapshots from ordinary
Hikvision/Dahua cameras.
2. **It replaces the dedicated edge-AI [[lpr-camera]]** as the recognition path: ordinary IP cam →
snapshot (`Snapshot.bytes`, already pulled by the camera driver) → vision service → plate +
vehicle. Removes the special LPR camera from the [[bom]] as a requirement (still allowed as an
option).
3. **Deployment: a separate local Python/OpenCV microservice** on the appliance, called over
**localhost HTTP** by the Node backend. Fully offline ([[offline-first]]); its own process and
failure domain; the host falls back to the ticket path if it's unavailable.
4. **Licensing exception:** AGPL components (e.g. YOLO plate/vehicle models, OpenALPR) are
**permitted inside this service only**, because it's a separate process not linked into the app —
the app stays strictly MIT/Apache/BSD. Amends [[standing-decisions]].
5. **Recognition is advisory, evidence is authoritative.** A read never single-handedly authorizes
a paid/access barrier open; it flags for [[reconciliation]] and attaches (with the source image)
to the signed [[append-only-event-chain]] entry. Low confidence → fallback, never strand a car
([[fail-state-safety]]).
## Why
- **Replace vs. edge-AI camera:** host-side recognition on cheap IP cams shifts cost from per-lane
smart cameras to one compute box + our software; gives us the raw image for the second job below.
- **Vehicle verification is the real prize (user-driven, 2026-06-15):** plate-only ANPR can't catch
a **printed/spoofed plate on a different car**. Extracting vehicle attributes/fingerprint lets the
system reconcile *the car*, not just the number — directly filling the independent-witness gap the
[[append-only-event-chain]] calls out as unbuilt.
- **Separate-process + AGPL-scoped** keeps the app's permissive-license guarantee intact while not
crippling accuracy (the strict permissive-only ANPR path is markedly weaker — that tradeoff was
weighed and the scoped exception chosen).
## Rejected / alternatives
- **Strict permissive-only ANPR in-app** — license-clean but weaker accuracy and more build; the
separate-process AGPL exception was chosen instead.
- **Keep the edge-AI LPR camera as primary** — viable fallback if host-side accuracy disappoints;
not chosen now, kept on the table in [[opencv-anpr-service]].
- **Embed OpenCV in Node** (opencv4nodejs/WASM) — rejected: native-build pain, weaker model
ecosystem, no process isolation, shares the app's failure + license surface.
## Open / next
- Recognizer + vehicle-model selection and accuracy targets; fingerprint method + anomaly
threshold ([[opencv-anpr-service]]).
- Appliance compute footprint (CPU vs. small GPU/NPU) — [[bom]] / [[open-questions]].
- Service API + the Node-side adapter; per-camera opt-in wiring.
- Reconciliation logic that consumes plate+vehicle witness vs. commanded opens (still unbuilt — see
[[append-only-event-chain]], [[reconciliation]]).
+35
View File
@@ -0,0 +1,35 @@
---
type: entity
tags: [parking, domain, business, access-control]
sources: []
updated: 2026-06-15
status: open
---
# Blocklist (Banlist)
Plates or credentials the lot **refuses** — barred vehicles (non-payers, abusers, court orders) and
revoked/stolen cards. Checked in the entry flow.
## Model
- A `blocklist` table of `{ kind: 'plate' | 'card' | 'qr', value, reason, addedBy, addedAt }` —
admin-managed master data (mutable: add/lift a ban).
- **Entry check:** after identifying the vehicle ([[parking-session]] identity — plate via
[[opencv-anpr-service|vision]]/LPR, or card/QR), if it matches an active blocklist entry, **refuse
entry** and append a signed event (`anomaly` / a refused-entry record) so the attempt is logged.
- **Exit is never blocked** — a barred car already inside must still leave ([[fail-state-safety]]:
never trap a vehicle). A blocklist hit at exit is logged for follow-up, not used to detain.
## Notes
- Plate matching depends on capture quality — a blocklist-by-plate is only as good as the
[[opencv-anpr-service|vision]] read; treat a near-miss as a flag for a human, not an automatic
refusal that could strand a misread innocent car.
- Bans are attributed (`addedBy`) and their enforcement is logged, so the control is auditable
([[reconciliation]]) rather than an invisible operator lever.
## Open
- Plate-match tolerance (exact vs. fuzzy) and the false-positive handling.
- Expiry / review of bans.
+123
View File
@@ -0,0 +1,123 @@
---
type: entity
tags: [parking, hardware, readers, qr]
sources: [gee-qr-er80]
updated: 2026-06-16
status: open
---
# GEE-QR-ER80 (QR access reader)
The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR /
DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]]
scanner** the design called for — read at the pay station and exit lane — and a path for **QR
[[permit]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
(See [[gee-qr-er80|datasheet summary]] / `raw/`.)
## What it is (and isn't)
- **Optical, not RFID-prox.** Earlier we *assumed* "ER80-EM" = a 125 kHz EM4100 card reader — the
datasheet corrects that: it's a **QR/barcode scanner**. The `-EM` in the original label was a
mis-id; the real model is **GEE-QR-ER80**. Optional `D`/`C` variants add ID/IC card, but the unit
on hand is **QR-only** (`-Q`).
- **Multi-interface** (Wiegand 26/34, RS-232, RS-485, USB, TCP/IP); the `-W` variant exposes
**Wiegand + RS-232/RS-485**.
## How it integrates — HTTP-GET push, server replies the verdict (confirmed via SDK)
The protocol is settled by the **[[qrcode-sdk|QRCode SDK v1.6.5]]** (not serial as first guessed).
The reader is configured (Windows tool `QRCode_v1_6_5.exe`) with a **server IP/port** and a "server
language" (only picks the URL path, e.g. `/qa/mcardsea.php`). **On each scan it HTTP-GETs the host:**
```
GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2 chars>&time=<utc>
```
`cardid` = scanned data; `status` low digit = **direction (1=in / 0=out)**. The host replies JSON
`{data:[{cardid,cjihao,mjihao,status,time,output}],code:0}` where reply **`status` 1=valid (beep
2×) / 0=invalid (beep 1×)**, **`output` 0=Access/1=WG26/2=WG34**, `time` syncs the clock.
This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the
decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint
must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent`
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[permit]]) to open the
barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.)
> **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A
> non-JSON / missing reply ⇒ no beep even though the scan worked. So "no beep" ≠ "didn't scan."
- Pushes over plain **HTTP** to our `10.0.10.x` host (on the device subnet); no serial wiring, no
Wiegand-decode hardware. Suits the host-in-the-loop model; autonomy is moot anyway
([[dingtian-relay]] has no onboard ACL).
- **Linux-supported**, 4–15 VDC, default IP `192.168.1.99` — fits the [[disk-os-hardening|appliance]].
## Resolved (2026-06-16)
- Protocol = HTTP GET poll + JSON verdict (above). The earlier "serial/Wiegand, find the baud"
open questions are **moot** — it's HTTP. Wiegand is the reader's *output line* on a valid read
(the reply `output` field), not the host transport.
## As-built (2026-06-16)
- **Endpoint** `GET/POST /qa/mcardsea.php` (`apps/server/src/routes/qr-reader.ts`, public — the
reader has no auth, sits on the device subnet). Parses `cardid/mjihao/cjihao/status/time`, runs
the scan through the **read dispatcher** (permit match → permit flow; else transient exit), and
replies the **SDK verdict**: `status` 1=valid(beep 2×)/0=invalid(beep 1×), `output` 0, `time`.
- The read flows were refactored to **return a `ReadOutcome` { accepted, direction, reason }** so the
endpoint's reply reflects the real accept/reject (the dispatcher decides AND opens the barrier via
the flows). A fire-and-forget reader ignores the outcome.
- **Reader identity:** the endpoint matches the device **serial (`cjihao`)** against each reader's
`config.serial` to find its `devices` row; the dispatcher then resolves the relay that row is
**bound** to (`config.controllerId` + `relay`) and opens it. See [[entry-exit-points]].
- Verified via inject: valid permit QR → `status:1` + open; re-scan → permit exit (still valid);
unknown QR → `status:0`; reader on a barrier-less lane → `status:0`.
## Verified on hardware (2026-06-16)
Captured a real scan (vendor-emulator logger on :3000). The reader **does scan, send, and beep** —
the earlier "no beep" was simply that no server was answering on :3000 with valid JSON. Real GET:
```
GET /qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=1781634494
from 10.0.10.7 (referer: http://www.fondvision.com — the OEM is Fondvision)
```
- **PATH carries the configured "server language" EXTENSION:** this unit is set to **JSP**, so it
GETs **`/qa/mcardsea.jsp`** — NOT `.php`. Our endpoint was registered at `.php` only → it would
have 404'd the real reader. **Fixed:** the route now registers `php/jsp/asp/aspx/cgi`.
- **`cjihao` = `H05M2AFA`** is the device **serial** — the value our endpoint matches against the
reader's `config.serial`. So assign the reader with **`config.serial = "H05M2AFA"`** and bind it
to a controller relay.
- **`mjihao` = 1** (device id). `cardid` = the scanned barcode (`52020056`). `status=11`.
- The reader **beeped on the vendor reply with `status:0`** — so it acts on the reply; `0` =
invalid/1-beep as documented. A matching permit/session will return `status:1` → 2-beep accept.
## Assignment (as-built 2026-06-16)
A dedicated **`gee-qr-reader`** driver ([[device-registry]], reader category) models the push reader:
its one config field is **`serial`** (the `cjihao` the device reports). The admin assigns it in the
[[first-run-setup|setup wizard]] like any device (normal UUID row id), enters the serial, and binds
it to a controller relay. The QR endpoint resolves the reader by **matching `config.serial` to the
scan's `cjihao`** — not by row id — so no DB hand-editing. Set the reader's server IP/port to this
host in the **vendor tool**; assign + enter its serial + bind it here.
- Verified via inject: assign `gee-qr-reader` {serial:"H05M2AFA"} bound to an access relay →
a `.jsp` scan with that serial + a matching permit QR → `status:1` (2-beep accept) + open; re-scan
→ permit exit; unknown card → `status:0`; unassigned serial → `status:0` (no relay, graceful).
- Note `tcpip-reader` is the WRONG model for this device (host-connects-out, a stub) — use
`gee-qr-reader`.
## Open / next
- Re-test on hardware against the real app (now `.jsp`-aware + serial-resolved): scan → expect a
`status:1` 2-beep when the QR matches a permit/open session.
- `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34)
to drive its access line, vs. `0`.
## ⚠️ Reply MUST set `Connection: close` (verified on hardware)
The reader sends `Connection: keep-alive` but **only acts on the verdict (beep/output) once the TCP
socket CLOSES**. Fastify's default keeps the connection alive → the reader waits out a **~10 s
keep-alive timeout before beeping**, even though the server replied in ~15 ms. Every vendor demo
replies **`Connection: close`** and shuts the socket. Fix: the endpoint sets
`reply.header("connection","close")`. Symptom if regressed: correct accept/reject but a ~10 s lag
before the beep. (The request arrives fast; the delay is entirely the reader waiting for close.)
+8 -1
View File
@@ -13,7 +13,14 @@ Authentication and authorization, kept **fully local** — a direct consequence
- `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to - `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to
start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no
insecure default — and mints tokens with an **8h expiry** (bound to a shift). insecure default.
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15, built).
Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is
forced to work two shifts in a row — a token that expired mid-duty would strand an active
operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**,
not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.)
The JWT carries no `exp`; the cookie has a long fixed `maxAge` (30 days) so a browser restart
doesn't log out an active operator, and `logout` clears it.
- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The - A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The
first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint). first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint).
- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier / - Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
+44 -6
View File
@@ -2,17 +2,22 @@
type: entity type: entity
tags: [parking, hardware, readers, offline-first] tags: [parking, hardware, readers, offline-first]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-06-14 updated: 2026-06-15
--- ---
# LPR Camera # LPR Camera
License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For License-plate-recognition camera. For **casual/transient** vehicles, the **plate acts as ticket +
**casual/transient** vehicles, the **plate acts as ticket + an independent record**. (See an independent record**. (See [[parking-system-architecture]] §8, §9.)
[[parking-system-architecture]] §8, §9.)
- **Edge AI**: recognition runs **on-device**, so it keeps working with no internet — fits > **Superseded direction (2026-06-15):** recognition now runs **host-side** on snapshots from
[[offline-first]]. > ordinary Hikvision/Dahua cameras via the [[opencv-anpr-service]], **not** on a dedicated edge-AI
> LPR camera — see [[vision-service]]. The edge-AI camera below is kept as the original assumption /
> a fallback option, but is no longer the planned path. The host-side service also does **vehicle
> verification** (anti-plate-spoofing), which an edge-LPR camera does not.
- **Edge AI (original assumption)**: recognition runs **on-device**, so it keeps working with no
internet — fits [[offline-first]].
- It's a **host-side** identity source: only the host sees the read; the host decides and - It's a **host-side** identity source: only the host sees the read; the host decides and
commands the relay open (the [[uhppote-controller]] is demoted to a commanded relay for that commands the relay open (the [[uhppote-controller]] is demoted to a commanded relay for that
lane). See [[entry-exit-readers]]. lane). See [[entry-exit-readers]].
@@ -20,3 +25,36 @@ License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For
host's signed [[append-only-event-chain]] entry + the controller's remote-open event) that host's signed [[append-only-event-chain]] entry + the controller's remote-open event) that
should reconcile one-to-one; any mismatch is an anomaly. should reconcile one-to-one; any mismatch is an anomaly.
- Mounting: within ~15° of vehicle travel at a controlled chokepoint for best reads. - Mounting: within ~15° of vehicle travel at a controlled chokepoint for best reads.
## Snapshot driver (entry/exit fraud-control record)
Separate from edge-AI LPR: the camera driver (`packages/devices/src/drivers/camera.ts`) does
**snapshot-on-event** — the host pulls a still over HTTP when an entry/exit fires and stores it,
referenced from the signed [[append-only-event-chain]] entry as an independent record. The camera
**pulls, it does not push** — so it is NOT `pushesToBackend` and the setup wizard correctly hides
the "Backend push IP" field for it (gated on the driver's `pushesToBackend` flag; only
[[dingtian-relay]] sets it).
- **Hikvision** uses **ISAPI**: `GET /ISAPI/Streaming/channels/<id>/picture` (`101` = ch1 main
stream) with **HTTP Digest** auth. The "Enable Hikvision-CGI" toggle (Network → Advanced →
Integration Protocol) is a *different* legacy CGI surface — **not** needed for ISAPI.
- **Dahua** uses CGI: `GET /cgi-bin/snapshot.cgi?channel=<n>` (0-based channel; the wizard's
1-based channel is decremented).
**Driver / storage boundary:** the driver FETCHES the image bytes (client-side HTTP Digest in
`drivers/http-digest.ts`) and returns them on `Snapshot.bytes`; **storage is the caller's job**
(the future entry/exit flow stores the bytes + mints a durable `imageRef`). This keeps the device
adapter free of any filesystem/blob-store dependency. `healthCheck()` is honest — it actually pulls
a frame (exercising reachability + auth + path/channel in one shot), not a fake `ready/stub`.
### Verified on hardware (2026-06-15)
A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.10.121`, creds
`admin` / `admin123` (Digest), TCP 80:
- Initial `curl` test confirmed the ISAPI path returns a 2688×1520 JPEG (~306 KB).
- The **real driver** (no longer a stub) was then run end to end against it:
`healthCheck()` → `ready` (pulled a frame), `captureSnapshot()` → valid `image/jpeg`, ~322 KB,
correct JPEG magic. Digest handshake works through `HttpCamera`.
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
+85
View File
@@ -0,0 +1,85 @@
---
type: entity
tags: [parking, vision, anpr, anti-fraud, service]
sources: []
updated: 2026-06-15
status: open
---
# OpenCV ANPR / Vision Service
A **local microservice** that analyses camera snapshots: reads the licence **plate** (ANPR) and
extracts **vehicle attributes** for verification. Built by us (decision 2026-06-15) to do
recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicated edge-AI
[[lpr-camera]]. See decision [[vision-service]].
## Two jobs
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
a plate-bound [[permit]] is matched.
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives
in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at
entry vs. exit (and vs. the [[permit]]'s known car) can. A plate that entered on a red hatchback
but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role
the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]].
> The two jobs are why this is worth building rather than just plate-OCR: the service is both an
> **identity source** and an **independent witness**, the visual analogue of the whole system's
> "two records that must reconcile" thesis.
## Architecture — separate localhost process
- A **Python service** (e.g. FastAPI) running **on the appliance**, called by the Node backend over
**localhost HTTP** (`POST /analyze` with the JPEG bytes the camera driver already pulls — see
[[lpr-camera]] "driver/storage boundary": `Snapshot.bytes`).
- **Fully offline** ([[offline-first]]): all inference is local, no cloud. Model weights ship on the
appliance.
- **Process isolation is deliberate** — it keeps a heavy Python/native/AGPL stack out of the
Node app's process and license surface (see licensing below), and gives it its own failure
domain. If the service is down/slow, the host falls back (transient ticket path) rather than
blocking the lane.
- **Request/response (first cut):**
- `POST /analyze` → `{ plate: {text, confidence, bbox}|null, vehicle: {colour, bodyType, make?, model?, embedding?}, modelVersion, tookMs }`
- `GET /health` → readiness + model versions.
- The Node side wraps it behind an internal interface (like a device adapter) so the recognizer can
be swapped without touching business logic.
## Licensing — scoped AGPL exception (amends the standing rule)
The app is strictly **MIT/Apache/BSD** ([[technology-stack]], [[standing-decisions]]). Accurate
ANPR/vehicle models are mostly **AGPL** (YOLO/Ultralytics detectors, OpenALPR) or commercial.
Decision (2026-06-15): **allow AGPL inside this service only.** It is a **separate process**, not
linked into the app, so its obligations don't reach the Node/React codebase; the app's permissive
guarantee is preserved. Recorded as an explicit exception in [[standing-decisions]] /
[[vision-service]].
- OpenCV core itself is **Apache-2.0** (clean either way).
- AGPL note: if the appliance is ever offered as a network service to third parties, AGPL's
network-use clause could require offering the service's source — relevant only if productised
beyond the on-site appliance; flag at that point.
## Anti-fraud / threat-model fit
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
vs. a [[permit]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record.
- The recognition result and the source image both attach to the signed [[append-only-event-chain]]
entry, so the *evidence* is tamper-evident even though recognition itself is host-side and
fallible.
- Recognition is **advisory, never the sole authority** to open a barrier where money/access is at
stake — confidence thresholds + fallback to ticket/manual; a low-confidence read must not strand a
car ([[fail-state-safety]]).
## Open
- **Recognizer choice** (permissive-only vs. AGPL model) and accuracy targets — see
[[vision-service]]; AGPL now permitted in-service.
- **Vehicle fingerprint**: attribute classifier vs. embedding-similarity; what threshold makes a
mismatch an anomaly without false-positiving on lighting/angle.
- **Compute footprint** on the appliance (CPU-only vs. a small GPU/NPU) — procurement input
([[bom]], [[open-questions]]).
- Per-camera **opt-in** ("optionally bound", user's word): which lanes/cameras route snapshots to
the service.
+149
View File
@@ -0,0 +1,149 @@
---
type: entity
tags: [parking, domain, business, subscriptions, identity]
sources: []
updated: 2026-06-15
status: open
---
# Permit (Subscription)
A **subscription**: a known holder authorized to enter/exit without paying per-stay, for a covered
period. The second of the "two populations" ([[entry-exit-readers]]); a valid permit
**short-circuits the payment step** of a [[parking-session]] ([[session-model]]). Transient is
built first; permits layer on top.
## Credentials (how a permit is presented) — confirmed with operator 2026-06-15
A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity
paths:
- **RF tag / chip / card.** An RFID/proximity credential. Read **host-side** (reader → host →
`pulseOpen`): autonomy isn't required (resolved below), and the [[dingtian-relay]] has no onboard
card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still
fine and keeps a future autonomous path open ([[entry-exit-readers]]), but isn't required.
- **QR code.** Read by the **optical reader** — inherently **host-side** ([[entry-exit-readers]]:
pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the
permit → decides.
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
already in the model) and whose value is the credential id.
## Two optional, independent bindings — confirmed 2026-06-15
A permit has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
neither — the four combinations are all valid.
### 1. Car-count binding (default: 1)
- **Optional.** By default a permit is bound to **1 car at a time**. The admin may raise the limit
(a household, a company fleet) or **unbind it entirely** (no cap on how many cars use it).
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the
[[parking-session]] projection: at entry, count the permit's currently-open sessions; if
`< maxConcurrent` (or unbound) allow, else reject (allowance full). This is exactly why
sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold
over open entry/exit events, **not a counter someone can edit**.
### 2. Plate binding (default: off)
- **Optional.** By default a permit is **not** plate-bound — any car may use it (identity is the
card/QR). The admin may bind it to a set of specific licence plates.
- When **bound**, an allowed plate is an **accepted identity in its own right** — a valid
**card/QR OR a matching plate** opens the lane (either, not a second factor):
```
entry: read card/QR → find permit → car-count ok → open
OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open
```
- **Accepted tradeoff:** card-OR-plate is the most convenient but does **not** prevent
card-sharing (a lent card still opens). Fine for a trusted permit population; the signed
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible
to [[reconciliation]] after the fact.
- **Plate-spoofing defence:** a printed copy of a registered plate on a *different* car is caught
not here but by the [[opencv-anpr-service]]'s **vehicle-attribute verification** — the seen car
must reconcile with the permit's known car, not just the plate string.
> The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept
> any plate. The binding fields are simply absent/null when a constraint isn't applied.
## Data model (first cut — to firm up with [[session-model]])
A `permits` table (and supporting rows). Unlike the event log, reference/master data like permits
**is** mutable (an admin grants/revokes/renews) — but every *use* of a permit still produces a
signed `vehicle_entry`/`vehicle_exit` event in the [[append-only-event-chain]], so the audit trail
stays append-only even though the permit record itself is editable.
| Field | Notes |
| --- | --- |
| `id`, `holderName`/contact | the subscriber |
| `credentials[]` | one or more: `{ kind: 'rf' \| 'qr', value }` |
| `maxConcurrent` | car-count binding; **default 1**, raise for fleets, or `null` = unbound |
| `plates[]` | plate binding; **default empty/false** = any car; when set, these plates are accepted identities |
| `validFrom`, `validTo` | coverage window |
| `status` | active / suspended / revoked |
> Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate,
> identified by its card/QR".
## Interaction with the session model
- **Entry:** credential read → permit lookup → valid (active, in window, plate allowed **if
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
(source = `wiegand`/`qr`/`lpr`), open barrier. No ticket, no fee. (A bare permit applies neither
extra check — just active + in window.)
- **Exit:** credential/plate read → matching open permit session → signed `vehicle_exit`, open. No
payment required.
- **Lapsed mid-stay:** permit expires while a car is parked → the uncovered time falls back to the
transient [[tariff]] (edge case to design).
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
refused, per policy (OPEN).
## As-built (2026-06-15)
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
- **Direction is inferred from session state for that car** — the read credential value is the
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
inside car is its exit, never a second entry).
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
carrying `permitId`.
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
replacement, RBAC, revoke/delete).
## Resolved (2026-06-15)
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
unbound) and plate-binding (`plates[]`, **default off** = any car). Either, both, or neither.
- **Plate vs. credential:** when plate-bound, **card/QR OR matching plate** — either is accepted
identity (not a second factor); card-sharing not prevented by design, caught by
[[reconciliation]] after.
- **Autonomy:** **host-in-the-loop for everything** — no onboard card list needed, so the
[[dingtian-relay]] stays sufficient (no new controller). Permit entry **fails closed** if the
host is down ([[fail-state-safety]]). One code path for transient + permit.
## Open questions
1. **Reader hardware** — confirm the RF reader and the QR/optical reader models (procurement;
relates to [[bom]] and [[open-questions]]). RF need not be Wiegand now that autonomy isn't
required, but a Wiegand-out reader keeps options open.
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm
with operator.
+27 -4
View File
@@ -7,7 +7,7 @@ updated: 2026-06-14
# Index # Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 1 source · 15 entities · 12 concepts · 2 decision records. Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
## Overview & navigation ## Overview & navigation
- [[overview]] — the top-level synthesis and entry point. - [[overview]] — the top-level synthesis and entry point.
@@ -16,6 +16,8 @@ Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
## Sources ## Sources
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions. - [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
- [[gee-qr-er80]] — datasheet: GEE QR access reader (QR/DM/1D; Wiegand/RS-232/485/USB/TCP; Linux).
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
## Entities — technology stack ## Entities — technology stack
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in. - [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
@@ -37,6 +39,7 @@ Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth). - [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path). - [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source. - [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
- [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued. - [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware). - [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6. - [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
@@ -54,11 +57,11 @@ Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
## Concepts — device architecture & safety ## Concepts — device architecture & safety
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter. - [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable). - [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
- [[first-run-setup]] — admin assigns devices per lane from the catalog at install. - [[first-run-setup]] — admin adds controllers + binds readers/cameras to relays from the catalog at install.
- [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth. - [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth.
- [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example). - [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example).
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[printer-roles-failover]] — ≥2 printers per lane by role; entry ticket falls back outside→booth. - [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI. - [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. - [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog. - [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
@@ -69,15 +72,35 @@ Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
- [[event-log-ingestion]] — host-side index tracking that makes the UHPPOTE log trustworthy. - [[event-log-ingestion]] — host-side index tracking that makes the UHPPOTE log trustworthy.
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay). - [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay. - [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention. - [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
## Concepts — business domain
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS).
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[permit]] — subscription; RF/QR or plate identity, registered-cars + max-concurrent, host-in-loop; short-circuits payment.
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness).
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
## Dev environment (reference) ## Dev environment (reference)
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin. - [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after. - [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
## Decisions ## Decisions
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers). - [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
- [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred. - [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker). - [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state. - [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale. - [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
+447
View File
@@ -297,3 +297,450 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a - Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
device field); device provenance is in `identity`. device field); device provenance is in `identity`.
- Updated [[append-only-event-chain]]. - Updated [[append-only-event-chain]].
## [2026-06-15] test+lesson | Hikvision camera verified; multi-subnet source-address trap
- Pulled a real snapshot from a Hikvision camera on the bench: `GET
http://10.0.10.121/ISAPI/Streaming/channels/101/picture`, Digest auth, admin/admin123 → HTTP 200,
2688×1520 JPEG. Path + auth + creds confirmed. ISAPI is the right surface; the device's
"Enable Hikvision-CGI" toggle is a *different* legacy CGI API and is NOT needed.
- Caveat recorded: the camera driver is still a STUB — the wizard's "● ready — stub / ●
preconditions OK" contacts nothing; cameras have no preconditions (only [[dingtian-relay]]
implements checkPreconditions). Noted the cosmetic "Backend push IP" bug (camera pulls, doesn't
push; field should gate on a `pushesToBackend` capability).
- LESSON (cost an hour of "why can't we ping the subnet"): with two device subnets stacked on one
NIC (`192.168.1.123` + `10.0.10.203` on eth1), Linux picked the WRONG source address for
`10.0.10.x` → ARP shows REACHABLE but all ping/TCP times out. Fix: pin `src` on the connected
route (`ip route change <subnet>/24 dev <nic> proto kernel scope link src <host-ip>`), or force
source per-call (`ping -I` / `curl --interface`). Devices arrive on assorted static `/24`s; the
host carries one IP per subnet — this trap is the recurring cost of that.
- Decision context: production is a dedicated hardened **Linux appliance** (this WSL2 box is a dev
stand-in). Multi-subnet config + `src` pinning is an appliance deployment concern (made
persistent via networkd/netplan), riding on [[network-isolation]]; long-term answer is to re-IP
devices onto one planned parking subnet at install.
- Updated [[lpr-camera]] (snapshot driver + verified-on-hardware section), [[wsl-dev-networking]]
(multi-subnet source-address trap + appliance pattern).
## [2026-06-15] driver+fix | Real Hikvision/Dahua camera driver; push-IP field gated
- Replaced the camera STUB with a real `HttpCamera` (`packages/devices/src/drivers/camera.ts`):
Hikvision ISAPI (`/ISAPI/Streaming/channels/<ch>01/picture`) + Dahua CGI (0-based channel), both
over client-side HTTP Digest (new `drivers/http-digest.ts`, two-shot 401→challenge→response,
qop=auth MD5 — the client counterpart to the server's digest-auth.ts). `healthCheck()` now
actually pulls a frame instead of returning `ready/stub`. Added `localAddress` + `timeoutMs` +
`channel` config; threads the device-facing NIC for the multi-subnet trap.
- Snapshot interface: `Snapshot` now carries `bytes: Buffer` (driver fetches); `imageRef` is
optional and set by the CALLER once stored — keeps the adapter free of storage deps. Nothing
consumed captureSnapshot yet, so no migration needed.
- Cosmetic bug fixed: "Backend push IP" showed for any reachable host. Added a `pushesToBackend`
flag to `DeviceDriver` (only [[dingtian-relay]] sets it), exposed as `pushCapable` in the catalog
(mirrors `discoverable`), and gated both the wizard's backend-IP fetch and the field on it.
Cameras/printers/readers no longer show it.
- VERIFIED on hardware: built clean (5/5 packages); ran the real driver against the Hikvision at
10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic).
- Updated [[lpr-camera]].
## [2026-06-15] fix | Permanent WSL2 source-address fix (systemd hook)
- The multi-subnet source-address trap kept recurring (every `wsl --shutdown` wipes the runtime
`ip route` pin — mirrored mode re-clones the Windows NIC's addresses fresh each boot, and NOTHING
inside Linux owns them: networkd/NM/netplan all inactive). Made it permanent on the dev box.
- `deploy/wsl-fix-route-source.sh`: walks each `proto kernel scope link` route on the NIC and pins
`src` to the host's own address in that same subnet — no hardcoded IPs (covers future device
subnets), idempotent, preserves route metric, non-fatal per route. `deploy/parking-net.service`:
oneshot, enabled, reapplies on every boot.
- BUGS hit + fixed while building it: (1) `ip route change` errors `RTNETLINK: No such file` when
the route isn't up yet at boot → use `replace`; (2) `set -e` made one failed `ip` abort the whole
unit → dropped it, per-route warnings instead; (3) `network.target` fires before mirrored-mode
addresses land → script waits up to 15s for a route.
- VERIFIED: service enabled+active, journal shows `pinned 10.0.10.0/24 -> src 10.0.10.203`, camera
pings with NO -I flag (0% loss), and the real Hikvision driver pulls a snapshot with NO
`localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the
self-contained Linux answer.
- Updated [[wsl-dev-networking]].
## [2026-06-15] design | Business layer kickoff — parking session model
- Pivoted from the (hardware-verified) device/integrity layer to the business domain. Wiki-first.
- KEY DECISION: a [[parking-session]] is a PROJECTION over the signed [[append-only-event-chain]],
never a mutable table — a mutable sessions row with paid/owed would reopen the operator-fraud
hole the whole system closes. "Paid" = a signed `payment` event (unforgeable, undeletable).
- Scope (user): mixed site, TRANSIENT-FIRST; [[permit]] holders layered as a 2nd identity source
that short-circuits payment. Payment = PAY-ON-FOOT / pay station (decoupled from exit; exit lane
only validates paid + within walk-back grace). Matches [[autonomous-direction]].
- New signed event types designed (not yet built): `vehicle_entry`, `vehicle_exit`, `payment`,
`void` — extend `input_received`. Lifecycle OPEN→PAID→CLOSED (+VOIDED); overstay top-up is the
one genuinely stateful edge case.
- New pages: [[parking-session]], [[tariff]] (pure/data-driven fee fn; gracePeriodExit is a real
pay-on-foot revenue param), decision [[session-model]]. Updated [[append-only-event-chain]],
[[index]]. Closes the dangling entry-flow thread from [[device-input-flow]].
- [[permit]] drafted + RESOLVED from user input: credentials = RF tag/chip/card + QR (optical
reader). Car limits = two numbers: `registeredCars[]` whitelist + admin-set `maxConcurrent` (in
at once) — enforced as a fold over the permit's open sessions. Identity = card/QR OR matching
plate (either opens; card-sharing not prevented by design, caught by reconciliation). Autonomy =
host-in-the-loop for everything → Dingtian stays sufficient, no new controller; permit entry
fails closed if host down. Remaining open: reader hardware models; lapsed/revoked policy.
- NEXT: schema (`packages/db`: permits/tariffs + session projection) + the
input_received→vehicle_entry flow (closes the [[device-input-flow]] thread).
## [2026-06-15] design | Host-side vision service (ANPR + vehicle verification)
- User: optionally bind camera images to an OpenCV service we build. Resolved scope: ANPR (plate →
`IdentitySource='lpr'`); a **separate local Python/OpenCV microservice** on the appliance (Node →
localhost HTTP), offline; it **replaces the dedicated edge-AI [[lpr-camera]]** (recognition on
ordinary Hikvision/Dahua snapshots — reuses `Snapshot.bytes`).
- LICENSING: best ANPR/vehicle models are AGPL/commercial vs. the MIT/Apache/BSD standing rule.
Decision: **scoped AGPL exception** — allowed INSIDE the vision service only (separate process,
not linked); app stays permissive. Amended [[standing-decisions]].
- USER ANTI-FRAUD INSIGHT: a fraudster can print a registered plate and enter with a different car.
→ service also does **vehicle-attribute / fingerprint verification**, so the *car* reconciles, not
just the plate. This fills the independent-witness gap [[append-only-event-chain]] calls out:
plate-on-different-car = anomaly. Recognition is advisory (confidence + ticket fallback), evidence
(read + image) attaches to the signed event.
- New pages: [[opencv-anpr-service]], decision [[vision-service]]. Updated [[standing-decisions]],
[[lpr-camera]] (host-side supersedes edge-AI), [[permit]] (plate-spoof defence),
[[append-only-event-chain]] (vision as witness), [[index]].
- Open: recognizer/vehicle-model choice + accuracy; fingerprint method + anomaly threshold; appliance
compute (CPU vs GPU/NPU); per-camera opt-in; the still-unbuilt reconciliation logic.
## [2026-06-15] design | Transient pricing — composable, versioned tariff
- User: pricing is unknown + constantly changing → must be **admin-composable at runtime**, currency
selectable, FX later. Reframed [[tariff]] from "config we ship with numbers" to a first-class
editable entity.
- DECISIONS: (1) rate structure = **stepped duration blocks + rolling-24h daily cap** (flat rate is
one block; expresses first-hour/taper/cap with no special cases); (2) overstay top-up =
**reprice the difference** (recompute entry→now − alreadyPaid); (3) tariffs are **effective-dated
immutable versions** — edits publish a new version, sessions reprice against the version in force,
the `payment` event records `tariffVersionId` (reproducible + fixed in the signed chain); (4)
**one active tariff per site**, but modelled with id/scope so multi-tariff needs no migration;
(5) **currency selectable (ISO 4217)**, money = `{minorUnits, currency}`, payment reserves a null
`fxRate` → FX-ready, **FX engine deferred** (needs offline rate source — new [[open-questions]] #8).
- Ships with **no rate card**; owner must compose+publish one (blank = free or gated, operator
policy — open). Numbers in the page are illustrative, not defaults.
- Wrote the pure integer fee algorithm into [[tariff]] (data model: `tariffs` + immutable
`tariff_versions`). Updated [[open-questions]] (#8 FX), [[index]].
- NEXT: schema (`packages/db`) for tariffs/versions + permits + session projection, then the
composer UI + the input_received→vehicle_entry flow.
## [2026-06-15] design | Shifts (manned-only) + Z-report; drop time-based token
- Q: what happens at operator shift end? Resolved scope, deliberately small.
- Shifts exist ONLY in manned mode — a human accountability boundary. The fully-automated/unmanned
system has NO shifts; the pay-station cash-collection cycle + [[reconciliation]] replace it.
- Shift is NOT time-based: relief arrives late / no-shows / one operator forced into a double.
→ **drop the 8h token expiry**; login valid **until explicit logout** (updated [[local-jwt-auth]];
code change pending). Start/End Shift are **explicit, independent of login** — one login spans many
shifts; a double = End then Start again, no re-login.
- End Shift = sum signed `payment` events in the shift by tender → append a signed `shift_z_report`
(type already in packages/shared, chained to prior Z) → **PRINT cash total + POS total (if a POS
is configured)**. That's the whole human-side ask. No blind count / variance gate / manager
override. Fraud control stays in the signed chain + later [[reconciliation]] (catch a skim after
the fact, not at close). Blind-count documented as an explicit optional add-on, not built.
- New page [[shift]]; updated [[local-jwt-auth]], [[index]].
- Open: Z sums by payment-time (the operator who took the money) — confirm; X-report (read-only
mid-shift); per-operator vs per-booth vs per-site (ties to [[open-questions]] #1). `payment` event
needs a `tender` field (cash/card) — fold into the schema step.
## [2026-06-15] design | Scope sweep — capacity, validation, reporting, integrity gaps
- "What else can a PMS do?" — swept the full feature surface against the design; user picked the
in-scope gaps. New pages:
- [[capacity-occupancy]] — occupancy = fold over open sessions; refuse entry + drive a FULL sign
when full; **exit never blocked** ([[fail-state-safety]]); zone-ready; counting-drift = anomaly.
- [[validation-discounts]] — merchant validates a ticket → **signed discount event** applied at
fee time ([[tariff]]); over-validation visible to [[reconciliation]]; payment records gross/disc/net.
- [[reporting-analytics]] — revenue/occupancy/stay/permit/anomaly reports as projections over the
chain; **plate-search** (admin looks up a session by plate IF captured — honest "not captured").
- [[clock-integrity]] — fees depend on the host clock; offline box → backdating attack; monotonic
index catches reorder, clock-regression = `anomaly`, RTC + privileged-only time change.
- [[blocklist]] — barred plates/cards refused at **entry only**; signed + attributed.
- Folded into existing pages: **manual overrides** = signed reason-coded events (legitimate
counterpart to the out-of-band-open anomaly) + **lost-ticket admin-arbitrary amount** →
[[parking-session]] + [[tariff]]; **backup/restore** confirmed in-scope, expanded [[open-questions]]
#5 (restored copy must still verifyChain; doubles as the reconciliation export).
- NOT captured (flagged): **intercom/help-call** — user didn't select it, but it's the only human
fallback for an unmanned lane; revisit. Deferred roadmap: reservations, mobile app, EV, loyalty.
- Updated [[index]].
## [2026-06-15] design | Second sweep — ticket encoding + anti-passback; money corners deferred
- More gap-hunting. New pages:
- [[ticket-encoding]] — the transient session key: opaque/unguessable **ticket id printed as QR**
by [[rongta-printer]], **scanned at pay station + exit** (new ReaderDevice/imager behind the
adapter); plate-as-ticket ticketless alt coexists per lane. The physical backbone of the
transient flow (was only implied).
- [[anti-passback]] — one id can't enter while it already has an OPEN session (card/ticket-passing
over the fence); a fold over the chain, *under* permit `maxConcurrent`. Soft (flag `anomaly`) by
default vs. hard (refuse); honest dependence on reliable exit detection.
- DEFERRED (user): **receipts/VAT invoices** + **refunds/change/overpay** — depend on pay-station
hardware + manned/unmanned payment subsystem; recorded as [[open-questions]] #9, revisit at
procurement (may change what the `payment` event stores → flagged before schema).
- Still open & load-bearing: **lane topology** (#1) — not resolved; scopes sessions/occupancy/shifts.
- Updated [[open-questions]] (#9), [[index]].
## [2026-06-15] decision | Split signed business ledger from device telemetry
- User correction before schema: the `events` table conflated TWO things — the anti-fraud business
ledger AND device telemetry (button pushes as `input_received`). Split them.
- `ledger_events` (rename of `events`): signed, hash-chained, ATECC608-signed business facts only
(vehicle_entry/exit, payment, void, shift_z_report + witness barrier_open_command/observed,
anomaly). Reconciliation + session/tariff/occupancy projections run on this.
- `device_events` (new, [[device-events]]): UNSIGNED hardware telemetry (relay fired, paper-out,
camera offline, reader read, raw input edges); high-volume, may rotate/prune; never reconciled.
- A raw button press is telemetry → device_events; the entry flow then mints a SIGNED vehicle_entry.
So `input_received`-as-signed-event is dropped (was transitional). No prod chain data exists, so
the rename/restructure is safe now (no signatures to invalidate).
- New: decision [[event-streams-split]], concept [[device-events]]; updated [[append-only-event-chain]]
(two streams + as-built-vs-pending), [[index]].
- NEXT (schema): rename events→ledger_events; add device_events; split ParkingEventType in shared;
then tariffs/versions, permits, blocklist, sessions projection. EventLog/canonicalize/verifyChain
+ /api/events follow the rename (code refactor, separate from this wiki commit).
## [2026-06-15] design+build | Entry flow (start) + valet/over-capacity captured
- Building the entry flow: device input → signed `vehicle_entry` → print ticket → pulseOpen.
- DECISION (print failure): **hold** — if all printers are down, sign an `anomaly` (entry attempt,
ticket unprinted) and do NOT open (no unticketed transient — couldn't pay on exit; operator
handles the held car). The `vehicle_entry` is appended ONLY on the success path, right before
pulseOpen — preserving "signed before open" and never logging an entry for a car that didn't get in.
- DECISION (capacity): wire transient entry now; the FULL gate comes later (needs capacity config +
occupancy fold).
- VALET / OVER-CAPACITY (user): "full" is a **soft, operator-configurable** policy — operator may
valet-accept over capacity (customer hands over keys + leaves, operator stacks the car). Manned-only,
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
soft policy; NOT built into the entry flow (clean seam left). Deferred.
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
## [2026-06-15] build | Exit flow (pay-on-foot validation)
- Built `apps/server/src/exit-flow.ts`. Added a `read` channel to the device bus (DeviceReadEvent:
ticket/plate/qr/card) — readers/LPR emit reads; entry stays button-driven, so reads are
unambiguously exit/identity events for now.
- 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.
- Pay station doesn't exist yet → no `payment` events → every transient exit currently REJECTS.
Correct end-state, not passable until pay-station lands (decided).
- VERIFIED against stubs: unpaid→anomaly+no-open; paid+grace→vehicle_exit+open+closed; grace-expired
→anomaly; unknown ticket→anomaly; verifyChain ok across entry→pay→exit.
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit);
fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to
[[open-questions]] #1). Updated [[parking-session]] as-built + gap, [[index]].
## [2026-06-15] build | Pay station + fee calc; JWT 8h → until-logout
- JWT: dropped the 8h `expiresIn` (server.ts global + login). Token now valid **until explicit
logout**; cookie maxAge = 30 days so a browser restart doesn't log out an active operator
(auth.ts `COOKIE_MAX_AGE_SECONDS`). Closes the pending change from the shift decision; updated
[[local-jwt-auth]].
- `computeFee(enteredAt, asOf, structure)` in `packages/shared` — pure integer fee calc.
TWO BUGS caught by tests: (1) grace must use RAW duration, not the rounded-up minutes (a 10-min
stay was being charged a full hour); (2) the block ladder must RESET each rolling-24h day (decision:
day 2 restarts at first-block pricing → 25h = 1200 cap + 200). Both fixed; 9 cases pass.
- Pay station (`apps/server/src/pay-station.ts` + routes `GET /api/pay/quote`, `POST /api/pay`):
open session → active tariff version → computeFee → signed `payment` event (amount/currency/tender/
tariffVersionId/graceExitMin); `overrideMinor` for lost-ticket/dispute. Cashier/operator/admin guard.
- VERIFIED: full loop entry→quote(300 for 90min)→pay→exit opens+closes, verifyChain ok. (A
raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.)
- Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full
loop passes).
## [2026-06-15] build | Tariff composer (makes the pay station operable)
- `validateTariffStructure` in `packages/shared` — non-negative ints, ascending block bounds, only
the last block open-ended; a malformed card can't be published.
- Routes (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active + history, any role) and
`POST /api/tariff/versions` (publish immutable version, ADMIN only). Single site `tariffs` row
created lazily. Editing = publish a new version (effective-dated, immutable).
- UI (`apps/web/src/TariffComposer.tsx`, admin shell next to SetupWizard): currency, grace windows,
increment, daily cap, lost-ticket, add/remove rate blocks; major-unit input → minor on submit;
shows active + history.
- VERIFIED via Fastify inject: GET empty→active null; invalid (out-of-order blocks)→400 w/ problem;
valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404
(session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5.
- Updated [[tariff]] (composer as-built).
## [2026-06-15] build | Permit entry/exit branch + read dispatcher
- `apps/server/src/permit-flow.ts` + `read-dispatch.ts`. 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`, shared in lane-map.ts). Refactored
ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution.
- Permit DIRECTION 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.
- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has
no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed.
No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open.
- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected,
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).
## [2026-06-15] build | Permit admin CRUD (route + UI)
- `apps/server/src/routes/permits.ts`: a permit is an aggregate (row + credentials + bound plates);
create/update replace the child sets as one unit. GET (any role, for lookup), POST/PUT/DELETE +
POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null; must have ≥1
credential OR ≥1 plate. Revoke = soft (keeps history); DELETE = hard (past ledger events untouched).
- `apps/web/src/PermitManager.tsx` in the admin shell: list + add/edit (holder, car-bound toggle →
maxConcurrent or unbound, validity window, credentials add/remove, plates as a list), revoke, delete.
- Makes permits usable without hand-seeding (companion to the tariff composer).
- VERIFIED via inject: empty + maxConcurrent=0 → 400 w/ messages; valid → 201; operator LIST 200 but
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
404, children cleaned. Full build 5/5.
- Updated [[permit]] (CRUD as-built).
## [2026-06-16] build | Shifts: open/close + signed Z-report (manned mode)
- Shift = two signed ledger events, NO mutable table: new `shift_open` event type + existing
`shift_z_report`. Operator = logged-in user (in event `identity`); open iff their latest shift
event is a `shift_open`. `apps/server/src/shift-service.ts`.
- Close sums `payment` events in the window by tender (cash/card, by payment time) → signed
`shift_z_report` (totals/counts/window) → prints via the NEW generic
`PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt printer.
Print is best-effort — failure doesn't undo the signed close (`printed:false` returned).
- Routes (`routes/shift.ts`, cashier/operator/admin): GET /api/shift/current, POST open (409 if
open), POST close (409 if none). UI `ShiftControl` in the shell (non-readonly): Start/End + Z totals.
- Added `printReport` to the PrinterDevice interface + Rongta driver (reusable for receipts later).
- VERIFIED: open→double-open 409→payments (cash+card; one dated outside the window excluded)→close
totals (cash 500/card 250/3)→close-again 409→re-open ok; readonly 403; verifyChain ok. Full build 5/5.
- Updated [[shift]] (as-built).
## [2026-06-16] build | Capacity / FULL gate (occupancy fold + transient refuse)
- Occupancy = fold over the ledger (entries−exits per identity; `apps/server/src/occupancy.ts`),
`getOccupancy` → {count, capacity, free, full}. Capacity = single-row `site_config` table (admin,
null=uncapped); migration 0001 (additive, no prompt).
- FULL gate in the TRANSIENT entry flow: occupancy.full → refuse (no ticket/entry/open) + signed
anomaly. Permit entry NOT gated (subscribers admitted past transient-full; their maxConcurrent
still applies) — occupancy can read over-capacity by design.
- Routes (`routes/site.ts`): GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-neg int or null). UI `SiteSettings`: live occupancy + FULL badge
(all), capacity editor (admin).
- VERIFIED: fill to cap=2 → 3rd transient refused (anomaly, no open); permit still admitted (occ 3/2,
free −1); exit frees a slot; routes RBAC (op can't set, −5→400, set/clear ok); verifyChain ok.
Full build 5/5. Physical FULL-sign relay output deferred.
- Updated [[capacity-occupancy]] (as-built).
## [2026-06-16] ingest | GEE-QR-ER80 QR access reader datasheet
- User has the reader; ingested `raw/GEE-QR-ER80 QR Code Access Control Reader.pdf`.
- CORRECTION: earlier guessed "ER80-EM" = a 125 kHz EM4100 prox-card reader. WRONG — the datasheet
shows **GEE-QR-ER80**, a **QR / DataMatrix / 1D barcode** optical access reader (optional ID/IC
card). It's the [[ticket-encoding|QR ticket]] scanner the design already needed, not a card reader.
- Specs: interfaces Wiegand 26/34 · RS-232 · RS-485 · USB · TCP/IP; 4–15 VDC <800 mA; 360°;
Windows + **Linux**; wiring VCC/GND/D0/D1/TX(R+)/RX(R-)/LED/BEEP. On hand: **`-Q-W`** (QR scanner;
Wiegand/RS-232/RS-485).
- Fit: host-side reader → a serial `ReaderDevice` adapter emitting `read` events → consumed by the
already-built exit flow + QR-permit path. Prefer RS-232/485 (serial) over Wiegand (Wiegand can't
carry variable-length QR; autonomy moot since [[dingtian-relay]] has no onboard ACL).
- New: source [[gee-qr-er80]] summary + entity [[gee-qr-er80]]. Updated [[ticket-encoding]],
[[entry-exit-readers]], [[index]].
- OPEN (blocks the adapter): the RS-232/485 **frame + baud** — is a QR scan an ASCII CR/LF string
(expected) or framed? Datasheet omits it; resolve via vendor docs or by observing the port.
## [2026-06-16] ingest+test | ER80 protocol = HTTP GET poll + JSON verdict (SDK)
- Hardware bring-up: configured the reader via the vendor Windows tool (server IP/port + "server
language"). Moved it to 10.0.10.7. It pings (source-pin must be 10.0.10.203 — trap recurs). No
beep on scans — initially looked like "not scanning."
- Found the QRCode SDK v1.6.5 (`QRCode_sdk - QRCode_v1_6_5/sdk/`). Protocol SETTLED, supersedes the
serial guess in [[gee-qr-er80]]: reader does **HTTP GET** `/qa/mcardsea.php?cardid&mjihao&cjihao&
status&time` on each scan; server replies **JSON** `{data:[{...,status,output}],code:0}`. Reply
`status` 1=valid(beep 2×)/0=invalid(beep 1×); `output` 0=Access/1=WG26/2=WG34; `time` syncs clock.
`status` low digit in the GET = direction (1=in/0=out).
- KEY: feedback/beep is decided by the SERVER REPLY, not locally → the "no beep" was my catch-all
replying plain "OK" not the JSON verdict, NOT a scan failure. Host-in-the-loop + SYNCHRONOUS.
- "Server language" (JSP/PHP/C#/ASP/CGI) only selects the URL PATH; transport is plain HTTP.
- New source [[qrcode-sdk]]; updated [[gee-qr-er80]] (protocol resolved, serial open-Qs dropped),
[[index]]. SDK kept in place (bulky+binaries), not copied to raw/.
- NEXT: backend route — parse GET, DECIDE (reuse permit/exit lookup), reply JSON verdict, emit on
read bus. Refactor read flows to RETURN an outcome so the reply can reflect accept/reject.
## [2026-06-16] build+fix | QR reader endpoint + ReadOutcome refactor; dev-DB migrate fix
- DB FIX: dev server crashed `no such table: lane_devices`. Cause: server `.env` DATABASE_URL points
at `apps/server/parking.sqlite` (the old dev DB I'd moved aside during the ledger split; new
migrations added since). Applied `drizzle-kit migrate` to that path → all 14 tables present. Fresh
DB → needs `seed-admin` + device re-assignment (empty, expected).
- REFACTOR: read flows now RETURN a `ReadOutcome {accepted,direction,reason}` (device-events.ts).
`ReadDispatcher.dispatch`, `ExitFlow.handleAt`, `PermitFlow.run` updated. A synchronous reader can
answer the device; fire-and-forget readers ignore it.
- ENDPOINT: `routes/qr-reader.ts` — `GET/POST /qa/mcardsea.php` (public; reader has no auth, on the
device subnet). Parses the SDK GET, dispatches the scan, replies the SDK verdict (status 1/0 →
beep 2×/1×, output 0, time-sync). Reader's lane keyed off device serial (cjihao) as lane_devices.id
for now.
- VERIFIED via inject: valid permit QR→status:1+open; re-scan→permit exit; unknown→status:0; reader
on barrier-less lane→status:0. Full build 5/5.
- Updated [[gee-qr-er80]] (endpoint as-built + hardware open items).
## [2026-06-16] test+fix | QR reader VERIFIED on hardware; path is .jsp not .php
- Ran a verbatim-vendor logger on :3000 (replies like mcardsea.php: status:0/output:2). Reader
**beeped** → it scans, sends, and acts on the reply. Earlier "no beep" = nothing was answering :3000.
- Real GET captured: `/qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=...`
from 10.0.10.7 (OEM = Fondvision, per referer).
- KEY FIX: the "server language" setting selects the URL EXTENSION — this unit is JSP → posts
**`.jsp`**, but our route was `.php` only (would 404 the reader). Route now registers
php/jsp/asp/aspx/cgi. Build green.
- Real serial **cjihao=H05M2AFA** = the lane key → assign reader as lane_devices.id="H05M2AFA".
Reader beeped on status:0 (invalid/1-beep); a matching permit/session → status:1 (2-beep accept).
- Updated [[gee-qr-er80]] (verified-on-hardware).
## [2026-06-16] feature | gee-qr-reader driver — assign by serial, resolve lane by config
- The QR reader is a push device; setup wizard always assigns a random-UUID id, so "id = serial"
isn't possible via the UI. Clean fix instead: new **`gee-qr-reader`** driver (reader category) with
a single `serial` config field. Admin assigns it in the wizard (UUID id) + types the serial.
- QR endpoint now resolves the lane by **matching `lane_devices.config.serial` to the scan's
`cjihao`** (was: row id == cjihao). `qrReaderRoutes(app, db, dispatcher)`. Unassigned serial →
no lane → status:0 (graceful).
- `tcpip-reader` flagged as the WRONG model for this device (host-connects-out stub).
- VERIFIED via inject through the real /api/setup/assign: assign {serial:"H05M2AFA"} → .jsp scan +
matching permit → status:1 + open; re-scan → exit; unknown card → status:0; unassigned serial →
status:0. Full build 5/5.
- Updated [[gee-qr-er80]] (assignment as-built).
## [2026-06-16] feature | stub-access driver (bench-test the flows without a relay)
- Live QR scan reached the real app (.jsp, serial resolved) but rejected: "reader not on an
access-equipped lane" — lane 1 had the reader but no access device. The dispatcher requires an
access device on the same lane.
- Added a no-op **`stub-access`** driver (access category, no config): `pulseOpen` just logs, no
device I/O — stands in on a lane to test QR→permit→accept (incl. the beep) without the
[[dingtian-relay]] connected. NOT for production. Registered in the catalog.
- To get a live accept: assign Stub barrier to the reader's lane + a permit whose QR = the scanned
code → status:1 (2-beep) + logged pulseOpen.
## [2026-06-16] fix | QR reader 10s beep delay — reply must Connection: close
- Live accept worked (status:1, pulseOpen, 2 beeps) but the beep came ~10 s LATE. Server responded
in 14.7 ms; user confirmed request is fast, only the beep lags → delay is the READER, not us.
- Cause: reader sends `Connection: keep-alive` but only ACTS on the verdict once the socket CLOSES;
Fastify kept it alive → reader waited out a ~10 s keep-alive timeout. Every vendor demo replies
`Connection: close` + shuts the socket.
- Fix: endpoint sets `reply.header("connection","close")`. Verified the header is now sent.
- Updated [[gee-qr-er80]] (⚠️ Connection: close requirement).
## [2026-06-16] feature | lane direction (per-device entry/exit) + camera snapshots
- Direction is per **device**, not per lane: added `direction` (`entry`/`exit`/`both`, default
`both`) to `lane_devices`. A lane's entry set = devices tagged entry|both, exit set likewise —
no join table; tagging in the wizard IS the grouping. Rejected a lane-level `lanes` table (can't
model one bidirectional lane). New [[lane-direction]] concept page; cross-linked [[entry-exit-readers]].
- All flows now resolve via `deviceRowsFor(lane, category, direction)` (lane-map.ts), replacing the
ad-hoc "first access on lane" lookups. Dispatcher trusts the **reader's own direction**; a
directional reader that contradicts the car's session state is a wrong-lane/anti-passback refusal.
`both` keeps the old infer-from-session behavior.
- Relay open channel is now config-driven (`config.openChannel`, default 1) since a lane can hold
an entry **and** an exit relay. LPR stays a snapshot sink — ANPR POSTs a `plate` read to the
reader endpoint (no camera-as-input coupling).
- Camera snapshots wired into all four paths (transient/permit × entry/exit): fired AFTER
pulseOpen, **never awaited** (evidence, not a gate — camera failure can't block an open). Stored
as BLOB in a new `snapshots` table (not files); telemetry `kind:"snapshot"` device_event per
capture/failure; linked to the signed event by `identity`. Served via `GET /api/snapshots/:id`.
- Migration `0002_wild_odin.sql` (additive). Snapshot **retention** left unresolved →
[[open-questions]] #10. Whole monorepo typechecks; no test suite exists in-repo.
## [2026-06-16] redesign | SUPERSEDES the above — pool-of-spaces, per-relay direction, NO lane
- User correction: direction is NOT a property of a device row. One Dingtian board has 2+ relays;
a single board drives both the entry barrier (relay 1) and the exit barrier (relay 2), and a
single relay can even serve **both**. So the row-level `direction` from the entry above was wrong.
- Further: the whole **"lane" concept was dropped**. Occupancy is site-wide, device grouping is now
the reader→relay binding, and anti-fraud never used lane. A parking lot = **one pool of spaces**
with a flexible set of entry/exit points (1 in + 2 out, etc). New [[entry-exit-points]] page
(replaces lane-direction); reworked [[entry-exit-readers]], [[parking-session]], [[first-run-setup]],
[[device-registry]].
- Model now: access `config.relays = [{ relay, direction: entry|exit|both, button? }]` (`button` =
the input terminal the entry button is wired to). Readers/cameras `config.controllerId + relay`
bind to the barrier they sit at; direction inherited ("the relay at that reader" opens on a read).
- Schema: dropped `lane` from `ledger_events`, `device_events`, `sessions`; renamed `lane_devices`
→ `devices` (no lane/direction columns). `lane` was in the SIGNED canonical form, so canonicalize()
dropped it and the signer keyId bumped **sw-hmac-v1 → sw-hmac-v2** (v1 events won't verify under
v2 — intentional, gated by per-event keyId; done pre-deployment on throwaway data). Migration
history reset to a fresh `0000_baseline` (dev DBs deleted + re-migrated).
- Resolvers in new `device-resolve.ts` (replaces lane-map.ts): `relayForButton`, `relayForDevice`,
`firstRelayByDirection`, `devicesByDirection`. `DeviceConfig` widened to nested JSON for `relays[]`.
- Wizard rewritten: no lane selector; Controllers section (relay map + entry-button terminal per
relay), then readers/cameras/printers bind to a controller relay. Whole monorepo typechecks +
builds; no test suite in-repo.
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
(device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass.
+37
View File
@@ -0,0 +1,37 @@
---
type: source
tags: [parking, hardware, readers, qr, datasheet]
sources: [gee-qr-er80]
updated: 2026-06-16
---
# Source: GEE-QR-ER80 QR Code Access Control Reader (datasheet)
Vendor datasheet (GEE NFC LIMITED, ©2007–2019) for the **GEE-QR-ER80** — a static
**QR-code access-control reader**, optional ID/IC card. The reader the project has
on hand for the [[ticket-encoding|QR ticket]] path. Raw:
`raw/GEE-QR-ER80 QR Code Access Control Reader.pdf` (3 pages). Entity: [[gee-qr-er80]].
## Key takeaways
- **Optical scanner**, not a prox-card reader: reads **QR, DataMatrix, 1D barcode** (static).
Optional add-ons for **IC card UID / ID card**.
- **Multi-interface:** **Wiegand 26/34, RS-232, RS-485, USB, TCP/IP** — selectable by variant.
- **Power:** 4–15 VDC, < 800 mA. **Read direction:** 360°. Built-in scanner LED.
- **OS:** Windows XP/7/8/10 **and Linux** (explicit) — fits the [[disk-os-hardening|Linux appliance]].
- **Wiring (Wiegand/RS-232/485 variant):** VCC(+12V), GND, **D0/D1** (Wiegand), **TX/R+ , RX/R-**
(RS-232 / RS-485), plus **LED** and **BEEP** control lines (host can drive feedback).
- **Order code** `GEE-QR-ER80-<scanner>-<interface>`: `Q`=QR scanner / `D`=ID reader / `C`=IC reader;
`W`=WG·RS232·RS485 / `U`=USB / `T`=RJ45 (TCP/IP). **On hand: `-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
## Section map
- p1 — overview, physical + feature table (interfaces, power, read direction).
- p2 — supported types (QR/DM/1D + optional IC/ID), OS, environment; **wire definition** (pin table).
- p3 — order-code breakdown, applications (access control / vacation rentals / time attendance).
## Not in this datasheet (open)
- The **RS-232/RS-485 data protocol**: baud rate, frame format, and whether a QR scan is emitted as
an **ASCII string** (expected) vs. some framed protocol. Decides the host-side adapter — see
[[gee-qr-er80]] open questions. Resolve by vendor docs or by observing the port on a scan.
+62
View File
@@ -0,0 +1,62 @@
---
type: source
tags: [parking, hardware, readers, qr, protocol, sdk]
sources: [qrcode-sdk]
updated: 2026-06-16
---
# Source: QRCode SDK v1.6.5 (GEE/Dingtian QR reader)
Vendor SDK for the QR access reader ([[gee-qr-er80]]; also branded Dingtian). Defines the
reader↔server **HTTP protocol** — the missing piece the datasheet omitted. Files at
`QRCode_sdk - QRCode_v1_6_5/sdk/` (config tool `QRCode_v1_6_5.exe`, demos in C#/PHP/VC++, protocol
docs `readme.txt`, `qrcode_HTTP_GET.txt`, `VC++/how to.txt`). **Not copied into `raw/`** — bulky +
binaries; this summary is the faithful capture. Entity: [[gee-qr-er80]].
## The protocol — HTTP GET poll, server replies the verdict
The reader is configured (via the Windows tool) with a **server IP/port + "server language"**
(JSP/PHP/C#/ASP/CGI — this only selects the URL path, e.g. `/qa/mcardsea.php`; transport is plain
HTTP either way). **On each scan** the reader sends:
```
GET /qa/mcardsea.php?cardid=445D2C&mjihao=1&cjihao=HW256097&status=11&time=1540402036 HTTP/1.0
```
| Param | Meaning |
| --- | --- |
| `cardid` | **the scanned QR/barcode data** (or card id) |
| `mjihao` | device id (machine number) |
| `cjihao` | device serial number |
| `status` | **2 chars**: high = valid `1`/invalid `0` (reader's own pre-check), low = direction **`1`=in / `0`=out**. A 1-char status = fail. |
| `time` | UTC time |
**Server → reader reply (JSON) — this is the access DECISION and drives the beep + output:**
```json
{"data":[{"cardid":"<echo>","cjihao":0,"mjihao":1,"status":1,"time":"<utc>","output":2}],"code":0,"message":""}
```
| Reply field | Meaning (from the C# demo comments) |
| --- | --- |
| `status` | **`1` = valid → buzzer 2×; `0` = invalid → buzzer 1×** |
| `output` | **`0` = Access, `1` = WG26, `2` = WG34** — output line/format driven on a valid read |
| `time` | UTC — **can sync the device clock** |
| `code` | `0` = success |
> **Implication (explains the "no beep"):** the reader's beep/accept is decided by the **server's
> reply**, not locally. A non-JSON / missing reply ⇒ no valid feedback ⇒ no beep, even though the
> scan succeeded. So "no beep" ≠ "didn't scan" — it means the server didn't answer with the verdict.
## Integration consequence
This is **host-in-the-loop, synchronous**: the GET *is* the access query; our JSON reply *is* the
decision. So the backend endpoint must **decide (valid/invalid + direction) and reply** — richer
than a fire-and-forget read. Direction comes from the `status` low digit. See [[gee-qr-er80]] +
[[device-input-flow]].
## Defaults / misc
- Default device IP `192.168.1.99` (`readme.txt`).
- Demos: PHP `qa/mcardsea.php` (minimal echo, status 0), C# raw-socket server on :80 (full parse),
VC++ raw HTTP example. All show the same GET-in / JSON-out contract.