feat(setup): operator-tested relay pulse, signed into the ledger

Add a per-relay "Test" control on each saved controller in /setup so an admin
can prove barrier wiring without a vehicle. POST /api/setup/test-relay pulses a
barrier relay — but because a physical open with no matching signed command is
the fraud signal, the route SIGNS a barrier_open_command (reason setup.relayTest,
source manual, attributed to the acting admin) BEFORE it fires. Reconciliation
then reads the open as explained, not an anomaly, and there's an audit trail.

- Admin-only (site:update), CSRF-guarded; fires only against a SAVED controller
  (real id → clean attribution; also stops a redirected/unsaved config from
  opening an arbitrary host's barrier). Sign-before-fire; a pulse failure is
  reported, not a 500. radarAlert relays (lamps) are excluded from the UI.
- New reason code setup.relayTest in @parking/shared (+ EN template); sq/en keys.
- EventLog constructed before setupRoutes so the route can sign.
- Integration test (stub controller, no hardware): RBAC 403, CSRF 403, signed
  barrier_open_command on success, 400 unknown relay w/ no ledger row, 404
  unknown controller, 400 bad relay value.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-04 13:40:58 +02:00
parent 61b9955160
commit 306d136a08
8 changed files with 310 additions and 12 deletions
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { devices, 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";
// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the
// wiring — it physically opens the barrier. Because "a physical open with no matching
// signed command is the fraud signal" (append-only-event-chain / reconciliation), the
// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it
// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs —
// no real hardware) so they exercise the validate → sign → pulse path safely.
let db: Db;
let close: () => void;
let app: FastifyInstance;
const CTL = "ctl-stub";
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
db.insert(devices).values({
id: CTL,
category: "access",
driverId: "stub-access",
config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] },
enabled: true,
}).run();
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
async function pulse(
body: unknown,
auth?: { cookie: string; csrf: string },
) {
return app.inject({
method: "POST",
url: "/api/setup/test-relay",
headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {},
payload: body as Record<string, unknown>,
});
}
describe("POST /api/setup/test-relay", () => {
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 pulse({ id: CTL, relay: 1 }, auth);
expect(res.statusCode).toBe(403);
});
it("requires CSRF on the mutation", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const { cookie } = await login(app, "admin", "pw");
const res = await app.inject({
method: "POST",
url: "/api/setup/test-relay",
headers: { cookie }, // no x-csrf-token
payload: { id: CTL, relay: 1 },
});
expect(res.statusCode).toBe(403);
});
it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
const res = await pulse({ id: CTL, relay: 2 }, auth);
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ ok: true });
// The deliberate open is EXPLAINED in the signed ledger — not an anomaly.
const rows = db.select().from(ledgerEvents).all();
const testOpen = rows.find((r) => r.type === "barrier_open_command");
expect(testOpen, "a barrier_open_command must be signed").toBeTruthy();
expect(testOpen!.source).toBe("manual"); // deliberate human action
expect(testOpen!.signature.length).toBeGreaterThan(0);
const payload = testOpen!.payload as Record<string, unknown>;
expect(payload.relayTest).toBe(true);
expect(payload.reasonCode).toBe("setup.relayTest");
expect(payload.relay).toBe(2);
expect(payload.controllerId).toBe(CTL);
expect(payload.operator).toBe("admin"); // attributed to the acting admin
});
it("rejects a relay the controller does not declare (400, no ledger row)", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
const res = await pulse({ id: CTL, relay: 9 }, auth);
expect(res.statusCode).toBe(400);
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed
});
it("404s an unknown controller id", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
const res = await pulse({ id: "nope", relay: 1 }, auth);
expect(res.statusCode).toBe(404);
});
it("rejects a bad relay value (non-positive-integer)", async () => {
await seedUser(db, { username: "admin", password: "pw" });
const auth = await login(app, "admin", "pw");
expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400);
expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400);
});
});
+92
View File
@@ -15,7 +15,9 @@ import {
type DeviceCategory,
type DeviceConfig,
} from "@parking/devices";
import { reasonPayload } from "@parking/shared";
import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
import type { VisionClient } from "../vision-client.js";
@@ -60,6 +62,12 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out;
}
/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup
* relay test needs it; a stub/reader/camera won't have it). */
function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise<void> } {
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
}
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
// secret may only be re-merged when these match the stored row — otherwise an admin
// could point a test at an attacker host while keeping a real device id and have the
@@ -220,6 +228,7 @@ export async function setupRoutes(
app: FastifyInstance,
db: Db,
vision?: VisionClient | null,
eventLog?: EventLog | null,
): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
@@ -456,6 +465,89 @@ export async function setupRoutes(
},
);
// PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any
// vehicle/session. This physically opens the barrier, so unlike the other tests it
// runs only against a SAVED controller (real id → clean attribution) and it SIGNS a
// `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the
// admin's identity. That is the whole point of doing it this way: a physical open with
// no matching signed command is the fraud signal ([[append-only-event-chain]],
// [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a
// silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is
// recorded whether or not the physical pulse then succeeds. Admin-only (site:update).
app.post<{ Body: { id: string; relay: number } }>(
"/api/setup/test-relay",
{ preHandler: adminGuard },
async (req, reply) => {
const { id, relay } = req.body;
if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" });
if (!Number.isInteger(relay) || relay < 1) {
return reply.code(400).send({ error: "relay must be a 1-based channel number" });
}
// A relay test fires REAL hardware, so it must target a persisted controller — no
// firing an unsaved/redirected config (that would let a probe open an arbitrary host's
// barrier). Load the saved row and build straight from its stored config (relayPassword
// included — it's on the row, never in the request).
const row = db.select().from(devices).where(eq(devices.id, id)).get();
if (!row) return reply.code(404).send({ error: "controller not found" });
if (row.category !== "access") {
return reply.code(400).send({ error: `device ${id} is not a controller` });
}
const cfg = (row.config ?? {}) as Record<string, unknown>;
const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : [];
if (!relays.some((r) => r.relay === relay)) {
return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` });
}
let device;
try {
device = registry.create(row.driverId, cfg as Record<string, string | number | boolean>);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
if (!hasPulseOpen(device)) {
return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` });
}
// Sign the deliberate open FIRST — recorded whether or not the physical pulse then
// succeeds. Skip only if no ledger is wired (test/degraded), in which case we still
// refuse rather than fire an unrecorded open.
const operator = req.user?.username ?? "unknown";
if (!eventLog) {
return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" });
}
await eventLog.append({
type: "barrier_open_command",
// A deliberate human action from the admin console → "manual" (the top-level
// IdentitySource). The relayTest marker + reason distinguish it in the payload.
source: "manual",
identity: `relay-test:${id}:${relay}`,
payload: {
...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }),
relayTest: true,
controllerId: id,
relay,
operator,
},
});
const startedAt = Date.now();
try {
await device.pulseOpen(relay);
} catch (err) {
// The failure we're testing for (relay unreachable, wrong password). The open is
// already signed; report the pulse failure, don't 500.
return reply.send({
ok: false,
reason: "pulse-failed",
detail: (err as Error).message,
tookMs: Date.now() - startedAt,
});
}
return reply.send({ ok: true, firedAt: new Date().toISOString(), tookMs: Date.now() - startedAt });
},
);
// Candidate backend IPs the device can push to, for a given device host. The
// wizard pre-fills with the on-subnet one and lets the admin override (matters
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
+15 -12
View File
@@ -115,11 +115,24 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const visionClient = new VisionClient(app.log);
if (visionClient.enabled) app.log.info("vision client enabled");
// Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// in device_events. The entry flow turns an input into a signed vehicle_entry once
// a ticket prints + the barrier is commanded. See event-streams-split.md.
// Constructed HERE (before setupRoutes) so the Setup relay-test can sign its
// deliberate barrier open into the ledger; the read routes are wired further down.
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
deviceEvents.emitLedger(row),
);
// Device-agnostic setup: the admin adds controllers (with their relays + entry
// button) and binds readers/cameras to a controller relay at first-run. There is
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
await setupRoutes(app, db, visionClient);
await setupRoutes(app, db, visionClient, eventLog);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from
@@ -158,17 +171,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => deviceMonitor.start());
app.addHook("onClose", async () => deviceMonitor.stop());
// Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// in device_events. The entry flow (TODO) turns an input into a signed
// vehicle_entry once a ticket prints + the barrier is commanded.
// See wiki/decisions/event-streams-split.md.
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
deviceEvents.emitLedger(row),
);
// Read routes for the signed ledger (constructed above, before setupRoutes).
await eventRoutes(app, db, eventLog);
// Admin reporting: read-only charts/totals aggregated from the signed ledger
+55
View File
@@ -10,6 +10,7 @@ import {
testAnpr,
testDevice,
testPrint,
testRelay,
unassignDevice,
type AnprTestResult,
type PrintTestResult,
@@ -305,6 +306,7 @@ function AssignmentRow({
<strong className="text-term-text">{assignment.driverId}</strong>
{host && <span className="tabular-nums text-term-muted">{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} />
{assignment.category === "access" && <RelayTester assignment={assignment} />}
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
<span className="flex-1" />
{error && <span className="text-term-red">{error}</span>}
@@ -318,6 +320,59 @@ function AssignmentRow({
);
}
/** Per-relay "Test" control on a SAVED controller row. Pulses a BARRIER relay to prove
* the wiring — this physically opens the barrier, so it confirms first, and the server
* signs the deliberate open into the ledger (reason setup.relayTest). radarAlert relays
* are lamps, not barriers — excluded (pulsing one is meaningless/wrong). */
function RelayTester({ assignment }: { assignment: Assignment }) {
const { t } = useTranslation();
const cfg = assignment.config as Record<string, unknown>;
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
const barriers = relays.filter((r) => r.direction !== "radarAlert");
const [busyRelay, setBusyRelay] = useState<number | null>(null);
const [result, setResult] = useState<{ relay: number; ok: boolean; detail?: string } | null>(null);
if (barriers.length === 0) return null;
async function testRelayNow(relay: number) {
if (!confirm(t("setup.confirmRelayTest", { relay }))) return;
setBusyRelay(relay);
setResult(null);
try {
const res = await testRelay(assignment.id, relay);
setResult({ relay, ok: res.ok, detail: res.ok ? undefined : res.detail ?? res.reason });
} catch (e) {
setResult({ relay, ok: false, detail: (e as Error).message });
} finally {
setBusyRelay(null);
}
}
return (
<span className="flex flex-wrap items-center gap-1">
{barriers.map((r) => (
<button
key={r.relay}
type="button"
className="btn btn-ghost btn-sm"
onClick={() => testRelayNow(r.relay)}
disabled={busyRelay != null}
title={t("setup.testRelayTitle", { relay: r.relay })}
>
{busyRelay === r.relay ? t("setup.relayTesting") : t("setup.testRelay", { relay: r.relay })}
</button>
))}
{result && (
<span className={result.ok ? "text-term-green" : "text-term-red"}>
{result.ok
? t("setup.relayTestOk", { relay: result.relay })
: t("setup.relayTestFailed", { relay: result.relay, detail: result.detail ?? "" })}
</span>
)}
</span>
);
}
/** Inline summary of an assignment's direction/binding for the list. */
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
const { t } = useTranslation();
+15
View File
@@ -460,6 +460,21 @@ export function testPrint(
});
}
export type RelayTestResult =
| { ok: true; firedAt: string; tookMs: number }
| { ok: false; reason: string; detail?: string; tookMs?: number };
/** Pulse a SAVED controller's barrier relay to test the wiring — physically opens the
* barrier. The server signs a `barrier_open_command` (reason setup.relayTest) before
* firing, so the open is explained, not a reconciliation anomaly. Saved controller only
* (needs a persisted id for attribution). */
export function testRelay(id: string, relay: number): Promise<RelayTestResult> {
return apiFetch<RelayTestResult>("/api/setup/test-relay", {
method: "POST",
body: JSON.stringify({ id, relay }),
});
}
// --- Admin reports -------------------------------------------------------
export type ReportBucket = "hour" | "day" | "month";
+7
View File
@@ -470,6 +470,13 @@ export const en: Catalog = {
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
// Relay wiring test — physically opens the barrier (the open is signed into the ledger).
testRelay: "Test R{{relay}}",
relayTesting: "Opening…",
testRelayTitle: "Pulse relay {{relay}} — opens the barrier",
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}}",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Show password",
hideSecret: "Hide password",
+7
View File
@@ -480,6 +480,13 @@ export const sq = {
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
// Provë e releut — hap fizikisht barrierën (hapja regjistrohet në ledger).
testRelay: "Provo R{{relay}}",
relayTesting: "Duke hapur…",
testRelayTitle: "Puls releu {{relay}} — hap barrierën",
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}}",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Shfaq fjalëkalimin",
hideSecret: "Fshih fjalëkalimin",