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:
@@ -1,6 +1,7 @@
|
||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||
@@ -47,6 +48,11 @@ interface SessionView {
|
||||
* the barrier didn't open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult =
|
||||
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
||||
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
||||
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
||||
// See wiki/concepts/plate-reconciliation.md.
|
||||
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
||||
| { ok: true; opened: true }
|
||||
| { ok: true; opened: false; reason: string };
|
||||
|
||||
@@ -57,6 +63,11 @@ export type BoothReopenResult =
|
||||
| { ok: false; reason: string }
|
||||
| { ok: true; opened: boolean; reason?: string };
|
||||
|
||||
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
||||
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
||||
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
||||
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
||||
|
||||
export class ExitFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
@@ -88,7 +99,7 @@ export class ExitFlow {
|
||||
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||
* operator opens manually. Payment is never rolled back.
|
||||
*/
|
||||
async exitForBooth(identity: string): Promise<BoothExitResult> {
|
||||
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
|
||||
const id = identity.trim();
|
||||
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||
|
||||
@@ -122,6 +133,40 @@ export class ExitFlow {
|
||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
||||
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
||||
// (unless they consciously override). A denial here never traps the car — exit fails
|
||||
// open and the operator can override; the anomaly is the control either way.
|
||||
const swap = this.#reconcilePlateAtExit(id);
|
||||
if (swap) {
|
||||
if (!opts?.override) {
|
||||
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
||||
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
||||
});
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
||||
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
||||
}
|
||||
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: {
|
||||
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||
source: "booth",
|
||||
plateSwapOverride: true,
|
||||
plate: swap.plate,
|
||||
otherIdentity: swap.otherIdentity,
|
||||
...(opts.operator ? { operator: opts.operator } : {}),
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
||||
}
|
||||
|
||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||
// reader path does.
|
||||
if (freeGrace && view.freeGrace) {
|
||||
@@ -336,6 +381,25 @@ export class ExitFlow {
|
||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
||||
// automated lane to make the override decision, and exit fails open for safety, so we
|
||||
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
||||
// path (operator-mediated) is where the hold + override lives.
|
||||
const swap = this.#reconcilePlateAtExit(e.value);
|
||||
if (swap) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: e.value,
|
||||
payload: {
|
||||
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||
plateSwapSuspected: true,
|
||||
plate: swap.plate,
|
||||
otherIdentity: swap.otherIdentity,
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
||||
}
|
||||
|
||||
// Valid (a real payment within walk-back grace): sign + open.
|
||||
return this.#signExitAndOpen(resolved, e);
|
||||
}
|
||||
@@ -406,6 +470,43 @@ export class ExitFlow {
|
||||
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
||||
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
||||
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
||||
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
||||
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
||||
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
||||
* or null. See wiki/concepts/plate-reconciliation.md.
|
||||
*/
|
||||
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
||||
// The exiting car's plate: prefer its own exit read, else its entry read.
|
||||
const mine = plateForIdentity(this.#db, exitingId);
|
||||
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
||||
const wanted = mine.plate.trim().toUpperCase();
|
||||
|
||||
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
||||
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
||||
const openIds = this.#db
|
||||
.select({ id: sessions.id })
|
||||
.from(sessions)
|
||||
.where(eq(sessions.state, "open"))
|
||||
.all()
|
||||
.map((r) => r.id)
|
||||
.filter((id) => id !== exitingId);
|
||||
if (openIds.length === 0) return null;
|
||||
|
||||
const plates = platesForIdentities(this.#db, openIds);
|
||||
for (const [otherId, pv] of plates) {
|
||||
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
||||
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
||||
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
||||
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
||||
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||
#sessionFor(identity: string): SessionView | null {
|
||||
const rows = this.#db
|
||||
|
||||
Reference in New Issue
Block a user