server: transient entry flow (button -> ticket -> signed entry -> open)

Closes the long-dangling thread from device-input-flow. On an access device's
rising input edge: print the ticket (failover), then sign vehicle_entry, then
pulseOpen, then cache the session projection.

Two invariants enforced:
- signed BEFORE open (an open with no signed event is the fraud signal);
- HOLD on print failure — no ticket means a transient can't pay on exit, so
  sign an anomaly and do NOT open, and do NOT write a vehicle_entry for a car
  that never got in.

Subscribes the same input bus as the device-telemetry writer (independent:
telemetry always records; entry acts only on an access device's on-edge,
debounced). Verified end to end against stubs: success path signs+opens+caches
and verifyChain ok; printer-down path emits only an anomaly with no open and
no entry; release edge ignored.
This commit is contained in:
2026-06-15 18:32:40 +02:00
parent 648d3254d6
commit 2696d281ce
2 changed files with 196 additions and 0 deletions
+12
View File
@@ -5,6 +5,7 @@ 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 { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js";
@@ -79,6 +80,17 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// See wiki/decisions/event-streams-split.md.
const eventLog = new EventLog(db, buildSigner(app.log));
await eventRoutes(app, db, eventLog);
// 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, laneMap, app.log);
const unsubscribeEntry = deviceEvents.onInput((e) => {
void entryFlow.onInput(e);
});
app.addHook("onClose", async () => unsubscribeEntry());
const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded