feat(entry): admin bypass of the presence gate for faulty radar/camera
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 41s

The entry button (physical press AND the operator-issued mint) requires
radar/loop presence + camera detection to confirm a real vehicle. When one
of those devices is faulty, the gate blocks legitimate transient entry. Let
the ADMIN drop a specific signal as a requirement until support fixes the
hardware — the admin is not the adversary, but weakening an anti-fraud gate
stays attributed and auditable:

- Granular: bypass radar and camera independently (Setup → controller
  section). A dead camera drops only the camera check; a dead radar only
  radar. Both off = normal gate; both on = press-to-print.
- Signed: a DEDICATED endpoint (PUT /api/site-config/presence-bypass,
  site:update) appends a signed config_change {setting, value, prev,
  operator} per actually-changed signal — new ledger type. No-op toggles
  sign nothing; disabling signs too. Kept out of the generic site PUT.
- Flagged: every vehicle_entry issued (and every refusal anomaly) while
  bypassed carries presenceBypassed:[...] in its signed payload.
- Persists until turned off; amber warning in Setup while active. The
  booth entry light treats a bypassed signal as satisfied (server
  re-checks authoritatively). Physical-button path falls through to the
  cooldown backstop when radar is bypassed.
- Migration 0020: two boolean site_config columns (default off).

Fixes a latent bug surfaced by the tests: firstRelayByDirection returned no
presenceInput, so issueForOperator's radar gate always read "presence loop
unavailable" — operator-issue never actually gated on radar. The resolver
now attaches the presence input serving the relay (mirrors relayForButton).

