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 { ButtonLightController } from "./button-light.js"; import { EntryFlow } from "./entry-flow.js"; import { EventLog } from "./event-log.js"; import { ExitFlow } from "./exit-flow.js"; import { VoidFlow } from "./void-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 { pruneSnapshots } from "./snapshot-retention.js"; import { BackupService } from "./backup-service.js"; import { backupRoutes } from "./routes/backup.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 { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js"; import { LaneStatus } from "./lane-status.js"; import { LanePresence } from "./lane-presence.js"; import { AnprBridge } from "./anpr-entry.js"; import { eventRoutes } from "./routes/events.js"; import { reportRoutes } from "./routes/reports.js"; import { recycleBinRoutes } from "./routes/recycle-bin.js"; import { sweepExpired, retentionDays } from "./recycle-bin.js"; import { payRoutes } from "./routes/pay.js"; import { subscriptionRoutes } from "./routes/subscriptions.js"; import { subscriptionPlanRoutes } from "./routes/subscription-plans.js"; import { qrReaderRoutes } from "./routes/qr-reader.js"; import { shiftRoutes } from "./routes/shift.js"; import { drawerRoutes } from "./routes/drawer.js"; import { entryRoutes } from "./routes/entry.js"; import { siteRoutes } from "./routes/site.js"; import { validationRoutes } from "./routes/validations.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"; import { registerSpa } from "./static-spa.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 knob: trace|debug|info|warn|error|fatal (pino). Default info; a booth // being diagnosed can run LOG_LEVEL=debug without a code change. level: process.env.LOG_LEVEL ?? "info", // Container logs are read by humans (`docker logs` / Komodo), so stamp // ISO-8601 UTC instead of pino's epoch-ms, and level NAMES instead of the // numeric codes (30/40/50). pinoDbStream accepts both encodings. timestamp: () => `,"time":"${new Date().toISOString()}"`, formatters: { level: (label) => ({ level: label }) }, 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 }, }); // Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen // test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any // other service that happens to answer on the address the operator typed. app.get("/health", async () => ({ status: "ok", app: "parking-system" })); // 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); // Vision (ANPR) client — built early so the device monitor can include the vision // service's health in the footer, AND so the setup wizard's "Test ANPR" can run a // snapshot→analyze probe on an ANPR-enabled camera. 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"); // 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 turns an input into a signed vehicle_entry once // a ticket prints + the barrier is commanded. See event-streams-split.md. // Constructed HERE (before setupRoutes) so the Setup relay-test can sign its // deliberate barrier open into the ledger; the read routes are wired further down. // 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), ); // 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, visionClient, eventLog); // 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); // Lane busy/free tracker: a camera's vehicle detection marks its bound lane busy // (advisory barrier lights on the booth); auto-clears on a timeout. See lane-status.ts. const laneStatus = new LaneStatus(db, app.log); app.addHook("onClose", async () => laneStatus.stop()); // Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the // physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI. const lanePresence = new LanePresence(db, app.log); lanePresence.start(); app.addHook("onClose", async () => lanePresence.stop()); // NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read // flows are constructed — because the ANPR bridge they carry depends on the // SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring. // 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) 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()); // Read routes for the signed ledger (constructed above, before setupRoutes). await eventRoutes(app, db, eventLog); // Admin reporting: read-only charts/totals aggregated from the signed ledger // (+ sessions cache for durations). Gated on report:read. See routes/reports.ts. await reportRoutes(app, db); // Recycle bin: view / restore / purge soft-deleted master data (users/roles/subs/ // plans/tariffs). Gated on recyclebin:*. See routes/recycle-bin.ts, recycle-bin.ts. await recycleBinRoutes(app, db); // 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, laneStatus, lanePresence); // 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()); // The camera press-gate: the entry flow mirrors the entry lane's camera state so a // physical press is live only in the lamp's SOLID state (see entry-flow.ts). const unsubscribeEntryLane = deviceEvents.onLaneStatus((s) => entryFlow.onLaneStatus(s)); app.addHook("onClose", async () => unsubscribeEntryLane()); // Button-light indicator: drives the entry button's lamp on a spare relay from the // RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera, // off otherwise). A non-barrier aux output; fails OFF. See button-light.ts. const buttonLight = new ButtonLightController(db, app.log); buttonLight.start(); app.addHook("onClose", async () => buttonLight.stop()); // 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()); // ANPR bridge: a subscriber's plate, read off the lane camera's vehicle detection, // admits them through the SAME gated SubscriptionFlow a QR/card scan uses (it emits a // plate read onto the bus, which the dispatcher above turns into a gated entry/exit). // Advisory + fail-soft + subscriber-only — never the sole reason a barrier opens. Needs // the subscriptionFlow constructed just above. See anpr-entry.ts. const anprBridge = new AnprBridge(db, visionClient, subscriptionFlow, app.log); // Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on // each detected target (vehicle). Source-IP guarded + optional Digest; records the raw // payload as a `kind:"alarm"` device_event, drives lane busy/free, AND hands a vehicle // detection to the ANPR bridge above. See routes/hikvision-alarm.ts. await hikvisionAlarmRoutes(app, db, laneStatus, anprBridge); // 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(); // Dingtian DT-008 QR/RFID 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/dingtian-dt008-reader.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); // Ticket-void (cancel a wrongly-printed ticket): appends a signed `void` referencing the // entry; the session projection folds it closed. See void-flow.ts. const voidFlow = new VoidFlow(db, eventLog, app.log); await payRoutes(app, db, payStation, exitFlow, shiftService, voidFlow); // 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); await subscriptionPlanRoutes(app, db); // Shift open/close (shiftService constructed above). await shiftRoutes(app, shiftService); // Drawer cash movements — operator records, admin reviews (routes/drawer.ts). await drawerRoutes(app, shiftService); // Operator-issued entry (broken physical button) — flagged mint, presence-gated. await entryRoutes(app, entryFlow, laneStatus, 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, eventLog); // Merchant validations (bar / lavazh): setup panel config + the merchant user's // scan-and-apply. The booth settlement folds the applied validations into its // quote (pay-station.ts). See wiki/concepts/validation-discounts.md. await validationRoutes(app, db, eventLog); // 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); // On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target // directory is admin-chosen (site_config), the key is an env secret; status + a manual "back // up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND // BACKUP_KEY is present. See wiki/concepts/backup-recovery.md. const backupService = new BackupService(db, app.log); await backupRoutes(app, db, backupService); // 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)); // Snapshot retention prune — DISK-PRESSURE safety valve: only when the DB's filesystem // crosses the high-water mark do we delete the oldest snapshots + VACUUM. A no-op the rest // of the time. Daily, unref'd, plus once at startup. See snapshot-retention.ts. const runSnapPrune = async () => { const res = await pruneSnapshots(db, {}, app.log); if (res.deletedRows > 0) { app.log.info( `pruned ${res.deletedRows} snapshots, freed ~${(res.freedBytesEst / 1048576).toFixed(0)} MB ` + `(disk was ${res.usedPctBefore.toFixed(0)}% used${res.vacuumed ? ", vacuumed" : ""})`, ); } }; const snapPruneTimer = setInterval(() => void runSnapPrune(), 24 * 60 * 60 * 1000); snapPruneTimer.unref(); void runSnapPrune(); // once at startup app.addHook("onClose", async () => clearInterval(snapPruneTimer)); // Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a // no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in // backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately // NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its // own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart: // always`), which could push a day's backup out arbitrarily far AND — before last-success was // persisted — made the admin UI show "Never" despite valid backups already on disk // (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock // timestamp is immune to both restart timing and to any single restart cadence. A no-op // (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an // unreachable/unmounted target by recording the error and trying again next check. NOT run // once at startup (a just-booted appliance after a power cut shouldn't immediately write to a // possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md. const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000); backupTimer.unref(); app.addHook("onClose", async () => clearInterval(backupTimer)); if (backupService.configured) { app.log.info("backup: scheduled daily encrypted backup enabled"); } // Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the // retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs // every 6h, unref'd, plus once at startup. See recycle-bin.ts. const binTimer = setInterval(() => { const purged = sweepExpired(db); const total = Object.values(purged).reduce((a, b) => a + b, 0); if (total > 0) app.log.info(`recycle-bin: auto-purged ${total} expired item(s) ${JSON.stringify(purged)}`); }, 6 * 60 * 60 * 1000); binTimer.unref(); if (retentionDays() > 0) sweepExpired(db); // once at startup app.addHook("onClose", async () => clearInterval(binTimer)); 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()); // LAST: serve the built React SPA (apps/web/dist) when present — so one container // serves the API + the operator UI (offline-first single appliance). No-op in dev (no // build → the Vite dev server serves the UI). Registered after every API route and // GET-only with /api + /health excluded, so it can never shadow the backend. // See static-spa.ts + wiki/decisions/container-deployment.md. await registerSpa(app); return app; }