feat(entry): admin bypass of the presence gate for faulty radar/camera
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user