db: business-layer schema — ledger/device event split, tariffs, permits, sessions

Implements the wiki design in packages/db + packages/shared.

Event split: rename events -> ledger_events (signed business ledger) and add
device_events (unsigned telemetry). ledger_events gains a signed JSON payload
(amount/tariffVersionId/sessionRef/tender…) + keyId; canonicalize() includes
the payload via sorted-key serialization so business data is tamper-evident.
Raw Dingtian input now writes device_events, not a signed input_received.

New tables: tariffs + immutable tariff_versions (composable/versioned, currency
+ FX-ready), permits (+ permit_credentials, permit_plates; maxConcurrent default
1), blocklist, sessions (rebuildable projection cache — not a source of truth).

shared: split ParkingEvent/Type into LedgerEvent/LedgerEventType + DeviceEventKind;
add LedgerPayload, Tender, TariffStructure/TariffBlock.

Regenerated a single baseline migration (no production chain data existed).
Verified: chain appends + verifyChain ok; tampering a payment payload breaks
the signature. Full repo builds (5/5).
This commit is contained in:
2026-06-15 18:13:35 +02:00
parent 9a4c7ee27b
commit 8c2cf93067
11 changed files with 958 additions and 349 deletions
+39 -10
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { desc, events, type Db, type EventRow } from "@parking/db";
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
// The append-only, hash-chained, signed event log — the system's core anti-fraud
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
@@ -16,11 +16,13 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
// so we guard it with an in-process async lock as well.
export interface AppendInput {
readonly type: ParkingEventType;
readonly type: LedgerEventType;
readonly lane: number;
readonly direction?: Direction | null;
readonly source?: IdentitySource | null;
readonly identity?: string | null;
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
readonly payload?: LedgerPayload | null;
/** Event time (ISO-8601). Defaults to now. */
readonly occurredAt?: string;
}
@@ -39,6 +41,7 @@ export function canonicalize(e: {
lane: number;
source: string | null;
identity: string | null;
payload: Record<string, unknown> | null;
occurredAt: string;
prevHash: string | null;
}): string {
@@ -49,11 +52,33 @@ export function canonicalize(e: {
e.lane,
e.source ?? null,
e.identity ?? null,
// Payload is part of the signed form so business data is tamper-evident.
// Serialize with sorted keys for byte-stability (object key order must not
// change a signature). null when the event type carries no payload.
canonicalPayload(e.payload),
e.occurredAt,
e.prevHash ?? null,
]);
}
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
if (p == null) return null;
const sort = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sort);
if (v && typeof v === "object") {
return Object.keys(v as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((o, k) => {
o[k] = sort((v as Record<string, unknown>)[k]);
return o;
}, {});
}
return v;
};
return sort(p);
}
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex");
@@ -71,24 +96,25 @@ export class EventLog {
}
/** Append one event to the chain. Returns the persisted row. Serialized. */
append(input: AppendInput): Promise<EventRow> {
append(input: AppendInput): Promise<LedgerEventRow> {
const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined);
return run;
}
#appendNow(input: AppendInput): EventRow {
#appendNow(input: AppendInput): LedgerEventRow {
const prev = this.#db
.select()
.from(events)
.orderBy(desc(events.index))
.from(ledgerEvents)
.orderBy(desc(ledgerEvents.index))
.limit(1)
.get();
const index = (prev?.index ?? 0) + 1;
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
const occurredAt = input.occurredAt ?? new Date().toISOString();
const payload = input.payload ?? null;
const canonical = canonicalize({
index,
@@ -97,6 +123,7 @@ export class EventLog {
lane: input.lane,
source: input.source ?? null,
identity: input.identity ?? null,
payload,
occurredAt,
prevHash,
});
@@ -109,13 +136,15 @@ export class EventLog {
lane: input.lane,
source: input.source ?? null,
identity: input.identity ?? null,
payload,
occurredAt,
prevHash,
signature: this.#signer.sign(canonical),
keyId: this.#signer.keyId,
};
this.#db.insert(events).values(row).run();
return row as EventRow;
this.#db.insert(ledgerEvents).values(row).run();
return row as LedgerEventRow;
}
/**
@@ -125,7 +154,7 @@ export class EventLog {
* row (index gap), and a forged/invalid signature.
*/
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(events).orderBy(events.index).all();
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
let expectedIndex = 1;
let prevHash: string | null = null;
for (const row of rows) {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { desc, events, type Db } from "@parking/db";
import { desc, ledgerEvents, type Db } from "@parking/db";
import { requireRole } from "../auth.js";
import type { EventLog } from "../event-log.js";
@@ -22,7 +22,7 @@ export async function eventRoutes(
{ preHandler: guard },
async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all();
return { events: rows };
},
);
+25 -22
View File
@@ -1,7 +1,8 @@
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import Fastify, { type FastifyInstance } from "fastify";
import { createDb, type Db } from "@parking/db";
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 { EventLog } from "./event-log.js";
@@ -70,39 +71,41 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop());
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
// trail. The device is NOT trusted; the host record is the source of truth, and
// a relay open with no matching signed event is itself the anomaly. We record
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
// 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);
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
// faithfully (the chain is append-only) rather than silently dropped or
// mis-stamped as lane 0, which is a real lane.
// faithfully rather than silently dropped or mis-stamped as lane 0 (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,
// `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,
})
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
try {
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: e.deviceId,
lane,
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());
// TODO: entry flow (input event → signed event → print → relay).
// TODO: entry flow (device input → signed vehicle_entry → print → relay).
return app;
}