commit bfe64032d849438c787b9dfb2210ae1cd44dd55c Author: Julian Cuni Date: Sun Jun 14 00:34:11 2026 +0200 Initial scaffold: Turborepo monorepo + design wiki Turborepo (pnpm workspaces) with all dependencies pinned to latest mutually-compatible versions: turbo 2.9, TypeScript 6, Fastify 5, React 19, Vite 8, better-sqlite3 12 + Drizzle ORM 0.45. Layout: - apps/server Fastify backend (local JWT auth + role guard, /health) - apps/web React 19 + Vite 8 operator SPA - packages/db Drizzle schema on SQLite/WAL; append-only events + users - packages/devices reader/printer/relay adapter interfaces (intent-only relay) - packages/shared shared domain types Architecture constraints from the design wiki are encoded in the scaffold: append-only hash-chained + signed event log, device-agnostic adapters, "a barrier is not a door" (relay expresses intent only), fully-local offline-first auth. wiki/ is an LLM-maintained Obsidian knowledge base (28 pages) ingested from the architecture & design notes, with its own maintenance schema. Verified: pnpm install, full turbo build (5/5), server boots and serves /health, drizzle-kit generates the initial migration. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5903dfc --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +dist/ +.turbo/ +*.tsbuildinfo + +# Local database & secrets +*.sqlite +*.sqlite-shm +*.sqlite-wal +*.db +.env +.env.* +!.env.example + +# Editor/OS +.DS_Store +*:Zone.Identifier diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9e3d578 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# Parking System — Project Guide + +A parking-management system: a **web app on a dedicated, hardened Linux appliance**, deployed +on-site at a parking facility. Two forces shape almost every decision: **offline-first** +operation and a **threat model whose primary adversary is the legitimate operator at the booth** +(not an outsider). Keep both front of mind. + +## Repository layout + +This directory is a **Turborepo** monorepo. App code lives here; the knowledge base lives in +`wiki/`. + +``` +parking-system/ +├── CLAUDE.md # this file — app development guide +├── package.json # turborepo root +├── turbo.json +├── apps/ +│ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA +│ └── web/ # React + Vite SPA (operator UI) +├── packages/ +│ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target) +│ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay) +│ └── shared/ # shared types/utils +└── wiki/ # LLM-maintained knowledge base (Obsidian vault) — see wiki/CLAUDE.md +``` + +> Code layout above is the intended target; scaffold packages as the work reaches them rather +> than all up front. + +## The wiki is the knowledge base — consult it first + +`wiki/` is an LLM-maintained design knowledge base (the "LLM Wiki" pattern). It is **not app +code** and has its own schema at `wiki/CLAUDE.md`. Before making architectural decisions or +implementing a subsystem, **read the relevant wiki pages** for the rationale, rejected +alternatives, and open questions: + +- Start at `wiki/overview.md`; catalog in `wiki/index.md`. +- Settled decisions: `wiki/decisions/standing-decisions.md`. +- Unsettled, procurement-driving items: `wiki/decisions/open-questions.md` — **do not hard-code + around these without flagging them.** + +When app work surfaces a new design fact, decision, or contradiction, **update the wiki** +following `wiki/CLAUDE.md` (ingest/query/lint workflows). Source documents go in `wiki/raw/`. + +## Stack (settled) + +All dependencies are **MIT / Apache / BSD** — a hard constraint to avoid vendor lock-in and +license rug-pulls. See `wiki/entities/technology-stack.md` for the full table and rationale. + +| Layer | Choice | +| --- | --- | +| Monorepo | Turborepo | +| Backend | Node.js + Fastify | +| Frontend | React (SPA, Vite), served by Fastify | +| Local DB | SQLite (`better-sqlite3`) + Drizzle ORM (Drizzle Kit) | +| Remote sync target | PostgreSQL (deferred — not a runtime dependency) | +| Auth | Local JWT (`@fastify/jwt`) + bcrypt + role guard (admin/operator/cashier/readonly) | + +## Architecture constraints that bind the code + +These are not negotiable defaults — they come from the threat model and safety analysis: + +- **Offline-first.** Nothing in core operation may depend on a network. Auth, DB, and device + decisions must work air-gapped. No external identity provider; no cloud runtime dependency. +- **Append-only, signed event log.** Entry/exit events are **never edited or deleted** — a + "void" is itself an appended event. Events are **hash-chained** (each stores the prior event's + hash) and **signed by an ATECC608 secure element**. This is the core anti-fraud mechanism; + don't add update/delete paths to event records. +- **Device-agnostic adapters.** Business logic talks **only to interfaces** (reader/printer/relay), + never to a device SDK. Hardware swaps = a new adapter in `packages/devices`, nothing else. +- **A barrier is not a door.** Never drive a barrier as a timed "open for N ms" auto-close. + Physical safety lives in the barrier operator's firmware; the app only ever expresses **intent + ("open")**. Relay interfaces are `pulseOpen`, never timed close. +- **Fail-state.** On power/network/host loss: **entry fails closed, exit fails open** (never trap + a vehicle — often a legal egress requirement). +- **Network isolation for access controllers.** The UHPPOTE controller speaks unauthenticated UDP; + it must sit on an isolated VLAN reachable only by the host. Treat its event log as + tamper-evident (host-side index tracking), not tamper-proof. +- **Keep PCI scope out of the app.** Payments go through a standalone bank-certified P2PE + terminal — the application must not handle card data. + +For the full reasoning behind each, follow the links from `wiki/overview.md`. + +## Conventions + +- TypeScript throughout. Match the style of surrounding code. +- Confirm before destructive or outward-facing actions. Commit/push only when asked. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..1e03c73 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,29 @@ +{ + "name": "@parking/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b", + "dev": "node --watch --experimental-strip-types src/index.ts", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@parking/db": "workspace:*", + "@parking/devices": "workspace:*", + "@parking/shared": "workspace:*", + "@fastify/cors": "11.2.0", + "@fastify/jwt": "10.1.0", + "@fastify/static": "9.1.3", + "bcrypt": "6.0.0", + "fastify": "5.8.5", + "fastify-plugin": "6.0.0" + }, + "devDependencies": { + "@types/bcrypt": "6.0.0", + "@types/node": "25.9.3", + "typescript": "6.0.3" + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..36fef05 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,13 @@ +import { buildServer } from "./server.js"; + +const PORT = Number(process.env.PORT ?? 3000); +const HOST = process.env.HOST ?? "0.0.0.0"; + +const app = await buildServer(); + +try { + await app.listen({ port: PORT, host: HOST }); +} catch (err) { + app.log.error(err); + process.exit(1); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts new file mode 100644 index 0000000..aa08fb8 --- /dev/null +++ b/apps/server/src/server.ts @@ -0,0 +1,45 @@ +import jwt from "@fastify/jwt"; +import Fastify, { type FastifyInstance } from "fastify"; +import type { Role } from "@parking/shared"; + +// The backend is Fastify (Node). Hardware drivers live as isolated Fastify +// plugins emitting onto a shared internal event bus; auth is fully local +// (offline-first). See wiki/entities/fastify.md and local-jwt-auth.md. + +declare module "@fastify/jwt" { + interface FastifyJWT { + payload: { sub: string; username: string; role: Role }; + user: { sub: string; username: string; role: Role }; + } +} + +export async function buildServer(): Promise { + const app = Fastify({ + logger: { level: process.env.LOG_LEVEL ?? "info" }, + }); + + // Local JWT signing with a local secret — no external identity provider. + await app.register(jwt, { + secret: process.env.JWT_SECRET ?? "dev-only-insecure-secret-change-me", + }); + + app.get("/health", async () => ({ status: "ok" })); + + // TODO: register device-driver plugins (packages/devices adapters), + // the append-only event-log routes, and the role-guarded admin API. + + return app; +} + +/** + * preHandler role guard. Authorization is a simple per-route role check — no + * Casbin/RBAC engine needed at this scale. See wiki/entities/local-jwt-auth.md. + */ +export function requireRole(...allowed: Role[]) { + return async (req: { jwtVerify: () => Promise; user?: { role: Role } }) => { + await req.jwtVerify(); + if (!req.user || !allowed.includes(req.user.role)) { + throw Object.assign(new Error("forbidden"), { statusCode: 403 }); + } + }; +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..c432d9e --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "references": [ + { "path": "../../packages/shared" }, + { "path": "../../packages/db" }, + { "path": "../../packages/devices" } + ], + "include": ["src/**/*"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..8987670 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Parking System + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..bc6e04b --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@parking/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@parking/shared": "workspace:*", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.2", + "typescript": "6.0.3", + "vite": "8.0.16" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..05c6651 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +// Operator UI shell. Plain React (no admin framework) — the operator UI is +// simple enough that a framework's abstractions cost more than they save. +// See wiki/entities/react-vite-spa.md. + +export function App() { + const [health, setHealth] = useState("checking…"); + + useEffect(() => { + fetch("/health") + .then((r) => r.json()) + .then((d: { status: string }) => setHealth(d.status)) + .catch(() => setHealth("unreachable")); + }, []); + + return ( +
+

Parking System

+

Operator console — scaffold.

+

+ API health: {health} +

