c24d99b0f4
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.
49 lines
1.9 KiB
TypeScript
49 lines
1.9 KiB
TypeScript
import { and, eq, laneDevices, type Db } from "@parking/db";
|
|
|
|
// Resolves a device instance id (lane_devices.id) to its lane number.
|
|
//
|
|
// Device pushes/events carry the `lane_devices` id (which device fired), not a
|
|
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
|
|
// rebuilt from the DB at startup and refreshed whenever assignments change
|
|
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
|
|
// every input event, so a cached map beats a per-event DB lookup.
|
|
export class LaneMap {
|
|
readonly #db: Db;
|
|
#byDeviceId = new Map<string, number>();
|
|
|
|
constructor(db: Db) {
|
|
this.#db = db;
|
|
}
|
|
|
|
/** (Re)load the id->lane map from the lane_devices table. */
|
|
refresh(): void {
|
|
const rows = this.#db.select().from(laneDevices).all();
|
|
const next = new Map<string, number>();
|
|
for (const r of rows) next.set(r.id, r.lane);
|
|
this.#byDeviceId = next;
|
|
}
|
|
|
|
/** Lane for a device instance id, or null if the device isn't known. */
|
|
laneFor(deviceId: string): number | null {
|
|
return this.#byDeviceId.get(deviceId) ?? null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The lane a reader/scanner belongs to, IF that lane has an access (barrier)
|
|
* device to open — shared by the read-driven flows (exit + permit). A read is an
|
|
* identity signal; it only drives a barrier where there's one to drive. Returns
|
|
* the lane number or null. (Distinguishing entry- vs. exit-readers per lane is a
|
|
* later lane-direction model.)
|
|
*/
|
|
export async function readerLaneWithAccess(db: Db, deviceId: string): Promise<number | null> {
|
|
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
|
if (!row || !row.enabled) return null;
|
|
const access = await db
|
|
.select()
|
|
.from(laneDevices)
|
|
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, row.lane)))
|
|
.get();
|
|
return access && access.enabled ? row.lane : null;
|
|
}
|