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:
2026-06-14 00:34:11 +02:00
commit bfe64032d8
74 changed files with 4970 additions and 0 deletions
+24
View File
@@ -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.
+28
View File
@@ -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.
+14
View File
@@ -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]].
+41
View File
@@ -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.
+19
View File
@@ -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.
+19
View File
@@ -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.
+14
View File
@@ -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]].
+22
View File
@@ -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.
+22
View File
@@ -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]].
+15
View File
@@ -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]].
+14
View File
@@ -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]].
+29
View File
@@ -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]].
+30
View File
@@ -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.
+13
View File
@@ -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]].
+34
View File
@@ -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.
+24
View File
@@ -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]].