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
+194
View File
@@ -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<string>();
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<void> {
// 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<void> {
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<number | null> {
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<AccessControlDevice | null> {
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;
}
}
}