From 2a36830880a710afb1577a7f54d3f23ec0f2fa85 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 15 Jun 2026 18:57:14 +0200 Subject: [PATCH] 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. --- apps/server/src/device-events.ts | 20 ++++ apps/server/src/exit-flow.ts | 194 +++++++++++++++++++++++++++++++ apps/server/src/server.ts | 10 ++ wiki/concepts/parking-session.md | 27 ++++- wiki/log.md | 17 +++ 5 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/exit-flow.ts diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index f643bbb..22b3549 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -15,6 +15,17 @@ export interface DeviceInputEvent { readonly source: "push" | "poll"; } +// A credential read at a lane: a ticket scanned at exit, a plate from LPR, a card +// at a reader. Drives identity-based flows (exit validation, and later permits / +// pay-station lookup). `kind` mirrors IdentitySource. See parking-session.md. +export interface DeviceReadEvent { + readonly driverId: string; + readonly deviceId: string; // lane_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 +} + /** A printer's status as tracked by the live monitor (status + identity). */ export interface PrinterStatusEvent { readonly deviceId: string; // lane_devices id @@ -33,6 +44,15 @@ class DeviceEventBus extends EventEmitter { return () => this.off("input", cb); } + /** A credential read (ticket scan, plate, card) at a lane. */ + 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. */ emitPrinterStatus(event: PrinterStatusEvent): void { this.emit("printer-status", event); diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts new file mode 100644 index 0000000..1dc954d --- /dev/null +++ b/apps/server/src/exit-flow.ts @@ -0,0 +1,194 @@ +import { and, eq, laneDevices, ledgerEvents, sessions, type Db } from "@parking/db"; +import { registry, type AccessControlDevice } from "@parking/devices"; +import type { LedgerPayload } from "@parking/shared"; +import type { FastifyBaseLogger } from "fastify"; +import type { DeviceReadEvent } 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 lane: number; + 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(); + + constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { + this.#db = db; + this.#log = log; + this.#logger = logger; + } + + /** Handle a credential read at an exit lane. */ + async onRead(e: DeviceReadEvent): Promise { + // Resolve which lane this reader belongs to, and that it's an exit reader. + const lane = await this.#exitLaneFor(e.deviceId); + if (lane == null) return; // not an exit-lane reader — ignore (other flows may handle) + + const key = `${e.deviceId}:${e.value}`; + if (this.#inFlight.has(key)) return; + this.#inFlight.add(key); + try { + await this.#runExit(lane, e); + } catch (err) { + this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`); + } finally { + this.#inFlight.delete(key); + } + } + + async #runExit(lane: number, e: DeviceReadEvent): Promise { + const view = this.#sessionFor(e.value); + + // No matching open session — unknown/duplicate ticket. Reject + log. + if (!view || !view.open) { + await this.#log.append({ + type: "anomaly", + lane, + identity: e.value, + payload: { reason: view ? "exit refused — session already closed" : "exit refused — no open session for credential", exitRefused: true }, + }); + this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`); + return; + } + + // 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", + lane, + identity: e.value, + payload: { reason, exitRefused: true, sessionRef: e.value }, + }); + this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`); + return; + } + + // Valid: sign the exit BEFORE opening, then open, then update the cache. + await this.#log.append({ + type: "vehicle_exit", + lane, + direction: "exit", + source: e.kind === "plate" ? "lpr" : "ticket", + identity: e.value, + payload: { sessionRef: e.value }, + }); + + const access = await this.#exitAccess(lane); + if (access) { + await access.pulseOpen(1); // exit barrier; door mapping is config-driven later + } else { + this.#logger.warn(`exit signed for ${e.value} but lane ${lane} has no access device to open`); + } + + 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}`); + } + } + + /** 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, + lane: entry.lane, + enteredAt: entry.occurredAt, + open: !exited, + paidAt, + graceExitMin, + }; + } + + /** The lane this reader belongs to, IF that lane has an access (barrier) device + * to open. A read event is an identity/exit signal (entry is button-driven), so + * any read at an access-equipped lane is treated as an exit attempt for now. + * (Distinguishing entry vs. exit readers per lane is a later lane-direction model.) */ + async #exitLaneFor(deviceId: string): Promise { + const row = await this.#db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get(); + if (!row || !row.enabled) return null; + const access = await this.#db + .select() + .from(laneDevices) + .where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, row.lane))) + .get(); + return access && access.enabled ? row.lane : null; + } + + /** The lane's access device, to open the exit barrier. */ + async #exitAccess(lane: number): Promise { + const row = await this.#db + .select() + .from(laneDevices) + .where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane))) + .get(); + if (!row || !row.enabled) return null; + const driver = registry.get(row.driverId); + if (!driver) return null; + try { + return driver.create(row.config as never) as AccessControlDevice; + } catch { + return null; + } + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32d8c2f..31996a4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 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 diff --git a/wiki/concepts/parking-session.md b/wiki/concepts/parking-session.md index e9c8385..2c7b199 100644 --- a/wiki/concepts/parking-session.md +++ b/wiki/concepts/parking-session.md @@ -96,8 +96,25 @@ Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorizatio ## What this unblocks (build order) -The device layer left the entry flow dangling — `input_received` events land in the log and stop -([[device-input-flow]] "the entry flow itself is the next build"). The session domain is that next -step: consume `input_received` / a reader event → mint a signed `vehicle_entry` → print + open. -Then the pay-station and exit-validation flows. Schema + code follow this page and [[tariff]]; -the decision is recorded in [[session-model]]. +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]]. +- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (new `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. + - **Currently every transient exit rejects** — no `payment` events exist until the pay station is + built; the validation is the correct end-state, just not passable yet. + +> **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is +> button-driven and exit is read-driven, so they don't currently collide — but a lane with both an +> entry reader and an exit reader can't yet be distinguished. A lane-direction/role model is needed +> before multi-reader lanes (relates to [[open-questions]] #1 topology). diff --git a/wiki/log.md b/wiki/log.md index 65d56a8..3bd5888 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -500,3 +500,20 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). 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]].