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.
This commit is contained in:
2026-06-15 19:47:01 +02:00
parent b4d0dfadd6
commit c24d99b0f4
8 changed files with 317 additions and 29 deletions
+10 -6
View File
@@ -9,6 +9,8 @@ import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js";
import { PermitFlow } from "./permit-flow.js";
import { ReadDispatcher } from "./read-dispatch.js";
import { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js";
@@ -96,14 +98,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
});
app.addHook("onClose", async () => unsubscribeEntry());
// Exit flow: a credential read (ticket scan / plate) at an exit lane → validate
// the session is PAID + within grace → signed vehicle_exit → open. Pay-on-foot:
// the exit lane only validates; payment happens at the station. See parking-session.md.
// 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 unsubscribeExit = deviceEvents.onRead((e) => {
void exitFlow.onRead(e);
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 () => unsubscribeExit());
app.addHook("onClose", async () => unsubscribeRead());
// Pay station (pay-on-foot): quote an open session against the active tariff +
// take payment → signed `payment` event. See wiki/concepts/tariff.md.