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
+65 -22
View File
@@ -60,6 +60,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
const { user } = rootRoute.useRouteContext();
const canVoid = can(user, "event:void");
const [voiding, setVoiding] = useState(false); // reason prompt revealed
// Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning
// panel; the operator must consciously "Override & release". See plate-reconciliation.md.
const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null);
const [voidReason, setVoidReason] = useState("");
const s: SessionLookup | undefined = session.data;
@@ -178,7 +181,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
}
}
async function handlePayAndExit() {
async function handlePayAndExit(override = false) {
if (!s) return;
setError(null);
try {
@@ -186,7 +189,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
// session is "already paid" but a new period accrued — we still charge (canPay
// is true). A settled within-grace session is not payable (canPay false) and is
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
if (canPay) {
// On an OVERRIDE re-submit the payment already happened; don't double-charge.
if (canPay && !override) {
setPhase("paying");
await paySession(identity, tender);
}
@@ -197,7 +201,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
const r = await printVoucher(identity);
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
} else {
const r = await boothExit(identity);
const r = await boothExit(identity, override);
// PLATE-SWAP suspected → don't exit; surface the warning + offer an override.
if (!r.ok) {
setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt });
setPhase("review");
return;
}
setSwap(null);
// No voucher → auto-print a standalone payment receipt for transparency.
// Best-effort: a printer fault must NOT block the exit that already happened;
// the operator can reprint from the done screen.
@@ -493,6 +504,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div>
)}
{/* PLATE-SWAP warning: the exiting plate is already inside under another
ticket. A prominent, deliberate hold — the operator must consciously
override to release. See wiki/concepts/plate-reconciliation.md. */}
{swap && (
<div className="rounded-term border border-term-red bg-term-red/10 px-3 py-2">
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-red">
{t("pay.swapTitle")}
</div>
<div className="mt-1 text-[0.75rem] text-term-text">
{t("pay.swapBody", {
plate: swap.plate,
other: swap.otherIdentity,
when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—",
})}
</div>
<div className="mt-1 text-[0.6875rem] text-term-muted">{t("pay.swapHint")}</div>
</div>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
@@ -602,26 +632,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{t("pay.cancelTicket")}
</button>
)}
<button
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="btn btn-go btn-lg"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
{swap ? (
// Plate-swap held → the only forward action is a conscious
// override (re-submit with override:true; payment already taken).
<button
type="button"
onClick={() => handlePayAndExit(true)}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-danger btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("pay.swapOverride")}
</button>
) : (
<button
type="button"
onClick={() => handlePayAndExit()}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="btn btn-go btn-lg"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
)}
</>
)}
</>