diff --git a/apps/server/src/routes/setup-relay-test.test.ts b/apps/server/src/routes/setup-relay-test.test.ts new file mode 100644 index 0000000..715f5e7 --- /dev/null +++ b/apps/server/src/routes/setup-relay-test.test.ts @@ -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, + }); +} + +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; + 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); + }); +}); diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 07a7f62..46b0cf7 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -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): Record 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 } { + 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 { 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; + 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); + } 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. diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 44a7b89..d627a5f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -115,11 +115,24 @@ export async function buildServer(opts: BuildOptions = {}): Promise + 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 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 diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index d530c6f..ad7a2f5 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -10,6 +10,7 @@ import { testAnpr, testDevice, testPrint, + testRelay, unassignDevice, type AnprTestResult, type PrintTestResult, @@ -305,6 +306,7 @@ function AssignmentRow({ {assignment.driverId} {host && {host}} + {assignment.category === "access" && } {!assignment.enabled && {t("setup.disabled")}} {error && {error}} @@ -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; + const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; + const barriers = relays.filter((r) => r.direction !== "radarAlert"); + const [busyRelay, setBusyRelay] = useState(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 ( + + {barriers.map((r) => ( + + ))} + {result && ( + + {result.ok + ? t("setup.relayTestOk", { relay: result.relay }) + : t("setup.relayTestFailed", { relay: result.relay, detail: result.detail ?? "" })} + + )} + + ); +} + /** Inline summary of an assignment's direction/binding for the list. */ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) { const { t } = useTranslation(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 6a57731..8891f72 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { + return apiFetch("/api/setup/test-relay", { + method: "POST", + body: JSON.stringify({ id, relay }), + }); +} + // --- Admin reports ------------------------------------------------------- export type ReportBucket = "hour" | "day" | "month"; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 61ae09d..677dfdc 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index a571331..638a308 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ee54fa8..54c3756 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -395,6 +395,9 @@ export const REASON_CODES = [ "sub.refused.unpaidWindow", // a wrongly-printed transient ticket cancelled by the operator (signed void event). "void.ticketCancelled", + // an admin fired a barrier relay from Setup to test the wiring. The physical open is + // DELIBERATE — signing it keeps reconciliation from reading it as an out-of-band open. + "setup.relayTest", ] as const; export type ReasonCode = (typeof REASON_CODES)[number]; @@ -428,6 +431,7 @@ export const REASON_EN: Record = { "sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)", "sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth", "void.ticketCancelled": "ticket cancelled — {reason}", + "setup.relayTest": "relay test — admin {operator} pulsed relay {relay} on controller {controller} from Setup", }; /**