diff --git a/apps/server/src/device-resolve.ts b/apps/server/src/device-resolve.ts index d855620..1592104 100644 --- a/apps/server/src/device-resolve.ts +++ b/apps/server/src/device-resolve.ts @@ -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; } diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index c64a877..2c6f2ee 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -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); diff --git a/apps/server/src/entry-presence-bypass.test.ts b/apps/server/src/entry-presence-bypass.test.ts new file mode 100644 index 0000000..311fc84 --- /dev/null +++ b/apps/server/src/entry-presence-bypass.test.ts @@ -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/); + }); +}); diff --git a/apps/server/src/routes/presence-bypass-route.test.ts b/apps/server/src/routes/presence-bypass-route.test.ts new file mode 100644 index 0000000..1411098 --- /dev/null +++ b/apps/server/src/routes/presence-bypass-route.test.ts @@ -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, + }); +} + +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); + }); +}); diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index ea2d60e..0e46452 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -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> { } /** 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; 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 { +export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog | null): Promise { const readGuard = requirePermission("site:read"); const writeGuard = requirePermission("site:update"); @@ -131,4 +136,64 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise { 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); + }, + ); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d627a5f..014c962 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -279,7 +279,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise 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 (
(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} /> {msg && ( diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index ad7a2f5..34e5ed0 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -6,7 +6,9 @@ import { discoverDevices, fetchBackendIps, fetchCatalog, + fetchSiteConfig, fetchState, + updatePresenceBypass, testAnpr, testDevice, testPrint, @@ -149,6 +151,8 @@ export function SetupWizard() { onChanged={reloadState} /> + + {BOUND.map(({ key, titleKey, nounKey }) => ( (null); + const [camera, setCamera] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+

{t("setup.presenceGateTitle")}

+

{t("setup.presenceGateHint")}

+
+ + +
+ {active &&

⚠ {t("setup.presenceBypassActive")}

} + {error &&

{error}

} +
+ ); +} + function CategorySection({ category, title, diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8891f72..7ed8dd6 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { export function saveSiteConfig(patch: Partial): Promise { 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 { + return apiFetch("/api/site-config/presence-bypass", { method: "PUT", body: JSON.stringify(patch) }); +} export function setCapacity(capacity: number | null): Promise { return saveSiteConfig({ capacity }); } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 677dfdc..4b3e016 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 638a308..cee4505 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", diff --git a/apps/web/src/ui/event-detail.tsx b/apps/web/src/ui/event-detail.tsx index 9ad1fbc..3aa2a69 100644 --- a/apps/web/src/ui/event-detail.tsx +++ b/apps/web/src/ui/event-detail.tsx @@ -24,6 +24,7 @@ export const EVENT_STYLE: Record = 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" }, }; diff --git a/packages/db/drizzle/0020_entry_presence_bypass.sql b/packages/db/drizzle/0020_entry_presence_bypass.sql new file mode 100644 index 0000000..04c8348 --- /dev/null +++ b/packages/db/drizzle/0020_entry_presence_bypass.sql @@ -0,0 +1,12 @@ +-- Entry presence-gate bypass (2026-07-02). The operator-issued entry button (and the +-- physical entry button) require a REAL vehicle at the barrier: radar/loop presence AND a +-- camera vehicle-detection. When a device is FAULTY (dead radar, dead camera), that gate +-- blocks legitimate transient entry. These flags let the ADMIN drop a specific signal as a +-- requirement until support fixes the hardware. Granular on purpose: a faulty camera drops +-- only the camera check (radar still gates); a faulty radar drops only radar. Both null/0 = +-- the normal both-required gate. Enabling/disabling is ALSO signed into the ledger +-- (config_change) — the admin is not the adversary, but weakening an anti-fraud gate stays +-- attributed + auditable. See wiki/concepts/entry-presence-bypass.md. +ALTER TABLE `site_config` ADD `bypass_presence_radar` integer DEFAULT 0 NOT NULL; +--> statement-breakpoint +ALTER TABLE `site_config` ADD `bypass_presence_camera` integer DEFAULT 0 NOT NULL; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index d934776..fad373b 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1781886200000, "tag": "0019_operator_session_create", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1781886300000, + "tag": "0020_entry_presence_bypass", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index dd7124b..b964183 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -251,6 +251,20 @@ export const siteConfig = sqliteTable("site_config", { anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" }) .notNull() .default(true), + /** Entry presence-gate BYPASS (2026-07-02). The entry button — physical press and the + * operator-issued mint — requires a real vehicle at the barrier: radar/loop presence AND + * camera detection. When a device is FAULTY, the admin can drop one of those signals as a + * requirement until support fixes it (the admin is not the adversary). Granular: a dead + * camera → set bypassPresenceCamera (radar still gates); a dead radar → bypassPresenceRadar. + * Both false (default) = the normal both-required gate; both true = press-to-print with no + * presence check. Enabling/disabling is signed as a `config_change` and every ticket issued + * while bypassed is flagged. Stored 0/1. See wiki/concepts/entry-presence-bypass.md. */ + bypassPresenceRadar: integer("bypass_presence_radar", { mode: "boolean" }) + .notNull() + .default(false), + bypassPresenceCamera: integer("bypass_presence_camera", { mode: "boolean" }) + .notNull() + .default(false), /** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a * tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into * each published tariff version's structure.tz so the windows are frozen/immutable diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 54c3756..10fd3b0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -265,6 +265,13 @@ export type LedgerEventType = // touch the drawer balance. Append-only, signed, so the decision is itself auditable. // See wiki/concepts/shift.md. | "cash_review" + // Signed record of an admin changing a FRAUD-RELEVANT setting, so the change is + // itself in the tamper-evident chain (who/when/what). Payload: { setting, value, + // operator, prev? }. First use: the entry presence-gate bypass (a faulty radar/ + // camera lets the admin drop that signal as a requirement until support fixes it — + // the admin is NOT the adversary, but weakening an anti-fraud gate must still be + // attributed + auditable). See wiki/concepts/entry-presence-bypass.md. + | "config_change" | "anomaly"; /** How money was tendered (for payment events + the shift Z-report). */