From 72ba4099eaae2b7375bcfd2ee79ce0c0cebd929f Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 14 Jun 2026 07:59:46 +0200 Subject: [PATCH] Device-agnostic driver registry + first-run setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/server/src/auth.ts | 40 +++ apps/server/src/routes/setup.ts | 82 ++++++ apps/server/src/server.ts | 51 +--- apps/web/src/App.tsx | 5 +- apps/web/src/SetupWizard.tsx | 111 ++++++++ apps/web/src/api.ts | 48 ++++ .../db/drizzle/0001_cuddly_maria_hill.sql | 14 + packages/db/drizzle/meta/0001_snapshot.json | 245 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/index.ts | 3 + packages/db/src/schema.ts | 29 +++ packages/devices/src/drivers/access.ts | 49 ++++ packages/devices/src/drivers/camera.ts | 57 ++++ packages/devices/src/drivers/common.ts | 46 ++++ packages/devices/src/drivers/index.ts | 30 +++ packages/devices/src/drivers/reader.ts | 60 +++++ packages/devices/src/index.ts | 38 +-- packages/devices/src/interfaces.ts | 80 ++++++ packages/devices/src/registry.ts | 116 +++++++++ wiki/concepts/device-adapter-pattern.md | 6 + wiki/concepts/device-registry.md | 43 +++ wiki/concepts/first-run-setup.md | 37 +++ wiki/index.md | 2 + wiki/log.md | 10 + 24 files changed, 1138 insertions(+), 71 deletions(-) create mode 100644 apps/server/src/auth.ts create mode 100644 apps/server/src/routes/setup.ts create mode 100644 apps/web/src/SetupWizard.tsx create mode 100644 apps/web/src/api.ts create mode 100644 packages/db/drizzle/0001_cuddly_maria_hill.sql create mode 100644 packages/db/drizzle/meta/0001_snapshot.json create mode 100644 packages/devices/src/drivers/access.ts create mode 100644 packages/devices/src/drivers/camera.ts create mode 100644 packages/devices/src/drivers/common.ts create mode 100644 packages/devices/src/drivers/index.ts create mode 100644 packages/devices/src/drivers/reader.ts create mode 100644 packages/devices/src/interfaces.ts create mode 100644 packages/devices/src/registry.ts create mode 100644 wiki/concepts/device-registry.md create mode 100644 wiki/concepts/first-run-setup.md diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts new file mode 100644 index 0000000..58329c8 --- /dev/null +++ b/apps/server/src/auth.ts @@ -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; user?: { role: Role } }) => { + await req.jwtVerify(); + if (!req.user || !allowed.includes(req.user.role)) { + throw Object.assign(new Error("forbidden"), { statusCode: 403 }); + } + }; +} diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts new file mode 100644 index 0000000..b497489 --- /dev/null +++ b/apps/server/src/routes/setup.ts @@ -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; +} + +export async function setupRoutes(app: FastifyInstance, db: Db): Promise { + 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 }; + }, + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b03a988..25242af 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 { +export async function buildServer(opts: BuildOptions = {}): Promise { 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 { 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; user?: { role: Role } }) => { - await req.jwtVerify(); - if (!req.user || !allowed.includes(req.user.role)) { - throw Object.assign(new Error("forbidden"), { statusCode: 403 }); - } - }; -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 05c6651..0e4e387 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ( -
+

Parking System

-

Operator console — scaffold.

API health: {health}

+
); } diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx new file mode 100644 index 0000000..9cb48c4 --- /dev/null +++ b/apps/web/src/SetupWizard.tsx @@ -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(null); + const [lane, setLane] = useState(1); + const [picked, setPicked] = useState>>({}); + const [error, setError] = useState(null); + + useEffect(() => { + fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message)); + }, []); + + if (error) return

Failed to load catalog: {error}

; + if (!catalog) return

Loading device catalog…

