diff --git a/apps/server/src/lane-map.ts b/apps/server/src/lane-map.ts new file mode 100644 index 0000000..88e0e81 --- /dev/null +++ b/apps/server/src/lane-map.ts @@ -0,0 +1,30 @@ +import { laneDevices, type Db } from "@parking/db"; + +// Resolves a device instance id (lane_devices.id) to its lane number. +// +// Device pushes/events carry the `lane_devices` id (which device fired), not a +// lane. The event log wants the lane, so we keep a small in-memory id->lane map +// rebuilt from the DB at startup and refreshed whenever assignments change +// (assign/unassign). It's tiny (one row per device) and read on the hot path of +// every input event, so a cached map beats a per-event DB lookup. +export class LaneMap { + readonly #db: Db; + #byDeviceId = new Map(); + + constructor(db: Db) { + this.#db = db; + } + + /** (Re)load the id->lane map from the lane_devices table. */ + refresh(): void { + const rows = this.#db.select().from(laneDevices).all(); + const next = new Map(); + for (const r of rows) next.set(r.id, r.lane); + this.#byDeviceId = next; + } + + /** Lane for a device instance id, or null if the device isn't known. */ + laneFor(deviceId: string): number | null { + return this.#byDeviceId.get(deviceId) ?? null; + } +} diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 5b27379..50d65d6 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -48,7 +48,13 @@ function redactSecrets(config: Record): Record return out; } -export async function setupRoutes(app: FastifyInstance, db: Db): Promise { +export async function setupRoutes( + app: FastifyInstance, + db: Db, + // Called after the set of assignments changes (assign/unassign) so the caller + // can refresh anything derived from it — e.g. the device id->lane map. + onAssignmentsChanged: () => void = () => {}, +): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); @@ -251,6 +257,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { enabled: true, }; await db.insert(laneDevices).values(row); + onAssignmentsChanged(); // refresh derived state (device->lane map) // Don't echo device secrets back (push Digest password, web-UI login, …). return reply.code(201).send({ ...row, @@ -281,6 +288,7 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { .get(); if (!existing) return reply.code(404).send({ error: "no such device assignment" }); await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id)); + onAssignmentsChanged(); // refresh derived state (device->lane map) app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`); return reply.code(204).send(); }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 24bad88..be38ecc 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,6 +5,7 @@ import { createDb, type Db } from "@parking/db"; import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; import { deviceEvents } from "./device-events.js"; import { EventLog } from "./event-log.js"; +import { LaneMap } from "./lane-map.js"; import { PrinterMonitor } from "./printer-monitor.js"; import { buildSigner } from "./signer.js"; import { authRoutes } from "./routes/auth.js"; @@ -46,9 +47,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise lane resolver. Built from lane_devices at startup and refreshed + // by setupRoutes on assign/unassign, so device events can be stamped with the + // lane the device belongs to (events carry the device id, not a lane). + const laneMap = new LaneMap(db); + laneMap.refresh(); + // Device-agnostic setup: the admin selects devices per lane from the driver // catalog at first-run. See wiki/concepts/first-run-setup.md. - await setupRoutes(app, db); + await setupRoutes(app, db, () => laneMap.refresh()); // Inbound device pushes (e.g. Dingtian Input Link URL → button events), // guarded by source-IP allowlist + a shared-secret path token, both read from @@ -72,10 +79,22 @@ export async function buildServer(opts: BuildOptions = {}): Promise { + // 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 + // faithfully (the chain is append-only) rather than silently dropped or + // mis-stamped as lane 0, which is a real lane. + const lane = laneMap.laneFor(e.deviceId) ?? -1; + if (lane === -1) { + app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`); + } eventLog .append({ type: "input_received", - lane: 0, // lane mapping is a TODO — device->lane lookup arrives with setup/lane wiring + lane, + // `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a + // VEHICLE was identified. A raw input has none, so it stays null. The + // device provenance lives in `identity` instead. + source: null, identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`, occurredAt: e.at, }) @@ -83,7 +102,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeInput()); - // TODO: entry flow (input event → signed event → print → relay); map device→lane. + // TODO: entry flow (input event → signed event → print → relay). return app; } diff --git a/wiki/concepts/append-only-event-chain.md b/wiki/concepts/append-only-event-chain.md index 91590f7..833c4ff 100644 --- a/wiki/concepts/append-only-event-chain.md +++ b/wiki/concepts/append-only-event-chain.md @@ -2,7 +2,7 @@ type: concept tags: [parking, security, integrity] sources: [parking-system-architecture] -updated: 2026-06-14 +updated: 2026-06-15 --- # Append-Only Event Chain @@ -62,8 +62,18 @@ so old events stay verifiable. Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]], [[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` — -the richer entry event waits for the entry flow (ticket print + barrier command). Device→lane -mapping is still a TODO (logged with `lane: 0`). +the richer entry event waits for the entry flow (ticket print + barrier command). + +- **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`) + caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every + assign/unassign. Device events carry the device instance id, not a lane; the handler looks it + up. A device with no mapping (assigned without a lane, or a stale id) logs **`lane: -1`** and a + warning — never `0`, which is a real lane — and is still recorded (the chain is append-only; + nothing is dropped). +- **`source` stays `null`** for `input_received`, and deliberately so: `source` is an + `IdentitySource` (`wiegand | lpr | qr | ticket | manual`) — *how a vehicle was identified* — not + a device/IP field. A raw button push has no vehicle identity. The device provenance lives in + **`identity`** (e.g. `dingtian: input:1/on`). ### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only diff --git a/wiki/log.md b/wiki/log.md index 7ac667a..be19acc 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -288,3 +288,12 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). - Verified on hardware (192.168.1.100): harden set login to a chosen pw; device then rejects admin/admin (&2&) and accepts the chosen pw (&0&). UDP2 warning surfaced as designed. - Updated [[dingtian-relay]]. + +## [2026-06-15] update | input_received lane resolution + source semantics +- Wired device→lane resolution: `LaneMap` (`apps/server/src/lane-map.ts`) caches + `lane_devices.id → lane`, refreshed by setup routes on assign/unassign. `input_received` + events now carry the firing device's lane instead of a hardcoded `lane: 0`. Unmapped device → + `lane: -1` + warn (0 is a real lane; never mis-stamp). +- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a + device field); device provenance is in `identity`. +- Updated [[append-only-event-chain]].