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
+71 -10
View File
@@ -1,7 +1,8 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { can, fetchEvents, fetchOccupancy, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js";
import { rootRoute } from "./router.js";
import { qk } from "./lib/query.js";
import { useLiveStore } from "./lib/live-store.js";
import { useShift } from "./lib/use-shift.js";
@@ -116,16 +117,40 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
* - otherwise → SOLID green: free
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
* whose children inherit the alternating colour via `currentColor`. */
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
* Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create`
* and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry),
* the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence
* rule as the physical button; the server re-checks it. See operator-issued-entry.md. */
function BarrierLight({
label,
busy,
radar,
onIssue,
issuing,
}: {
label: string;
busy: boolean;
radar: boolean;
/** When set (entry light + permission), clicking issues an entry ticket — only enabled
* when both presence conditions meet (radar && busy). */
onIssue?: () => void;
issuing?: boolean;
}) {
const { t } = useTranslation();
// Blink only when the radar sees something the camera hasn't confirmed.
const blinking = radar && !busy;
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
// The issue control is active only with a REAL car present (radar AND camera).
const canIssue = !!onIssue && radar && busy && !issuing;
const clickable = !!onIssue && radar && busy;
return (
<div
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
title={label}
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid} ${
clickable ? "cursor-pointer hover:brightness-125" : ""
}`}
title={clickable ? t("booth.issueEntryTitle") : label}
onClick={canIssue ? onIssue : undefined}
role={clickable ? "button" : undefined}
>
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -135,22 +160,58 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
</svg>
<div className="leading-tight">
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
<div className="text-xs font-bold">
{issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"}
</div>
</div>
</div>
);
}
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
* and lane-presence (radar). */
* and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when
* the physical button is broken (permission + presence gated). */
function LaneIndicators() {
const { t } = useTranslation();
const lanes = useLiveStore((s) => s.lanes);
const radar = useLiveStore((s) => s.radar);
const { user } = rootRoute.useRouteContext();
const { isOpen: shiftOpen, isMine } = useShift();
const qc = useQueryClient();
const canIssue = can(user, "session:create") && shiftOpen && isMine;
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const issue = useMutation({
mutationFn: issueEntryTicket,
onSuccess: (r) => {
setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) });
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
setTimeout(() => setMsg(null), 4000);
},
onError: (e) => {
setMsg({ ok: false, text: (e as Error).message });
setTimeout(() => setMsg(null), 4000);
},
});
function onIssue() {
if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate();
}
return (
<div className="flex items-center gap-2">
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
<BarrierLight
label={t("booth.laneEntry")}
busy={lanes?.entry ?? false}
radar={radar?.entry ?? false}
onIssue={canIssue ? onIssue : undefined}
issuing={issue.isPending}
/>
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
{msg && (
<span className={`text-[0.6875rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</span>
)}
</div>
);
}