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.
This commit is contained in:
+17
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<FastifyInstance> {
|
||||||
|
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<void>; user?: { role: Role } }) => {
|
||||||
|
await req.jwtVerify();
|
||||||
|
if (!req.user || !allowed.includes(req.user.role)) {
|
||||||
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Parking System</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>("checking…");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/health")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d: { status: string }) => setHealth(d.status))
|
||||||
|
.catch(() => setHealth("unreachable"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ fontFamily: "system-ui", padding: "2rem" }}>
|
||||||
|
<h1>Parking System</h1>
|
||||||
|
<p>Operator console — scaffold.</p>
|
||||||
|
<p>
|
||||||
|
API health: <strong>{health}</strong>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781389618205,
|
||||||
|
"tag": "0000_absent_rocket_raccoon",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof createDb>;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"references": [{ "path": "../shared" }],
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
onCardRead(cb: (cardNumber: string, door: number) => void): void;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketData {
|
||||||
|
readonly ticketId: string;
|
||||||
|
readonly lane: number;
|
||||||
|
readonly issuedAt: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrinterDevice {
|
||||||
|
printTicket(data: TicketData): Promise<void>;
|
||||||
|
checkStatus(): Promise<"ready" | "offline" | "paper_out">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayDevice {
|
||||||
|
/** Express intent to open. NEVER timed/forced closed against a vehicle. */
|
||||||
|
pulseOpen(doorId: number): Promise<void>;
|
||||||
|
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"references": [{ "path": "../shared" }],
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Generated
+2576
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Vendored
+33
@@ -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
|
||||||
|
}
|
||||||
@@ -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/<slug>.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 | <Source Title>`.
|
||||||
|
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.
|
||||||
@@ -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.
|
||||||
@@ -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]]).
|
||||||
@@ -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.
|
||||||
@@ -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<void>
|
||||||
|
onCardRead(cb: (cardNumber: string, door: number) => void): void
|
||||||
|
disconnect(): Promise<void>
|
||||||
|
}
|
||||||
|
interface PrinterDevice {
|
||||||
|
printTicket(data: TicketData): Promise<void>
|
||||||
|
checkStatus(): Promise<'ready' | 'offline' | 'paper_out'>
|
||||||
|
}
|
||||||
|
interface RelayDevice {
|
||||||
|
pulseOpen(doorId: number): Promise<void> // 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.
|
||||||
@@ -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]].)
|
||||||
@@ -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).
|
||||||
@@ -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 <id>`. 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]].
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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]]).
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
@@ -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]]).
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -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]].
|
||||||
@@ -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]].
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Wiki Log
|
||||||
|
|
||||||
|
Append-only chronological record. Each entry: `## [YYYY-MM-DD] <op> | <subject>`.
|
||||||
|
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.
|
||||||
@@ -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]].
|
||||||
@@ -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<void>
|
||||||
|
onCardRead(cb: (cardNumber: string, door: number) => void): void
|
||||||
|
disconnect(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrinterDevice {
|
||||||
|
printTicket(data: TicketData): Promise<void>
|
||||||
|
checkStatus(): Promise<'ready' | 'offline' | 'paper_out'>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelayDevice {
|
||||||
|
pulseOpen(doorId: number): Promise<void> // 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 <id>`; 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.
|
||||||
@@ -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]] |
|
||||||
Reference in New Issue
Block a user