diff --git a/apps/server/src/event-log.ts b/apps/server/src/event-log.ts index 0784eef..11f152a 100644 --- a/apps/server/src/event-log.ts +++ b/apps/server/src/event-log.ts @@ -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 | 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 | 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) + .sort() + .reduce>((o, k) => { + o[k] = sort((v as Record)[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 { + append(input: AppendInput): Promise { 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) { diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index 09ac71a..221d2fc 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -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 }; }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index be38ecc..c464865 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 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; } diff --git a/packages/db/drizzle/0000_absent_rocket_raccoon.sql b/packages/db/drizzle/0000_absent_rocket_raccoon.sql deleted file mode 100644 index e25c6a3..0000000 --- a/packages/db/drizzle/0000_absent_rocket_raccoon.sql +++ /dev/null @@ -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`); \ No newline at end of file diff --git a/packages/db/drizzle/0000_baseline.sql b/packages/db/drizzle/0000_baseline.sql new file mode 100644 index 0000000..db53efe --- /dev/null +++ b/packages/db/drizzle/0000_baseline.sql @@ -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`); \ No newline at end of file diff --git a/packages/db/drizzle/0001_cuddly_maria_hill.sql b/packages/db/drizzle/0001_cuddly_maria_hill.sql deleted file mode 100644 index a78cb00..0000000 --- a/packages/db/drizzle/0001_cuddly_maria_hill.sql +++ /dev/null @@ -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 -); diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json index 0f5ac6d..192d039 100644 --- a/packages/db/drizzle/meta/0000_snapshot.json +++ b/packages/db/drizzle/meta/0000_snapshot.json @@ -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": { diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 3e8ac6c..0000000 --- a/packages/db/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -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": {} - } -} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 7a0b9c3..45476f9 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -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 } ] diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index f6cae0c..e628e7c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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>(), 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>(), + 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>(), + 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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 930cdfd..6206731 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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",