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:
@@ -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.
|
||||
Reference in New Issue
Block a user