d0841c8601
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
362 lines
19 KiB
TypeScript
362 lines
19 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { blob, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
|
|
|
// Schema notes:
|
|
// - 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/subscriptions/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.
|
|
// - Authorization is DYNAMIC RBAC, fully local (offline-first): `roles` are data
|
|
// (admin-composable), `role_permissions` is the role→permission grid, and each
|
|
// `users` row points at one role via `role_id`. Permissions are checked per
|
|
// route (see @parking/shared PERMISSIONS). A built-in, locked `admin` role
|
|
// (id='admin') always holds every permission. See wiki/entities/local-jwt-auth.md.
|
|
|
|
/** A composable role: a named bundle of permissions. `builtin` rows (the `admin`
|
|
* role) are protected — not editable or deletable, and always granted all
|
|
* permissions. Everything else is admin-composed at runtime. */
|
|
export const roles = sqliteTable("roles", {
|
|
id: text("id").primaryKey(),
|
|
name: text("name").notNull().unique(),
|
|
// 1 = protected built-in (the `admin` role). 0 = admin-composed.
|
|
builtin: integer("builtin").notNull().default(0),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
/** The role→permission grid. One row per granted `resource:action` permission.
|
|
* The `admin` role is granted all permissions implicitly in code, so its rows
|
|
* here are belt-and-suspenders. See @parking/shared PERMISSIONS. */
|
|
export const rolePermissions = sqliteTable(
|
|
"role_permissions",
|
|
{
|
|
roleId: text("role_id")
|
|
.notNull()
|
|
.references(() => roles.id),
|
|
permission: text("permission").notNull(),
|
|
},
|
|
(t) => ({
|
|
// A permission is granted to a role at most once.
|
|
uniq: unique().on(t.roleId, t.permission),
|
|
}),
|
|
);
|
|
|
|
export const users = sqliteTable("users", {
|
|
id: text("id").primaryKey(),
|
|
username: text("username").notNull().unique(),
|
|
passwordHash: text("password_hash").notNull(),
|
|
// One role per user (RBAC). Resolves to a permission set at request time.
|
|
roleId: text("role_id")
|
|
.notNull()
|
|
.references(() => roles.id),
|
|
// Preferred UI language for this user (operator-facing). Loaded on login and
|
|
// restored from any booth. Albanian is the default. Printed tickets are NOT
|
|
// governed by this — they're always Albanian (customer-facing). See i18n.md.
|
|
language: text("language", { enum: ["sq", "en"] })
|
|
.notNull()
|
|
.default("sq"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// --- 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(),
|
|
type: text("type").notNull(),
|
|
direction: text("direction", { enum: ["entry", "exit"] }),
|
|
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(),
|
|
});
|
|
|
|
// --- Device telemetry (unsigned, prunable) -------------------------------
|
|
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
|
|
// offline, reader read, raw input edges. Keyed to a `devices` instance. No
|
|
// prevHash/signature — this stream may rotate/prune.
|
|
export const deviceEvents = sqliteTable("device_events", {
|
|
id: text("id").primaryKey(),
|
|
// The `devices` instance that produced it (raw provenance).
|
|
deviceId: text("device_id"),
|
|
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)`),
|
|
});
|
|
|
|
// --- Camera snapshots (unsigned, prunable, blob-in-DB) -------------------
|
|
// An entry/exit snapshot captured asynchronously AFTER the barrier opens — evidence,
|
|
// not a gate (camera failure never blocks an open; see entry/exit flows). Stored as a
|
|
// BLOB so the appliance keeps a single backed-up file with nothing scattered on disk.
|
|
// Kept in its own table (not inline in device_events) so the hot telemetry scans don't
|
|
// drag image bytes, and so images can be pruned independently. The signed
|
|
// vehicle_entry/exit references a snapshot by `id` in its payload — the image is an
|
|
// independent record (anti-fraud), unsigned and prunable. Retention policy is an open
|
|
// question — see wiki/concepts/entry-exit-points.md. Served via GET /api/snapshots/:id.
|
|
export const snapshots = sqliteTable("snapshots", {
|
|
id: text("id").primaryKey(),
|
|
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
|
|
// The camera `devices` instance that captured it (raw provenance).
|
|
deviceId: text("device_id"),
|
|
// The session/credential ref (ticket id, plate, subscription) — links to the ledger event.
|
|
identity: text("identity"),
|
|
contentType: text("content_type").notNull(),
|
|
bytes: blob("bytes").notNull().$type<Buffer>(),
|
|
capturedAt: text("captured_at").notNull(),
|
|
});
|
|
|
|
// --- Device assignments (first-run setup) --------------------------------
|
|
// One row per device instance. `driverId` references a driver in the @parking/devices
|
|
// registry; `config` is that driver's JSON config. There is NO lane: a parking lot is
|
|
// one pool of spaces with a flexible set of entry/exit points. Direction lives INSIDE
|
|
// the config, per the hardware:
|
|
// - access controller: config.relays = [{ relay, direction: entry|exit|both, button? }]
|
|
// — one physical board has several relays; each relay opens one barrier in one
|
|
// direction (or both). `button` = the input terminal the entry button is wired to
|
|
// (transient entry trigger; absent = no button at that barrier).
|
|
// - reader / camera: config.controllerId + config.relay BIND it to the barrier it sits
|
|
// at; its direction is INHERITED from that relay. Unbound → falls back to a
|
|
// direction picked in config.
|
|
// See device-registry.md, first-run-setup.md, wiki/concepts/entry-exit-points.md.
|
|
export const devices = sqliteTable("devices", {
|
|
id: text("id").primaryKey(),
|
|
category: text("category", {
|
|
enum: ["access", "reader", "camera", "printer"],
|
|
}).notNull(),
|
|
driverId: text("driver_id").notNull(),
|
|
// Driver-specific connection config as JSON (validated against the driver's
|
|
// declared config fields before persisting). Secrets live here — protect at rest.
|
|
config: text("config", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// Tracks whether first-run setup has been completed (single-row marker).
|
|
export const setupState = sqliteTable("setup_state", {
|
|
id: integer("id").primaryKey(), // always 1
|
|
completedAt: text("completed_at"),
|
|
});
|
|
|
|
// Single-row site settings (admin-configurable). The home for site-wide knobs;
|
|
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
|
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
|
// Park identity/metadata (all optional) lives here too — display name, the legal
|
|
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
|
|
// header (park name + NIUS are commonly required on an Albanian parking receipt)
|
|
// and admin display. All nullable: the lot runs fine with none set.
|
|
// See wiki/concepts/site-metadata.md.
|
|
export const siteConfig = sqliteTable("site_config", {
|
|
id: integer("id").primaryKey(), // always 1
|
|
capacity: integer("capacity"), // null = no capacity limit
|
|
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
|
|
parkName: text("park_name"),
|
|
/** Legal entity operating the lot, for receipts (may differ from parkName). */
|
|
operatorName: text("operator_name"),
|
|
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
|
|
nius: text("nius"),
|
|
/** Free-text postal address (multi-line allowed). */
|
|
address: text("address"),
|
|
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
|
|
phone: text("phone"),
|
|
/** Contact email. */
|
|
email: text("email"),
|
|
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
|
|
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
|
|
* customer pays at the booth and self-exits later by scanning a printed exit
|
|
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
|
|
* opens the barrier directly. The operator may still override per transaction.
|
|
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
|
|
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
|
|
.notNull()
|
|
.default(false),
|
|
/** Default monthly subscription price in minor units (e.g. 1000000 = 10,000.00).
|
|
* A starting value the subscription form pre-fills; each subscription stores its
|
|
* own price and may differ. null = no site default set. See
|
|
* wiki/entities/subscription.md. */
|
|
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
|
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
|
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
|
* each published tariff version's structure.tz so the windows are frozen/immutable
|
|
* per version — historical sessions reprice deterministically regardless of any
|
|
* later config change. null/absent ⇒ default "Europe/Tirane" at publish time.
|
|
* See wiki/concepts/tariff-time-tiers.md. */
|
|
timezone: text("timezone"),
|
|
/** Default vehicle/customer category assigned to a transient entry when none is
|
|
* captured at the lane (every transient today). Operator policy — a plain car park
|
|
* leaves it "default"; a mixed lot might set "car". Frozen into each vehicle_entry
|
|
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
|
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
|
defaultVehicleCategory: text("default_vehicle_category"),
|
|
updatedAt: text("updated_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// --- 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; "zone" reserved for multi-tariff later.
|
|
scope: text("scope", { enum: ["site", "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)`),
|
|
});
|
|
|
|
// --- Subscriptions --------------------------------------------------------
|
|
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
|
|
// month) instead of paying per stay. Mutable master data; every USE still 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 (LPR/ANPR future).
|
|
// Pricing: priceMinor + period + currency record the plan; collecting the fee into
|
|
// the ledger/shift is deferred. See wiki/entities/subscription.md.
|
|
// NB: signed ledger events still carry `permitId` in their payload — immutable
|
|
// history, intentionally NOT renamed. These tables are the mutable master data,
|
|
// renamed permit→subscription in migration 0004.
|
|
export const subscriptions = sqliteTable("subscriptions", {
|
|
id: text("id").primaryKey(),
|
|
holderName: text("holder_name"),
|
|
contact: text("contact"),
|
|
// Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
|
|
// null = no price set (comp/legacy). The `period` says what it recurs over.
|
|
priceMinor: integer("price_minor"),
|
|
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
|
|
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
|
|
currency: text("currency"),
|
|
// Car-count binding: how many of the subscription'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 subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
|
|
export const subscriptionCredentials = sqliteTable("subscription_credentials", {
|
|
id: text("id").primaryKey(),
|
|
subscriptionId: text("subscription_id").notNull(),
|
|
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
|
value: text("value").notNull(),
|
|
});
|
|
|
|
// Plate binding (optional). When a subscription has plate rows, a matching plate read
|
|
// is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
|
export const subscriptionPlates = sqliteTable("subscription_plates", {
|
|
id: text("id").primaryKey(),
|
|
subscriptionId: text("subscription_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(),
|
|
// Identity that opened the session, and how it was read.
|
|
identity: text("identity"),
|
|
source: text("source"),
|
|
// null while transient; set when matched to a subscription.
|
|
subscriptionId: text("subscription_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 RoleRow = typeof roles.$inferSelect;
|
|
export type RolePermissionRow = typeof rolePermissions.$inferSelect;
|
|
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
|
|
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
|
export type SnapshotRow = typeof snapshots.$inferSelect;
|
|
export type DeviceRow = typeof devices.$inferSelect;
|
|
export type SetupStateRow = typeof setupState.$inferSelect;
|
|
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
|
export type TariffRow = typeof tariffs.$inferSelect;
|
|
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
|
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
|
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
|
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
|
export type BlocklistRow = typeof blocklist.$inferSelect;
|
|
export type SessionRow = typeof sessions.$inferSelect;
|