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
+13 -1
View File
@@ -289,7 +289,19 @@ export function firstRelayByDirection(db: Db, direction: FlowDirection): Resolve
(r): r is RelaySpec & { direction: Direction } =>
r.direction === direction || r.direction === "both",
);
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
if (spec) {
// Attach the presence sensor (if any) serving the SAME relay, so callers that gate on
// presence (the operator-issued entry) see it. Without this the ResolvedRelay carried
// no presenceInput and the presence gate read as "unavailable". Mirrors relayForButton.
const presence = inputsOf(controller).find((i) => i.role === "presence" && i.relay === spec.relay);
return {
controller,
relay: spec.relay,
direction: spec.direction,
presenceInput: presence?.input,
presenceKind: presence?.kind ?? "loop",
};
}
}
return null;
}
+60 -10
View File
@@ -51,6 +51,10 @@ import type { VisionClient } from "./vision-client.js";
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
// See wiki/concepts/entry-double-press.md.
/** A presence signal the entry gate can require (or, when a device is faulty, the admin
* can bypass): the radar/loop presence input, or the camera vehicle-detection. */
export type PresenceSignal = "radar" | "camera";
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
interface RelayGuardState {
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
@@ -158,7 +162,12 @@ export class EntryFlow {
#suppressReason(r: ResolvedRelay): string | null {
const s = this.#guardState(r);
if (typeof r.presenceInput === "number") {
// Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints.
// We fall THROUGH to the cooldown backstop below (a dead loop can't re-arm one-car-one-
// ticket, so the time cooldown is what stops a held button minting a burst). If no
// cooldown is configured there's no anti-double-press left — that's the admin's accepted
// tradeoff while bypassed. See wiki/concepts/entry-presence-bypass.md.
if (typeof r.presenceInput === "number" && !this.#presenceBypass().radar) {
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
// ticket already issued for this still-present car).
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
@@ -244,7 +253,14 @@ export class EntryFlow {
*/
async #issueTicket(
resolved: ResolvedRelay,
opts: { source: "ticket" | "manual"; operator?: string; overCapacity?: { count: number; capacity: number | null } },
opts: {
source: "ticket" | "manual";
operator?: string;
overCapacity?: { count: number; capacity: number | null };
/** Presence signals that were BYPASSED (admin dropped them due to faulty hardware).
* Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */
presenceBypassed?: PresenceSignal[];
},
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
@@ -304,6 +320,9 @@ export class EntryFlow {
category,
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
...(opts.presenceBypassed && opts.presenceBypassed.length > 0
? { presenceBypassed: opts.presenceBypassed }
: {}),
},
occurredAt: issuedAt,
});
@@ -366,13 +385,33 @@ export class EntryFlow {
const resolved = firstRelayByDirection(this.#db, "entry");
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
// PRESENCE GATE — require BOTH a presence loop (configured + currently occupied) AND
// the camera confirming a vehicle. No loop configured → feature unavailable here.
if (typeof resolved.presenceInput !== "number") {
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable" };
// PRESENCE GATE — normally require BOTH radar/loop presence AND camera detection. An
// admin may BYPASS a signal when its device is faulty (site_config, signed config_change);
// the bypassed signal is dropped as a requirement and RECORDED on the issued ticket.
const bypass = this.#presenceBypass();
const bypassed: PresenceSignal[] = [];
// Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED;
// if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass).
const radarRequired = !bypass.radar;
let radarPresent: boolean | null = null;
if (radarRequired) {
if (typeof resolved.presenceInput !== "number") {
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" };
}
radarPresent = this.#guardState(resolved).present;
} else {
bypassed.push("radar");
}
const present = this.#guardState(resolved).present;
if (!present || !cameraBusy) {
// Camera side.
const cameraRequired = !bypass.camera;
if (!cameraRequired) bypassed.push("camera");
// Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle.
const radarOk = !radarRequired || radarPresent === true;
const cameraOk = !cameraRequired || cameraBusy;
if (!radarOk || !cameraOk) {
await this.#log.append({
type: "anomaly",
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
@@ -380,11 +419,14 @@ export class EntryFlow {
...reasonPayload("entry.issue.noPresence", { operator }),
source: "booth",
operator,
radarPresent: present,
radarPresent,
cameraBusy,
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
},
});
this.#logger.warn(`operator entry refused by ${operator}: no vehicle present (radar=${present}, camera=${cameraBusy})`);
this.#logger.warn(
`operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`,
);
return { ok: false, reason: "no vehicle detected at the entry" };
}
@@ -397,6 +439,7 @@ export class EntryFlow {
source: "manual",
operator,
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
});
if (!res.ok) return res;
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
@@ -414,6 +457,13 @@ export class EntryFlow {
);
}
/** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect
* with no restart. Default: nothing bypassed (the normal both-required gate). */
#presenceBypass(): { radar: boolean; camera: boolean } {
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false };
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it } from "vitest";
import { devices, siteConfig, ledgerEvents, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { EntryFlow } from "./entry-flow.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// The entry presence gate normally requires BOTH radar/loop presence AND camera detection.
// An admin may BYPASS a signal when its device is faulty (site_config, set via a signed
// endpoint). These tests pin the GATE decision in EntryFlow.issueForOperator under each
// bypass combination: a still-required-but-absent signal refuses (+ signs an anomaly); a
// bypassed signal is dropped and recorded. We assert the gate outcome via the refuse path
// (deterministic, no printer needed); the allow path is proven by getting PAST the gate
// (it then fails at printing — a different reason — which is exactly "the gate opened").
let db: Db;
let flow: EntryFlow;
const CTL = "ctl-entry";
const PRESENCE_INPUT = 2;
beforeEach(() => {
({ db } = createTestDb());
// A controller with an entry barrier (R1), a presence loop on input 2, and an entry button
// on input 1 — the shape device-resolve expects (relays[] + inputs[]).
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "stub-access",
config: {
relays: [{ relay: 1, direction: "entry" }],
inputs: [
{ input: 1, role: "button", relay: 1 },
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "loop" },
],
},
enabled: true,
}).run();
flow = new EntryFlow(db, makeLog(db), silentLogger());
});
function setBypass(patch: { radar?: boolean; camera?: boolean }) {
db.insert(siteConfig)
.values({ id: 1, bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false })
.onConflictDoUpdate({
target: siteConfig.id,
set: { bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false },
})
.run();
}
/** Drive a presence loop edge so the flow's per-relay guard marks a car present/clear. */
async function setRadarPresent(present: boolean) {
await flow.onInput({
driverId: "stub-access",
deviceId: CTL,
input: PRESENCE_INPUT,
edge: present ? "on" : "off",
at: new Date().toISOString(),
source: "poll",
});
}
const anomalies = () =>
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
describe("entry presence-gate bypass", () => {
it("no bypass + no vehicle → refuses and signs a noPresence anomaly", async () => {
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
expect(res.ok).toBe(false);
expect(anomalies()).toHaveLength(1);
expect(anomalies()[0].payload).toMatchObject({ reasonCode: "entry.issue.noPresence" });
});
it("camera bypassed + radar present → gate OPENS (no refuse anomaly)", async () => {
setBypass({ camera: true });
await setRadarPresent(true);
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false); // camera absent but bypassed
// Gate passed: no noPresence refusal. (It then proceeds to print — no printer configured,
// so it HOLDS with a print reason, not a presence reason. Either way the gate opened.)
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
expect(refusals).toHaveLength(0);
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
});
it("radar bypassed + camera busy → gate OPENS even with NO presence loop reading", async () => {
setBypass({ radar: true });
// radar NOT set present; camera busy=true → radar dropped, camera satisfies.
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
expect(refusals).toHaveLength(0);
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
});
it("camera bypassed but radar STILL required and absent → refuses (only the faulty signal is dropped)", async () => {
setBypass({ camera: true });
await setRadarPresent(false); // radar required (not bypassed) and clear
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
expect(res.ok).toBe(false);
const refusal = anomalies().find((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
expect(refusal, "the still-required radar gates the button").toBeTruthy();
// The refusal records which signal was bypassed (audit).
expect(refusal!.payload).toMatchObject({ presenceBypassed: ["camera"] });
});
it("both bypassed → gate OPENS with no radar and no camera (press-to-print)", async () => {
setBypass({ radar: true, camera: true });
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
expect(refusals).toHaveLength(0);
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
});
});
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ledgerEvents, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// PUT /api/site-config/presence-bypass toggles the entry presence-gate bypass. It's a
// DEDICATED, SIGNED endpoint: each signal that actually changes appends a config_change to
// the ledger (attributed), and it persists to site_config. Admin-only.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
const configChanges = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
async function put(body: unknown, auth: { cookie: string; csrf: string }) {
return app.inject({
method: "PUT",
url: "/api/site-config/presence-bypass",
headers: { cookie: auth.cookie, "x-csrf-token": auth.csrf },
payload: body as Record<string, unknown>,
});
}
describe("PUT /api/site-config/presence-bypass", () => {
it("is admin-only: a non-site:update user is 403", async () => {
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
const auth = await login(app, "op", "pw");
const res = await put({ camera: true }, auth);
expect(res.statusCode).toBe(403);
});
it("enabling a signal persists it AND signs an attributed config_change", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
const res = await put({ camera: true }, auth);
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ bypassPresenceCamera: true, bypassPresenceRadar: false });
const changes = configChanges();
expect(changes).toHaveLength(1);
expect(changes[0].source).toBe("manual");
expect(changes[0].signature.length).toBeGreaterThan(0);
expect(changes[0].payload).toMatchObject({
setting: "entryPresenceBypass.camera",
value: true,
prev: false,
operator: "admin",
});
});
it("a no-op toggle (already in that state) signs nothing", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
await put({ camera: true }, auth); // 1st: on → 1 event
await put({ camera: true }, auth); // 2nd: still on → no new event
expect(configChanges()).toHaveLength(1);
});
it("disabling signs the off transition too (auditable both ways)", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
await put({ radar: true }, auth);
await put({ radar: false }, auth);
const changes = configChanges();
expect(changes).toHaveLength(2);
expect(changes[1].payload).toMatchObject({ setting: "entryPresenceBypass.radar", value: false, prev: true });
});
it("rejects a non-boolean and an empty body", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
expect((await put({ camera: "yes" }, auth)).statusCode).toBe(400);
expect((await put({}, auth)).statusCode).toBe(400);
});
});
+67 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { getOccupancy } from "../occupancy.js";
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
@@ -38,13 +39,15 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
}
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
* + every metadata field. */
* + the entry presence-bypass flags + every metadata field. */
type SiteConfig = {
capacity: number | null;
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean;
anprEntryEnabled: boolean;
bypassPresenceRadar: boolean;
bypassPresenceCamera: boolean;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -54,6 +57,8 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
anprEntryEnabled: row?.anprEntryEnabled ?? true,
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
@@ -66,7 +71,7 @@ function normText(v: unknown): string | null {
return s === "" ? null : s;
}
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog | null): Promise<void> {
const readGuard = requirePermission("site:read");
const writeGuard = requirePermission("site:update");
@@ -131,4 +136,64 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
});
// Entry presence-gate BYPASS — a DEDICATED, SIGNED endpoint (not the generic PUT above),
// because dropping a radar/camera requirement weakens an anti-fraud gate. The admin is not
// the adversary (a faulty device blocks legit entry until support fixes it), but the change
// must be attributed + auditable: each toggled signal appends a signed `config_change`
// {setting, value, prev, operator}. Granular per signal. See wiki/concepts/entry-presence-bypass.md.
app.put<{ Body: { radar?: boolean; camera?: boolean } }>(
"/api/site-config/presence-bypass",
{ preHandler: writeGuard },
async (req, reply) => {
const body = req.body ?? {};
for (const k of ["radar", "camera"] as const) {
if (k in body && typeof body[k] !== "boolean") {
return reply.code(400).send({ error: `${k} must be a boolean` });
}
}
if (!("radar" in body) && !("camera" in body)) {
return reply.code(400).send({ error: "nothing to change (send radar and/or camera)" });
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const prev = {
radar: existing?.bypassPresenceRadar ?? false,
camera: existing?.bypassPresenceCamera ?? false,
};
const next = {
radar: "radar" in body ? (body.radar as boolean) : prev.radar,
camera: "camera" in body ? (body.camera as boolean) : prev.camera,
};
// Sign a config_change for each signal that ACTUALLY changed (before persisting, so the
// audit record exists whether or not a later write hiccups). No-op toggles sign nothing.
const operator = req.user?.username ?? "unknown";
for (const signal of ["radar", "camera"] as const) {
if (next[signal] !== prev[signal]) {
await eventLog?.append({
type: "config_change",
source: "manual",
identity: `presence-bypass:${signal}`,
payload: {
setting: `entryPresenceBypass.${signal}`,
value: next[signal],
prev: prev[signal],
operator,
},
});
}
}
const updatedAt = new Date().toISOString();
const patch = { bypassPresenceRadar: next.radar, bypassPresenceCamera: next.camera, updatedAt };
if (existing) {
db.update(siteConfig).set(patch).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, ...patch }).run();
}
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
},
);
}
+1 -1
View File
@@ -279,7 +279,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db);
await siteRoutes(app, db, eventLog);
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
+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" },
};