import { randomInt } from "node:crypto"; import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db"; import { NoPrinterAvailableError, printWithFailover, registry, type AccessControlDevice, type PrinterDevice, type PrinterInstance, type TicketData, type TicketHeader, } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceInputEvent } from "./device-events.js"; import { getOccupancy } from "./occupancy.js"; import type { EventLog } from "./event-log.js"; import { devicesByDirection, relayForButton, type ResolvedRelay } from "./device-resolve.js"; import { snapshotAsync } from "./snapshot.js"; // The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry // → open the barrier. The button is wired into an access controller's input; the // admin maps that input terminal to a relay (config.relays[].button), so a press // resolves to exactly the entry relay it should open. See entry-exit-points.md. // // Two invariants from the threat model + safety analysis: // 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger // BEFORE pulseOpen fires; an open with no matching signed event is the fraud // signal (wiki/concepts/append-only-event-chain.md). // 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if // all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket // unprinted) and leave the barrier closed; the operator handles the held car. // Crucially, NO vehicle_entry is written in that case — we never record an // "entered" event for a car that didn't get in (decision 2026-06-15). // // Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session. // (fail) sign anomaly, stop. export class EntryFlow { readonly #db: Db; readonly #log: EventLog; readonly #logger: FastifyBaseLogger; /** Guard against double-fire from the same physical press (on edge only). */ readonly #inFlight = new Set(); constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { this.#db = db; this.#log = log; this.#logger = logger; } /** Handle a device input edge. Acts only on the rising ("on") edge of an entry * button — an input terminal mapped to an entry relay on its controller. */ async onInput(e: DeviceInputEvent): Promise { if (e.edge !== "on") return; // release edge is just telemetry // The firing device must be an access controller, and the pressed input terminal // must map to an ENTRY (or both) relay — that's an entry button. Anything else // (reader/printer edge, exit-only relay's input) is not a transient-entry trigger. const resolved = relayForButton(this.#db, e.deviceId, e.input); if (!resolved) return; const key = `${e.deviceId}:${e.input}`; if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing this.#inFlight.add(key); try { await this.#runEntry(resolved); } catch (err) { this.#logger.error(`entry-flow failed: ${(err as Error).message}`); } finally { this.#inFlight.delete(key); } } async #runEntry(resolved: ResolvedRelay): Promise { // CAPACITY GATE (transient only). When the lot is full, refuse transient entry: // no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT // gated here (their flow ignores site-full; their own maxConcurrent applies), so // subscribers aren't locked out. "Full" is a soft policy seam for valet over- // capacity later. See wiki/concepts/capacity-occupancy.md. const occ = getOccupancy(this.#db); if (occ.full) { await this.#log.append({ type: "anomaly", payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true }, }); this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`); return; } const ticketId = newTicketId(); const issuedAt = new Date().toISOString(); const printers = this.#loadPrinters(); // 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry. const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() }; try { const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) => d.printTicket(ticket), ); this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`); } catch (err) { // HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the // failed attempt is in the tamper-evident record for the operator. const reason = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message; await this.#log.append({ type: "anomaly", identity: ticketId, payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false }, }); this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`); return; } // 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant). await this.#log.append({ type: "vehicle_entry", direction: "entry", source: "ticket", identity: ticketId, payload: { sessionRef: ticketId, ticketPrinted: true }, occurredAt: issuedAt, }); // 3. OPEN the resolved entry barrier (intent only; the barrier owns the close). const access = this.#buildAccess(resolved.controller); if (access) await access.pulseOpen(resolved.relay); else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`); // 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; // a camera failure must not delay or block the already-open barrier). void snapshotAsync({ db: this.#db, direction: "entry", identity: ticketId, logger: this.#logger, }).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`)); // 4. Update the session projection cache (rebuildable from the ledger; this is // just a fast read-model, never the source of truth). try { this.#db .insert(sessions) .values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" }) .run(); } catch (err) { // Cache miss is non-fatal — the ledger is authoritative and the projection // can be rebuilt. Log it; don't fail the (already-open) entry. this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`); } } /** Build a live access adapter from a resolved controller row, or null. */ #buildAccess(row: DeviceRow): AccessControlDevice | null { const driver = registry.get(row.driverId); if (!driver) return null; try { return driver.create(row.config as never) as AccessControlDevice; } catch { return null; } } /** Build live ENTRY printer instances (for failover selection). */ #loadPrinters(): PrinterInstance[] { const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered const out: PrinterInstance[] = []; for (const row of rows) { const driver = registry.get(row.driverId); if (!driver) continue; const cfg = row.config as Record; const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; try { out.push({ id: row.id, role, failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0, device: driver.create(cfg as never) as PrinterDevice, }); } catch { // skip a printer whose config won't build } } return out; } /** Park identity for the ticket header, from site_config (all fields optional; * the driver prints only what's set). See wiki/concepts/site-metadata.md. */ #ticketHeader(): TicketHeader | undefined { const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); if (!row) return undefined; return { parkName: row.parkName, operatorName: row.operatorName, nius: row.nius, address: row.address, phone: row.phone, }; } } /** * Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). * * Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check * digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and * an operator can hand-key it if every reader is down. RANDOM (not sequential): the * id must stay unguessable so an attacker can't iterate to claim a cheaper session * — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so * collisions are negligible at lot scale; the unique constraints on * ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual * entry reject a typo (validateTicketCode) instead of failing as "session not found". */ function newTicketId(): string { let body = ""; for (let i = 0; i < 12; i += 1) body += String(randomInt(10)); return body + luhnCheckDigit(body); } /** The Luhn (mod-10) check digit for an all-digit string. */ function luhnCheckDigit(digits: string): string { let sum = 0; // Walk right-to-left; the check digit sits at position 0 from the right, so the // last body digit is an "even" position that gets doubled. let double = true; for (let i = digits.length - 1; i >= 0; i -= 1) { let d = digits.charCodeAt(i) - 48; if (double) { d *= 2; if (d > 9) d -= 9; } sum += d; double = !double; } return String((10 - (sum % 10)) % 10); } /** * True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum. * Lets a manual-entry path (operator types the code off the ticket when readers are * down) reject a typo up front. A scanned/looked-up id that predates this format * (e.g. legacy `T-`) won't pass — callers should only gate MANUAL entry on it, * never reject an id that already exists in the ledger. See ticket-encoding.md. */ export function validateTicketCode(code: string): boolean { if (!/^\d{13}$/.test(code)) return false; const body = code.slice(0, 12); return luhnCheckDigit(body) === code[12]; }