feat(entry): operator-issued entry + exit plate-swap reconciliation
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s

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:
2026-07-01 12:17:52 +02:00
parent 114a32e6f2
commit 33c4ea1e91
20 changed files with 760 additions and 62 deletions
+75 -1
View File
@@ -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);
});
});