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;
}
@@ -1,23 +0,0 @@
CREATE TABLE `events` (
`id` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`type` text NOT NULL,
`direction` text,
`lane` integer NOT NULL,
`source` text,
`identity` text,
`occurred_at` text NOT NULL,
`prev_hash` text,
`signature` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `events_index_unique` ON `events` (`index`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+113
View File
@@ -0,0 +1,113 @@
CREATE TABLE `blocklist` (
`id` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`value` text NOT NULL,
`reason` text,
`active` integer DEFAULT true NOT NULL,
`added_by` text,
`added_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `device_events` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text,
`lane` integer,
`category` text,
`kind` text NOT NULL,
`detail` text,
`occurred_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `lane_devices` (
`id` text PRIMARY KEY NOT NULL,
`lane` integer NOT NULL,
`category` text NOT NULL,
`driver_id` text NOT NULL,
`config` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `ledger_events` (
`id` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`type` text NOT NULL,
`direction` text,
`lane` integer NOT NULL,
`source` text,
`identity` text,
`payload` text,
`occurred_at` text NOT NULL,
`prev_hash` text,
`signature` text NOT NULL,
`key_id` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `ledger_events_index_unique` ON `ledger_events` (`index`);--> statement-breakpoint
CREATE TABLE `permit_credentials` (
`id` text PRIMARY KEY NOT NULL,
`permit_id` text NOT NULL,
`kind` text NOT NULL,
`value` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `permit_plates` (
`id` text PRIMARY KEY NOT NULL,
`permit_id` text NOT NULL,
`plate` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `permits` (
`id` text PRIMARY KEY NOT NULL,
`holder_name` text,
`contact` text,
`max_concurrent` integer DEFAULT 1,
`valid_from` text,
`valid_to` text,
`status` text DEFAULT 'active' NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`lane` integer,
`identity` text,
`source` text,
`permit_id` text,
`entered_at` text NOT NULL,
`exited_at` text,
`state` text DEFAULT 'open' NOT NULL,
`last_event_index` integer
);
--> statement-breakpoint
CREATE TABLE `setup_state` (
`id` integer PRIMARY KEY NOT NULL,
`completed_at` text
);
--> statement-breakpoint
CREATE TABLE `tariff_versions` (
`id` text PRIMARY KEY NOT NULL,
`tariff_id` text NOT NULL,
`effective_from` text NOT NULL,
`currency` text NOT NULL,
`structure` text NOT NULL,
`created_by` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tariffs` (
`id` text PRIMARY KEY NOT NULL,
`scope` text DEFAULT 'site' NOT NULL,
`name` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
@@ -1,14 +0,0 @@
CREATE TABLE `lane_devices` (
`id` text PRIMARY KEY NOT NULL,
`lane` integer NOT NULL,
`category` text NOT NULL,
`driver_id` text NOT NULL,
`config` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `setup_state` (
`id` integer PRIMARY KEY NOT NULL,
`completed_at` text
);
+537 -5
View File
@@ -1,11 +1,193 @@
{
"version": "6",
"dialect": "sqlite",
"id": "721bbb8f-b929-4018-9420-0ae75b03ff93",
"id": "cd09c11f-4306-4ac8-a335-7c050d080ab6",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"events": {
"name": "events",
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lane_devices": {
"name": "lane_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
@@ -56,6 +238,13 @@
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
@@ -76,11 +265,18 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"events_index_unique": {
"name": "events_index_unique",
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
@@ -92,6 +288,342 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
-245
View File
@@ -1,245 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1073123c-0df9-4109-84bf-7f23b95ec5bd",
"prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93",
"tables": {
"events": {
"name": "events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"events_index_unique": {
"name": "events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lane_devices": {
"name": "lane_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+2 -9
View File
@@ -5,15 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1781389618205,
"tag": "0000_absent_rocket_raccoon",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781416636098,
"tag": "0001_cuddly_maria_hill",
"when": 1781539958008,
"tag": "0000_baseline",
"breakpoints": true
}
]
+164 -10
View File
@@ -2,10 +2,16 @@ import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Schema notes:
// - `events` is APPEND-ONLY. Never expose UPDATE/DELETE on it. A correction or
// void is a new row of type 'void'. Each row chains to the previous via
// `prevHash` and is signed by the ATECC608 (`signature`). This is the core
// anti-fraud integrity mechanism. See wiki/concepts/append-only-event-chain.md.
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
// • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
// Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
// chains via `prevHash` and is signed (`signature`). The anti-fraud record; sessions,
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
// See wiki/entities/local-jwt-auth.md.
@@ -21,7 +27,13 @@ export const users = sqliteTable("users", {
.default(sql`(current_timestamp)`),
});
export const events = sqliteTable("events", {
// --- The signed business ledger (formerly `events`) ----------------------
// Holds ONLY business/accountability facts: vehicle_entry, vehicle_exit, payment,
// void, shift_z_report, plus witness-grade barrier_open_command/observed, anomaly.
// `payload` carries type-specific data (amount, tariffVersionId, sessionRef, tender,
// plate confidence…) and is part of the SIGNED canonical form, so it is tamper-evident
// like the rest of the row. See packages/shared ParkingEventType + LedgerPayload.
export const ledgerEvents = sqliteTable("ledger_events", {
id: text("id").primaryKey(),
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
index: integer("index").notNull().unique(),
@@ -30,18 +42,43 @@ export const events = sqliteTable("events", {
lane: integer("lane").notNull(),
source: text("source"),
identity: text("identity"),
// Type-specific business payload (JSON). Signed as part of the canonical form.
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>(),
occurredAt: text("occurred_at").notNull(),
// Hash of the previous event (hex). Null only for the genesis event.
prevHash: text("prev_hash"),
// ATECC608 signature over the canonical event payload (hex).
signature: text("signature").notNull(),
// Which signer/key produced `signature` (e.g. "sw-hmac-v1", "atecc608-slot0"),
// so old events stay verifiable across a signer swap. See packages/shared Signer.
keyId: text("key_id").notNull(),
});
// Per-lane device assignments chosen by the admin during first-run setup.
// --- Device telemetry (unsigned, prunable) -------------------------------
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
// offline, reader read, raw input edges. Keyed to a lane_devices instance; lane is
// resolved via the LaneMap. No prevHash/signature — this stream may rotate/prune.
export const deviceEvents = sqliteTable("device_events", {
id: text("id").primaryKey(),
// The lane_devices instance that produced it (raw provenance).
deviceId: text("device_id"),
lane: integer("lane"),
category: text("category", {
enum: ["access", "reader", "camera", "printer"],
}),
// e.g. "input", "relay", "status", "read", "snapshot".
kind: text("kind").notNull(),
// Free-form telemetry detail (input number + edge, status flags, error…).
detail: text("detail", { mode: "json" }).$type<Record<string, unknown>>(),
occurredAt: text("occurred_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Per-lane device assignments (first-run setup) -----------------------
// One row per (lane, category, instance). `driverId` references a driver in the
// @parking/devices registry; `config` is that driver's JSON config (host, port,
// credentials…). Lets the system stay device-agnostic and admin-configurable.
// See wiki/concepts/device-registry.md and first-run-setup.md.
// @parking/devices registry; `config` is that driver's JSON config. Keeps the
// system device-agnostic + admin-configurable. See device-registry.md, first-run-setup.md.
export const laneDevices = sqliteTable("lane_devices", {
id: text("id").primaryKey(),
lane: integer("lane").notNull(),
@@ -64,7 +101,124 @@ export const setupState = sqliteTable("setup_state", {
completedAt: text("completed_at"),
});
// --- Tariffs (composable, versioned) -------------------------------------
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
// A session reprices against the version in force at its entry time; the `payment`
// ledger event records the tariffVersionId used. "One active tariff per site" today;
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
export const tariffs = sqliteTable("tariffs", {
id: text("id").primaryKey(),
// Only "site" used now; "lane"/"zone" reserved for multi-tariff later.
scope: text("scope", { enum: ["site", "lane", "zone"] }).notNull().default("site"),
name: text("name").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
export const tariffVersions = sqliteTable("tariff_versions", {
id: text("id").primaryKey(),
tariffId: text("tariff_id").notNull(),
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
effectiveFrom: text("effective_from").notNull(),
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
currency: text("currency").notNull(),
// The composable rate card (stepped blocks + caps/grace). Shape: TariffStructure
// in packages/shared. Immutable once published.
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Permits (subscriptions) ---------------------------------------------
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
export const permits = sqliteTable("permits", {
id: text("id").primaryKey(),
holderName: text("holder_name"),
contact: text("contact"),
// Car-count binding: how many of the permit's cars may be inside at once.
// null = unbound. Default 1.
maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"),
validTo: text("valid_to"),
status: text("status", { enum: ["active", "suspended", "revoked"] })
.notNull()
.default("active"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
export const permitCredentials = sqliteTable("permit_credentials", {
id: text("id").primaryKey(),
permitId: text("permit_id").notNull(),
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
value: text("value").notNull(),
});
// Plate binding (optional). When a permit has plate rows, a matching plate read is
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
export const permitPlates = sqliteTable("permit_plates", {
id: text("id").primaryKey(),
permitId: text("permit_id").notNull(),
plate: text("plate").notNull(),
});
// --- Blocklist (banlist) -------------------------------------------------
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
export const blocklist = sqliteTable("blocklist", {
id: text("id").primaryKey(),
kind: text("kind", { enum: ["plate", "card", "qr"] }).notNull(),
value: text("value").notNull(),
reason: text("reason"),
active: integer("active", { mode: "boolean" }).notNull().default(true),
addedBy: text("added_by"),
addedAt: text("added_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Sessions (PROJECTION cache) -----------------------------------------
// NOT a source of truth — a rebuildable fold over ledger_events for fast queries
// (occupancy, pay-station lookup, anti-passback, plate search). Always reconstructable
// from the signed chain; never the authority for "paid". See wiki/concepts/parking-session.md.
export const sessions = sqliteTable("sessions", {
// The session key = the entry's identity (ticket id or plate).
id: text("id").primaryKey(),
lane: integer("lane"),
// Identity that opened the session, and how it was read.
identity: text("identity"),
source: text("source"),
// null while transient; set when matched to a permit.
permitId: text("permit_id"),
enteredAt: text("entered_at").notNull(),
// null until exit; presence = CLOSED.
exitedAt: text("exited_at"),
// Derived state for quick filtering: open | paid | closed | voided.
state: text("state", { enum: ["open", "paid", "closed", "voided"] })
.notNull()
.default("open"),
// Index of the last ledger event folded into this row (cache freshness / rebuild).
lastEventIndex: integer("last_event_index"),
});
export type UserRow = typeof users.$inferSelect;
export type EventRow = typeof events.$inferSelect;
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
export type LaneDeviceRow = typeof laneDevices.$inferSelect;
export type SetupStateRow = typeof setupState.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type PermitRow = typeof permits.$inferSelect;
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
export type PermitPlateRow = typeof permitPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
+76 -9
View File
@@ -13,39 +13,106 @@ export type Direction = "entry" | "exit";
export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual";
/**
* An append-only parking event. Records are never mutated; corrections are new
* A signed business-LEDGER event. Records are never mutated; corrections are new
* events. `prevHash` chains each event to the previous one; `signature` is the
* ATECC608 signature over the event contents. See wiki/append-only-event-chain.
* ATECC608 signature over the canonical contents (which INCLUDE `payload`).
* Distinct from device telemetry — see wiki/decisions/event-streams-split.md.
*/
export interface ParkingEvent {
export interface LedgerEvent {
readonly id: string;
readonly index: number;
readonly type: ParkingEventType;
readonly type: LedgerEventType;
readonly direction: Direction | null;
readonly lane: number;
readonly source: IdentitySource | null;
/** Card number, plate, ticket id, etc. — depends on `source`. */
readonly identity: string | null;
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
readonly payload: LedgerPayload | null;
readonly occurredAt: string; // ISO-8601
/** Hash of the previous event in the chain (hex). Null only for genesis. */
readonly prevHash: string | null;
/** ATECC608 signature over the canonical event payload (hex). */
readonly signature: string;
/** Which signer/key produced `signature` (verifiable across a signer swap). */
readonly keyId: string;
}
export type ParkingEventType =
// A raw device input (e.g. a Dingtian button press) was received and recorded.
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
// entry flow once a ticket prints and the barrier is commanded.
| "input_received"
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
export type LedgerEventType =
| "vehicle_entry"
| "vehicle_exit"
| "payment"
| "void"
// Witness-grade: a host-commanded open, and an independently-observed open
// (loop/sensor) — reconciled against each other.
| "barrier_open_command"
| "barrier_open_observed"
| "shift_z_report"
| "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */
export type Tender = "cash" | "card";
/**
* Type-specific data carried on a ledger event's `payload`. All amounts are
* integer minor units in the named currency — never floats. Fields are optional
* because they're event-type-specific; the producer fills what applies.
*/
export interface LedgerPayload {
/** The parking_session this event concerns (entry/exit/payment/void). */
readonly sessionRef?: string;
/** payment: amount in minor units, its currency, and how it was tendered. */
readonly amountMinor?: number;
readonly currency?: string;
readonly tender?: Tender;
/** payment: which tariff_version priced it (reproducible repricing). */
readonly tariffVersionId?: string;
/** payment: gross/discount/net split when a validation applied. */
readonly grossMinor?: number;
readonly discountMinor?: number;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null;
/** void / anomaly / override: a human/machine reason code. */
readonly reason?: string;
/** plate/vehicle from the vision service (advisory). */
readonly plate?: string;
readonly plateConfidence?: number;
/** Free-form for forward-compat without a schema change. */
readonly [k: string]: unknown;
}
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
/**
* The composable rate card stored in a tariff_version.structure. Pure data the
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
* a flat rate is just one block. See wiki/concepts/tariff.md.
*/
export interface TariffStructure {
/** Free if exited within this (drop-off/turnaround). */
readonly gracePeriodEntryMin: number;
/** Billing granularity; partial increments round UP. */
readonly incrementMin: number;
/** Consumed in order as duration accrues; last block may be open-ended. */
readonly blocks: readonly TariffBlock[];
/** Cap per rolling 24h (null = no cap). */
readonly dailyCapMinor: number | null;
/** Flat charge when there's no entry id (admin may override at the moment). */
readonly lostTicketMinor: number;
/** Pay-on-foot walk-back window: minutes after payment to reach the car. */
readonly gracePeriodExitMin: number;
/** How an overstay top-up is charged. "reprice" = recompute(entry→now) − paid. */
readonly overstay: "reprice";
}
export interface TariffBlock {
/** Upper bound of this block in minutes; null = open-ended (thereafter). */
readonly uptoMin: number | null;
readonly priceMinorPerIncrement: number;
}
export const ROLES: readonly Role[] = [
"admin",
"operator",