Files
parking_solution/apps/server/src/server.ts
T
julian a20400c2c5 fix: record subscription sale as a signed payment (close off-book hole)
Creating a priced subscription wrote only the mutable `subscriptions`
master row and appended NOTHING to the signed ledger — so the cash an
operator collected showed in the live feed, drawer, and shift Z-report
nowhere, leaving no signed trace. A booth operator could sell
subscriptions and pocket the money untraceably — the exact
operator-as-adversary path the append-only signed ledger exists to close.
Found live: 3 priced subscriptions (27,000 ALL) had zero payment events.

Selling a priced subscription now appends a signed `payment` event at
create time: amount = priceMinor x months (full multi-month prepay),
operator-chosen tender (cash->drawer / card->bank), payload
{ subscriptionSale: true, permitId, operator, months }. Folds into the
shift Z-report/drawer with no new summing logic; the feed badges it
"subscription sale" and resolves the holder name. The create response
returns the recorded { sale }; subscriptionRoutes now takes the EventLog
and ShiftService.

Not hard-gated on an open shift (a sale can happen outside the booth money
path) — it warns instead. The 3 historical off-book sales are not
back-fillable (append-only forbids forging dated events) — reconcile via
cash_movement or a Z-report note.

Verified against a copy of the live DB with the real signing modules:
signed payment appended, hash-chain still verifies, lands in shift cash
totals. Build + lint 12/12.

Wiki: subscription "Collecting the fee" deferred -> BUILT (+ the off-book
hole and why); shift sale-folds-in; threat-model worked example
("store the price != account for the sale").

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:46:17 +02:00

250 lines
12 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 { 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<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: 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;
}