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
+3 -21
View File
@@ -44,12 +44,9 @@ export class ExitFlow {
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)
/** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the
* read dispatcher, which has already ruled out a permit match). */
async handleAt(lane: number, e: DeviceReadEvent): Promise<void> {
const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return;
this.#inFlight.add(key);
@@ -160,21 +157,6 @@ export class ExitFlow {
};
}
/** 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