diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 1dc954d..2dc5c37 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -44,12 +44,9 @@ export class ExitFlow { 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) - + /** 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 { 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 { - 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 diff --git a/apps/server/src/lane-map.ts b/apps/server/src/lane-map.ts index 88e0e81..4a586d2 100644 --- a/apps/server/src/lane-map.ts +++ b/apps/server/src/lane-map.ts @@ -1,4 +1,4 @@ -import { laneDevices, type Db } from "@parking/db"; +import { and, eq, laneDevices, type Db } from "@parking/db"; // Resolves a device instance id (lane_devices.id) to its lane number. // @@ -28,3 +28,21 @@ export class LaneMap { 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 { + 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; +} diff --git a/apps/server/src/permit-flow.ts b/apps/server/src/permit-flow.ts new file mode 100644 index 0000000..279c17b --- /dev/null +++ b/apps/server/src/permit-flow.ts @@ -0,0 +1,204 @@ +import { and, eq, laneDevices, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db } from "@parking/db"; +import { registry, type AccessControlDevice } from "@parking/devices"; +import type { FastifyBaseLogger } from "fastify"; +import type { DeviceReadEvent } from "./device-events.js"; +import type { EventLog } from "./event-log.js"; + +// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying. +// Reached from the read dispatcher when a read matches a permit (not an open ticket). +// See wiki/entities/permit.md. +// +// Two optional, independent bindings: +// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the +// permit's cars may be inside at once; enforced over the session projection. +// - plate: optional `plates[]` — when set, a matching plate is an accepted identity +// too (card/QR OR plate). When unset, any car may use the permit's card/QR. +// +// Direction is inferred from session state for THAT car (the read credential value +// is the per-car session key): no open session → ENTRY; open session → EXIT. So a +// fleet permit can have several cars in at once, each its own session, and +// anti-passback falls out (a second "entry" on a car already in becomes its exit). + +export interface PermitMatch { + readonly permitId: string; + /** The specific credential/plate value read — the per-car session key. */ + readonly carKey: string; + readonly via: "card" | "qr" | "plate"; +} + +export class PermitFlow { + 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; + } + + /** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */ + match(e: DeviceReadEvent): PermitMatch | null { + // Card / QR / generic credential value. + const cred = this.#db + .select() + .from(permitCredentials) + .where(eq(permitCredentials.value, e.value)) + .get(); + if (cred) { + return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" }; + } + // Plate binding: a read plate that matches a permit's bound plate is an identity. + if (e.kind === "plate") { + const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get(); + if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" }; + } + return null; + } + + /** Run the permit entry/exit for a matched read at a lane. */ + async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { + const key = `${m.permitId}:${m.carKey}`; + if (this.#inFlight.has(key)) return; + this.#inFlight.add(key); + try { + await this.#run(lane, e, m); + } catch (err) { + this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`); + } finally { + this.#inFlight.delete(key); + } + } + + async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise { + const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get(); + if (!permit) return; + + // Validity: active + within the coverage window. + const now = new Date().toISOString(); + const invalid = + permit.status !== "active" || + (permit.validFrom != null && now < permit.validFrom) || + (permit.validTo != null && now > permit.validTo); + if (invalid) { + await this.#reject(lane, m, `permit ${permit.status}/out-of-window`); + return; + } + + const carOpen = this.#carHasOpenSession(m.carKey); + + if (carOpen) { + // EXIT: this car is already inside → the read is its exit. + await this.#log.append({ + type: "vehicle_exit", + lane, + direction: "exit", + source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", + identity: m.carKey, + payload: { sessionRef: m.carKey, permitId: m.permitId }, + }); + await this.#open(lane, m.carKey, "permit exit"); + this.#closeCache(m.carKey); + return; + } + + // ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. + if (permit.maxConcurrent != null) { + const open = this.#permitOpenCount(m.permitId); + if (open >= permit.maxConcurrent) { + await this.#reject(lane, m, `permit at capacity (${open}/${permit.maxConcurrent} cars in)`); + return; + } + } + + await this.#log.append({ + type: "vehicle_entry", + lane, + direction: "entry", + source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", + identity: m.carKey, + // No ticket, no fee — the permit IS the authorization. Recorded for audit. + payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true }, + occurredAt: now, + }); + await this.#open(lane, m.carKey, "permit entry"); + try { + this.#db + .insert(sessions) + .values({ id: m.carKey, lane, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" }) + .run(); + } catch (err) { + this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`); + } + } + + /** Does this specific car (credential value) have an open session right now? */ + #carHasOpenSession(carKey: string): boolean { + const rows = this.#db + .select() + .from(ledgerEvents) + .where(eq(ledgerEvents.identity, carKey)) + .orderBy(ledgerEvents.index) + .all(); + const entries = rows.filter((r) => r.type === "vehicle_entry").length; + const exits = rows.filter((r) => r.type === "vehicle_exit").length; + return entries > exits; + } + + /** How many of this permit's cars are inside right now (fold over the ledger). */ + #permitOpenCount(permitId: string): number { + const rows = this.#db + .select() + .from(ledgerEvents) + .where(and(eq(ledgerEvents.type, "vehicle_entry"))) + .all() + .filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId); + let open = 0; + for (const entry of rows) { + if (!this.#carHasOpenSession(entry.identity ?? "")) continue; + open += 1; + } + return open; + } + + async #reject(lane: number, m: PermitMatch, reason: string): Promise { + await this.#log.append({ + type: "anomaly", + lane, + identity: m.carKey, + payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true }, + }); + this.#logger.warn(`permit refused (lane ${lane}, ${m.carKey}): ${reason}`); + } + + async #open(lane: number, carKey: string, what: string): Promise { + const access = await this.#access(lane); + if (access) await access.pulseOpen(1); + else this.#logger.warn(`${what} signed for ${carKey} but lane ${lane} has no access device`); + } + + #closeCache(carKey: string): void { + try { + this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run(); + } catch (err) { + this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`); + } + } + + async #access(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/read-dispatch.ts b/apps/server/src/read-dispatch.ts new file mode 100644 index 0000000..5809dcb --- /dev/null +++ b/apps/server/src/read-dispatch.ts @@ -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 { + 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); + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 752e125..d29d5f0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -9,6 +9,8 @@ import { EntryFlow } from "./entry-flow.js"; import { EventLog } from "./event-log.js"; import { ExitFlow } from "./exit-flow.js"; import { PayStation } from "./pay-station.js"; +import { PermitFlow } from "./permit-flow.js"; +import { ReadDispatcher } from "./read-dispatch.js"; import { LaneMap } from "./lane-map.js"; import { PrinterMonitor } from "./printer-monitor.js"; import { buildSigner } from "./signer.js"; @@ -96,14 +98,16 @@ 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. + // Read-driven flows: a credential read (ticket scan / plate / card) routes via the + // dispatcher to either the PERMIT flow (if it matches a permit) or the transient + // EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md. const exitFlow = new ExitFlow(db, eventLog, app.log); - const unsubscribeExit = deviceEvents.onRead((e) => { - void exitFlow.onRead(e); + const permitFlow = new PermitFlow(db, eventLog, app.log); + const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log); + const unsubscribeRead = deviceEvents.onRead((e) => { + void readDispatcher.dispatch(e); }); - app.addHook("onClose", async () => unsubscribeExit()); + app.addHook("onClose", async () => unsubscribeRead()); // Pay station (pay-on-foot): quote an open session against the active tariff + // take payment → signed `payment` event. See wiki/concepts/tariff.md. diff --git a/wiki/concepts/parking-session.md b/wiki/concepts/parking-session.md index 99b48e8..5246196 100644 --- a/wiki/concepts/parking-session.md +++ b/wiki/concepts/parking-session.md @@ -104,7 +104,10 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]]. - **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) → +- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the + **permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**. + Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built. +- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `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. diff --git a/wiki/entities/permit.md b/wiki/entities/permit.md index 6cbafe2..b848daa 100644 --- a/wiki/entities/permit.md +++ b/wiki/entities/permit.md @@ -99,6 +99,26 @@ stays append-only even though the permit record itself is editable. - **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or refused, per policy (OPEN). +## As-built (2026-06-15) + +`apps/server/src/permit-flow.ts`, reached via the **read dispatcher** +(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit** +(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler +serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*. + +- **Direction is inferred from session state for that car** — the read credential value is the + per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign + `vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet + permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an + inside car is its exit, never a second entry). +- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's + `vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound). +- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed. + No ticket, no fee — the permit is the authorization; every use is still a signed ledger event + carrying `permitId`. +- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays + closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch). + ## Resolved (2026-06-15) - **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or diff --git a/wiki/log.md b/wiki/log.md index d97a167..2671a42 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -548,3 +548,19 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404 (session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5. - Updated [[tariff]] (composer as-built). + +## [2026-06-15] build | Permit entry/exit branch + read dispatcher +- `apps/server/src/permit-flow.ts` + `read-dispatch.ts`. 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`, shared in lane-map.ts). Refactored + ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution. +- Permit DIRECTION 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. +- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has + no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed. + No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open. +- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected, + F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through + to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5. +- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).