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:
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user