feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.
(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
- PRESENCE-GATED like the physical button: a real car must be present (radar/
loop AND camera busy). Enforced BOTH sides — the server re-checks current
presence so a direct POST can't bypass a disabled button; no presence loop
=> feature unavailable; a no-presence attempt signs an anomaly.
- FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
companion entry.operatorIssued anomaly (the adversary path always leaves a
red-flag row).
- capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
a legit car).
New session:create permission (migration 0019 -> operator role, admin-
revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
factored into one shared #issueTicket (button + operator). UI: the entry
BarrierLight becomes a clickable issue-control when presence+permission+shift
meet (confirm -> issue).
(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
- BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
pay/exit modal shows a red warning + "Override & release" (override signs an
attributed exit.plateSwapOverride). Flag+override, never a silent hard block
(exit fails-open; a plate is never the sole gate).
- READER path (no operator): log-only anomaly + fail-open.
Extended BoothExitResult + /api/exit (override); boothExit client returns a
structured swap result.
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+114
-17
@@ -15,7 +15,7 @@ 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, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
@@ -229,9 +229,29 @@ export class EntryFlow {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#issueTicket(resolved, { source: "ticket" });
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
||||
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
||||
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
||||
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
||||
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
||||
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
||||
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
||||
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
||||
*/
|
||||
async #issueTicket(
|
||||
resolved: ResolvedRelay,
|
||||
opts: { source: "ticket" | "manual"; operator?: string; overCapacity?: { count: number; capacity: number | null } },
|
||||
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
||||
const ticketId = newTicketId();
|
||||
const issuedAt = new Date().toISOString();
|
||||
const printers = this.#loadPrinters();
|
||||
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
||||
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
||||
const operatorInitiated = opts.source === "manual";
|
||||
|
||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||
@@ -260,17 +280,14 @@ export class EntryFlow {
|
||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||
return;
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||
// later reprices the same way at exit. Today every transient takes the SITE
|
||||
// default category (operator policy, site_config.default_vehicle_category;
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
||||
// ignores it; only V2 category cards consult it.
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const category =
|
||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||
@@ -279,33 +296,113 @@ export class EntryFlow {
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
source: opts.source,
|
||||
identity: ticketId,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
||||
payload: {
|
||||
sessionRef: ticketId,
|
||||
ticketPrinted: true,
|
||||
category,
|
||||
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
||||
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
||||
},
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
||||
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
||||
if (operatorInitiated) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: ticketId,
|
||||
payload: {
|
||||
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
||||
source: "booth",
|
||||
operatorInitiated: true,
|
||||
...(opts.operator ? { operator: opts.operator } : {}),
|
||||
...(opts.overCapacity ? { lotFull: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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`);
|
||||
let opened = false;
|
||||
if (access) {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
opened = true;
|
||||
} 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).
|
||||
// 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). This is ALSO
|
||||
// what records the plate that plate-reconciliation reads at exit.
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||
// just a fast read-model, never the source of truth).
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||
.values({ id: ticketId, identity: ticketId, source: opts.source, 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}`);
|
||||
}
|
||||
return { ok: true, ticketId, opened };
|
||||
}
|
||||
|
||||
/**
|
||||
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
||||
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
||||
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
||||
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
||||
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
||||
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
||||
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
||||
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
||||
* See wiki/concepts/operator-issued-entry.md.
|
||||
*/
|
||||
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
||||
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
||||
> {
|
||||
const resolved = firstRelayByDirection(this.#db, "entry");
|
||||
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
||||
|
||||
// PRESENCE GATE — require BOTH a presence loop (configured + currently occupied) AND
|
||||
// the camera confirming a vehicle. No loop configured → feature unavailable here.
|
||||
if (typeof resolved.presenceInput !== "number") {
|
||||
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable" };
|
||||
}
|
||||
const present = this.#guardState(resolved).present;
|
||||
if (!present || !cameraBusy) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||
payload: {
|
||||
...reasonPayload("entry.issue.noPresence", { operator }),
|
||||
source: "booth",
|
||||
operator,
|
||||
radarPresent: present,
|
||||
cameraBusy,
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`operator entry refused by ${operator}: no vehicle present (radar=${present}, camera=${cameraBusy})`);
|
||||
return { ok: false, reason: "no vehicle detected at the entry" };
|
||||
}
|
||||
|
||||
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
||||
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
const occ = getOccupancy(this.#db);
|
||||
const res = await this.#issueTicket(resolved, {
|
||||
source: "manual",
|
||||
operator,
|
||||
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||
|
||||
Reference in New Issue
Block a user