Files
parking_solution/wiki/raw/parking-system-architecture.md
T
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

21 KiB
Raw Blame History

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:

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.


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.