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