# Parking Management System — Architecture & Design Notes > A working record of the architectural decisions, the reasoning behind them, and > the alternatives that were considered and rejected. Written as a design reference, > not a final spec — several items are still open and flagged as such. --- ## 1. System context A parking management system delivered as a **web application running on Linux**, deployed on-site at the parking facility. Core characteristics: - **Offline-first.** A park may have no internet connection, intermittent connectivity, or be fully air-gapped. Nothing in the core operation may depend on a network being present. - **Device-agnostic.** It must discover and control local hardware — readers, barriers/relays, printers — through a clean abstraction so hardware can be swapped without touching business logic. - **Optional remote sync.** Later, the local database may sync to our own private remote infrastructure. This is a deferred capability, not a runtime dependency. The two forces that shape almost every decision below are **offline operation** and the **physical-security reality** of a machine sitting in an exposed parking booth. --- ## 2. Technology stack ### Decisions | Layer | Choice | License | | --- | --- | --- | | Monorepo | Turborepo | MIT | | Backend | Node.js + Fastify | MIT | | Frontend | React (SPA, Vite), served by Fastify | MIT | | Local database | SQLite (`better-sqlite3`) | Public domain / BSD | | ORM | Drizzle ORM + Drizzle Kit | Apache 2.0 | | Remote sync target | PostgreSQL (when implemented) | PostgreSQL License | | Auth | Local JWT (`@fastify/jwt`) + bcrypt + roles | MIT | ### Rationale **Node.js + Fastify** keeps the whole stack in one language, has a mature ecosystem for device I/O (`serialport`, `node-hid`, `escpos`, network protocols), and Fastify is lighter and faster than Express with a clean plugin/hook model. Hardware drivers live as isolated Fastify plugins emitting onto a shared internal event bus. **SQLite locally** is the right call for a single-site, single-writer system. Its real limit is write concurrency (one writer at a time, mitigated by WAL mode), which a parking system never approaches. PostgreSQL is reserved for the remote sync target, where Drizzle's schema ports over with minimal change. ### Alternatives considered and rejected - **Payload CMS** — genuinely strong (free admin UI, built-in auth/RBAC, runs on Node so it *can* host device drivers via init hooks). Rejected primarily because of its **v3 license shift to BSL** (source-available, not open source). For a long-lived business system, a vendor that can change licensing terms underneath us is an unacceptable risk given a strong preference for vendor-agnostic, rug-pull-proof tooling. Secondary concerns: it's a CMS at heart, weaker on real-time/event-driven workloads, and Next.js is heavier than needed here. - **Refine** — a browser-only React framework (comparable to React+Vite, *not* Next.js). Dropped in favour of plain React; the operator UI is simple enough that an admin framework's abstractions cost more than they save. - **Logto / Zitadel / any OIDC-OAuth identity provider** — ruled out by the offline-first constraint. An air-gapped park cannot depend on an external (or even self-hosted networked) identity provider. Auth is therefore **local**: `@fastify/jwt` signing with a local secret, a users table in SQLite with bcrypt password hashes, and a role column. Authorization is a simple `preHandler` role guard per route (admin / operator / cashier / readonly) — no Casbin or full RBAC engine needed at this scale. --- ## 3. Data security and the threat model ### The key reframing Early discussion focused on protecting the database **at rest** — SQLCipher (AES-256 file encryption), LUKS volume encryption, BitLocker, TPM-sealed keys. All of that defends against **an outsider who steals the machine or boots from external media**. That is the *wrong primary threat* for a parking system. The most likely adversary is the **legitimate operator** sitting at the booth. While the application is running, the database is decrypted in memory and the operator has full, authorised access *through the app*. Encryption does nothing against the classic parking fraud: take the cash, then void or delete the entry/exit record so the books balance. ### Consequences for design The controls that actually address insider/operator fraud are different in kind: - **Append-only event model.** Entry and exit events are never edited or deleted, only appended. A "void" is itself a recorded event, not an erasure. - **Tamper-evident chaining.** Each event stores the hash of the previous event (a hash chain). Reordering or deleting breaks the chain visibly. - **Hardware-backed signing.** An inexpensive **secure element (ATECC608)** holds a signing key that cannot be extracted, even by someone who owns the machine. Each event is signed. This is what makes the chain unforgeable rather than merely self-consistent. - **Reconciliation against an authority the operator can't alter.** This is what the remote sync really is — a *fraud-control* mechanism, not just a backup. ### Reconciliation when a park is offline Offline-first does **not** mean "no reconciliation." It means **deferred, intermittent reconciliation**. A manager visiting weekly with a USB stick, a phone hotspot once a day, or a monthly export all provide a path to compare local records against something outside the operator's reach. Only design for "never, by anyone" if that is genuinely true — and if it is, the network-free controls are: the signed hash-chained log (above), physically pre-numbered ticket stock, end-of-shift signed Z-reports, and CCTV/LPR footage as an independent record. ### Disk / OS hardening (still worthwhile, just not the main event) Physical-access attacks on Windows are trivial (boot media + password reset tools), so a **dedicated Linux machine is the correct platform**, not Windows or WSL: - LUKS full-disk encryption (defeats boot-from-USB) - GRUB password + Secure Boot (prevents boot-parameter tampering / unsigned loaders) - No desktop environment; single-purpose appliance - Key-based SSH only With LUKS in place, SQLCipher becomes optional defence-in-depth rather than the critical layer. --- ## 4. SQLite limits (for reference) The official limits are far beyond anything a parking system reaches: - Max database size ~281 TB; rows per table effectively unlimited (disk-bound) - 32,767 columns per table; 1 GB per text/blob cell - The **only** practical limit is write concurrency: one writer at a time. WAL mode allows many concurrent readers plus one writer. A single-site parking workload is nowhere near this. You would only outgrow SQLite with multiple machines writing to the same database (never do this over a network share) or sustained high-frequency concurrent writes. Neither applies. The move to remote PostgreSQL is a business/durability decision, not a capacity one. --- ## 5. Device architecture ### Device-agnostic adapter pattern Business logic talks only to interfaces, never to a device SDK. Each physical device is an adapter implementing one of these: ```ts interface CardReaderDevice { connect(): Promise onCardRead(cb: (cardNumber: string, door: number) => void): void disconnect(): Promise } interface PrinterDevice { printTicket(data: TicketData): Promise checkStatus(): Promise<'ready' | 'offline' | 'paper_out'> } interface RelayDevice { pulseOpen(doorId: number): Promise // see safety note below getDoorStatus(doorId: number): Promise<'open' | 'closed'> } ``` Swapping hardware means writing a new adapter; nothing else changes. ### Safety principle: a barrier is not a door A vehicle barrier must **not** be driven as a timed "door open for N ms" by the application — a timed auto-close can drop a boom on a vehicle or person. **Physical safety lives in the barrier operator's own firmware** (induction loops, anti-crush, auto-reverse). The application and any relay board only ever express *intent* ("open"); they never time or force a close against a vehicle. This separation holds regardless of which relay device is used. ### The core fork: where is the trust boundary? Two valid architectures, chosen per deployment (and mixable per lane): - **Trust boundary = the network.** Use an off-the-shelf controller (UHPPOTE/ZKTeco) and contain its weaknesses by network isolation. Auditable. - **Trust boundary = the device.** Use a custom controller whose firmware enforces authentication. Unforgeable, but you own the firmware. --- ## 6. Access control: UHPPOTE (current choice) The starting hardware is a UHPPOTE Wiegand 26/34 network controller (4-door). It is a reasonable, cheap reader-plus-relay frontend **provided you understand its limits**. ### The protocol weakness UHPPOTE communicates over **UDP (port 60000) with no authentication and no encryption**. Anyone who can place a packet on that LAN can send an "open" command to any door. This is *the* security issue — not safety (safety is handled by the barrier operator if wired correctly). **Mitigation: network isolation is mandatory.** The control devices go on their own VLAN with no route to the booth/office network and no wireless bridge. The security boundary is the network, because it cannot be the device. ### Firmware is not changeable The open-source `uhppoted` ecosystem is **protocol reverse-engineering only** — clients that speak the existing UDP protocol. There is no source, SDK, schematic, or toolchain to build and flash custom firmware. The controllers accept *firmware updates*, but only the manufacturer's official images — not your own authenticated firmware. You cannot configure or patch your way to authentication on this hardware. ### The event log — confirmed, and useful Verified against the official protocol reference: - The controller **stores an indexed event log**. `get-events` returns the stored range plus a current index; each `get-event` record contains event ID, timestamp, card number, door, access-granted flag, and a reason code. - **At the record level it is effectively append-only** — there is no command to edit or delete an individual event. ### But it is not tamper-proof over UDP Several **unauthenticated** commands undermine the log without touching individual records: | Vector | Command | Effect | | --- | --- | --- | | Blinding | `record-special-events false` | Stops logging door open/close/button events going forward | | Wipe | `restore-default-parameters` | Factory reset — clears config and event state | | Rollover | (generate events / fall behind) | Finite circular buffer; old events overwritten and lost | | Time skew | `set-time` | Corrupts/ backdates event timestamps | | Index desync | `set-event-index` | Moves the *retrieval* pointer (a user-managed convenience value, not auto-managed) — naive ingestion skips events | ### Ingestion design that makes the log trustworthy - **Track your own last-ingested index on the host** — do not rely on the controller's current-index pointer (it's user-managed and settable by anyone). - Walk **absolute** indices with `get-event `; treat three things as alarms: a gap in the sequence, an "event has been overwritten" error (you fell behind — data loss), and any door-open event the host never requested. - Use `set-listener` auto-push for low latency, but always reconcile by index (UDP pushes can drop). - Size polling cadence against the busiest lane's event rate so unread events never roll off. - Land every event in the host's **signed append-only chain** (the ATECC608 log from §3). ### Net result **Tamper-evident, behind network isolation.** The same unauthenticated UDP that opens a gate can also blind the log, reset the device, or skew the clock — so the log is only trustworthy when only the host can reach the controller. Combined with host-side index tracking and the signed chain, it becomes a solid detection/audit layer. It does **not** become tamper-*proof*; that requires the custom controller (§7). --- ## 7. Custom ESP32 controller (the prevention alternative) For device-level authentication — a control path that holds even against an attacker on the wire — a small custom controller is the right build, and the requirement is narrow enough to own safely. ### Reframing the requirement The threat is **forged or replayed commands**, not eavesdropping ("open lane 2" is not secret). So the essential requirement is **authenticity + freshness (anti-replay)**; **encryption is optional** defence-in-depth. Building only authentication closes the actual hole. ### The design: challenge–response with asymmetric signatures ``` Host (private key) ESP32 controller (host's PUBLIC key only) │── "open lane 2" ───────────────────────▶│ generates fresh random nonce │◀──────────── nonce ──────────────────────│ │ sign(nonce ‖ command ‖ timestamp) ──────▶│ verify against stored public key │ │ check nonce fresh + unused → pulse relay ``` The elegant property: **the controller stores only a public key**. Physically compromising the ESP32 (popping the cabinet, dumping flash) yields nothing usable for forging commands. The fresh per-command nonce defeats replay without counter-persistence headaches. A shared-secret/encrypted channel would *not* have this property (the secret sits on both ends). ### Hardware - **Olimex ESP32-POE** (wired Ethernet + PoE, open-source hardware) or **ESP32-S3 + W5500**. - **ATECC608** secure element holding the key(s); generated on-chip, non-extractable. - **Opto-isolated relay** between GPIO and the barrier operator's dry-contact open input. - Enable **ESP32 flash encryption + secure boot** regardless. - Transport: Ethernet (keeps one network paradigm on the existing managed switch). **RS-485** multidrop is a robust alternative for long/noisy runs, with the same scheme layered on top. ### Fail-state and safety (treat as seriously as the crypto) - Define behaviour on power/network/host loss: **entry fails closed**, **exit fails open** (never trap a vehicle — often a legal egress requirement). - **Hardware manual override** (key switch/button) that opens the barrier with the ESP32 dead. - Watchdog with a defined safe default. - The **barrier operator still owns physical safety** — the ESP32 only signals intent. ### Honest trade-offs You take on firmware reliability, EMC/surge protection (TVS diodes, isolation, grounding, Ethernet surge arrestor on outdoor runs), and field maintenance. Mitigate by keeping the firmware **tiny and auditable** — verify a signed, fresh command and pulse a relay, with a watchdog and safe state, and nothing more. The moment it grows "smart," reliability drops. All parking logic stays on the host. --- ## 8. Entry / exit readers There are **two populations**, and they map to two integration paths: - **Permit holders / subscribers** — want hands-free or quick entry. Best served by reads that reach the **controller directly** (Wiegand), so the controller can decide autonomously. - **Casual / transient** — printed ticket, pay-on-exit, or plate recognition. These are inherently **host-side** identity sources. ### How reads reach the system | Reader type | Who sees the read | Decision made by | Offline autonomy | | --- | --- | --- | --- | | Wiegand reader → UHPPOTE port | The controller | Controller (onboard card list) | Yes — works if host is down | | Pure TCP/IP reader (no Wiegand out) | Host only | Host, then commands relay via UDP `open` | No — host on critical path | | LPR camera / QR ticket scanner | Host only | Host | No | ### Key points - **Pure network readers are invisible to the UHPPOTE.** The board only generates events for its own terminals (Wiegand reads, door sensors, buttons, remote opens). So for a pure-TCP reader, *only the host can listen*, the host decides, and the host commands the relay. The controller is demoted to a commanded relay for that lane (and its onboard card DB / offline autonomy is bypassed). - **Check for a Wiegand output first.** Many "network" readers (e.g. Nedap/UHF units) have *both* a network interface and a Wiegand output. Wire the Wiegand output into the UHPPOTE reader port and you keep autonomous decisioning and the native event log, with the network port available for other uses. This sidesteps the host dependency entirely. - **Both models can share one relay.** A UHPPOTE door relay opens on *either* a valid Wiegand read on its reader port *or* a host `open` command (when door control mode = "controlled"). So one lane can serve permit holders via Wiegand (autonomous) and casual/LPR via host command, on the same relay. - **Host-in-the-loop is good for fraud detection.** When the host decides and commands the open, you get two independent records — the host's signed log entry (reader/plate/card identity) and the UHPPOTE remote-open event. They should reconcile one-to-one; any mismatch is an anomaly to flag. > Note on autonomy: if remote-host control is enabled on the controller, it expects the host to > communicate at least every ~30 s or it reverts to local (onboard-card) control. Relevant only to > Wiegand-on-board lanes. --- ## 9. Recommended devices (reference BOM) Models to verify for local availability (Albania/EU); the payment terminal is dictated by the acquiring bank. | Subsystem | Recommendation | Why | | --- | --- | --- | | Barrier operator | Magnetic Autocontrol / FAAC / CAME / Nice | Owns physical safety in firmware | | Induction loops | Feig / BEA / EMX | Safety + free-exit detection | | Access controller | UHPPOTE now → ZKTeco later | Reader + relay; **isolate the VLAN** | | Permit readers | Nedap/Kathrein UHF, or Mifare → Wiegand | Hands-free, or autonomous offline decisions | | Casual identity | Milesight LPR (edge AI, offline-capable) | Plate = ticket + independent record | | Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS | | Booth printer | Epson TM / Citizen (USB or network) | ESC/POS; same adapter covers both transports | | Payment | Bank-certified P2PE standalone terminal + cash drawer | Keeps the app out of PCI-DSS scope | | Host machine | Fanless industrial PC + UPS + ATECC608 | Reliability, power-loss safety, offline signing | | Network | Managed VLAN switch, PoE+ | Isolate the open control protocol | LPR note: edge-AI LPR cameras run recognition on-device and keep working with no internet, which fits the offline-first constraint. Mount within ~15° of vehicle travel at a controlled chokepoint for best reads. --- ## 10. Open decisions / next steps These are **not yet decided** and should be settled before procurement, because they drive everything else: 1. **Lane topology.** One host per lane, or one central host driving networked devices in each lane? This decides how many controllers, printers, UPSs, and SQLite instances exist, and the failure blast radius. (A single central host is a single point of failure for *all* lanes.) 2. **Failure modes.** Define per direction what happens to barriers on host/power/network loss — particularly **fail-open on exit** for egress safety. Currently unaddressed. 3. **Payment subsystem.** Manned booth (P2PE terminal + cash drawer) vs unmanned pay station; confirm PCI scope is kept out of the application via a standalone certified terminal. 4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot, manager visit) to reconcile the signed log against an external authority — this is the real anti-fraud control. 5. **Durability/backup.** Backup strategy for the SQLite database and a recovery plan; "sync later" currently leaves a disk failure as total revenue-history loss. 6. **Secure-element integration.** Confirm ATECC608 wiring/usage on both the host (event signing) and, if pursued, the custom controller (command authentication). --- ## Summary of standing decisions - **Stack:** Turborepo · Fastify (Node) · React/Vite SPA · SQLite + Drizzle · local JWT auth. All MIT/Apache/BSD — no vendor lock, no rug-pull risk. - **Platform:** dedicated, hardened Linux appliance (LUKS + GRUB password + Secure Boot), not Windows/WSL. - **Integrity:** append-only, hash-chained, ATECC608-signed event log; reconciliation is the anti-fraud control, encryption protects only at-rest. - **Access control:** UHPPOTE for now, on an isolated VLAN; event log used as a tamper-evident audit source with host-side index tracking. Custom ESP32 controller documented as the prevention-grade upgrade path. - **Readers:** prefer Wiegand-into-controller for permit holders (autonomous); host-in-the-loop for LPR/QR/pure-network readers; both can share a relay.