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