Device-agnostic driver registry + first-run setup

Make the device-adapter pattern selectable so the admin chooses hardware at
install — per lane, from a catalog of supported drivers. Adding a device =
registering one more driver; no business-logic change.

packages/devices:
- interfaces.ts: AccessControlDevice / ReaderDevice / CameraDevice / PrinterDevice
  (adds CameraDevice for entry/exit snapshot-on-event; access relay stays
  intent-only per "a barrier is not a door").
- registry.ts: driver catalog with per-driver config fields + factory, config
  validation, and a catalog payload for the setup UI.
- drivers/: stub adapters — access (zkteco, esp32-relay), reader (wiegand,
  tcp-ip), camera (hikvision, dahua). Real vendor protocols TBD.

packages/db:
- lane_devices + setup_state tables (migration 0001); re-export query helpers.

apps/server:
- routes/setup.ts: GET /api/setup/catalog (public schema), and admin-only
  /assign, /state, /complete with registry validation before persisting.
- extract auth.ts (requireJwtSecret, requireRole, JWT type aug).

apps/web:
- SetupWizard scaffold + api client: pick a driver per category for a lane,
  render its config fields.

wiki: device-registry + first-run-setup concept pages; cross-link from
device-adapter-pattern; index + log updated.

Verified: full turbo build (5/5); catalog lists all drivers; admin assign
persists; missing-config and no-token requests are rejected.
This commit is contained in:
2026-06-14 07:59:46 +02:00
parent 7de5c74500
commit 72ba4099ea
24 changed files with 1138 additions and 71 deletions
+40
View File
@@ -0,0 +1,40 @@
import type { Role } from "@parking/shared";
// Local JWT auth helpers — fully local, no external identity provider
// (offline-first). See wiki/entities/local-jwt-auth.md.
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: { sub: string; username: string; role: Role };
user: { sub: string; username: string; role: Role };
}
}
/**
* Resolve the JWT signing secret, refusing to start without a strong one.
* There is deliberately no fallback default — a missing, short, or placeholder
* secret throws so the server never runs with forgeable tokens.
*/
export function requireJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32 || /change.?me|insecure|dev-only/i.test(secret)) {
throw new Error(
"JWT_SECRET must be set to a strong random value (>=32 chars). " +
"Generate one with: openssl rand -hex 32",
);
}
return secret;
}
/**
* preHandler role guard. Authorization is a simple per-route role check — no
* Casbin/RBAC engine needed at this scale. See wiki/entities/local-jwt-auth.md.
*/
export function requireRole(...allowed: Role[]) {
return async (req: { jwtVerify: () => Promise<void>; user?: { role: Role } }) => {
await req.jwtVerify();
if (!req.user || !allowed.includes(req.user.role)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
};
}
+82
View File
@@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, laneDevices, setupState, type Db } from "@parking/db";
import {
registerBuiltinDrivers,
registry,
setDeviceLogSink,
type DeviceCategory,
} from "@parking/devices";
import { requireRole } from "../auth.js";
// First-run setup API. The admin reads the driver catalog and assigns devices
// per lane. See wiki/concepts/first-run-setup.md.
interface AssignBody {
lane: number;
category: DeviceCategory;
driverId: string;
config: Record<string, string | number | boolean>;
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
// Catalog of selectable drivers per category (no secrets — schema only).
app.get("/api/setup/catalog", async () => registry.catalog());
// Current setup status + assignments.
app.get(
"/api/setup/state",
{ preHandler: requireRole("admin") },
async () => {
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
const assignments = await db.select().from(laneDevices).all();
return { completedAt: state?.completedAt ?? null, assignments };
},
);
// Assign a device to a lane. Validates the chosen driver + config against the
// registry before persisting; rejects unknown drivers / missing config.
app.post<{ Body: AssignBody }>(
"/api/setup/assign",
{ preHandler: requireRole("admin") },
async (req, reply) => {
const { lane, category, driverId, config } = req.body;
const driver = registry.get(driverId);
if (!driver || driver.category !== category) {
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
}
try {
registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
const row = {
id: randomUUID(),
lane,
category,
driverId,
config,
enabled: true,
};
await db.insert(laneDevices).values(row);
return reply.code(201).send(row);
},
);
// Mark first-run setup complete.
app.post(
"/api/setup/complete",
{ preHandler: requireRole("admin") },
async () => {
const completedAt = new Date().toISOString();
await db
.insert(setupState)
.values({ id: 1, completedAt })
.onConflictDoUpdate({ target: setupState.id, set: { completedAt } });
return { completedAt };
},
);
}
+13 -38
View File
@@ -1,39 +1,24 @@
import jwt from "@fastify/jwt";
import Fastify, { type FastifyInstance } from "fastify";
import type { Role } from "@parking/shared";
import { createDb, type Db } from "@parking/db";
import { requireJwtSecret } from "./auth.js";
import { setupRoutes } from "./routes/setup.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
// plugins emitting onto a shared internal event bus; auth is fully local
// (offline-first). See wiki/entities/fastify.md and local-jwt-auth.md.
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: { sub: string; username: string; role: Role };
user: { sub: string; username: string; role: Role };
}
export interface BuildOptions {
db?: Db;
}
/**
* Resolve the JWT signing secret, refusing to start without a strong one.
* There is deliberately no fallback default — a missing, short, or placeholder
* secret throws so the server never runs with forgeable tokens.
*/
function requireJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32 || /change.?me|insecure|dev-only/i.test(secret)) {
throw new Error(
"JWT_SECRET must be set to a strong random value (>=32 chars). " +
"Generate one with: openssl rand -hex 32",
);
}
return secret;
}
export async function buildServer(): Promise<FastifyInstance> {
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
const app = Fastify({
logger: { level: process.env.LOG_LEVEL ?? "info" },
});
const db = opts.db ?? createDb();
// Local JWT signing with a local secret — no external identity provider.
// Fail fast rather than fall back to a known default: a booth machine started
// without a real secret would sign tokens anyone could forge (incl. an admin
@@ -45,21 +30,11 @@ export async function buildServer(): Promise<FastifyInstance> {
app.get("/health", async () => ({ status: "ok" }));
// TODO: register device-driver plugins (packages/devices adapters),
// the append-only event-log routes, and the role-guarded admin API.
// Device-agnostic setup: the admin selects devices per lane from the driver
// catalog at first-run. See wiki/concepts/first-run-setup.md.
await setupRoutes(app, db);
// TODO: device-driver runtime plugins, append-only event-log routes, login.
return app;
}
/**
* preHandler role guard. Authorization is a simple per-route role check — no
* Casbin/RBAC engine needed at this scale. See wiki/entities/local-jwt-auth.md.
*/
export function requireRole(...allowed: Role[]) {
return async (req: { jwtVerify: () => Promise<void>; user?: { role: Role } }) => {
await req.jwtVerify();
if (!req.user || !allowed.includes(req.user.role)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
};
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { SetupWizard } from "./SetupWizard.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is
// simple enough that a framework's abstractions cost more than they save.
@@ -15,12 +16,12 @@ export function App() {
}, []);
return (
<main style={{ fontFamily: "system-ui", padding: "2rem" }}>
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
<h1>Parking System</h1>
<p>Operator console — scaffold.</p>
<p>
API health: <strong>{health}</strong>
</p>
<SetupWizard />
</main>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import {
fetchCatalog,
type Catalog,
type CatalogEntry,
type DeviceCategory,
} from "./api.js";
// First-run setup wizard (scaffold). The admin picks a device per category for
// a lane from the driver catalog and fills in its connection config. Persisting
// goes through POST /api/setup/assign (admin-only). The actual auth/token flow
// and a multi-lane stepper come later — this proves the device-agnostic
// selection end to end. See wiki/concepts/first-run-setup.md.
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
{ key: "access", title: "Access controller" },
{ key: "reader", title: "Reader" },
{ key: "camera", title: "Camera (entry/exit snapshot)" },
{ key: "printer", title: "Printer" },
];
export function SetupWizard() {
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [lane, setLane] = useState(1);
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
}, []);
if (error) return <p style={{ color: "crimson" }}>Failed to load catalog: {error}</p>;
if (!catalog) return <p>Loading device catalog…</p>;
return (
<section>
<h2>First-run setup</h2>
<label>
Lane{" "}
<input
type="number"
min={1}
value={lane}
onChange={(e) => setLane(Number(e.target.value))}
style={{ width: "4rem" }}
/>
</label>
{CATEGORIES.map(({ key, title }) => (
<CategoryPicker
key={key}
title={title}
entries={catalog[key]}
selectedId={picked[key]}
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
/>
))}
</section>
);
}
function CategoryPicker({
title,
entries,
selectedId,
onSelect,
}: {
title: string;
entries: CatalogEntry[];
selectedId: string | undefined;
onSelect: (id: string) => void;
}) {
const selected = entries.find((e) => e.id === selectedId);
return (
<fieldset style={{ marginTop: "1rem" }}>
<legend>{title}</legend>
{entries.length === 0 ? (
<em>No drivers registered.</em>
) : (
<select value={selectedId ?? ""} onChange={(e) => onSelect(e.target.value)}>
<option value="" disabled>
Choose a device…
</option>
{entries.map((e) => (
<option key={e.id} value={e.id}>
{e.label} ({e.transports.join(", ")})
</option>
))}
</select>
)}
{selected && (
<div style={{ marginTop: "0.5rem" }}>
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
{selected.configFields.map((f) => (
<div key={f.key} style={{ margin: "0.25rem 0" }}>
<label>
{f.label}
{f.required ? " *" : ""}{" "}
<input
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
defaultValue={f.default as string | number | undefined}
placeholder={f.help}
/>
</label>
</div>
))}
</div>
)}
</fieldset>
);
}
+48
View File
@@ -0,0 +1,48 @@
// Thin API client for the operator/admin UI.
export interface ConfigField {
key: string;
label: string;
type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select";
required: boolean;
default?: string | number | boolean;
options?: { value: string; label: string }[];
help?: string;
}
export interface CatalogEntry {
id: string;
label: string;
description: string;
transports: string[];
configFields: ConfigField[];
}
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
export type Catalog = Record<DeviceCategory, CatalogEntry[]>;
export async function fetchCatalog(): Promise<Catalog> {
const res = await fetch("/api/setup/catalog");
if (!res.ok) throw new Error(`catalog: ${res.status}`);
return res.json() as Promise<Catalog>;
}
export interface AssignBody {
lane: number;
category: DeviceCategory;
driverId: string;
config: Record<string, string | number | boolean>;
}
export async function assignDevice(token: string, body: AssignBody): Promise<unknown> {
const res = await fetch("/api/setup/assign", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(msg.error ?? `assign: ${res.status}`);
}
return res.json();
}