10 new tests: 5 gate combinations (each bypass drops only its signal +
records it), 5 route tests (RBAC, signed transitions, no-op, validation).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-04 16:52:34 +02:00
parent 8b65e199a3
commit 6505a4a73b
16 changed files with 497 additions and 20 deletions
+24 -6
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { can, fetchEvents, fetchOccupancy, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js";
import { can, fetchEvents, fetchOccupancy, fetchSiteConfig, 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";
@@ -127,22 +127,30 @@ function BarrierLight({
radar,
onIssue,
issuing,
bypassRadar,
bypassCamera,
}: {
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). */
/** When set (entry light + permission), clicking issues an entry ticket — enabled when
* both presence conditions are satisfied, treating a BYPASSED signal as satisfied. */
onIssue?: () => void;
issuing?: boolean;
/** Admin bypass of a faulty device: a bypassed signal counts as present (server re-checks). */
bypassRadar?: boolean;
bypassCamera?: 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;
// A bypassed signal counts as satisfied (its device is faulty). The SERVER re-checks the
// effective gate authoritatively; this only governs button affordance.
const radarOk = radar || !!bypassRadar;
const cameraOk = busy || !!bypassCamera;
const canIssue = !!onIssue && radarOk && cameraOk && !issuing;
const clickable = !!onIssue && radarOk && cameraOk;
return (
<div
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid} ${
@@ -179,6 +187,14 @@ function LaneIndicators() {
const { isOpen: shiftOpen, isMine } = useShift();
const qc = useQueryClient();
const canIssue = can(user, "session:create") && shiftOpen && isMine;
// Presence-gate bypass flags (admin, for faulty radar/camera). Refetched on interval so a
// toggle reaches the booth without a reload; the server still re-checks authoritatively.
const { data: site } = useQuery({
queryKey: qk.siteConfig,
queryFn: fetchSiteConfig,
staleTime: 30_000,
refetchInterval: 60_000,
});
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const issue = useMutation({
@@ -207,6 +223,8 @@ function LaneIndicators() {
radar={radar?.entry ?? false}
onIssue={canIssue ? onIssue : undefined}
issuing={issue.isPending}
bypassRadar={site?.bypassPresenceRadar ?? false}
bypassCamera={site?.bypassPresenceCamera ?? false}
/>
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
{msg && (
+61
View File
@@ -6,7 +6,9 @@ import {
discoverDevices,
fetchBackendIps,
fetchCatalog,
fetchSiteConfig,
fetchState,
updatePresenceBypass,
testAnpr,
testDevice,
testPrint,
@@ -149,6 +151,8 @@ export function SetupWizard() {
onChanged={reloadState}
/>
<PresenceGatePanel />
{BOUND.map(({ key, titleKey, nounKey }) => (
<CategorySection
key={key}
@@ -167,6 +171,63 @@ export function SetupWizard() {
);
}
/** Admin control (in the controller section) to BYPASS a presence signal when its device is
* faulty. The entry button normally needs radar/loop AND camera; a dead device blocks legit
* transient entry. Dropping a signal is signed (config_change) + flags every ticket issued
* while bypassed. Persists until turned off. See wiki/concepts/entry-presence-bypass.md. */
function PresenceGatePanel() {
const { t } = useTranslation();
const [radar, setRadar] = useState<boolean | null>(null);
const [camera, setCamera] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchSiteConfig()
.then((c) => {
setRadar(c.bypassPresenceRadar);
setCamera(c.bypassPresenceCamera);
})
.catch((e) => setError((e as Error).message));
}, []);
async function toggle(signal: "radar" | "camera", next: boolean) {
setBusy(true);
setError(null);
try {
const c = await updatePresenceBypass({ [signal]: next });
setRadar(c.bypassPresenceRadar);
setCamera(c.bypassPresenceCamera);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
if (radar == null || camera == null) return null;
const active = radar || camera;
return (
<div className="mb-6 rounded-term border border-term-border/60 px-4 py-3">
<h3 className="mb-1 text-sm font-semibold text-term-text">{t("setup.presenceGateTitle")}</h3>
<p className="hint mb-3 max-w">{t("setup.presenceGateHint")}</p>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-[0.8125rem]">
<input type="checkbox" checked={radar} disabled={busy} onChange={(e) => toggle("radar", e.target.checked)} />
{t("setup.presenceBypassRadar")}
</label>
<label className="flex items-center gap-2 text-[0.8125rem]">
<input type="checkbox" checked={camera} disabled={busy} onChange={(e) => toggle("camera", e.target.checked)} />
{t("setup.presenceBypassCamera")}
</label>
</div>
{active && <p className="mt-2 text-[0.75rem] text-term-amber">⚠ {t("setup.presenceBypassActive")}</p>}
{error && <p className="mt-2 text-[0.75rem] text-term-red">{error}</p>}
</div>
);
}
function CategorySection({
category,
title,
+11
View File
@@ -1151,6 +1151,11 @@ export interface SiteConfig {
reserveSubscriberSpots: boolean;
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
anprEntryEnabled: boolean;
/** Entry presence-gate bypass: drop radar/loop as an entry-button requirement (faulty
* device). Set only via the dedicated signed endpoint, not saveSiteConfig. */
bypassPresenceRadar: boolean;
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
bypassPresenceCamera: boolean;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
@@ -1404,6 +1409,12 @@ export function fetchSiteConfig(): Promise<SiteConfig> {
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
}
/** Toggle the entry presence-gate bypass (radar/camera). Dedicated signed endpoint —
* each changed signal appends a config_change to the ledger. See entry-presence-bypass. */
export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean }): Promise<SiteConfig> {
return apiFetch("/api/site-config/presence-bypass", { method: "PUT", body: JSON.stringify(patch) });
}
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
}
+8
View File
@@ -218,6 +218,7 @@ export const en: Catalog = {
evtCashIn: "PAY-IN",
evtCashOut: "PAY-OUT",
evtCashReview: "REVIEW",
evtConfigChange: "CONFIG",
decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
@@ -477,6 +478,13 @@ export const en: Catalog = {
confirmRelayTest: "Pulse relay {{relay}} now? This physically opens the barrier and is recorded in the ledger as a test.",
relayTestOk: "✓ R{{relay}} pulsed — barrier opened",
relayTestFailed: "✗ R{{relay}} failed: {{detail}}",
// Entry presence-gate bypass (faulty radar/camera) — admin drops a signal as a requirement.
presenceGateTitle: "Entry presence gate",
presenceGateHint:
"The entry button normally needs both a radar/loop and a camera detection to confirm a real vehicle. If a device is faulty, bypass it so transients can enter until support fixes it. Each change is signed to the ledger, and tickets issued while bypassed are flagged.",
presenceBypassRadar: "Bypass radar / loop (faulty presence sensor)",
presenceBypassCamera: "Bypass camera (faulty vehicle detection)",
presenceBypassActive: "Presence bypass active — the entry gate is weakened. Turn off once the device is repaired.",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Show password",
hideSecret: "Hide password",
+8
View File
@@ -222,6 +222,7 @@ export const sq = {
evtCashIn: "ARKËTIM",
evtCashOut: "PAGESË",
evtCashReview: "SHQYRTIM",
evtConfigChange: "KONFIG",
decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
@@ -487,6 +488,13 @@ export const sq = {
confirmRelayTest: "Ky veprim hap fizikisht barrierën dhe regjistrohet në ledger si provë.",
relayTestOk: "✓ R{{relay}} u pulsua — barriera u hap",
relayTestFailed: "✗ R{{relay}} dështoi: {{detail}}",
// Anashkalimi i portës së pranisë (radar/kamera me defekt) — admini heq një sinjal si kusht.
presenceGateTitle: "Porta e pranisë në hyrje",
presenceGateHint:
"Butoni i hyrjes normalisht kërkon edhe radarin/lakun edhe një zbulim nga kamera për të konfirmuar një automjet real. Nëse një pajisje ka defekt, anashkaloje që kalimtarët të mund të hyjnë derisa ta rregullojë ekipi i mbështetjes. Çdo ndryshim regjistrohet në ledger, dhe biletat e lëshuara gjatë anashkalimit shënohen.",
presenceBypassRadar: "Anashkalo radarin / lakun (sensor prania me defekt)",
presenceBypassCamera: "Anashkalo kamerën (zbulim automjeti me defekt)",
presenceBypassActive: "Anashkalimi i pranisë aktiv — porta e hyrjes është dobësuar. Fike sapo pajisja të rregullohet.",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Shfaq fjalëkalimin",
hideSecret: "Fshih fjalëkalimin",
+1
View File
@@ -24,6 +24,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};