727c62da90
- site_config gains optional park identity (park_name, operator_name, nius, address, phone, email); additive Drizzle migration 0001. GET/PUT /api/site-config read/write the full config (PUT partial patch, admin only); SiteSettings + SetupWizard expose the fields. - renderTicket() prints an Albanian header sourced from site_config, the all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits, and a lost-ticket footer. CP852 codepage so ë/ç render. - Widen the Code128 module width 2->3 and height 80->100 dots so the short-range "Simple" QR/barcode reader decodes reliably (was barely reading at module width 2 on the 80mm head). See wiki/concepts/site-metadata.md and ticket-encoding.md.
250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
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<string>();
|
|
|
|
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<void> {
|
|
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<void> {
|
|
// 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<string, unknown>;
|
|
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-<uuid>`) 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];
|
|
}
|