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 } 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 { PrinterMonitor } from "./printer-monitor.js"; import { DeviceMonitor } from "./device-monitor.js"; import { buildSigner, buildVerifier } from "./signer.js"; import { authRoutes } from "./routes/auth.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 { const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, }); const db = opts.db ?? createDb(); 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); // 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()); // Unified device-status monitor: polls EVERY configured device (relays/readers/ // cameras via healthCheck, printers via rich readStatus) and feeds the booth's // device-status footer over the WS. Read-only — never drives a relay. // See wiki/concepts/device-status-monitoring.md. const deviceMonitor = new DeviceMonitor(db, app.log); 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. const entryFlow = new EntryFlow(db, eventLog, app.log); 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); const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log); const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log); const unsubscribeRead = deviceEvents.onRead((e) => { void readDispatcher.dispatch(e); }); app.addHook("onClose", async () => unsubscribeRead()); // GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON // verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher // and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md. await qrReaderRoutes(app, db, readDispatcher); // 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. See wiki/entities/subscription.md. await subscriptionRoutes(app, db); // 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); 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; }