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 { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { PayStation } from "./pay-station.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
@@ -33,6 +34,19 @@ async function enter(identity: string, enteredAt: string, payload?: Record<strin
|
||||
function exitsSigned(identity: string) {
|
||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||
}
|
||||
function anomalies(reason?: string) {
|
||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
|
||||
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
|
||||
}
|
||||
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
|
||||
* plate-reconciliation check can see this identity's plate against open sessions. */
|
||||
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
|
||||
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
|
||||
db.insert(deviceEventsTable).values({
|
||||
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
|
||||
detail: { identity, direction: "entry", plate, confidence },
|
||||
}).run();
|
||||
}
|
||||
|
||||
describe("exitForBooth — refusal gates", () => {
|
||||
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||
@@ -116,3 +130,63 @@ describe("reopenBarrier — no unpaid re-open", () => {
|
||||
expect(exitsSigned("T1")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
|
||||
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
|
||||
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
|
||||
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
|
||||
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
|
||||
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(exitsSigned("1237")).toHaveLength(1); // released
|
||||
const ov = anomalies("released a suspected ticket-swap");
|
||||
expect(ov.length).toBe(1);
|
||||
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
|
||||
});
|
||||
|
||||
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r.ok).toBe(true); // no warning — exits normally
|
||||
expect(exitsSigned("1237")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1237", minutesAgo(90));
|
||||
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
|
||||
expect(exitsSigned("1237")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user