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>
)}
</>
)}
</>
+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>
);
}
+41 -7
View File
@@ -30,7 +30,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
}
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] };
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
const error = msg.error ?? `${path}: ${res.status}`;
// Ship the failed request to the backend log store (best-effort, loop-safe — the
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
@@ -38,7 +38,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
if (res.status !== 401) {
logFailedRequest({ path, method, status: res.status, error });
}
throw new ApiError(error, res.status, msg.problems);
throw new ApiError(error, res.status, msg.problems, msg);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
@@ -50,6 +50,9 @@ export class ApiError extends Error {
readonly status: number,
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
readonly problems?: string[],
/** The full parsed error body, for callers that need extra fields (e.g. a booth
* exit's plate-swap detail: { status, plate, otherIdentity, otherEnteredAt }). */
readonly body?: Record<string, unknown>,
) {
super(message);
}
@@ -1284,12 +1287,43 @@ export function voidTicket(identity: string, reason: string): Promise<{ ok: bool
}
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
* open (payment stands; operator opens manually). */
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
* open (payment stands; operator opens manually). `swapSuspected` = the exiting car's
* plate is already inside under a DIFFERENT ticket (possible ticket-swap); the operator
* must review and re-call with override:true to release. See plate-reconciliation.md. */
export type BoothExitResult =
| { ok: true; opened: boolean; reason?: string }
| { ok: false; swapSuspected: true; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null };
/** Validate + open the barrier for a session from the booth (when near the exit). */
export function boothExit(identity: string): Promise<BoothExitResult> {
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
/** Validate + open the barrier for a session from the booth (when near the exit).
* Pass override:true to consciously release a suspected plate-swap exit. */
export async function boothExit(identity: string, override = false): Promise<BoothExitResult> {
try {
return await apiFetch<{ ok: true; opened: boolean; reason?: string }>("/api/exit", {
method: "POST",
body: JSON.stringify({ identity, ...(override ? { override: true } : {}) }),
});
} catch (e) {
// A suspected plate-swap comes back 409 with status:"swap_suspected" + detail — surface
// it as a structured result (not a thrown error) so the modal can warn + offer override.
if (e instanceof ApiError && e.body?.status === "swap_suspected") {
const b = e.body;
return {
ok: false,
swapSuspected: true,
reason: String(b.error ?? ""),
plate: String(b.plate ?? ""),
otherIdentity: String(b.otherIdentity ?? ""),
otherEnteredAt: (b.otherEnteredAt as string | null) ?? null,
};
}
throw e;
}
}
/** Operator issues an entry ticket when the physical button is broken. A FLAGGED mint,
* server-gated on real vehicle presence (radar + camera). Returns the new ticket id. */
export function issueEntryTicket(): Promise<{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean }> {
return apiFetch("/api/entry/issue", { method: "POST", body: JSON.stringify({}) });
}
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
+12
View File
@@ -191,6 +191,10 @@ export const en: Catalog = {
fEvtAnomaly: "Anomaly",
openPayExit: "Open pay / exit",
openReopenBarrier: "Open — paid, awaiting barrier",
issueEntry: "Issue ticket",
issueEntryTitle: "Issue an entry ticket & open the barrier (physical button broken)",
issueEntryConfirm: "A vehicle is at the entry. Issue an entry ticket and open the barrier?",
issueEntryOk: "Entry ticket {{ticket}} issued.",
exitedGrace: "exited · grace",
exitedGraceLeft: "exited · {{time}}",
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
@@ -275,6 +279,8 @@ export const en: Catalog = {
reason: {
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry",
"exit.refused.closed": "Exit refused — session already closed",
"exit.refused.noSession": "Exit refused — unknown ticket",
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
@@ -284,6 +290,8 @@ export const en: Catalog = {
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
"exit.freeGrace": "Free entry-grace (no charge)",
"exit.manualOpen": "Manual barrier open (human intervention)",
"exit.plateSwapSuspected": "Possible ticket swap — plate {{plate}} is already inside under ticket {{otherIdentity}}",
"exit.plateSwapOverride": "Operator {{operator}} released a suspected ticket-swap exit (plate {{plate}}, also open under {{otherIdentity}})",
"sub.refused.notFound": "Subscription refused — not found",
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
@@ -944,6 +952,10 @@ export const en: Catalog = {
lookingUp: "looking up…",
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
swapTitle: "Possible ticket swap",
swapBody: "Plate {{plate}} is already inside under ticket {{other}} (entered {{when}}). This car may be exiting on a different ticket than it entered on.",
swapHint: "Verify the vehicle before releasing. Overriding is recorded against you.",
swapOverride: "Override & release",
subscription: "SUBSCRIPTION",
plan: "Plan",
prepaid: "PREPAID",
+12
View File
@@ -193,6 +193,10 @@ export const sq = {
fEvtAnomaly: "Anomali",
openPayExit: "Hap pagesën / daljen",
openReopenBarrier: "Hap — paguar, pret barrierën",
issueEntry: "Lësho biletë",
issueEntryTitle: "Lësho një biletë hyrjeje & hap barrierën (butoni fizik i prishur)",
issueEntryConfirm: "Një automjet është te hyrja. Të lëshohet një biletë hyrjeje dhe të hapet barriera?",
issueEntryOk: "Bileta e hyrjes {{ticket}} u lëshua.",
exitedGrace: "doli · në afat",
exitedGraceLeft: "doli · {{time}}",
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
@@ -278,6 +282,8 @@ export const sq = {
reason: {
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)",
"entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja",
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
@@ -287,6 +293,8 @@ export const sq = {
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
"exit.plateSwapSuspected": "Mundësi ndërrimi biletash — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
"exit.plateSwapOverride": "Operatori {{operator}} lëshoi një dalje me dyshim ndërrimi biletash (targa {{plate}}, edhe e hapur me {{otherIdentity}})",
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
@@ -960,6 +968,10 @@ export const sq = {
lookingUp: "Duke kërkuar…",
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
swapTitle: "Mundësi ndërrimi biletash",
swapBody: "Targa {{plate}} është tashmë brenda me biletën {{other}} (hyri {{when}}). Ky automjet mund të jetë duke dalë me një biletë tjetër nga ajo me të cilën hyri.",
swapHint: "Verifiko automjetin para se ta lëshosh. Anashkalimi regjistrohet në emrin tënd.",
swapOverride: "Anashkalo & lësho",
subscription: "ABONIM",
plan: "Plani",
prepaid: "I PARAPAGUAR",