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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user