1efa77bf56
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.
Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)
Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
(v1 events won't verify under v2 — intentional, gated per-event by keyId)
Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]
Web:
- wizard: no lane selector; add controllers (relay map + entry-button
terminal) first, then bind readers/cameras/printers to a controller relay
Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
165 lines
7.6 KiB
TypeScript
165 lines
7.6 KiB
TypeScript
import cookie from "@fastify/cookie";
|
|
import jwt from "@fastify/jwt";
|
|
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 { PermitFlow } from "./permit-flow.js";
|
|
import { ShiftService } from "./shift-service.js";
|
|
import { ReadDispatcher } from "./read-dispatch.js";
|
|
import { PrinterMonitor } from "./printer-monitor.js";
|
|
import { buildSigner } 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 { permitRoutes } from "./routes/permits.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";
|
|
|
|
// 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> {
|
|
const app = Fastify({
|
|
logger: { level: process.env.LOG_LEVEL ?? "info" },
|
|
});
|
|
|
|
const db = opts.db ?? createDb();
|
|
|
|
await app.register(cookie);
|
|
|
|
// 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());
|
|
|
|
// 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.
|
|
const eventLog = new EventLog(db, buildSigner(app.log));
|
|
await eventRoutes(app, db, eventLog);
|
|
|
|
// 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 PERMIT flow (if it matches a permit) or the transient
|
|
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
|
|
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
|
const permitFlow = new PermitFlow(db, eventLog, app.log);
|
|
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, 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);
|
|
|
|
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
|
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
|
const payStation = new PayStation(db, eventLog, app.log);
|
|
await payRoutes(app, payStation);
|
|
|
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
|
await tariffRoutes(app, db);
|
|
|
|
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
|
await permitRoutes(app, db);
|
|
|
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
|
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
|
const shiftService = new ShiftService(db, eventLog, app.log);
|
|
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;
|
|
}
|