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.
This commit is contained in:
2026-06-15 18:57:14 +02:00
parent 2696d281ce
commit 2a36830880
5 changed files with 263 additions and 5 deletions
+10
View File
@@ -7,6 +7,7 @@ import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
import { deviceEvents } from "./device-events.js";
import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js";
import { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js";
@@ -91,6 +92,15 @@ 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.
const exitFlow = new ExitFlow(db, eventLog, app.log);
const unsubscribeExit = deviceEvents.onRead((e) => {
void exitFlow.onRead(e);
});
app.addHook("onClose", async () => unsubscribeExit());
const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded