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