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
+41
View File
@@ -0,0 +1,41 @@
import type { Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js";
import type { PermitFlow } from "./permit-flow.js";
import { readerLaneWithAccess } from "./lane-map.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 (direction inferred from
// the car's open-session state),
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
// Lane is resolved once here; both flows act on a known access-equipped lane.
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<void> {
const lane = await readerLaneWithAccess(this.#db, e.deviceId);
if (lane == null) return; // reader not on an access-equipped lane — ignore
const permit = this.#permit.match(e);
if (permit) {
await this.#permit.run(lane, e, permit);
return;
}
// Not a permit → transient ticket exit (the exit flow rejects+logs if unknown).
await this.#exit.handleAt(lane, e);
}
}