; + + return ( +
+

First-run setup

+ + + {CATEGORIES.map(({ key, title }) => ( + setPicked((p) => ({ ...p, [key]: id }))} + /> + ))} +
+ ); +} + +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 ( +
+ {title} + {entries.length === 0 ? ( + No drivers registered. + ) : ( + + )} + {selected && ( +
+

{selected.description}

+ {selected.configFields.map((f) => ( +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 0000000..5419ec3 --- /dev/null +++ b/apps/web/src/api.ts @@ -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; + +export async function fetchCatalog(): Promise { + const res = await fetch("/api/setup/catalog"); + if (!res.ok) throw new Error(`catalog: ${res.status}`); + return res.json() as Promise; +} + +export interface AssignBody { + lane: number; + category: DeviceCategory; + driverId: string; + config: Record; +} + +export async function assignDevice(token: string, body: AssignBody): Promise { + 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(); +} diff --git a/packages/db/drizzle/0001_cuddly_maria_hill.sql b/packages/db/drizzle/0001_cuddly_maria_hill.sql new file mode 100644 index 0000000..a78cb00 --- /dev/null +++ b/packages/db/drizzle/0001_cuddly_maria_hill.sql @@ -0,0 +1,14 @@ +CREATE TABLE `lane_devices` ( + `id` text PRIMARY KEY NOT NULL, + `lane` integer NOT NULL, + `category` text NOT NULL, + `driver_id` text NOT NULL, + `config` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `setup_state` ( + `id` integer PRIMARY KEY NOT NULL, + `completed_at` text +); diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..3e8ac6c --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,245 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1073123c-0df9-4109-84bf-7f23b95ec5bd", + "prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93", + "tables": { + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lane": { + "name": "lane", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_index_unique": { + "name": "events_index_unique", + "columns": [ + "index" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lane_devices": { + "name": "lane_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lane": { + "name": "lane", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "driver_id": { + "name": "driver_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setup_state": { + "name": "setup_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index adda799..7a0b9c3 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781389618205, "tag": "0000_absent_rocket_raccoon", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1781416636098, + "tag": "0001_cuddly_maria_hill", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ce52d47..f426f98 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,6 +3,9 @@ import { drizzle } from "drizzle-orm/better-sqlite3"; import * as schema from "./schema.js"; export * from "./schema.js"; +// Re-export the query helpers consumers need, so they don't depend on +// drizzle-orm directly (it's an implementation detail of this package). +export { eq, and, desc, sql } from "drizzle-orm"; /** * Open the local SQLite database in WAL mode. WAL allows many concurrent readers diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 94a6389..f6cae0c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -37,5 +37,34 @@ export const events = sqliteTable("events", { signature: text("signature").notNull(), }); +// Per-lane device assignments chosen by the admin during first-run setup. +// One row per (lane, category, instance). `driverId` references a driver in the +// @parking/devices registry; `config` is that driver's JSON config (host, port, +// credentials…). Lets the system stay device-agnostic and admin-configurable. +// See wiki/concepts/device-registry.md and first-run-setup.md. +export const laneDevices = sqliteTable("lane_devices", { + id: text("id").primaryKey(), + lane: integer("lane").notNull(), + category: text("category", { + enum: ["access", "reader", "camera", "printer"], + }).notNull(), + driverId: text("driver_id").notNull(), + // Driver-specific connection config as JSON (validated against the driver's + // declared config fields before persisting). Secrets live here — protect at rest. + config: text("config", { mode: "json" }).notNull().$type>(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + +// Tracks whether first-run setup has been completed (single-row marker). +export const setupState = sqliteTable("setup_state", { + id: integer("id").primaryKey(), // always 1 + completedAt: text("completed_at"), +}); + export type UserRow = typeof users.$inferSelect; export type EventRow = typeof events.$inferSelect; +export type LaneDeviceRow = typeof laneDevices.$inferSelect; +export type SetupStateRow = typeof setupState.$inferSelect; diff --git a/packages/devices/src/drivers/access.ts b/packages/devices/src/drivers/access.ts new file mode 100644 index 0000000..087bd31 --- /dev/null +++ b/packages/devices/src/drivers/access.ts @@ -0,0 +1,49 @@ +import type { AccessControlDevice, DeviceHealth } from "../interfaces.js"; +import type { AccessDriver, DeviceConfig } from "../registry.js"; +import { hostField, portField, stubLog } from "./common.js"; + +// Access-control drivers. Each implements AccessControlDevice (intent-only relay +// — "a barrier is not a door"). STUBS: connect/log only, no real protocol yet. + +class StubAccessControl implements AccessControlDevice { + constructor( + readonly driverId: string, + protected readonly config: DeviceConfig, + ) {} + async connect(): Promise { + stubLog(this.driverId, `connect ${this.config.host}:${this.config.port}`); + } + async disconnect(): Promise { + stubLog(this.driverId, "disconnect"); + } + async healthCheck(): Promise { + return { status: "ready", detail: "stub" }; + } + async pulseOpen(doorId: number): Promise { + // Intent only — never times/forces a close against a vehicle. + stubLog(this.driverId, `pulseOpen door=${doorId}`); + } + async getDoorStatus(): Promise<"open" | "closed"> { + return "closed"; + } +} + +export const zktecoDriver: AccessDriver = { + id: "zkteco", + category: "access", + label: "ZKTeco controller", + description: "ZKTeco network access controller (TCP/IP). Reader + relay.", + transports: ["tcp-ip"], + configFields: [hostField, portField(4370), { key: "doors", label: "Door count", type: "number", required: true, default: 4 }], + create: (c) => new StubAccessControl("zkteco", c), +}; + +export const esp32RelayDriver: AccessDriver = { + id: "esp32-relay", + category: "access", + label: "ESP32 relay controller", + description: "Simple ESP32-based relay controller over the network.", + transports: ["tcp-ip"], + configFields: [hostField, portField(80), { key: "doors", label: "Relay channels", type: "number", required: true, default: 1 }], + create: (c) => new StubAccessControl("esp32-relay", c), +}; diff --git a/packages/devices/src/drivers/camera.ts b/packages/devices/src/drivers/camera.ts new file mode 100644 index 0000000..1a2f2c3 --- /dev/null +++ b/packages/devices/src/drivers/camera.ts @@ -0,0 +1,57 @@ +import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js"; +import type { CameraDriver, DeviceConfig } from "../registry.js"; +import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js"; + +// Camera drivers — entry/exit snapshot-on-event. The image is stored and +// referenced from the signed event as an independent fraud-control record. +// Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL. STUBS only. + +class StubCamera implements CameraDevice { + constructor( + readonly driverId: string, + protected readonly config: DeviceConfig, + protected readonly snapshotPath: string, + ) {} + async connect(): Promise { + stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`); + } + async disconnect(): Promise { + stubLog(this.driverId, "disconnect"); + } + async healthCheck(): Promise { + return { status: "ready", detail: "stub" }; + } + async captureSnapshot(ctx: SnapshotContext): Promise { + // Real driver: GET http(s)://host{snapshotPath}, store bytes, return ref. + stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction}`); + return { + imageRef: `stub://${this.driverId}/lane${ctx.lane}/${ctx.direction}/${Date.now()}`, + contentType: "image/jpeg", + capturedAt: new Date().toISOString(), + }; + } +} + +const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, { key: "channel", label: "Channel", type: "number" as const, required: false, default: 1 }]; + +export const hikvisionDriver: CameraDriver = { + id: "hikvision", + category: "camera", + label: "Hikvision camera", + description: "Hikvision snapshot via ISAPI.", + transports: ["tcp-ip"], + configFields: cameraConfigFields, + // /ISAPI/Streaming/channels//picture + create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"), +}; + +export const dahuaDriver: CameraDriver = { + id: "dahua", + category: "camera", + label: "Dahua camera", + description: "Dahua snapshot via CGI.", + transports: ["tcp-ip"], + configFields: cameraConfigFields, + // /cgi-bin/snapshot.cgi?channel= + create: (c) => new StubCamera("dahua", c, "/cgi-bin/snapshot.cgi"), +}; diff --git a/packages/devices/src/drivers/common.ts b/packages/devices/src/drivers/common.ts new file mode 100644 index 0000000..b65ae0e --- /dev/null +++ b/packages/devices/src/drivers/common.ts @@ -0,0 +1,46 @@ +import type { ConfigField } from "../registry.js"; + +// Shared config-field presets so drivers stay terse and consistent. + +export const hostField: ConfigField = { + key: "host", + label: "IP address / host", + type: "host", + required: true, + help: "On the isolated device VLAN. See wiki/concepts/network-isolation.md.", +}; + +export function portField(def: number): ConfigField { + return { key: "port", label: "Port", type: "port", required: true, default: def }; +} + +export const usernameField: ConfigField = { + key: "username", + label: "Username", + type: "string", + required: false, +}; + +export const passwordField: ConfigField = { + key: "password", + label: "Password", + type: "secret", + required: false, +}; + +/** + * Sink for stub/diagnostic device messages. Defaults to a no-op so the package + * has no host/runtime dependency; the server sets this to its Fastify logger. + */ +export type DeviceLogSink = (line: string) => void; + +let sink: DeviceLogSink = () => {}; + +export function setDeviceLogSink(fn: DeviceLogSink): void { + sink = fn; +} + +/** Stubs log instead of performing real I/O. Replaced with real protocols later. */ +export function stubLog(driverId: string, msg: string): void { + sink(`[device:${driverId}] ${msg}`); +} diff --git a/packages/devices/src/drivers/index.ts b/packages/devices/src/drivers/index.ts new file mode 100644 index 0000000..bff4293 --- /dev/null +++ b/packages/devices/src/drivers/index.ts @@ -0,0 +1,30 @@ +// Register all bundled drivers into the singleton registry. Importing this +// module wires the catalog. Add a new device by registering it here. + +import { registry } from "../registry.js"; +import { esp32RelayDriver, zktecoDriver } from "./access.js"; +import { dahuaDriver, hikvisionDriver } from "./camera.js"; +import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; + +let registered = false; + +/** Idempotently register the built-in drivers. Called once at server startup. */ +export function registerBuiltinDrivers(): void { + if (registered) return; + registered = true; + registry.register(zktecoDriver); + registry.register(esp32RelayDriver); + registry.register(wiegandReaderDriver); + registry.register(tcpipReaderDriver); + registry.register(hikvisionDriver); + registry.register(dahuaDriver); +} + +export { + zktecoDriver, + esp32RelayDriver, + wiegandReaderDriver, + tcpipReaderDriver, + hikvisionDriver, + dahuaDriver, +}; diff --git a/packages/devices/src/drivers/reader.ts b/packages/devices/src/drivers/reader.ts new file mode 100644 index 0000000..0ee9e42 --- /dev/null +++ b/packages/devices/src/drivers/reader.ts @@ -0,0 +1,60 @@ +import type { DeviceHealth, ReaderDevice, ReaderEvent } from "../interfaces.js"; +import type { DeviceConfig, ReaderDriver } from "../registry.js"; +import { hostField, portField, stubLog } from "./common.js"; + +// Reader drivers (RF / optical). Two integration paths: Wiegand reads reach the +// access controller directly (autonomous); TCP-IP readers are seen host-side. +// See wiki/concepts/entry-exit-readers.md. STUBS only. + +class StubReader implements ReaderDevice { + #cb: ((r: ReaderEvent) => void) | null = null; + constructor( + readonly driverId: string, + protected readonly config: DeviceConfig, + ) {} + async connect(): Promise { + stubLog(this.driverId, "connect"); + } + async disconnect(): Promise { + stubLog(this.driverId, "disconnect"); + } + async healthCheck(): Promise { + return { status: "ready", detail: "stub" }; + } + onRead(cb: (r: ReaderEvent) => void): void { + this.#cb = cb; + stubLog(this.driverId, "onRead handler registered"); + } + /** Test hook for stubs — real drivers emit from hardware events. */ + protected emit(r: ReaderEvent): void { + this.#cb?.(r); + } +} + +export const wiegandReaderDriver: ReaderDriver = { + id: "wiegand-reader", + category: "reader", + label: "Wiegand reader (into controller)", + description: + "RF/optical reader wired Wiegand 26/34 into the access controller's reader port. Autonomous offline decisions.", + transports: ["wiegand"], + configFields: [ + { key: "door", label: "Controller reader port / door", type: "number", required: true, default: 1 }, + { key: "format", label: "Wiegand format", type: "select", required: true, default: "26", options: [ + { value: "26", label: "Wiegand 26" }, + { value: "34", label: "Wiegand 34" }, + ] }, + ], + create: (c) => new StubReader("wiegand-reader", c), +}; + +export const tcpipReaderDriver: ReaderDriver = { + id: "tcpip-reader", + category: "reader", + label: "TCP/IP reader (host-side)", + description: + "Network RF/optical reader seen only by the host; host decides and commands the relay.", + transports: ["tcp-ip"], + configFields: [hostField, portField(9000)], + create: (c) => new StubReader("tcpip-reader", c), +}; diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index 5943ea8..7110729 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -1,33 +1,9 @@ -// Device-agnostic adapter interfaces. +// @parking/devices — device-agnostic adapters + the driver registry that lets +// an admin select between supported devices at first-run setup. // -// Business logic talks ONLY to these interfaces, never to a device SDK. Swapping -// hardware means writing a new adapter that implements one of these — nothing -// else changes. See wiki/concepts/device-adapter-pattern.md. -// -// SAFETY: a barrier is NOT a door. The relay interface expresses INTENT only -// (`pulseOpen`); it never times or forces a close against a vehicle. Physical -// safety (induction loops, anti-crush, auto-reverse) lives in the barrier -// operator's own firmware. See wiki/concepts/barrier-not-a-door.md. +// See wiki/concepts/device-adapter-pattern.md and device-registry.md. -export interface CardReaderDevice { - connect(): Promise; - onCardRead(cb: (cardNumber: string, door: number) => void): void; - disconnect(): Promise; -} - -export interface TicketData { - readonly ticketId: string; - readonly lane: number; - readonly issuedAt: string; // ISO-8601 -} - -export interface PrinterDevice { - printTicket(data: TicketData): Promise; - checkStatus(): Promise<"ready" | "offline" | "paper_out">; -} - -export interface RelayDevice { - /** Express intent to open. NEVER timed/forced closed against a vehicle. */ - pulseOpen(doorId: number): Promise; - getDoorStatus(doorId: number): Promise<"open" | "closed">; -} +export * from "./interfaces.js"; +export * from "./registry.js"; +export { registerBuiltinDrivers } from "./drivers/index.js"; +export { setDeviceLogSink, type DeviceLogSink } from "./drivers/common.js"; diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts new file mode 100644 index 0000000..229e587 --- /dev/null +++ b/packages/devices/src/interfaces.ts @@ -0,0 +1,80 @@ +// Device-agnostic adapter interfaces. +// +// Business logic talks ONLY to these interfaces, never to a device SDK. Swapping +// hardware means writing a new adapter that implements one of these — nothing +// else changes. See wiki/concepts/device-adapter-pattern.md. +// +// SAFETY: a barrier is NOT a door. The relay interface expresses INTENT only +// (`pulseOpen`); it never times or forces a close against a vehicle. Physical +// safety (induction loops, anti-crush, auto-reverse) lives in the barrier +// operator's own firmware. See wiki/concepts/barrier-not-a-door.md. + +/** The four device categories an admin configures per lane. */ +export type DeviceCategory = "access" | "reader" | "camera" | "printer"; + +/** Lifecycle shared by every device adapter. */ +export interface Device { + /** Stable id of the driver that produced this instance (e.g. "zkteco"). */ + readonly driverId: string; + connect(): Promise; + disconnect(): Promise; + /** Liveness/health probe used by setup ("Test connection") and monitoring. */ + healthCheck(): Promise; +} + +export interface DeviceHealth { + readonly status: "ready" | "offline" | "degraded"; + readonly detail?: string; +} + +// --- Access control (barrier relay) -------------------------------------- +// ZKTeco, an ESP32 relay controller, UHPPOTE, etc. all implement this. +export interface AccessControlDevice extends Device { + /** Express intent to open. NEVER timed/forced closed against a vehicle. */ + pulseOpen(doorId: number): Promise; + getDoorStatus(doorId: number): Promise<"open" | "closed">; +} + +// --- Readers (RF / optical; TCP-IP or Wiegand) --------------------------- +export interface ReaderDevice extends Device { + /** Emits when a credential is read (card number, plate, QR payload, …). */ + onRead(cb: (read: ReaderEvent) => void): void; +} + +export interface ReaderEvent { + readonly value: string; + readonly kind: "card" | "plate" | "qr" | "ticket"; + readonly door: number; + readonly at: string; // ISO-8601 +} + +// --- Cameras (entry/exit snapshot) --------------------------------------- +// Hikvision / Dahua implement this. Snapshot-on-event: the host asks for an +// image at entry/exit; the image is stored and referenced from the signed event +// as an independent record (anti-fraud). See wiki/concepts/append-only-event-chain. +export interface CameraDevice extends Device { + captureSnapshot(ctx: SnapshotContext): Promise; +} + +export interface SnapshotContext { + readonly lane: number; + readonly direction: "entry" | "exit"; +} + +export interface Snapshot { + /** Storage reference for the captured image (file path / blob id). */ + readonly imageRef: string; + readonly contentType: string; + readonly capturedAt: string; // ISO-8601 +} + +// --- Printers (ticket dispenser / booth printer) ------------------------- +export interface TicketData { + readonly ticketId: string; + readonly lane: number; + readonly issuedAt: string; // ISO-8601 +} + +export interface PrinterDevice extends Device { + printTicket(data: TicketData): Promise; +} diff --git a/packages/devices/src/registry.ts b/packages/devices/src/registry.ts new file mode 100644 index 0000000..c29d5ba --- /dev/null +++ b/packages/devices/src/registry.ts @@ -0,0 +1,116 @@ +// Driver registry — the catalog of selectable device drivers. +// +// This is what makes the system admin-configurable: each category (access / +// reader / camera / printer) has multiple drivers, and the first-run setup UI +// reads this catalog to let the admin pick one per lane and fill in its config. +// Adding support for a new device = registering one more driver here; no +// business-logic changes. See wiki/concepts/device-registry.md. + +import type { + AccessControlDevice, + CameraDevice, + Device, + DeviceCategory, + PrinterDevice, + ReaderDevice, +} from "./interfaces.js"; + +/** A single configurable connection field shown in the setup wizard. */ +export interface ConfigField { + readonly key: string; + readonly label: string; + readonly type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select"; + readonly required: boolean; + readonly default?: string | number | boolean; + /** For type "select". */ + readonly options?: readonly { value: string; label: string }[]; + readonly help?: string; +} + +/** Opaque per-instance config the admin fills in (host, port, credentials…). */ +export type DeviceConfig = Record; + +/** + * A driver: metadata describing a supported device model/family, the config + * fields the admin must supply, and a factory that builds a live adapter. + */ +export interface DeviceDriver { + readonly id: string; // stable, e.g. "zkteco", "esp32-relay", "hikvision" + readonly category: DeviceCategory; + readonly label: string; // human name for the picker, e.g. "ZKTeco controller" + readonly description: string; + /** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */ + readonly transports: readonly string[]; + readonly configFields: readonly ConfigField[]; + /** Build a live adapter instance from validated config. */ + create(config: DeviceConfig): T; +} + +export type AccessDriver = DeviceDriver; +export type ReaderDriver = DeviceDriver; +export type CameraDriver = DeviceDriver; +export type PrinterDriver = DeviceDriver; + +class DeviceRegistry { + readonly #drivers = new Map(); + + register(driver: DeviceDriver): void { + if (this.#drivers.has(driver.id)) { + throw new Error(`duplicate driver id: ${driver.id}`); + } + this.#drivers.set(driver.id, driver); + } + + /** All drivers, optionally filtered by category (used by the setup catalog). */ + list(category?: DeviceCategory): DeviceDriver[] { + const all = [...this.#drivers.values()]; + return category ? all.filter((d) => d.category === category) : all; + } + + get(id: string): DeviceDriver | undefined { + return this.#drivers.get(id); + } + + /** Validate config against a driver's declared fields and build the adapter. */ + create(id: string, config: DeviceConfig): Device { + const driver = this.#drivers.get(id); + if (!driver) throw new Error(`unknown driver: ${id}`); + for (const field of driver.configFields) { + if (field.required && config[field.key] === undefined) { + throw new Error(`driver ${id}: missing required config "${field.key}"`); + } + } + return driver.create(config); + } + + /** Catalog payload for the setup UI — drivers grouped by category, no secrets. */ + catalog() { + const byCategory: Record = { + access: [], + reader: [], + camera: [], + printer: [], + }; + for (const d of this.#drivers.values()) { + byCategory[d.category].push({ + id: d.id, + label: d.label, + description: d.description, + transports: d.transports, + configFields: d.configFields, + }); + } + return byCategory; + } +} + +export interface CatalogEntry { + readonly id: string; + readonly label: string; + readonly description: string; + readonly transports: readonly string[]; + readonly configFields: readonly ConfigField[]; +} + +/** Singleton registry. Drivers self-register on import (see ./drivers). */ +export const registry = new DeviceRegistry(); diff --git a/wiki/concepts/device-adapter-pattern.md b/wiki/concepts/device-adapter-pattern.md index ac6f54d..ba91efc 100644 --- a/wiki/concepts/device-adapter-pattern.md +++ b/wiki/concepts/device-adapter-pattern.md @@ -30,3 +30,9 @@ interface RelayDevice { Note the `RelayDevice` expresses **intent only** — see the [[barrier-not-a-door]] safety principle. The choice of *which* adapter to trust is the [[trust-boundary]] decision. + +> **In practice** the adapters are made *selectable*: a [[device-registry]] catalogs the +> supported drivers (ZKTeco / ESP32 relay, Wiegand / TCP-IP readers, Hikvision / Dahua cameras), +> and the admin assigns one per lane during [[first-run-setup]]. Adding hardware support = one +> more registered driver, no business-logic change. (The implemented interfaces add a +> `CameraDevice` for entry/exit snapshots alongside reader/relay/printer.) diff --git a/wiki/concepts/device-registry.md b/wiki/concepts/device-registry.md new file mode 100644 index 0000000..99ba696 --- /dev/null +++ b/wiki/concepts/device-registry.md @@ -0,0 +1,43 @@ +--- +type: concept +tags: [parking, architecture, devices, configurable] +sources: [parking-system-architecture] +updated: 2026-06-15 +--- + +# Device Registry (selectable drivers) + +How the system goes from "device-agnostic in principle" ([[device-adapter-pattern]]) to +"**admin picks the device at setup**" in practice. A **registry** holds a catalog of supported +**drivers**, grouped by category; the [[first-run-setup]] UI reads it so an +operator can choose a device per lane and fill in its connection config. + +> Implementation-derived (from `packages/devices`), not the source doc. + +## The four categories + +| Category | Examples (drivers) | Interface | +| --- | --- | --- | +| **access** | ZKTeco, ESP32 relay (also UHPPOTE) | `AccessControlDevice` — intent-only relay ([[barrier-not-a-door]]) | +| **reader** | Wiegand-into-controller, TCP/IP reader | `ReaderDevice` — RF/optical; two paths ([[entry-exit-readers]]) | +| **camera** | Hikvision, Dahua | `CameraDevice` — entry/exit snapshot-on-event | +| **printer** | (ticket dispenser / booth printer) | `PrinterDevice` | + +## How a driver is described + +Each driver declares: a stable `id`, its `category`, a human `label`/`description`, the +`transports` it uses (`tcp-ip`, `wiegand`, …), a list of **`configFields`** (host, port, +credentials, selects — what the setup UI renders), and a `create(config)` **factory** that +validates config and returns a live adapter. Adding hardware support = registering one more +driver; **no business-logic change** — this is the [[device-adapter-pattern]] made selectable. + +## Why a registry (not hard-coded wiring) + +- The admin chooses between **multiple devices per category** at install time, per lane + (mirrors the "mixable per lane" principle — see [[trust-boundary]], [[entry-exit-readers]]). +- Config is **validated against the driver's declared fields** before persisting. +- Selections persist in the `lane_devices` table and drive runtime adapter construction. + +Cameras are modelled as **snapshot-on-event**: the host requests an image at entry/exit; it's +stored and referenced from the signed event as an **independent record** — a fraud-control input +to the [[append-only-event-chain]] (cf. [[lpr-camera]] as the recognition-based identity source). diff --git a/wiki/concepts/first-run-setup.md b/wiki/concepts/first-run-setup.md new file mode 100644 index 0000000..414a6c4 --- /dev/null +++ b/wiki/concepts/first-run-setup.md @@ -0,0 +1,37 @@ +--- +type: concept +tags: [parking, architecture, devices, admin] +sources: [parking-system-architecture] +updated: 2026-06-15 +--- + +# First-Run Setup (device selection) + +The admin install flow that makes the system **device-agnostic in practice**: on first run, an +admin assigns devices **per lane** by choosing from the [[device-registry]] catalog and entering +each device's connection config. + +> Implementation-derived (from `apps/server` + `apps/web`), not the source doc. + +## Flow + +1. **Read the catalog** — `GET /api/setup/catalog` returns supported drivers per category (no + secrets, just schema). The web `SetupWizard` renders a picker + the driver's config fields. +2. **Assign per lane** — `POST /api/setup/assign` (admin-only, role-guarded; see + [[local-jwt-auth]]). The server validates the chosen driver + config against the registry + before persisting to the `lane_devices` table; unknown drivers / missing required fields are + rejected. +3. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`. + +## Config granularity + +Organized **per lane** — each lane gets an access controller, reader(s), and camera(s), each with +its own connection settings. Matches the architecture's "mixable per lane" reality (a lane can +serve permit holders via [[wiegand]] and casual via host-side reads on one relay — see +[[entry-exit-readers]]). + +## Security notes + +- The assign/state/complete endpoints require the **admin** role ([[local-jwt-auth]]). +- Device **credentials are stored in `lane_devices.config`** — protect at rest + ([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]). diff --git a/wiki/index.md b/wiki/index.md index 3825624..509837d 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -50,6 +50,8 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records. ## Concepts — device architecture & safety - [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter. +- [[device-registry]] — catalog of selectable drivers per category (admin-configurable). +- [[first-run-setup]] — admin assigns devices per lane from the catalog at install. - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. - [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. - [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog. diff --git a/wiki/log.md b/wiki/log.md index 5323285..419bd9e 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -18,3 +18,13 @@ property). Marked [[esp32-custom-controller]] `status: deferred` per decision no implement device-level auth for now (access control stays on UHPPOTE + network isolation); noted in [[open-questions]] #6. Updated [[local-jwt-auth]] (hardened secret handling + 8h expiry, asymmetric-key pointer) and the index. + +## [2026-06-15] decision | Device-agnostic registry + first-run setup +From app work. Made the [[device-adapter-pattern]] selectable: added a +[[device-registry]] (catalog of drivers per category) and a [[first-run-setup]] +flow so the admin picks a device per lane at install. Categories: access +(ZKTeco / ESP32 relay), reader (Wiegand / TCP-IP), camera (Hikvision / Dahua, +snapshot-on-event), printer. Added a `CameraDevice` interface; new `lane_devices` ++ `setup_state` tables (migration 0001); admin-only setup endpoints. Stub drivers +for now (no real vendor protocols yet). Verified catalog + assign + validation + +auth end to end.