8fa66c9911
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never send. Every desktop handshake 401'd and use-live-feed reconnected every 10s (confirmed in the park-2 server log). - routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s, single-use, in-memory ticket; the WS preHandler accepts it via an x-ws-ticket header after the Origin check, then the same report:read role check. Browser cookie path unchanged; JWT stays out of JS. - platform-ws.ts: fetch a ticket before connect, send it with the Origin header; connect failures now go through logClient (rate-limited). - logger.ts: flush read the CSRF token from document.cookie, null on desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had ever reached app_logs. Stash moved to a dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts. - backend-config.ts: ConnectScreen probe uses the unauthenticated /health (now also returns app: "parking-system") instead of accepting any 401. - README: local-AppImage release gate — tauri dev runs at http://localhost:5173, not tauri://localhost, so none of these origin-dependent bugs reproduce there. - wiki: new section + log entry; four citation corrections. Requires the server image with this commit deployed before the new desktop build connects (the ticket endpoint must exist). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
404 lines
22 KiB
TypeScript
404 lines
22 KiB
TypeScript
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<FastifyInstance> {
|
|
// 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;
|
|
}
|