import cookie from "@fastify/cookie"; import jwt from "@fastify/jwt"; import websocket from "@fastify/websocket"; import Fastify, { type FastifyInstance } from "fastify"; import { randomUUID } from "node:crypto"; import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js"; import { deviceEvents } from "./device-events.js"; import { EntryFlow } from "./entry-flow.js"; import { EventLog } from "./event-log.js"; import { ExitFlow } from "./exit-flow.js"; import { PayStation } from "./pay-station.js"; import { SubscriptionFlow } from "./subscription-flow.js"; import { ShiftService } from "./shift-service.js"; import { ReadDispatcher } from "./read-dispatch.js"; import { CredentialCapture } from "./credential-capture.js"; import { PrinterMonitor } from "./printer-monitor.js"; import { DeviceMonitor } from "./device-monitor.js"; import { buildSigner, buildVerifier } from "./signer.js"; import { LogService, pinoDbStream } from "./log-service.js"; import { logRoutes } from "./routes/logs.js"; import { VisionClient } from "./vision-client.js"; import { authRoutes } from "./routes/auth.js"; import { userRoutes } from "./routes/users.js"; import { roleRoutes } from "./routes/roles.js"; import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; import { payRoutes } from "./routes/pay.js"; import { subscriptionRoutes } from "./routes/subscriptions.js"; import { qrReaderRoutes } from "./routes/qr-reader.js"; import { shiftRoutes } from "./routes/shift.js"; import { siteRoutes } from "./routes/site.js"; import { snapshotRoutes } from "./routes/snapshots.js"; import { tariffRoutes } from "./routes/tariffs.js"; import { printerRoutes } from "./routes/printers.js"; import { setupRoutes } from "./routes/setup.js"; import { deviceStatusRoutes } from "./routes/device-status.js"; import { wsRoutes } from "./routes/ws.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. export interface BuildOptions { db?: Db; } export async function buildServer(opts: BuildOptions = {}): Promise { // DB first — the logger's DB sink needs it before Fastify is constructed. const db = opts.db ?? createDb(); // Application-log store: a pino stream tees warn+ lines into app_logs (and still // writes them to stdout), so backend warnings/errors are queryable from the booth // alongside frontend errors. See log-service.ts + wiki/concepts/app-logs.md. const logService = new LogService(db); const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info", stream: pinoDbStream(logService, process.stdout), }, }); // Wire the RBAC permission resolver to this DB (route guards resolve a user's // role → permission set through it). See auth.ts. initAuth(db); await app.register(cookie); // WebSocket support for the live booth feed (/api/ws). Registered before the // routes so the `{ websocket: true }` route option is available. await app.register(websocket); // 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 // token), defeating the whole local-auth/anti-fraud model. No insecure default. // The token is carried in an HttpOnly cookie (not the Authorization header). await app.register(jwt, { secret: requireJwtSecret(), // No expiry: a login is valid until explicit logout — a shift is a separate // boundary, not the token lifetime (see auth.ts + wiki/concepts/shift.md). cookie: { cookieName: TOKEN_COOKIE, signed: false }, }); app.get("/health", async () => ({ status: "ok" })); // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie. await authRoutes(app, db); // RBAC administration: compose roles (role:*) + manage users (user:*). The // built-in admin role is protected; the last admin can't be removed. See auth.ts. await userRoutes(app, db); await roleRoutes(app, db); // 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); // Inbound device pushes (e.g. Dingtian Input Link URL → button events), // guarded by source-IP allowlist + a shared-secret path token, both read from // the device's lane_devices config (written on assign). await deviceRoutes(app, db); // Live printer-status monitor: polls printers (paper/cover/cutter/offline) and // pushes changes to the booth UI. setupRoutes() has already registered the // built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md. const printerMonitor = new PrinterMonitor(db, app.log); await printerRoutes(app, printerMonitor); app.addHook("onReady", async () => printerMonitor.start()); app.addHook("onClose", async () => printerMonitor.stop()); // Vision (ANPR) client — built early so the device monitor can include the vision // service's health in the footer. Opt-in (VISION_ENABLED) + fail-soft; advisory only. // See wiki/entities/opencv-anpr-service.md. const visionClient = new VisionClient(app.log); if (visionClient.enabled) app.log.info("vision client enabled"); // Unified device-status monitor: polls EVERY configured device (relays/readers/ // cameras via healthCheck, printers via rich readStatus) PLUS the vision service's // /health, and feeds the booth's device-status footer over the WS. Read-only. // See wiki/concepts/device-status-monitoring.md. const deviceMonitor = new DeviceMonitor(db, app.log, undefined, visionClient); await deviceStatusRoutes(app, deviceMonitor); app.addHook("onReady", async () => deviceMonitor.start()); app.addHook("onClose", async () => deviceMonitor.stop()); // Append-only signed business LEDGER (ledger_events). Holds only business facts // (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw // button press is NOT a business fact: it's device telemetry, recorded UNSIGNED // in device_events. The entry flow (TODO) turns an input into a signed // vehicle_entry once a ticket prints + the barrier is commanded. // See wiki/decisions/event-streams-split.md. // The 4th arg is a read-side fan-out fired AFTER each durable append — used to // push the event to live booth clients (WS). It cannot affect the sign/chain path. const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) => deviceEvents.emitLedger(row), ); await eventRoutes(app, db, eventLog); // Live booth feed: server-pushed ledger + occupancy + printer-status over a // single authenticated WebSocket (/api/ws). See routes/ws.ts. await wsRoutes(app, db, deviceMonitor); // Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts. await snapshotRoutes(app, db); // Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen. // Subscribes to the SAME input bus as the telemetry writer below; the two are // independent (telemetry always records; the entry flow acts only on an access // device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md. // The flows take the vision client so ANPR rides their entry/exit SNAPSHOT: a button // press / QR / RFID triggers the open + snapshot, and the plate is recognized off that // same image and recorded against the session (advisory; never changes the decision). // No polling — recognition fires only on a real entry/exit. See snapshot.ts + // wiki/entities/opencv-anpr-service.md. const entryFlow = new EntryFlow(db, eventLog, app.log, visionClient); const unsubscribeEntry = deviceEvents.onInput((e) => { void entryFlow.onInput(e); }); app.addHook("onClose", async () => unsubscribeEntry()); // Read-driven flows: a credential read (ticket scan / plate / card) routes via the // dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the // transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts, // parking-session.md. const exitFlow = new ExitFlow(db, eventLog, app.log, visionClient); const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log, visionClient); const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log); const unsubscribeRead = deviceEvents.onRead((e) => { void readDispatcher.dispatch(e); }); app.addHook("onClose", async () => unsubscribeRead()); // Credential capture ("enroll a card"): lets the operator present an RFID card to a // CHOSEN reader to populate a subscription credential, without blocking the other // reader's live flow. Single-shot + TTL. See credential-capture.ts. const credentialCapture = new CredentialCapture(); // GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON // verdict (host-in-the-loop, synchronous). The capture service can intercept a read // on an armed reader for enrollment; otherwise the read routes through the // dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md. await qrReaderRoutes(app, db, readDispatcher, credentialCapture); // Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report // (sum payments by tender, print the Z-report). Constructed before the pay routes // because the booth money path is GATED on an open shift. See wiki/concepts/shift.md. const shiftService = new ShiftService(db, eventLog, app.log); // Pay station (pay-on-foot): quote an open session against the active tariff + // take payment → signed `payment` event. The booth pay/exit/voucher/re-open // endpoints require an open shift (passed in). See wiki/concepts/tariff.md. const payStation = new PayStation(db, eventLog, app.log); await payRoutes(app, db, payStation, exitFlow, shiftService); // Tariff composer: admin publishes effective-dated, immutable rate-card versions // the pay station prices against. See wiki/concepts/tariff.md. await tariffRoutes(app, db); // Subscription admin CRUD + credential capture (arm/poll/cancel). See // wiki/entities/subscription.md. await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService); // Shift open/close + drawer endpoints (shiftService constructed above). await shiftRoutes(app, shiftService); // Site config (capacity) + live occupancy. The FULL gate (refuse transient entry // at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md. await siteRoutes(app, db); // Application logs: ingest frontend errors (POST /api/logs, any signed-in user) + // read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md. await logRoutes(app, logService); // Periodic retention prune (age + row cap) so the log table stays bounded on the // offline appliance. Runs hourly; unref'd so it never holds the process open. const pruneTimer = setInterval(() => { const n = logService.prune(); if (n > 0) app.log.debug(`pruned ${n} app_log rows`); }, 60 * 60 * 1000); pruneTimer.unref(); logService.prune(); // once at startup app.addHook("onClose", async () => clearInterval(pruneTimer)); const unsubscribeInput = deviceEvents.onInput((e) => { // Record every input edge as unsigned telemetry, keyed to the device that fired // (provenance). No lane — the pool-of-spaces model has none. The entry flow // (above) independently decides whether this edge is an entry button. try { db.insert(deviceEventsTable) .values({ id: randomUUID(), deviceId: e.deviceId, category: "access", kind: "input", detail: { driverId: e.driverId, input: e.input, edge: e.edge }, occurredAt: e.at, }) .run(); } catch (err) { app.log.error(`device-event insert failed: ${(err as Error).message}`); } }); app.addHook("onClose", async () => unsubscribeInput()); return app; }