Files
parking_solution/wiki/raw/parking-system-architecture.md
julian bfe64032d8 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.
2026-06-14 00:34:11 +02:00

391 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.