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