+
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..e3c697c --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; + +const rootEl = document.getElementById("root"); +if (!rootEl) throw new Error("root element not found"); + +createRoot(rootEl).render( + + + , +); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..5525783 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "allowImportingTsExtensions": true + }, + "references": [{ "path": "../../packages/shared" }], + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..1c3118c --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,19 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// Operator SPA. Built by Vite and served by Fastify in production +// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the +// local Fastify server. +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": "http://localhost:3000", + "/health": "http://localhost:3000", + }, + }, + build: { + outDir: "dist", + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..087168c --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "parking-system", + "version": "0.0.0", + "private": true, + "packageManager": "pnpm@10.24.0", + "engines": { + "node": ">=22.12" + }, + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck", + "test": "turbo run test" + }, + "devDependencies": { + "turbo": "2.9.18", + "typescript": "6.0.3", + "@types/node": "25.9.3" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "bcrypt", + "better-sqlite3", + "esbuild" + ] + } +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..65ea990 --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "drizzle-kit"; + +// Local store is SQLite (single-site, single-writer, WAL). The same schema is +// designed to port to PostgreSQL for the deferred remote sync target — that is +// a durability/anti-fraud decision, not a capacity one. See wiki/entities/sqlite. +export default defineConfig({ + dialect: "sqlite", + schema: "./src/schema.ts", + out: "./drizzle", + dbCredentials: { + url: process.env.DATABASE_URL ?? "./parking.sqlite", + }, +}); diff --git a/packages/db/drizzle/0000_absent_rocket_raccoon.sql b/packages/db/drizzle/0000_absent_rocket_raccoon.sql new file mode 100644 index 0000000..e25c6a3 --- /dev/null +++ b/packages/db/drizzle/0000_absent_rocket_raccoon.sql @@ -0,0 +1,23 @@ +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/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..0f5ac6d --- /dev/null +++ b/packages/db/drizzle/meta/0000_snapshot.json @@ -0,0 +1,160 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "721bbb8f-b929-4018-9420-0ae75b03ff93", + "prevId": "00000000-0000-0000-0000-000000000000", + "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": {} + }, + "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 new file mode 100644 index 0000000..adda799 --- /dev/null +++ b/packages/db/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1781389618205, + "tag": "0000_absent_rocket_raccoon", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..125a2fd --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,36 @@ +{ + "name": "@parking/db", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./schema": { + "types": "./dist/schema.d.ts", + "default": "./dist/schema.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "dev": "tsc -b --watch", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate" + }, + "dependencies": { + "@parking/shared": "workspace:*", + "better-sqlite3": "12.10.1", + "drizzle-orm": "0.45.2" + }, + "devDependencies": { + "@types/better-sqlite3": "7.6.13", + "drizzle-kit": "0.31.10", + "typescript": "6.0.3" + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..ce52d47 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,19 @@ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import * as schema from "./schema.js"; + +export * from "./schema.js"; + +/** + * Open the local SQLite database in WAL mode. WAL allows many concurrent readers + * plus one writer — a parking workload never approaches SQLite's single-writer + * limit. See wiki/entities/sqlite.md. + */ +export function createDb(url = process.env.DATABASE_URL ?? "./parking.sqlite") { + const sqlite = new Database(url); + sqlite.pragma("journal_mode = WAL"); + sqlite.pragma("foreign_keys = ON"); + return drizzle(sqlite, { schema }); +} + +export type Db = ReturnType; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..94a6389 --- /dev/null +++ b/packages/db/src/schema.ts @@ -0,0 +1,41 @@ +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. +// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first). +// See wiki/entities/local-jwt-auth.md. + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + username: text("username").notNull().unique(), + passwordHash: text("password_hash").notNull(), + role: text("role", { + enum: ["admin", "operator", "cashier", "readonly"], + }).notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + +export const events = sqliteTable("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"] }), + lane: integer("lane").notNull(), + source: text("source"), + identity: text("identity"), + 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(), +}); + +export type UserRow = typeof users.$inferSelect; +export type EventRow = typeof events.$inferSelect; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..0059635 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "composite": true + }, + "references": [{ "path": "../shared" }], + "include": ["src/**/*"] +} diff --git a/packages/devices/package.json b/packages/devices/package.json new file mode 100644 index 0000000..c40d611 --- /dev/null +++ b/packages/devices/package.json @@ -0,0 +1,26 @@ +{ + "name": "@parking/devices", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "dev": "tsc -b --watch", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@parking/shared": "workspace:*" + }, + "devDependencies": { + "typescript": "6.0.3" + } +} diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts new file mode 100644 index 0000000..5943ea8 --- /dev/null +++ b/packages/devices/src/index.ts @@ -0,0 +1,33 @@ +// Device-agnostic adapter interfaces. +// +// Business logic talks ONLY to these interfaces, never to a device SDK. Swapping +// hardware means writing a new adapter that implements one of these — nothing +// else changes. See wiki/concepts/device-adapter-pattern.md. +// +// SAFETY: a barrier is NOT a door. The relay interface expresses INTENT only +// (`pulseOpen`); it never times or forces a close against a vehicle. Physical +// safety (induction loops, anti-crush, auto-reverse) lives in the barrier +// operator's own firmware. See wiki/concepts/barrier-not-a-door.md. + +export interface CardReaderDevice { + connect(): Promise; + onCardRead(cb: (cardNumber: string, door: number) => void): void; + disconnect(): Promise; +} + +export interface TicketData { + readonly ticketId: string; + readonly lane: number; + readonly issuedAt: string; // ISO-8601 +} + +export interface PrinterDevice { + printTicket(data: TicketData): Promise; + checkStatus(): Promise<"ready" | "offline" | "paper_out">; +} + +export interface RelayDevice { + /** Express intent to open. NEVER timed/forced closed against a vehicle. */ + pulseOpen(doorId: number): Promise; + getDoorStatus(doorId: number): Promise<"open" | "closed">; +} diff --git a/packages/devices/tsconfig.json b/packages/devices/tsconfig.json new file mode 100644 index 0000000..0059635 --- /dev/null +++ b/packages/devices/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "composite": true + }, + "references": [{ "path": "../shared" }], + "include": ["src/**/*"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..e40fc5c --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@parking/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "dev": "tsc -b --watch", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "6.0.3" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..f4204e6 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,50 @@ +// Shared types and utilities across the parking system. +// +// The domain is offline-first and threat-model driven. The central integrity +// primitive is an append-only, hash-chained, ATECC608-signed event log: entry +// and exit events are never edited or deleted — a "void" is itself an appended +// event. See wiki/concepts/append-only-event-chain.md. + +export type Role = "admin" | "operator" | "cashier" | "readonly"; + +export type Direction = "entry" | "exit"; + +/** What kind of identity source produced a read. */ +export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual"; + +/** + * An append-only parking 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. + */ +export interface ParkingEvent { + readonly id: string; + readonly index: number; + readonly type: ParkingEventType; + readonly direction: Direction | null; + readonly lane: number; + readonly source: IdentitySource | null; + /** Card number, plate, ticket id, etc. — depends on `source`. */ + readonly identity: string | 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; +} + +export type ParkingEventType = + | "vehicle_entry" + | "vehicle_exit" + | "void" + | "barrier_open_command" + | "barrier_open_observed" + | "shift_z_report" + | "anomaly"; + +export const ROLES: readonly Role[] = [ + "admin", + "operator", + "cashier", + "readonly", +] as const; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..bfdb609 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "composite": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ec15f52 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2576 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: 25.9.3 + version: 25.9.3 + turbo: + specifier: 2.9.18 + version: 2.9.18 + typescript: + specifier: 6.0.3 + version: 6.0.3 + + apps/server: + dependencies: + '@fastify/cors': + specifier: 11.2.0 + version: 11.2.0 + '@fastify/jwt': + specifier: 10.1.0 + version: 10.1.0 + '@fastify/static': + specifier: 9.1.3 + version: 9.1.3 + '@parking/db': + specifier: workspace:* + version: link:../../packages/db + '@parking/devices': + specifier: workspace:* + version: link:../../packages/devices + '@parking/shared': + specifier: workspace:* + version: link:../../packages/shared + bcrypt: + specifier: 6.0.0 + version: 6.0.0 + fastify: + specifier: 5.8.5 + version: 5.8.5 + fastify-plugin: + specifier: 6.0.0 + version: 6.0.0 + devDependencies: + '@types/bcrypt': + specifier: 6.0.0 + version: 6.0.0 + '@types/node': + specifier: 25.9.3 + version: 25.9.3 + typescript: + specifier: 6.0.3 + version: 6.0.3 + + apps/web: + dependencies: + '@parking/shared': + specifier: workspace:* + version: link:../../packages/shared + react: + specifier: 19.2.7 + version: 19.2.7 + react-dom: + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: 19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: 6.0.2 + version: 6.0.2(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.0.16 + version: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4) + + packages/db: + dependencies: + '@parking/shared': + specifier: workspace:* + version: link:../shared + better-sqlite3: + specifier: 12.10.1 + version: 12.10.1 + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.1) + devDependencies: + '@types/better-sqlite3': + specifier: 7.6.13 + version: 7.6.13 + drizzle-kit: + specifier: 0.31.10 + version: 0.31.10 + typescript: + specifier: 6.0.3 + version: 6.0.3 + + packages/devices: + dependencies: + '@parking/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + + packages/shared: + devDependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + +packages: + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/jwt@10.1.0': + resolution: {integrity: sha512-U1y8ZbxoH1Pjon3euzPJmbCkuYBM+hrQlFWLQWvKmJGCNT6mVsAolnVJdEWfXeQOKpgmuRVCIsPll5RLZxj10A==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@turbo/darwin-64@2.9.18': + resolution: {integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.18': + resolution: {integrity: sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.18': + resolution: {integrity: sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.18': + resolution: {integrity: sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.18': + resolution: {integrity: sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.18': + resolution: {integrity: sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA==} + cpu: [arm64] + os: [win32] + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + better-sqlite3@12.10.1: + resolution: {integrity: sha512-HfFtzCqnSfwB3+HroF6PSKzyh+7RfNMGPCzHFUZXRlvrPCb4P3cvxKZNN43Sr7IrkofqQZM+gIvffGpA8VvqgA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-jwt@6.2.4: + resolution: {integrity: sha512-IoQa53wI6TbARU2yelb0L44ggFQnP2qVcwswCSYHbCAWuwpr70icDb3QjG0v01I8Tt01rVGDkN/rRvpk0lKFTA==} + engines: {node: '>=20'} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastfall@1.5.1: + resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} + engines: {node: '>=0.10.0'} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mnemonist@0.40.4: + resolution: {integrity: sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + node-addon-api@8.8.0: + resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + steed@1.1.3: + resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turbo@2.9.18: + resolution: {integrity: sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg==} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + +snapshots: + + '@drizzle-team/brocli@0.10.2': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.1 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/jwt@10.1.0': + dependencies: + '@fastify/error': 4.2.0 + '@lukeed/ms': 2.0.2 + fast-jwt: 6.2.4 + fastify-plugin: 5.1.0 + steed: 1.1.3 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@lukeed/ms@2.0.2': {} + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@pinojs/redact@0.4.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@turbo/darwin-64@2.9.18': + optional: true + + '@turbo/darwin-arm64@2.9.18': + optional: true + + '@turbo/linux-64@2.9.18': + optional: true + + '@turbo/linux-arm64@2.9.18': + optional: true + + '@turbo/windows-64@2.9.18': + optional: true + + '@turbo/windows-arm64@2.9.18': + optional: true + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 25.9.3 + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.9.3 + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4) + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.8.0 + node-gyp-build: 4.8.4 + + better-sqlite3@12.10.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bn.js@4.12.3: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + chownr@1.1.4: {} + + content-disposition@1.1.0: {} + + cookie@1.1.1: {} + + csstype@3.2.3: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.22.4 + + drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.1): + optionalDependencies: + '@types/better-sqlite3': 7.6.13 + better-sqlite3: 12.10.1 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-html@1.0.3: {} + + expand-template@2.0.3: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-jwt@6.2.4: + dependencies: + '@lukeed/ms': 2.0.2 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.40.4 + safe-regex2: 5.1.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.2: {} + + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.4 + toad-cache: 3.7.1 + + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-uri-to-path@1.0.0: {} + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipaddr.js@2.4.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lru-cache@11.5.1: {} + + mime@3.0.0: {} + + mimic-response@3.1.0: {} + + minimalistic-assert@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: {} + + mnemonist@0.40.4: + dependencies: + obliterator: 2.0.5 + + nanoid@3.3.12: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.92.0: + dependencies: + semver: 7.8.4 + + node-addon-api@8.8.0: {} + + node-gyp-build@4.8.4: {} + + obliterator@2.0.5: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + quick-format-unescaped@4.0.4: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.4: {} + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + split2@4.2.0: {} + + statuses@2.0.2: {} + + steed@1.1.3: + dependencies: + fastfall: 1.5.1 + fastparallel: 2.4.1 + fastq: 1.20.1 + fastseries: 1.7.2 + reusify: 1.1.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + toad-cache@3.7.1: {} + + toidentifier@1.0.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + turbo@2.9.18: + optionalDependencies: + '@turbo/darwin-64': 2.9.18 + '@turbo/darwin-arm64': 2.9.18 + '@turbo/linux-64': 2.9.18 + '@turbo/linux-arm64': 2.9.18 + '@turbo/windows-64': 2.9.18 + '@turbo/windows-arm64': 2.9.18 + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + util-deprecate@1.0.2: {} + + vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(tsx@4.22.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + tsx: 4.22.4 + + wrappy@1.0.2: {} + + xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..8d951d3 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + } +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..bf9dd9f --- /dev/null +++ b/turbo.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": {}, + "typecheck": { + "dependsOn": ["^build"] + }, + "test": { + "dependsOn": ["^build"] + } + } +} diff --git a/wiki/.obsidian/app.json b/wiki/.obsidian/app.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/wiki/.obsidian/app.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/wiki/.obsidian/appearance.json b/wiki/.obsidian/appearance.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/wiki/.obsidian/appearance.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/wiki/.obsidian/core-plugins.json b/wiki/.obsidian/core-plugins.json new file mode 100644 index 0000000..639b90d --- /dev/null +++ b/wiki/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": true, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} \ No newline at end of file diff --git a/wiki/CLAUDE.md b/wiki/CLAUDE.md new file mode 100644 index 0000000..4d3cb25 --- /dev/null +++ b/wiki/CLAUDE.md @@ -0,0 +1,90 @@ +# Parking System Wiki — Schema & Maintenance Guide + +This directory is an **LLM-maintained wiki** (an Obsidian vault). It follows the +"LLM Wiki" pattern: raw sources are immutable inputs; the wiki is a persistent, +compounding, interlinked knowledge base that the LLM writes and maintains. The +human curates sources and asks questions; the LLM does all bookkeeping. + +## Layers + +- **`raw/`** — immutable source documents. Read from, never modify. Source of truth. + (Windows downloads sometimes leave `:Zone.Identifier` metadata companions — delete them.) +- **wiki pages** — everything else under this directory. LLM-owned: summaries, + entity pages, concept pages, decision records, the overview, the synthesis. +- **this file (`CLAUDE.md`)** — the schema. How the wiki is structured and the + workflows to follow. Co-evolve it as conventions change. + +## Directory layout + +``` +wiki/ +├── CLAUDE.md # this schema +├── index.md # content catalog — every page, one line each, by category +├── log.md # append-only chronological record (ingests, queries, lints) +├── overview.md # the top-level synthesis / entry point to the wiki +├── raw/ # immutable sources +├── sources/ # one summary page per raw source +├── entities/ # concrete things: devices, components, technologies, parties +├── concepts/ # ideas, patterns, constraints, threat-model elements +└── decisions/ # standing decisions (settled) and open questions (unsettled) +``` + +## Page conventions + +- **Filenames**: kebab-case, `.md`. Title = `# H1` matching the topic. +- **Frontmatter** (YAML) on every wiki page: + ```yaml + --- + type: source | entity | concept | decision | overview + tags: [parking, ...] + sources: [parking-system-architecture] # raw source slugs this draws from + updated: 2026-06-14 + status: settled | open # decisions only + --- + ``` +- **Links**: use Obsidian `[[wikilinks]]` liberally — link entities, concepts, and + decisions wherever they're mentioned. A link to a page that doesn't exist yet is + fine; it marks a page worth writing. Prefer `[[page-name]]` or `[[page-name|alias]]`. +- **Citations**: when a claim comes from a source, reference it, e.g. + `(see [[parking-system-architecture]] §6)`. Keep section numbers when the source has them. +- Keep pages focused and atomic. One entity/concept per page. Cross-link rather than duplicate. + +## Workflows + +### Ingest (new source dropped in `raw/`) +1. Read the source fully. +2. Write/update a summary in `sources/.md` (key takeaways + section map). +3. Create or update **entity** pages for every concrete thing it describes. +4. Create or update **concept** pages for every pattern/constraint/idea. +5. Update `decisions/` — record settled decisions and open questions. +6. Update `overview.md` synthesis if the big picture changed. +7. Update `index.md` (add/adjust catalog lines). +8. Append a `log.md` entry: `## [YYYY-MM-DD] ingest | `. +9. Note any **contradictions** with existing pages explicitly on the affected page. + +### Query (a question against the wiki) +1. Read `index.md` to locate relevant pages, then drill in. +2. Synthesize an answer with `[[links]]` and source citations. +3. If the answer is durable (a comparison, analysis, discovered connection), **file + it back** as a new page and catalog it. Append a `query` line to `log.md`. + +### Lint (health check) +Look for: contradictions between pages, stale claims superseded by newer sources, +orphan pages (no inbound links), concepts mentioned but lacking a page, missing +cross-references, and data gaps worth a web search. Report findings; suggest next +sources/questions. Append a `lint` line to `log.md`. + +## Log convention + +Append-only. Each entry starts with a parseable prefix so +`grep "^## \[" log.md | tail -5` works: + +``` +## [2026-06-14] ingest | Parking System — Architecture & Design Notes +``` + +## Style + +- Faithful to sources. Flag uncertainty and open questions rather than papering over them. +- This domain (a parking-management system) is **offline-first** and **fraud/threat-model + driven** — those two forces shape most content; keep them front of mind when synthesizing. diff --git a/wiki/concepts/append-only-event-chain.md b/wiki/concepts/append-only-event-chain.md new file mode 100644 index 0000000..bc9fece --- /dev/null +++ b/wiki/concepts/append-only-event-chain.md @@ -0,0 +1,26 @@ +--- +type: concept +tags: [parking, security, integrity] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Append-Only Event Chain + +The core integrity mechanism against operator fraud (see [[threat-model]]). (See +[[parking-system-architecture]] §3.) + +Three layered properties: + +1. **Append-only event model.** Entry/exit events are never edited or deleted, only appended. A + "void" is itself a **recorded event**, not an erasure. +2. **Tamper-evident chaining.** Each event stores the **hash of the previous event** (a hash + chain). Reordering or deleting **breaks the chain visibly**. +3. **Hardware-backed signing.** The **[[atecc608]]** secure element signs each event with a + non-extractable key. This is what makes the chain **unforgeable** rather than merely + self-consistent — someone who owns the machine still cannot forge a valid entry. + +It only becomes trustworthy as an external fraud control when paired with [[reconciliation]] +against an authority the operator can't alter. Every device event — including those ingested +from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side +chain. diff --git a/wiki/concepts/barrier-not-a-door.md b/wiki/concepts/barrier-not-a-door.md new file mode 100644 index 0000000..bcf7765 --- /dev/null +++ b/wiki/concepts/barrier-not-a-door.md @@ -0,0 +1,20 @@ +--- +type: concept +tags: [parking, safety, devices] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Safety Principle: A Barrier Is Not a Door + +A vehicle barrier must **not** be driven as a timed "door open for N ms" by the application — a +timed auto-close can **drop a boom on a vehicle or person**. (See [[parking-system-architecture]] +§5.) + +- **Physical safety lives in the barrier operator's own firmware** — induction loops, anti-crush, + auto-reverse. (Recommended barrier operators in the [[bom]] are chosen because they own this.) +- The application and any relay board **only ever express *intent* ("open")**; they never time or + force a close against a vehicle. Reflected in the [[device-adapter-pattern]]'s `pulseOpen`. +- Holds **regardless of which relay device** is used — [[uhppote-controller]] or + [[esp32-custom-controller]]. The ESP32 design restates it: "the ESP32 only signals intent" + (see [[fail-state-safety]]). diff --git a/wiki/concepts/challenge-response-auth.md b/wiki/concepts/challenge-response-auth.md new file mode 100644 index 0000000..039c7ab --- /dev/null +++ b/wiki/concepts/challenge-response-auth.md @@ -0,0 +1,29 @@ +--- +type: concept +tags: [parking, security, crypto, access-control] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Challenge–Response Auth (asymmetric signatures) + +The authentication scheme for the [[esp32-custom-controller]]. Closes the actual hole in the +[[uhppote-udp-protocol]]: **forged or replayed commands**. The requirement is **authenticity + +freshness (anti-replay)**; encryption is optional. (See [[parking-system-architecture]] §7.) + +``` +Host (private key) ESP32 (host's PUBLIC key only) + │── "open lane 2" ──────────────────▶│ generates fresh random nonce + │◀──────────── nonce ─────────────────│ + │ sign(nonce ‖ command ‖ ts) ────────▶│ verify vs stored public key + │ │ check nonce fresh + unused → pulse relay +``` + +## The elegant property + +The controller stores **only a public key**. Physically compromising the ESP32 (popping the +cabinet, dumping flash via the [[atecc608]]) yields **nothing usable for forging commands**. The +fresh per-command **nonce** defeats replay without counter-persistence headaches. + +A shared-secret / encrypted channel would **not** have this property — the secret would sit on +both ends. That's why authentication (not encryption) is the right build here. diff --git a/wiki/concepts/device-adapter-pattern.md b/wiki/concepts/device-adapter-pattern.md new file mode 100644 index 0000000..ac6f54d --- /dev/null +++ b/wiki/concepts/device-adapter-pattern.md @@ -0,0 +1,32 @@ +--- +type: concept +tags: [parking, architecture, devices] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Device-Adapter Pattern + +How the system stays **device-agnostic**: business logic talks **only to interfaces, never to a +device SDK**. Each physical device is an adapter implementing one interface; **swapping hardware +means writing a new adapter and nothing else changes.** Implemented as isolated [[fastify]] +plugins emitting onto a shared internal event bus. (See [[parking-system-architecture]] §5.) + +```ts +interface CardReaderDevice { + connect(): Promise + onCardRead(cb: (cardNumber: string, door: number) => void): void + disconnect(): Promise +} +interface PrinterDevice { + printTicket(data: TicketData): Promise + checkStatus(): Promise<'ready' | 'offline' | 'paper_out'> +} +interface RelayDevice { + pulseOpen(doorId: number): Promise // intent only — see safety note + getDoorStatus(doorId: number): Promise<'open' | 'closed'> +} +``` + +Note the `RelayDevice` expresses **intent only** — see the [[barrier-not-a-door]] safety +principle. The choice of *which* adapter to trust is the [[trust-boundary]] decision. diff --git a/wiki/concepts/disk-os-hardening.md b/wiki/concepts/disk-os-hardening.md new file mode 100644 index 0000000..78c1502 --- /dev/null +++ b/wiki/concepts/disk-os-hardening.md @@ -0,0 +1,24 @@ +--- +type: concept +tags: [parking, security, platform] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Disk / OS Hardening + +Worthwhile, but **not the main event** — it defends against the outsider-with-physical-access, +not the operator (see [[threat-model]]). (See [[parking-system-architecture]] §3.) + +Physical-access attacks on Windows are trivial (boot media + password-reset tools), so a +**dedicated Linux machine is the correct platform** — not Windows or WSL. This is a +[[standing-decisions|standing decision]]. + +- **LUKS full-disk encryption** — defeats boot-from-USB. +- **GRUB password + Secure Boot** — prevents boot-parameter tampering / unsigned loaders. +- **No desktop environment** — single-purpose appliance. +- **Key-based SSH only.** + +With LUKS in place, **SQLCipher becomes optional** defence-in-depth rather than the critical +layer. (The custom controller adds its own: ESP32 flash encryption + secure boot — see +[[esp32-custom-controller]].) diff --git a/wiki/concepts/entry-exit-readers.md b/wiki/concepts/entry-exit-readers.md new file mode 100644 index 0000000..3d83211 --- /dev/null +++ b/wiki/concepts/entry-exit-readers.md @@ -0,0 +1,40 @@ +--- +type: concept +tags: [parking, architecture, readers] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Entry / Exit Readers + +There are **two populations** of users, and they map to **two integration paths**. (See +[[parking-system-architecture]] §8.) + +- **Permit holders / subscribers** — want hands-free/quick entry. Best served by reads reaching + the **controller directly** ([[wiegand]]) so it can decide autonomously (works if host is down). +- **Casual / transient** — printed ticket, pay-on-exit, or plate recognition. Inherently + **host-side** identity sources ([[lpr-camera]], QR/ticket scanner). + +## How reads reach the system + +| Reader type | Who sees the read | Decision by | Offline autonomy | +| --- | --- | --- | --- | +| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down | +| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path | +| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No | + +## Key points + +- **Pure network readers are invisible to the [[uhppote-controller]]** — it only generates events + for its own terminals. For a pure-TCP reader, only the host can listen/decide/command; the + controller is demoted to a commanded relay (onboard card DB + offline autonomy bypassed). +- **Check for a Wiegand output first** — many "network" readers have both; wiring Wiegand in + keeps autonomy + native event log. +- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled" + mode), so one lane serves permit + casual. +- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed + [[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any + mismatch is an anomaly. + +Autonomy caveat: with remote-host control enabled, the controller expects host comms every ~30 s +or reverts to local control (Wiegand-on-board lanes only). diff --git a/wiki/concepts/event-log-ingestion.md b/wiki/concepts/event-log-ingestion.md new file mode 100644 index 0000000..b13a011 --- /dev/null +++ b/wiki/concepts/event-log-ingestion.md @@ -0,0 +1,26 @@ +--- +type: concept +tags: [parking, security, access-control, integrity] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Event-Log Ingestion (making the UHPPOTE log trustworthy) + +The host-side discipline that turns the [[uhppote-controller]]'s log — undermined by the +[[uhppote-udp-protocol]] — into a solid detection/audit layer. (See +[[parking-system-architecture]] §6.) + +- **Track your own last-ingested index on the host.** Do **not** rely on the controller's + current-index pointer — it's user-managed and settable by anyone (`set-event-index`). +- Walk **absolute** indices with `get-event `. Treat three things as **alarms**: + 1. a **gap** in the sequence, + 2. an **"event has been overwritten" error** (you fell behind — data loss), + 3. any **door-open event the host never requested**. +- Use `set-listener` **auto-push** for low latency, but **always reconcile by index** (UDP + pushes can drop). +- **Size polling cadence** against the busiest lane's event rate so unread events never roll off. +- **Land every event** in the host's signed [[append-only-event-chain]]. + +Net result: **tamper-evident, behind [[network-isolation]]** — a solid detection layer, but not +tamper-proof. Prevention requires the [[esp32-custom-controller]]. diff --git a/wiki/concepts/fail-state-safety.md b/wiki/concepts/fail-state-safety.md new file mode 100644 index 0000000..408f489 --- /dev/null +++ b/wiki/concepts/fail-state-safety.md @@ -0,0 +1,21 @@ +--- +type: concept +tags: [parking, safety, devices] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Fail-State & Safety (custom controller) + +For the [[esp32-custom-controller]], fail-state behaviour is to be treated **as seriously as the +crypto**. (See [[parking-system-architecture]] §7.) + +- **Define behaviour on power/network/host loss:** **entry fails closed**, **exit fails open** — + **never trap a vehicle** (often a legal egress requirement). +- **Hardware manual override** (key switch/button) that opens the barrier **with the ESP32 dead**. +- **Watchdog** with a defined safe default. +- The **barrier operator still owns physical safety** — the ESP32 only signals intent + ([[barrier-not-a-door]]). + +The general "fail-open on exit" principle is also an [[open-questions|open question]] (#2) for +the system as a whole, not just the custom controller. diff --git a/wiki/concepts/network-isolation.md b/wiki/concepts/network-isolation.md new file mode 100644 index 0000000..d6f6c4f --- /dev/null +++ b/wiki/concepts/network-isolation.md @@ -0,0 +1,22 @@ +--- +type: concept +tags: [parking, security, network, access-control] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Network Isolation + +**Mandatory** mitigation for the unauthenticated [[uhppote-udp-protocol]]: because the +[[uhppote-controller]] can't authenticate, the **network must be the security boundary** (the +[[trust-boundary]] = the network). (See [[parking-system-architecture]] §6.) + +- Control devices go on **their own VLAN** with **no route** to the booth/office network and + **no wireless bridge**. +- Requires a **managed VLAN switch** (in the [[bom]]). +- Only when *only the host* can reach the controller does the controller's event log become a + trustworthy audit source (combined with [[event-log-ingestion]] + the + [[append-only-event-chain]]). + +This makes the UHPPOTE setup **tamper-evident behind isolation** — but never tamper-*proof*; +that requires the [[esp32-custom-controller]]. diff --git a/wiki/concepts/offline-first.md b/wiki/concepts/offline-first.md new file mode 100644 index 0000000..5e10adb --- /dev/null +++ b/wiki/concepts/offline-first.md @@ -0,0 +1,31 @@ +--- +type: concept +tags: [parking, constraint, foundational] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Offline-First + +One of the **two foundational forces** shaping the whole system (the other is the +[[threat-model]]). (See [[parking-system-architecture]] §1.) + +A park may have **no internet, intermittent connectivity, or be fully air-gapped**. **Nothing +in the core operation may depend on a network being present.** + +## What it forces + +- **Local auth** — no external identity provider; hence [[local-jwt-auth]] and the rejection of + [[logto-zitadel-oidc]]. +- **Local database** — [[sqlite]] on-site; remote PostgreSQL is a *deferred*, optional sync + target, never a runtime dependency. +- **Autonomous device decisions** where possible — [[wiegand]]-into-controller lets the + [[uhppote-controller]] decide even if the host is down; [[lpr-camera]] uses edge AI so + recognition runs with no internet. + +## What it does NOT mean + +Offline-first does **not** mean "no [[reconciliation]]." It means **deferred, intermittent** +reconciliation — a manager's weekly USB stick, a daily phone hotspot, a monthly export. Only +design for "never, by anyone" if that's genuinely true (see [[reconciliation]] for the +network-free fallback controls). diff --git a/wiki/concepts/reconciliation.md b/wiki/concepts/reconciliation.md new file mode 100644 index 0000000..31744bf --- /dev/null +++ b/wiki/concepts/reconciliation.md @@ -0,0 +1,29 @@ +--- +type: concept +tags: [parking, security, anti-fraud, offline-first] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Reconciliation + +**The real anti-fraud control.** Comparing local records against **an authority the operator +cannot alter**. This is what remote sync *really is* — a fraud-control mechanism, not just a +backup. (See [[parking-system-architecture]] §3.) + +## Reconciliation when offline + +[[offline-first]] does not mean no reconciliation — it means **deferred, intermittent**: a +manager visiting weekly with a USB stick, a phone hotspot once a day, a monthly export. Any of +these provides a path to compare local records against something outside the operator's reach. + +## If it truly is "never, by anyone" + +Only design for that if it's genuinely true. The network-free fallback controls are: + +- the signed, hash-chained log ([[append-only-event-chain]]), +- physically **pre-numbered ticket stock**, +- **end-of-shift signed Z-reports**, +- **CCTV/LPR footage** as an independent record (see [[lpr-camera]]). + +Establishing *some* periodic reconciliation channel is [[open-questions]] #4. diff --git a/wiki/concepts/threat-model.md b/wiki/concepts/threat-model.md new file mode 100644 index 0000000..8164749 --- /dev/null +++ b/wiki/concepts/threat-model.md @@ -0,0 +1,37 @@ +--- +type: concept +tags: [parking, security, foundational] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Threat Model + +The **second foundational force** (with [[offline-first]]). The central insight is a +**reframing of who the adversary is**. (See [[parking-system-architecture]] §3.) + +## The key reframing + +Early thinking focused on protecting the database **at rest** — SQLCipher, LUKS, BitLocker, +TPM-sealed keys. All of that defends against **an outsider who steals the machine or boots from +external media**. + +That is the **wrong primary threat**. The most likely adversary is the **legitimate operator at +the booth**. While the app runs, the database is decrypted in memory and the operator has full +authorised access *through the app*. Encryption does nothing against the classic parking fraud: +**take the cash, then void/delete the entry/exit record so the books balance.** + +## Consequences + +The controls that actually address insider/operator fraud are different in kind: + +- **[[append-only-event-chain]]** — events appended, never edited/deleted; a "void" is itself a + recorded event, hash-chained, and **[[atecc608]]-signed** (unforgeable). +- **[[reconciliation]]** against an authority the operator can't alter — *this is what remote + sync really is*: a fraud-control mechanism, not just a backup. +- **[[disk-os-hardening]]** still worthwhile (defeats boot-from-USB) but **not the main event**; + with LUKS in place, SQLCipher is optional defence-in-depth. + +The same reframing recurs at the device layer: the [[uhppote-controller]]'s real problem is +unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection +([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]). diff --git a/wiki/concepts/trust-boundary.md b/wiki/concepts/trust-boundary.md new file mode 100644 index 0000000..92127ab --- /dev/null +++ b/wiki/concepts/trust-boundary.md @@ -0,0 +1,20 @@ +--- +type: concept +tags: [parking, architecture, security, decision] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# The Core Fork: Where Is the Trust Boundary? + +The pivotal device-layer decision. Two valid architectures, **chosen per deployment and mixable +per lane**. (See [[parking-system-architecture]] §5.) + +| Trust boundary | Approach | Property | +| --- | --- | --- | +| **= the network** | Off-the-shelf controller ([[uhppote-controller]]/ZKTeco), contained by [[network-isolation]] | **Auditable** — tamper-evident, you don't own firmware | +| **= the device** | Custom controller whose firmware enforces auth ([[esp32-custom-controller]]) | **Unforgeable** — but you own the firmware | + +This is the detection-vs-prevention choice. With the UHPPOTE path, trustworthiness comes from +[[event-log-ingestion]] + the [[append-only-event-chain]]. With the ESP32 path, it comes from +[[challenge-response-auth]]. See [[uhppote-vs-esp32]] for the head-to-head. diff --git a/wiki/concepts/uhppote-udp-protocol.md b/wiki/concepts/uhppote-udp-protocol.md new file mode 100644 index 0000000..be2a288 --- /dev/null +++ b/wiki/concepts/uhppote-udp-protocol.md @@ -0,0 +1,38 @@ +--- +type: concept +tags: [parking, security, access-control, protocol] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# UHPPOTE UDP Protocol (the weakness) + +The [[uhppote-controller]] communicates over **UDP port 60000 with no authentication and no +encryption**. Anyone who can place a packet on that LAN can send an "open" command to any door. +This is *the* security issue — not safety (safety is the barrier operator's, per +[[barrier-not-a-door]]). (See [[parking-system-architecture]] §6.) + +**Mitigation: [[network-isolation]] is mandatory.** The security boundary is the network because +it cannot be the device. + +## Why you can't fix it in firmware + +The open-source `uhppoted` ecosystem is **protocol reverse-engineering only** — clients speaking +the existing UDP protocol. No source, SDK, schematic, or toolchain to build/flash custom +firmware. The controller accepts only the **manufacturer's official** firmware images. You +cannot configure or patch your way to authentication on this hardware. + +## Unauthenticated commands that undermine the log + +The record-level log is append-only, but these don't touch individual records: + +| Vector | Command | Effect | +| --- | --- | --- | +| Blinding | `record-special-events false` | Stops logging door events going forward | +| Wipe | `restore-default-parameters` | Factory reset — clears config + event state | +| Rollover | (generate events / fall behind) | Finite circular buffer; old events overwritten | +| Time skew | `set-time` | Corrupts / backdates timestamps | +| Index desync | `set-event-index` | Moves the retrieval pointer; naive ingestion skips events | + +The defensive response is [[event-log-ingestion]]. The preventive alternative is the +[[esp32-custom-controller]]. diff --git a/wiki/concepts/uhppote-vs-esp32.md b/wiki/concepts/uhppote-vs-esp32.md new file mode 100644 index 0000000..e9a0913 --- /dev/null +++ b/wiki/concepts/uhppote-vs-esp32.md @@ -0,0 +1,31 @@ +--- +type: concept +tags: [parking, comparison, access-control, security] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# UHPPOTE vs. Custom ESP32 — Detection vs. Prevention + +A head-to-head on the [[trust-boundary]] fork: the off-the-shelf [[uhppote-controller]] versus +the [[esp32-custom-controller]]. (Synthesized from [[parking-system-architecture]] §6–7.) + +| | [[uhppote-controller]] | [[esp32-custom-controller]] | +| --- | --- | --- | +| **Trust boundary** | The network | The device | +| **Security posture** | Tamper-**evident** (detection) | Tamper-**proof** (prevention) | +| **Command auth** | None — [[uhppote-udp-protocol]] is open UDP | [[challenge-response-auth]] (asymmetric sigs) | +| **Key mitigation** | [[network-isolation]] (mandatory) + [[event-log-ingestion]] | [[atecc608]] holds non-extractable key; controller stores only a public key | +| **Firmware** | Manufacturer-only; not customizable | You own it (tiny + auditable) | +| **Cost / effort** | Cheap, off-the-shelf, available now | Build + firmware reliability, EMC/surge, field maintenance | +| **Replay/forgery on the wire** | Possible — contained only by isolation | Defeated by fresh per-command nonce | +| **Safety** | Barrier operator owns it ([[barrier-not-a-door]]) | Same + explicit [[fail-state-safety]] | + +## Bottom line + +- The UHPPOTE is the **current choice**: good enough as a detection/audit layer **when only the + host can reach it** (isolation) and every event lands in the [[append-only-event-chain]]. +- The ESP32 is the **documented upgrade** when you need a control path that holds even against an + attacker on the wire. They're **mixable per lane**. +- Both still rely on host-side integrity ([[append-only-event-chain]]) and external + [[reconciliation]] as the ultimate anti-fraud control. diff --git a/wiki/decisions/open-questions.md b/wiki/decisions/open-questions.md new file mode 100644 index 0000000..8b8f7a2 --- /dev/null +++ b/wiki/decisions/open-questions.md @@ -0,0 +1,30 @@ +--- +type: decision +tags: [parking, decisions, open] +sources: [parking-system-architecture] +updated: 2026-06-14 +status: open +--- + +# Open Questions / Next Steps + +**Not yet decided**, and they drive everything else — settle before procurement. (See +[[parking-system-architecture]] §10.) + +1. **Lane topology.** One host per lane, or one central host driving networked devices in each + lane? Decides how many controllers, printers, UPSs, and [[sqlite]] instances exist, and the + failure blast radius. (A single central host is a single point of failure for *all* lanes.) +2. **Failure modes.** Define per direction what happens to barriers on host/power/network loss — + particularly **fail-open on exit** for egress safety. Currently unaddressed. See + [[fail-state-safety]]. +3. **Payment subsystem.** Manned booth (P2PE terminal + cash drawer) vs unmanned pay station; + confirm **PCI scope is kept out of the application** via a standalone certified terminal + (see [[bom]]). +4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot, + manager visit) to reconcile the signed log against an external authority — the real anti-fraud + control. See [[reconciliation]]. +5. **Durability / backup.** Backup strategy for the [[sqlite]] database + recovery plan; "sync + later" currently leaves a disk failure as **total revenue-history loss**. +6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on both the host (event + signing) and, if pursued, the custom controller (command authentication — see + [[esp32-custom-controller]]). diff --git a/wiki/decisions/standing-decisions.md b/wiki/decisions/standing-decisions.md new file mode 100644 index 0000000..f245258 --- /dev/null +++ b/wiki/decisions/standing-decisions.md @@ -0,0 +1,30 @@ +--- +type: decision +tags: [parking, decisions] +sources: [parking-system-architecture] +updated: 2026-06-14 +status: settled +--- + +# Standing Decisions (settled) + +The decisions treated as settled in the design notes. (See [[parking-system-architecture]] +"Summary of standing decisions".) + +- **Stack:** [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · [[sqlite]] + + [[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — **no vendor lock, no rug-pull + risk** (see [[payload-cms]]). Full table in [[technology-stack]]. +- **Platform:** a **dedicated, hardened Linux appliance** (LUKS + GRUB password + Secure Boot), + **not Windows/WSL** — see [[disk-os-hardening]]. +- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log + ([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption + protects only at-rest (see [[threat-model]]). +- **Access control:** [[uhppote-controller]] for now, on an **isolated VLAN** + ([[network-isolation]]); event log used as a tamper-evident audit source with host-side index + tracking ([[event-log-ingestion]]). The [[esp32-custom-controller]] is the documented + prevention-grade upgrade path (the [[trust-boundary]] fork). +- **Readers:** prefer [[wiegand]]-into-controller for permit holders (autonomous); host-in-the-loop + for [[lpr-camera|LPR]]/QR/pure-network readers; both can share a relay (see + [[entry-exit-readers]]). + +Unsettled items live in [[open-questions]]. diff --git a/wiki/entities/atecc608.md b/wiki/entities/atecc608.md new file mode 100644 index 0000000..c079253 --- /dev/null +++ b/wiki/entities/atecc608.md @@ -0,0 +1,24 @@ +--- +type: entity +tags: [parking, hardware, security, crypto] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# ATECC608 (secure element) + +An inexpensive **secure element** holding a signing key that **cannot be extracted, even by +someone who owns the machine**. The keystone of integrity in this system. (See +[[parking-system-architecture]] §3, §7.) + +Two distinct uses: + +1. **Host-side event signing.** Each event in the [[append-only-event-chain]] is signed by the + ATECC608 on the host machine. This is what makes the hash chain **unforgeable** rather than + merely self-consistent. +2. **Custom controller command authentication.** On the [[esp32-custom-controller]], it holds + the key(s) for [[challenge-response-auth]] — generated on-chip, non-extractable, so popping + the cabinet and dumping flash yields nothing usable. + +Confirming ATECC608 wiring/usage on both ends is [[open-questions]] #6. Listed in the [[bom]] +on the host machine. diff --git a/wiki/entities/bom.md b/wiki/entities/bom.md new file mode 100644 index 0000000..9452eb1 --- /dev/null +++ b/wiki/entities/bom.md @@ -0,0 +1,28 @@ +--- +type: entity +tags: [parking, hardware, bom, reference] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Reference BOM (recommended devices) + +Reference bill of materials. Models to **verify for local availability (Albania/EU)**; the +payment terminal is dictated by the acquiring bank. (See [[parking-system-architecture]] §9.) + +| Subsystem | Recommendation | Why | +| --- | --- | --- | +| Barrier operator | Magnetic Autocontrol / FAAC / CAME / Nice | Owns physical safety in firmware ([[barrier-not-a-door]]) | +| Induction loops | Feig / BEA / EMX | Safety + free-exit detection | +| Access controller | [[uhppote-controller]] now → ZKTeco later | Reader + relay; **isolate the VLAN** ([[network-isolation]]) | +| Permit readers | Nedap/Kathrein UHF, or Mifare → [[wiegand]] | Hands-free, or autonomous offline decisions | +| Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record | +| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS | +| Booth printer | Epson TM / Citizen (USB or network) | ESC/POS; one adapter covers both transports | +| Payment | Bank-certified P2PE standalone terminal + cash drawer | Keeps the app out of **PCI-DSS scope** | +| Host machine | Fanless industrial PC + UPS + [[atecc608]] | Reliability, power-loss safety, offline signing | +| Network | Managed VLAN switch, PoE+ | Isolate the open control protocol | + +Open procurement-driving decisions live in [[open-questions]] (esp. lane topology, payment +subsystem). Payment subsystem detail: a standalone certified terminal keeps PCI scope out of +the application — [[open-questions]] #3. diff --git a/wiki/entities/drizzle-orm.md b/wiki/entities/drizzle-orm.md new file mode 100644 index 0000000..986f778 --- /dev/null +++ b/wiki/entities/drizzle-orm.md @@ -0,0 +1,14 @@ +--- +type: entity +tags: [parking, stack, database] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Drizzle ORM + +The ORM (+ Drizzle Kit), Apache 2.0. Talks to [[sqlite]] locally; its schemas **port to +PostgreSQL with minimal change**, which is what makes the deferred remote-sync target cheap. +(See [[parking-system-architecture]] §2.) + +Part of the [[technology-stack]]. diff --git a/wiki/entities/esp32-custom-controller.md b/wiki/entities/esp32-custom-controller.md new file mode 100644 index 0000000..23dec5c --- /dev/null +++ b/wiki/entities/esp32-custom-controller.md @@ -0,0 +1,41 @@ +--- +type: entity +tags: [parking, hardware, access-control, upgrade-path] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Custom ESP32 Controller (prevention alternative) + +A small custom controller for **device-level authentication** — a control path that holds even +against an attacker on the wire. The prevention-grade upgrade from the [[uhppote-controller]] +(which is only tamper-*evident*). It moves the [[trust-boundary]] to the device. (See +[[parking-system-architecture]] §7.) + +## Requirement reframed + +The threat is **forged or replayed commands**, not eavesdropping ("open lane 2" isn't secret). +So the essential requirement is **authenticity + freshness (anti-replay)**; encryption is +optional defence-in-depth. This is implemented as [[challenge-response-auth]]. + +## Hardware + +- **Olimex ESP32-POE** (wired Ethernet + PoE, open-source hardware) or **ESP32-S3 + W5500**. +- **[[atecc608]]** secure element holding the key(s), generated on-chip, non-extractable. +- **Opto-isolated relay** between GPIO and the barrier operator's dry-contact open input. +- Enable **ESP32 flash encryption + secure boot** regardless. +- Transport: Ethernet (one network paradigm on the managed switch), or **RS-485** multidrop for + long/noisy runs. + +## Safety — treat as seriously as the crypto + +Governed by [[fail-state-safety]]: **entry fails closed, exit fails open**, a **hardware manual +override** (key switch) that works with the ESP32 dead, a watchdog with a safe default, and the +barrier operator still owns physical safety ([[barrier-not-a-door]]). + +## Trade-offs + +You take on firmware reliability, EMC/surge protection (TVS diodes, isolation, grounding, +Ethernet surge arrestor outdoors), and field maintenance. Mitigate by keeping firmware **tiny +and auditable**: verify a signed fresh command, pulse a relay, watchdog + safe state, nothing +more. All parking logic stays on the host. diff --git a/wiki/entities/fastify.md b/wiki/entities/fastify.md new file mode 100644 index 0000000..b42d71d --- /dev/null +++ b/wiki/entities/fastify.md @@ -0,0 +1,19 @@ +--- +type: entity +tags: [parking, stack, backend] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Fastify + +The backend framework (Node.js). Chosen over Express: lighter, faster, with a clean +plugin/hook model. (See [[parking-system-architecture]] §2.) + +- **Hardware drivers live as isolated Fastify plugins** emitting onto a shared internal event + bus — this is the implementation vehicle for the [[device-adapter-pattern]]. +- Also serves the [[react-vite-spa]] frontend. +- Hosts [[local-jwt-auth]] via `@fastify/jwt`; authorization is a simple `preHandler` role + guard per route. + +Part of the [[technology-stack]]. License: MIT. diff --git a/wiki/entities/local-jwt-auth.md b/wiki/entities/local-jwt-auth.md new file mode 100644 index 0000000..64cec9c --- /dev/null +++ b/wiki/entities/local-jwt-auth.md @@ -0,0 +1,19 @@ +--- +type: entity +tags: [parking, stack, auth, offline-first] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Local JWT Auth + +Authentication and authorization, kept **fully local** — a direct consequence of +[[offline-first]] (an air-gapped park cannot reach an external identity provider; see +[[logto-zitadel-oidc]] for the rejected alternative). (See [[parking-system-architecture]] §2.) + +- `@fastify/jwt` signs tokens with a **local secret**. +- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. +- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier / + readonly**. No Casbin or full RBAC engine needed at this scale. + +Part of the [[technology-stack]]. License: MIT. diff --git a/wiki/entities/logto-zitadel-oidc.md b/wiki/entities/logto-zitadel-oidc.md new file mode 100644 index 0000000..59cb3f6 --- /dev/null +++ b/wiki/entities/logto-zitadel-oidc.md @@ -0,0 +1,14 @@ +--- +type: entity +tags: [parking, rejected, auth, offline-first] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Logto / Zitadel / OIDC providers (rejected) + +Any OIDC/OAuth identity provider was **ruled out by the [[offline-first]] constraint**. An +air-gapped park cannot depend on an external — or even self-hosted but networked — identity +provider. Auth is therefore [[local-jwt-auth]] instead. (See [[parking-system-architecture]] §2.) + +Related rejected alternatives: [[payload-cms]], [[refine]]. diff --git a/wiki/entities/lpr-camera.md b/wiki/entities/lpr-camera.md new file mode 100644 index 0000000..343fbf7 --- /dev/null +++ b/wiki/entities/lpr-camera.md @@ -0,0 +1,22 @@ +--- +type: entity +tags: [parking, hardware, readers, offline-first] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# LPR Camera + +License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For +**casual/transient** vehicles, the **plate acts as ticket + an independent record**. (See +[[parking-system-architecture]] §8, §9.) + +- **Edge AI**: recognition runs **on-device**, so it keeps working with no internet — fits + [[offline-first]]. +- It's a **host-side** identity source: only the host sees the read; the host decides and + commands the relay open (the [[uhppote-controller]] is demoted to a commanded relay for that + lane). See [[entry-exit-readers]]. +- Being host-in-the-loop is **good for fraud detection** — you get two independent records (the + host's signed [[append-only-event-chain]] entry + the controller's remote-open event) that + should reconcile one-to-one; any mismatch is an anomaly. +- Mounting: within ~15° of vehicle travel at a controlled chokepoint for best reads. diff --git a/wiki/entities/payload-cms.md b/wiki/entities/payload-cms.md new file mode 100644 index 0000000..be607f1 --- /dev/null +++ b/wiki/entities/payload-cms.md @@ -0,0 +1,22 @@ +--- +type: entity +tags: [parking, rejected, stack] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Payload CMS (rejected) + +A genuinely strong option that was **rejected** as the backend. (See +[[parking-system-architecture]] §2.) + +- It has real strengths: free admin UI, built-in auth/RBAC, runs on Node so it *can* host + device drivers via init hooks. +- **Primary reason rejected: the v3 license shift to BSL** (source-available, not open source). + For a long-lived business system, a vendor that can change licensing terms underneath you is + an unacceptable risk. This is the cautionary case behind the whole stack's + "vendor-agnostic, rug-pull-proof" preference (see [[technology-stack]]). +- Secondary concerns: it's a CMS at heart (weaker on real-time/event-driven workloads), and + Next.js is heavier than needed here. + +Related rejected alternatives: [[refine]], [[logto-zitadel-oidc]]. diff --git a/wiki/entities/react-vite-spa.md b/wiki/entities/react-vite-spa.md new file mode 100644 index 0000000..1cd286c --- /dev/null +++ b/wiki/entities/react-vite-spa.md @@ -0,0 +1,15 @@ +--- +type: entity +tags: [parking, stack, frontend] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# React + Vite SPA + +The frontend: a React single-page app built with Vite, **served by [[fastify]]** (MIT). Plain +React was chosen over an admin framework — see [[refine]], which was dropped because the +operator UI is simple enough that a framework's abstractions cost more than they save. +(See [[parking-system-architecture]] §2.) + +Part of the [[technology-stack]]. diff --git a/wiki/entities/refine.md b/wiki/entities/refine.md new file mode 100644 index 0000000..c0b4cbd --- /dev/null +++ b/wiki/entities/refine.md @@ -0,0 +1,14 @@ +--- +type: entity +tags: [parking, rejected, frontend] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Refine (rejected) + +A browser-only React framework (comparable to React+Vite, **not** Next.js). **Dropped in +favour of plain [[react-vite-spa]]** — the operator UI is simple enough that an admin +framework's abstractions cost more than they save. (See [[parking-system-architecture]] §2.) + +Related rejected alternatives: [[payload-cms]], [[logto-zitadel-oidc]]. diff --git a/wiki/entities/sqlite.md b/wiki/entities/sqlite.md new file mode 100644 index 0000000..e50c1ea --- /dev/null +++ b/wiki/entities/sqlite.md @@ -0,0 +1,29 @@ +--- +type: entity +tags: [parking, stack, database] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# SQLite + +The local database (`better-sqlite3` driver, accessed via [[drizzle-orm]]). The right call +for a **single-site, single-writer** system. (See [[parking-system-architecture]] §2, §4.) + +## Why it fits + +Its only practical limit is **write concurrency** — one writer at a time, mitigated by **WAL +mode** (many concurrent readers + one writer). A parking workload never approaches this. +Other official limits are far beyond reach: ~281 TB max DB size, effectively unlimited rows, +32,767 columns/table, 1 GB per text/blob cell. + +You'd only outgrow SQLite with multiple machines writing the same DB (**never do this over a +network share**) or sustained high-frequency concurrent writes — neither applies here. + +## Relation to PostgreSQL + +The move to remote PostgreSQL is a **business/durability** decision (the remote sync target), +**not a capacity** one. [[drizzle-orm]] schemas port over with minimal change. + +Open risk: a disk failure currently means total revenue-history loss — see +[[open-questions]] #5 (durability/backup). Part of the [[technology-stack]]. diff --git a/wiki/entities/technology-stack.md b/wiki/entities/technology-stack.md new file mode 100644 index 0000000..ac4f3b5 --- /dev/null +++ b/wiki/entities/technology-stack.md @@ -0,0 +1,30 @@ +--- +type: entity +tags: [parking, stack] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Technology Stack + +The standing stack for the parking system. Every choice is **MIT/Apache/BSD** — a deliberate +constraint to avoid vendor lock-in and license rug-pulls (see [[payload-cms]] for the +cautionary case). (See [[parking-system-architecture]] §2.) + +| Layer | Choice | License | +| --- | --- | --- | +| Monorepo | [[turborepo]] | MIT | +| Backend | [[fastify]] (Node.js) | MIT | +| Frontend | [[react-vite-spa]] | MIT | +| Local database | [[sqlite]] (`better-sqlite3`) | Public domain / BSD | +| ORM | [[drizzle-orm]] (+ Drizzle Kit) | Apache 2.0 | +| Remote sync target | PostgreSQL (when implemented) | PostgreSQL License | +| Auth | [[local-jwt-auth]] (`@fastify/jwt` + bcrypt + roles) | MIT | + +## Why these + +One language end-to-end (Node), a mature ecosystem for device I/O (`serialport`, `node-hid`, +`escpos`), and a database whose only real limit (single-writer) a parking workload never +approaches. The stack is shaped by [[offline-first]]: nothing depends on a network at runtime. + +See [[standing-decisions]] for the full list of settled decisions. diff --git a/wiki/entities/turborepo.md b/wiki/entities/turborepo.md new file mode 100644 index 0000000..2cd8d20 --- /dev/null +++ b/wiki/entities/turborepo.md @@ -0,0 +1,13 @@ +--- +type: entity +tags: [parking, stack, tooling] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Turborepo + +The monorepo tool (MIT). Holds the backend ([[fastify]]) and frontend ([[react-vite-spa]]) +in one repository. (See [[parking-system-architecture]] §2.) + +Part of the [[technology-stack]]. diff --git a/wiki/entities/uhppote-controller.md b/wiki/entities/uhppote-controller.md new file mode 100644 index 0000000..9330192 --- /dev/null +++ b/wiki/entities/uhppote-controller.md @@ -0,0 +1,34 @@ +--- +type: entity +tags: [parking, hardware, access-control, current-choice] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# UHPPOTE Controller (current choice) + +The starting access-control hardware: a **UHPPOTE Wiegand 26/34 network controller (4-door)** — +a cheap reader-plus-relay frontend, acceptable **provided you understand its limits**. The plan +is UHPPOTE now → ZKTeco later (see [[bom]]). (See [[parking-system-architecture]] §6.) + +## What it is + +- Combines reader input ([[wiegand]]) and door relays, with an onboard card list enabling + **autonomous offline decisions** for Wiegand lanes. +- Stores an **indexed event log** (see [[event-log-ingestion]]): `get-events` returns the + stored range + current index; each record has event ID, timestamp, card number, door, + access-granted flag, reason code. **At the record level it's effectively append-only** — no + command edits/deletes an individual event. + +## The catch + +It speaks the [[uhppote-udp-protocol]]: **UDP port 60000, no auth, no encryption**. Anyone on +the LAN can open any door — and several unauthenticated commands can blind/reset/skew the log. +So the device is **tamper-evident, not tamper-proof**, and only trustworthy behind +[[network-isolation]] (mandatory). **Firmware cannot be customized** — the open-source +`uhppoted` ecosystem is protocol reverse-engineering only; the controller accepts only the +manufacturer's official firmware images. + +Make the log trustworthy via [[event-log-ingestion]] (host-side index tracking) landing into +the [[append-only-event-chain]]. For prevention-grade authentication, see the +[[esp32-custom-controller]]. The choice between them is the [[trust-boundary]] decision. diff --git a/wiki/entities/wiegand.md b/wiki/entities/wiegand.md new file mode 100644 index 0000000..9e751eb --- /dev/null +++ b/wiki/entities/wiegand.md @@ -0,0 +1,24 @@ +--- +type: entity +tags: [parking, hardware, readers] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Wiegand + +The reader-wiring standard (Wiegand 26/34) used to feed reads **directly into the +[[uhppote-controller]]'s reader port**. This is the preferred path for **permit +holders/subscribers**, because it lets the controller **decide autonomously** from its onboard +card list — it keeps working even if the host is down. (See [[parking-system-architecture]] §8.) + +- A door relay opens on **either** a valid Wiegand read on its reader port **or** a host `open` + command — so one lane can serve permit holders (Wiegand, autonomous) and casual/[[lpr-camera|LPR]] + (host command) on the same relay. +- **Tip: check for a Wiegand output first.** Many "network" readers (e.g. Nedap/UHF) have *both* + a network interface and a Wiegand output. Wire the Wiegand output into the controller and you + keep autonomous decisioning + the native event log, sidestepping host dependency. +- Autonomy caveat: if remote-host control is enabled, the controller expects host comms at least + every ~30 s or it reverts to local (onboard-card) control. + +Contrast with host-side identity sources in [[entry-exit-readers]]. diff --git a/wiki/index.md b/wiki/index.md new file mode 100644 index 0000000..bd7d2a3 --- /dev/null +++ b/wiki/index.md @@ -0,0 +1,67 @@ +--- +type: overview +tags: [parking, index] +updated: 2026-06-14 +--- + +# Index + +Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. +Counts: 1 source · 14 entities · 10 concepts · 2 decision records. + +## Overview & navigation +- [[overview]] — the top-level synthesis and entry point. +- [[index]] — this catalog. +- `log.md` — chronological record of ingests/queries/lints. + +## Sources +- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions. + +## Entities — technology stack +- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in. +- [[fastify]] — Node backend; hosts device-driver plugins + auth; serves the SPA. +- [[sqlite]] — local single-writer DB (WAL); limits & why it fits. +- [[drizzle-orm]] — ORM; schemas port to PostgreSQL for remote sync. +- [[turborepo]] — monorepo tool. +- [[react-vite-spa]] — React/Vite frontend served by Fastify. +- [[local-jwt-auth]] — fully local auth (JWT + bcrypt + role guard); forced by offline-first. + +## Entities — rejected alternatives +- [[payload-cms]] — strong, but rejected over BSL license shift (the rug-pull cautionary case). +- [[refine]] — dropped for plain React; UI too simple to justify a framework. +- [[logto-zitadel-oidc]] — OIDC providers ruled out by offline-first. + +## Entities — hardware & devices +- [[uhppote-controller]] — current access controller; cheap, tamper-evident, open-UDP, fixed firmware. +- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth. +- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth). +- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path). +- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source. +- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network). + +## Concepts — foundational forces +- [[offline-first]] — no network dependency in core operation; what it forces (and doesn't). +- [[threat-model]] — the operator-at-the-booth reframing; why encryption defends the wrong threat. + +## Concepts — integrity & anti-fraud +- [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log. +- [[reconciliation]] — the real anti-fraud control; what remote sync actually is. +- [[disk-os-hardening]] — LUKS/GRUB/Secure Boot; worthwhile but not the main event. + +## Concepts — device architecture & safety +- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter. +- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. +- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. +- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog. + +## Concepts — access control +- [[uhppote-udp-protocol]] — open UDP weakness + the unauthenticated commands that undermine the log. +- [[network-isolation]] — mandatory VLAN isolation; the network as security boundary. +- [[event-log-ingestion]] — host-side index tracking that makes the UHPPOTE log trustworthy. +- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay). +- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay. +- [[uhppote-vs-esp32]] — comparison: detection vs. prevention. + +## Decisions +- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers). +- [[open-questions]] — 6 unsettled items that drive procurement. diff --git a/wiki/log.md b/wiki/log.md new file mode 100644 index 0000000..54c80b6 --- /dev/null +++ b/wiki/log.md @@ -0,0 +1,11 @@ +# Wiki Log + +Append-only chronological record. Each entry: `## [YYYY-MM-DD] | `. +Query with `grep "^## \[" log.md | tail -5`. + +## [2026-06-14] ingest | Parking System — Architecture & Design Notes +First source ingested. Bootstrapped wiki scaffolding (CLAUDE.md schema, index.md, +overview.md, log.md). Created source summary, 14 entity pages, 9 concept pages, and +decision records (settled decisions + 6 open questions). Source is a dense design +doc covering stack, threat model, device architecture, UHPPOTE access control, the +custom ESP32 controller alternative, readers, and a reference BOM. diff --git a/wiki/overview.md b/wiki/overview.md new file mode 100644 index 0000000..e19d299 --- /dev/null +++ b/wiki/overview.md @@ -0,0 +1,52 @@ +--- +type: overview +tags: [parking, overview, synthesis] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Parking System — Overview + +The synthesis / entry point for this wiki. Start here, then follow links. Catalog of every page: +[[index]]. Source summaries: [[parking-system-architecture]]. + +## What it is + +A **parking-management web application** running on a **dedicated, hardened Linux appliance** +deployed on-site at a parking facility. Two forces shape nearly every decision: + +1. **[[offline-first]]** — a park may be air-gapped; nothing core may depend on a network. +2. **[[threat-model]]** — the primary adversary is the **legitimate operator at the booth**, not + an outsider. The classic fraud is *take the cash, delete the record.* + +## The architecture in one pass + +- **Stack** ([[technology-stack]] / [[standing-decisions]]): [[turborepo]] · [[fastify]] · + [[react-vite-spa]] · [[sqlite]] + [[drizzle-orm]] · [[local-jwt-auth]] — all open-licensed to + avoid lock-in (cf. rejected [[payload-cms]], [[refine]], [[logto-zitadel-oidc]]). +- **Integrity** is the heart of it: an [[append-only-event-chain]] (hash-chained, [[atecc608]]- + signed) plus external [[reconciliation]] — *that's* what remote sync really is. Encryption at + rest ([[disk-os-hardening]]) defends a secondary threat. +- **Devices** sit behind a [[device-adapter-pattern]] (swap hardware → new adapter only), with + the [[barrier-not-a-door]] safety principle keeping physical safety in barrier-operator firmware. +- **Access control** hinges on the [[trust-boundary]] fork: + [[uhppote-vs-esp32|detection vs. prevention]]. Today: [[uhppote-controller]] behind + [[network-isolation]], its open [[uhppote-udp-protocol]] contained, its log made trustworthy by + [[event-log-ingestion]]. Upgrade path: the [[esp32-custom-controller]] with + [[challenge-response-auth]] and [[fail-state-safety]]. +- **Readers** split two ways ([[entry-exit-readers]]): permit holders via [[wiegand]] + (autonomous), casual/transient via host-side [[lpr-camera]] / QR; both can share a relay. +- A reference [[bom]] lists recommended devices. + +## Where it stands + +6 [[open-questions]] still drive procurement — most critically **lane topology**, **failure +modes (fail-open on exit)**, the **reconciliation channel**, and **backup/durability**. + +## Reading paths + +- *Security-first:* [[threat-model]] → [[append-only-event-chain]] → [[reconciliation]] → + [[uhppote-vs-esp32]]. +- *Hardware-first:* [[bom]] → [[uhppote-controller]] → [[entry-exit-readers]] → + [[esp32-custom-controller]]. +- *Stack-first:* [[technology-stack]] → [[offline-first]] → [[device-adapter-pattern]]. diff --git a/wiki/raw/parking-system-architecture.md b/wiki/raw/parking-system-architecture.md new file mode 100644 index 0000000..34b62d4 --- /dev/null +++ b/wiki/raw/parking-system-architecture.md @@ -0,0 +1,390 @@ +# Parking Management System — Architecture & Design Notes + +> A working record of the architectural decisions, the reasoning behind them, and +> the alternatives that were considered and rejected. Written as a design reference, +> not a final spec — several items are still open and flagged as such. + +--- + +## 1. System context + +A parking management system delivered as a **web application running on Linux**, deployed +on-site at the parking facility. Core characteristics: + +- **Offline-first.** A park may have no internet connection, intermittent connectivity, or + be fully air-gapped. Nothing in the core operation may depend on a network being present. +- **Device-agnostic.** It must discover and control local hardware — readers, barriers/relays, + printers — through a clean abstraction so hardware can be swapped without touching business logic. +- **Optional remote sync.** Later, the local database may sync to our own private remote + infrastructure. This is a deferred capability, not a runtime dependency. + +The two forces that shape almost every decision below are **offline operation** and the +**physical-security reality** of a machine sitting in an exposed parking booth. + +--- + +## 2. Technology stack + +### Decisions + +| Layer | Choice | License | +| --- | --- | --- | +| Monorepo | Turborepo | MIT | +| Backend | Node.js + Fastify | MIT | +| Frontend | React (SPA, Vite), served by Fastify | MIT | +| Local database | SQLite (`better-sqlite3`) | Public domain / BSD | +| ORM | Drizzle ORM + Drizzle Kit | Apache 2.0 | +| Remote sync target | PostgreSQL (when implemented) | PostgreSQL License | +| Auth | Local JWT (`@fastify/jwt`) + bcrypt + roles | MIT | + +### Rationale + +**Node.js + Fastify** keeps the whole stack in one language, has a mature ecosystem for +device I/O (`serialport`, `node-hid`, `escpos`, network protocols), and Fastify is lighter +and faster than Express with a clean plugin/hook model. Hardware drivers live as isolated +Fastify plugins emitting onto a shared internal event bus. + +**SQLite locally** is the right call for a single-site, single-writer system. Its real limit +is write concurrency (one writer at a time, mitigated by WAL mode), which a parking system +never approaches. PostgreSQL is reserved for the remote sync target, where Drizzle's schema +ports over with minimal change. + +### Alternatives considered and rejected + +- **Payload CMS** — genuinely strong (free admin UI, built-in auth/RBAC, runs on Node so it + *can* host device drivers via init hooks). Rejected primarily because of its **v3 license + shift to BSL** (source-available, not open source). For a long-lived business system, a + vendor that can change licensing terms underneath us is an unacceptable risk given a strong + preference for vendor-agnostic, rug-pull-proof tooling. Secondary concerns: it's a CMS at + heart, weaker on real-time/event-driven workloads, and Next.js is heavier than needed here. +- **Refine** — a browser-only React framework (comparable to React+Vite, *not* Next.js). + Dropped in favour of plain React; the operator UI is simple enough that an admin framework's + abstractions cost more than they save. +- **Logto / Zitadel / any OIDC-OAuth identity provider** — ruled out by the offline-first + constraint. An air-gapped park cannot depend on an external (or even self-hosted networked) + identity provider. Auth is therefore **local**: `@fastify/jwt` signing with a local secret, + a users table in SQLite with bcrypt password hashes, and a role column. Authorization is a + simple `preHandler` role guard per route (admin / operator / cashier / readonly) — no Casbin + or full RBAC engine needed at this scale. + +--- + +## 3. Data security and the threat model + +### The key reframing + +Early discussion focused on protecting the database **at rest** — SQLCipher (AES-256 file +encryption), LUKS volume encryption, BitLocker, TPM-sealed keys. All of that defends against +**an outsider who steals the machine or boots from external media**. + +That is the *wrong primary threat* for a parking system. The most likely adversary is the +**legitimate operator** sitting at the booth. While the application is running, the database +is decrypted in memory and the operator has full, authorised access *through the app*. +Encryption does nothing against the classic parking fraud: take the cash, then void or delete +the entry/exit record so the books balance. + +### Consequences for design + +The controls that actually address insider/operator fraud are different in kind: + +- **Append-only event model.** Entry and exit events are never edited or deleted, only + appended. A "void" is itself a recorded event, not an erasure. +- **Tamper-evident chaining.** Each event stores the hash of the previous event (a hash chain). + Reordering or deleting breaks the chain visibly. +- **Hardware-backed signing.** An inexpensive **secure element (ATECC608)** holds a signing key + that cannot be extracted, even by someone who owns the machine. Each event is signed. This is + what makes the chain unforgeable rather than merely self-consistent. +- **Reconciliation against an authority the operator can't alter.** This is what the remote sync + really is — a *fraud-control* mechanism, not just a backup. + +### Reconciliation when a park is offline + +Offline-first does **not** mean "no reconciliation." It means **deferred, intermittent +reconciliation**. A manager visiting weekly with a USB stick, a phone hotspot once a day, or a +monthly export all provide a path to compare local records against something outside the +operator's reach. Only design for "never, by anyone" if that is genuinely true — and if it is, +the network-free controls are: the signed hash-chained log (above), physically pre-numbered +ticket stock, end-of-shift signed Z-reports, and CCTV/LPR footage as an independent record. + +### Disk / OS hardening (still worthwhile, just not the main event) + +Physical-access attacks on Windows are trivial (boot media + password reset tools), so a +**dedicated Linux machine is the correct platform**, not Windows or WSL: + +- LUKS full-disk encryption (defeats boot-from-USB) +- GRUB password + Secure Boot (prevents boot-parameter tampering / unsigned loaders) +- No desktop environment; single-purpose appliance +- Key-based SSH only + +With LUKS in place, SQLCipher becomes optional defence-in-depth rather than the critical layer. + +--- + +## 4. SQLite limits (for reference) + +The official limits are far beyond anything a parking system reaches: + +- Max database size ~281 TB; rows per table effectively unlimited (disk-bound) +- 32,767 columns per table; 1 GB per text/blob cell +- The **only** practical limit is write concurrency: one writer at a time. WAL mode allows many + concurrent readers plus one writer. A single-site parking workload is nowhere near this. + +You would only outgrow SQLite with multiple machines writing to the same database (never do +this over a network share) or sustained high-frequency concurrent writes. Neither applies. The +move to remote PostgreSQL is a business/durability decision, not a capacity one. + +--- + +## 5. Device architecture + +### Device-agnostic adapter pattern + +Business logic talks only to interfaces, never to a device SDK. Each physical device is an +adapter implementing one of these: + +```ts +interface CardReaderDevice { + connect(): Promise + onCardRead(cb: (cardNumber: string, door: number) => void): void + disconnect(): Promise +} + +interface PrinterDevice { + printTicket(data: TicketData): Promise + checkStatus(): Promise<'ready' | 'offline' | 'paper_out'> +} + +interface RelayDevice { + pulseOpen(doorId: number): Promise // see safety note below + getDoorStatus(doorId: number): Promise<'open' | 'closed'> +} +``` + +Swapping hardware means writing a new adapter; nothing else changes. + +### Safety principle: a barrier is not a door + +A vehicle barrier must **not** be driven as a timed "door open for N ms" by the application — a +timed auto-close can drop a boom on a vehicle or person. **Physical safety lives in the barrier +operator's own firmware** (induction loops, anti-crush, auto-reverse). The application and any +relay board only ever express *intent* ("open"); they never time or force a close against a +vehicle. This separation holds regardless of which relay device is used. + +### The core fork: where is the trust boundary? + +Two valid architectures, chosen per deployment (and mixable per lane): + +- **Trust boundary = the network.** Use an off-the-shelf controller (UHPPOTE/ZKTeco) and contain + its weaknesses by network isolation. Auditable. +- **Trust boundary = the device.** Use a custom controller whose firmware enforces authentication. + Unforgeable, but you own the firmware. + +--- + +## 6. Access control: UHPPOTE (current choice) + +The starting hardware is a UHPPOTE Wiegand 26/34 network controller (4-door). It is a reasonable, +cheap reader-plus-relay frontend **provided you understand its limits**. + +### The protocol weakness + +UHPPOTE communicates over **UDP (port 60000) with no authentication and no encryption**. Anyone +who can place a packet on that LAN can send an "open" command to any door. This is *the* security +issue — not safety (safety is handled by the barrier operator if wired correctly). + +**Mitigation: network isolation is mandatory.** The control devices go on their own VLAN with no +route to the booth/office network and no wireless bridge. The security boundary is the network, +because it cannot be the device. + +### Firmware is not changeable + +The open-source `uhppoted` ecosystem is **protocol reverse-engineering only** — clients that speak +the existing UDP protocol. There is no source, SDK, schematic, or toolchain to build and flash +custom firmware. The controllers accept *firmware updates*, but only the manufacturer's official +images — not your own authenticated firmware. You cannot configure or patch your way to +authentication on this hardware. + +### The event log — confirmed, and useful + +Verified against the official protocol reference: + +- The controller **stores an indexed event log**. `get-events` returns the stored range plus a + current index; each `get-event` record contains event ID, timestamp, card number, door, + access-granted flag, and a reason code. +- **At the record level it is effectively append-only** — there is no command to edit or delete an + individual event. + +### But it is not tamper-proof over UDP + +Several **unauthenticated** commands undermine the log without touching individual records: + +| Vector | Command | Effect | +| --- | --- | --- | +| Blinding | `record-special-events false` | Stops logging door open/close/button events going forward | +| Wipe | `restore-default-parameters` | Factory reset — clears config and event state | +| Rollover | (generate events / fall behind) | Finite circular buffer; old events overwritten and lost | +| Time skew | `set-time` | Corrupts/ backdates event timestamps | +| Index desync | `set-event-index` | Moves the *retrieval* pointer (a user-managed convenience value, not auto-managed) — naive ingestion skips events | + +### Ingestion design that makes the log trustworthy + +- **Track your own last-ingested index on the host** — do not rely on the controller's current-index + pointer (it's user-managed and settable by anyone). +- Walk **absolute** indices with `get-event `; treat three things as alarms: a gap in the + sequence, an "event has been overwritten" error (you fell behind — data loss), and any door-open + event the host never requested. +- Use `set-listener` auto-push for low latency, but always reconcile by index (UDP pushes can drop). +- Size polling cadence against the busiest lane's event rate so unread events never roll off. +- Land every event in the host's **signed append-only chain** (the ATECC608 log from §3). + +### Net result + +**Tamper-evident, behind network isolation.** The same unauthenticated UDP that opens a gate can +also blind the log, reset the device, or skew the clock — so the log is only trustworthy when only +the host can reach the controller. Combined with host-side index tracking and the signed chain, it +becomes a solid detection/audit layer. It does **not** become tamper-*proof*; that requires the +custom controller (§7). + +--- + +## 7. Custom ESP32 controller (the prevention alternative) + +For device-level authentication — a control path that holds even against an attacker on the wire — +a small custom controller is the right build, and the requirement is narrow enough to own safely. + +### Reframing the requirement + +The threat is **forged or replayed commands**, not eavesdropping ("open lane 2" is not secret). +So the essential requirement is **authenticity + freshness (anti-replay)**; **encryption is +optional** defence-in-depth. Building only authentication closes the actual hole. + +### The design: challenge–response with asymmetric signatures + +``` +Host (private key) ESP32 controller (host's PUBLIC key only) + │── "open lane 2" ───────────────────────▶│ generates fresh random nonce + │◀──────────── nonce ──────────────────────│ + │ sign(nonce ‖ command ‖ timestamp) ──────▶│ verify against stored public key + │ │ check nonce fresh + unused → pulse relay +``` + +The elegant property: **the controller stores only a public key**. Physically compromising the +ESP32 (popping the cabinet, dumping flash) yields nothing usable for forging commands. The fresh +per-command nonce defeats replay without counter-persistence headaches. A shared-secret/encrypted +channel would *not* have this property (the secret sits on both ends). + +### Hardware + +- **Olimex ESP32-POE** (wired Ethernet + PoE, open-source hardware) or **ESP32-S3 + W5500**. +- **ATECC608** secure element holding the key(s); generated on-chip, non-extractable. +- **Opto-isolated relay** between GPIO and the barrier operator's dry-contact open input. +- Enable **ESP32 flash encryption + secure boot** regardless. +- Transport: Ethernet (keeps one network paradigm on the existing managed switch). **RS-485** + multidrop is a robust alternative for long/noisy runs, with the same scheme layered on top. + +### Fail-state and safety (treat as seriously as the crypto) + +- Define behaviour on power/network/host loss: **entry fails closed**, **exit fails open** + (never trap a vehicle — often a legal egress requirement). +- **Hardware manual override** (key switch/button) that opens the barrier with the ESP32 dead. +- Watchdog with a defined safe default. +- The **barrier operator still owns physical safety** — the ESP32 only signals intent. + +### Honest trade-offs + +You take on firmware reliability, EMC/surge protection (TVS diodes, isolation, grounding, Ethernet +surge arrestor on outdoor runs), and field maintenance. Mitigate by keeping the firmware **tiny and +auditable** — verify a signed, fresh command and pulse a relay, with a watchdog and safe state, and +nothing more. The moment it grows "smart," reliability drops. All parking logic stays on the host. + +--- + +## 8. Entry / exit readers + +There are **two populations**, and they map to two integration paths: + +- **Permit holders / subscribers** — want hands-free or quick entry. Best served by reads that + reach the **controller directly** (Wiegand), so the controller can decide autonomously. +- **Casual / transient** — printed ticket, pay-on-exit, or plate recognition. These are inherently + **host-side** identity sources. + +### How reads reach the system + +| Reader type | Who sees the read | Decision made by | Offline autonomy | +| --- | --- | --- | --- | +| Wiegand reader → UHPPOTE port | The controller | Controller (onboard card list) | Yes — works if host is down | +| Pure TCP/IP reader (no Wiegand out) | Host only | Host, then commands relay via UDP `open` | No — host on critical path | +| LPR camera / QR ticket scanner | Host only | Host | No | + +### Key points + +- **Pure network readers are invisible to the UHPPOTE.** The board only generates events for its own + terminals (Wiegand reads, door sensors, buttons, remote opens). So for a pure-TCP reader, *only the + host can listen*, the host decides, and the host commands the relay. The controller is demoted to a + commanded relay for that lane (and its onboard card DB / offline autonomy is bypassed). +- **Check for a Wiegand output first.** Many "network" readers (e.g. Nedap/UHF units) have *both* a + network interface and a Wiegand output. Wire the Wiegand output into the UHPPOTE reader port and you + keep autonomous decisioning and the native event log, with the network port available for other uses. + This sidesteps the host dependency entirely. +- **Both models can share one relay.** A UHPPOTE door relay opens on *either* a valid Wiegand read on + its reader port *or* a host `open` command (when door control mode = "controlled"). So one lane can + serve permit holders via Wiegand (autonomous) and casual/LPR via host command, on the same relay. +- **Host-in-the-loop is good for fraud detection.** When the host decides and commands the open, you + get two independent records — the host's signed log entry (reader/plate/card identity) and the + UHPPOTE remote-open event. They should reconcile one-to-one; any mismatch is an anomaly to flag. + +> Note on autonomy: if remote-host control is enabled on the controller, it expects the host to +> communicate at least every ~30 s or it reverts to local (onboard-card) control. Relevant only to +> Wiegand-on-board lanes. + +--- + +## 9. Recommended devices (reference BOM) + +Models to verify for local availability (Albania/EU); the payment terminal is dictated by the acquiring bank. + +| Subsystem | Recommendation | Why | +| --- | --- | --- | +| Barrier operator | Magnetic Autocontrol / FAAC / CAME / Nice | Owns physical safety in firmware | +| Induction loops | Feig / BEA / EMX | Safety + free-exit detection | +| Access controller | UHPPOTE now → ZKTeco later | Reader + relay; **isolate the VLAN** | +| Permit readers | Nedap/Kathrein UHF, or Mifare → Wiegand | Hands-free, or autonomous offline decisions | +| Casual identity | Milesight LPR (edge AI, offline-capable) | Plate = ticket + independent record | +| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS | +| Booth printer | Epson TM / Citizen (USB or network) | ESC/POS; same adapter covers both transports | +| Payment | Bank-certified P2PE standalone terminal + cash drawer | Keeps the app out of PCI-DSS scope | +| Host machine | Fanless industrial PC + UPS + ATECC608 | Reliability, power-loss safety, offline signing | +| Network | Managed VLAN switch, PoE+ | Isolate the open control protocol | + +LPR note: edge-AI LPR cameras run recognition on-device and keep working with no internet, which fits +the offline-first constraint. Mount within ~15° of vehicle travel at a controlled chokepoint for best reads. + +--- + +## 10. Open decisions / next steps + +These are **not yet decided** and should be settled before procurement, because they drive everything else: + +1. **Lane topology.** One host per lane, or one central host driving networked devices in each lane? + This decides how many controllers, printers, UPSs, and SQLite instances exist, and the failure + blast radius. (A single central host is a single point of failure for *all* lanes.) +2. **Failure modes.** Define per direction what happens to barriers on host/power/network loss — + particularly **fail-open on exit** for egress safety. Currently unaddressed. +3. **Payment subsystem.** Manned booth (P2PE terminal + cash drawer) vs unmanned pay station; confirm + PCI scope is kept out of the application via a standalone certified terminal. +4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot, manager + visit) to reconcile the signed log against an external authority — this is the real anti-fraud control. +5. **Durability/backup.** Backup strategy for the SQLite database and a recovery plan; "sync later" + currently leaves a disk failure as total revenue-history loss. +6. **Secure-element integration.** Confirm ATECC608 wiring/usage on both the host (event signing) and, + if pursued, the custom controller (command authentication). + +--- + +## Summary of standing decisions + +- **Stack:** Turborepo · Fastify (Node) · React/Vite SPA · SQLite + Drizzle · local JWT auth. All MIT/Apache/BSD — no vendor lock, no rug-pull risk. +- **Platform:** dedicated, hardened Linux appliance (LUKS + GRUB password + Secure Boot), not Windows/WSL. +- **Integrity:** append-only, hash-chained, ATECC608-signed event log; reconciliation is the anti-fraud control, encryption protects only at-rest. +- **Access control:** UHPPOTE for now, on an isolated VLAN; event log used as a tamper-evident audit source with host-side index tracking. Custom ESP32 controller documented as the prevention-grade upgrade path. +- **Readers:** prefer Wiegand-into-controller for permit holders (autonomous); host-in-the-loop for LPR/QR/pure-network readers; both can share a relay. diff --git a/wiki/sources/parking-system-architecture.md b/wiki/sources/parking-system-architecture.md new file mode 100644 index 0000000..c3af57d --- /dev/null +++ b/wiki/sources/parking-system-architecture.md @@ -0,0 +1,51 @@ +--- +type: source +tags: [parking, architecture, source] +sources: [parking-system-architecture] +updated: 2026-06-14 +--- + +# Source: Parking Management System — Architecture & Design Notes + +A working design reference (not a final spec) recording architectural decisions, the +reasoning behind them, and rejected alternatives for a parking-management system. Several +items are explicitly open. Raw file: `raw/parking-system-architecture.md`. + +## Key takeaways + +- The system is a **web app on Linux, deployed on-site**. Two forces shape every decision: + [[offline-first]] operation and the **physical-security reality** of a machine in an exposed booth. +- **Stack** ([[technology-stack]]): [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · + [[sqlite]] + [[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — chosen for **no vendor + lock / no rug-pull risk** (the reason [[payload-cms]] was rejected). +- **The threat-model reframing** ([[threat-model]]): the primary adversary is the **legitimate + operator at the booth**, not an outsider stealing the machine. Encryption-at-rest defends the + wrong threat. The real controls are the [[append-only-event-chain]] (hash-chained, + [[atecc608]]-signed) plus [[reconciliation]] against an authority the operator can't alter. +- **Devices** go through a [[device-adapter-pattern]] so hardware swaps don't touch business logic. + Safety principle: [[barrier-not-a-door]] — physical safety lives in the barrier operator firmware. +- **Access control** today is the [[uhppote-controller]] on an isolated VLAN — tamper-*evident*, + not tamper-*proof*, because its [[uhppote-udp-protocol|UDP protocol]] is unauthenticated. The + [[esp32-custom-controller]] is documented as the prevention-grade upgrade (challenge–response + with asymmetric signatures). +- **Readers** split into two populations ([[entry-exit-readers]]): permit holders (best via + [[wiegand]] into the controller, autonomous) and casual/transient (host-side: [[lpr-camera]], + QR/ticket). Both can share one relay. +- A reference [[bom|BOM]] lists recommended devices; **6 open decisions** remain + ([[open-questions]]) that drive procurement. + +## Section map + +| § | Topic | Wiki pages | +| --- | --- | --- | +| 1 | System context | [[offline-first]], [[threat-model]] | +| 2 | Technology stack | [[technology-stack]], [[fastify]], [[sqlite]], [[drizzle-orm]], [[turborepo]], [[react-vite-spa]], [[local-jwt-auth]], [[payload-cms]] | +| 3 | Data security & threat model | [[threat-model]], [[append-only-event-chain]], [[atecc608]], [[reconciliation]], [[disk-os-hardening]] | +| 4 | SQLite limits | [[sqlite]] | +| 5 | Device architecture | [[device-adapter-pattern]], [[barrier-not-a-door]], [[trust-boundary]] | +| 6 | UHPPOTE access control | [[uhppote-controller]], [[uhppote-udp-protocol]], [[network-isolation]], [[event-log-ingestion]] | +| 7 | Custom ESP32 controller | [[esp32-custom-controller]], [[challenge-response-auth]], [[atecc608]], [[fail-state-safety]] | +| 8 | Entry/exit readers | [[entry-exit-readers]], [[wiegand]], [[lpr-camera]] | +| 9 | Recommended devices (BOM) | [[bom]] | +| 10 | Open decisions / next steps | [[open-questions]] | +| — | Summary of standing decisions | [[standing-decisions]] |