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
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+33
View File
@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
+90
View File
@@ -0,0 +1,90 @@
# Parking System Wiki — Schema & Maintenance Guide
This directory is an **LLM-maintained wiki** (an Obsidian vault). It follows the
"LLM Wiki" pattern: raw sources are immutable inputs; the wiki is a persistent,
compounding, interlinked knowledge base that the LLM writes and maintains. The
human curates sources and asks questions; the LLM does all bookkeeping.
## Layers
- **`raw/`** — immutable source documents. Read from, never modify. Source of truth.
(Windows downloads sometimes leave `:Zone.Identifier` metadata companions — delete them.)
- **wiki pages** — everything else under this directory. LLM-owned: summaries,
entity pages, concept pages, decision records, the overview, the synthesis.
- **this file (`CLAUDE.md`)** — the schema. How the wiki is structured and the
workflows to follow. Co-evolve it as conventions change.
## Directory layout
```
wiki/
├── CLAUDE.md # this schema
├── index.md # content catalog — every page, one line each, by category
├── log.md # append-only chronological record (ingests, queries, lints)
├── overview.md # the top-level synthesis / entry point to the wiki
├── raw/ # immutable sources
├── sources/ # one summary page per raw source
├── entities/ # concrete things: devices, components, technologies, parties
├── concepts/ # ideas, patterns, constraints, threat-model elements
└── decisions/ # standing decisions (settled) and open questions (unsettled)
```
## Page conventions
- **Filenames**: kebab-case, `.md`. Title = `# H1` matching the topic.
- **Frontmatter** (YAML) on every wiki page:
```yaml
---
type: source | entity | concept | decision | overview
tags: [parking, ...]
sources: [parking-system-architecture] # raw source slugs this draws from
updated: 2026-06-14
status: settled | open # decisions only
---
```
- **Links**: use Obsidian `[[wikilinks]]` liberally — link entities, concepts, and
decisions wherever they're mentioned. A link to a page that doesn't exist yet is
fine; it marks a page worth writing. Prefer `[[page-name]]` or `[[page-name|alias]]`.
- **Citations**: when a claim comes from a source, reference it, e.g.
`(see [[parking-system-architecture]] §6)`. Keep section numbers when the source has them.
- Keep pages focused and atomic. One entity/concept per page. Cross-link rather than duplicate.
## Workflows
### Ingest (new source dropped in `raw/`)
1. Read the source fully.
2. Write/update a summary in `sources/<slug>.md` (key takeaways + section map).
3. Create or update **entity** pages for every concrete thing it describes.
4. Create or update **concept** pages for every pattern/constraint/idea.
5. Update `decisions/` — record settled decisions and open questions.
6. Update `overview.md` synthesis if the big picture changed.
7. Update `index.md` (add/adjust catalog lines).
8. Append a `log.md` entry: `## [YYYY-MM-DD] ingest | <Source Title>`.
9. Note any **contradictions** with existing pages explicitly on the affected page.
### Query (a question against the wiki)
1. Read `index.md` to locate relevant pages, then drill in.
2. Synthesize an answer with `[[links]]` and source citations.
3. If the answer is durable (a comparison, analysis, discovered connection), **file
it back** as a new page and catalog it. Append a `query` line to `log.md`.
### Lint (health check)
Look for: contradictions between pages, stale claims superseded by newer sources,
orphan pages (no inbound links), concepts mentioned but lacking a page, missing
cross-references, and data gaps worth a web search. Report findings; suggest next
sources/questions. Append a `lint` line to `log.md`.
## Log convention
Append-only. Each entry starts with a parseable prefix so
`grep "^## \[" log.md | tail -5` works:
```
## [2026-06-14] ingest | Parking System — Architecture & Design Notes
```
## Style
- Faithful to sources. Flag uncertainty and open questions rather than papering over them.
- This domain (a parking-management system) is **offline-first** and **fraud/threat-model
driven** — those two forces shape most content; keep them front of mind when synthesizing.
+26
View File
@@ -0,0 +1,26 @@
---
type: concept
tags: [parking, security, integrity]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Append-Only Event Chain
The core integrity mechanism against operator fraud (see [[threat-model]]). (See
[[parking-system-architecture]] §3.)
Three layered properties:
1. **Append-only event model.** Entry/exit events are never edited or deleted, only appended. A
"void" is itself a **recorded event**, not an erasure.
2. **Tamper-evident chaining.** Each event stores the **hash of the previous event** (a hash
chain). Reordering or deleting **breaks the chain visibly**.
3. **Hardware-backed signing.** The **[[atecc608]]** secure element signs each event with a
non-extractable key. This is what makes the chain **unforgeable** rather than merely
self-consistent — someone who owns the machine still cannot forge a valid entry.
It only becomes trustworthy as an external fraud control when paired with [[reconciliation]]
against an authority the operator can't alter. Every device event — including those ingested
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
chain.
+20
View File
@@ -0,0 +1,20 @@
---
type: concept
tags: [parking, safety, devices]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# 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**. (See [[parking-system-architecture]]
§5.)
- **Physical safety lives in the barrier operator's own firmware** — induction loops, anti-crush,
auto-reverse. (Recommended barrier operators in the [[bom]] are chosen because they own this.)
- The application and any relay board **only ever express *intent* ("open")**; they never time or
force a close against a vehicle. Reflected in the [[device-adapter-pattern]]'s `pulseOpen`.
- Holds **regardless of which relay device** is used — [[uhppote-controller]] or
[[esp32-custom-controller]]. The ESP32 design restates it: "the ESP32 only signals intent"
(see [[fail-state-safety]]).
+29
View File
@@ -0,0 +1,29 @@
---
type: concept
tags: [parking, security, crypto, access-control]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Challenge–Response Auth (asymmetric signatures)
The authentication scheme for the [[esp32-custom-controller]]. Closes the actual hole in the
[[uhppote-udp-protocol]]: **forged or replayed commands**. The requirement is **authenticity +
freshness (anti-replay)**; encryption is optional. (See [[parking-system-architecture]] §7.)
```
Host (private key) ESP32 (host's PUBLIC key only)
│── "open lane 2" ──────────────────▶│ generates fresh random nonce
│◀──────────── nonce ─────────────────│
│ sign(nonce ‖ command ‖ ts) ────────▶│ verify vs 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 via the [[atecc608]]) 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 would sit on
both ends. That's why authentication (not encryption) is the right build here.
+32
View File
@@ -0,0 +1,32 @@
---
type: concept
tags: [parking, architecture, devices]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Device-Adapter Pattern
How the system stays **device-agnostic**: business logic talks **only to interfaces, never to a
device SDK**. Each physical device is an adapter implementing one interface; **swapping hardware
means writing a new adapter and nothing else changes.** Implemented as isolated [[fastify]]
plugins emitting onto a shared internal event bus. (See [[parking-system-architecture]] §5.)
```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> // intent only — see safety note
getDoorStatus(doorId: number): Promise<'open' | 'closed'>
}
```
Note the `RelayDevice` expresses **intent only** — see the [[barrier-not-a-door]] safety
principle. The choice of *which* adapter to trust is the [[trust-boundary]] decision.
+24
View File
@@ -0,0 +1,24 @@
---
type: concept
tags: [parking, security, platform]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Disk / OS Hardening
Worthwhile, but **not the main event** — it defends against the outsider-with-physical-access,
not the operator (see [[threat-model]]). (See [[parking-system-architecture]] §3.)
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. This is a
[[standing-decisions|standing decision]].
- **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. (The custom controller adds its own: ESP32 flash encryption + secure boot — see
[[esp32-custom-controller]].)
+40
View File
@@ -0,0 +1,40 @@
---
type: concept
tags: [parking, architecture, readers]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Entry / Exit Readers
There are **two populations** of users, and they map to **two integration paths**. (See
[[parking-system-architecture]] §8.)
- **Permit holders / subscribers** — want hands-free/quick entry. Best served by reads reaching
the **controller directly** ([[wiegand]]) so it can decide autonomously (works if host is down).
- **Casual / transient** — printed ticket, pay-on-exit, or plate recognition. Inherently
**host-side** identity sources ([[lpr-camera]], QR/ticket scanner).
## How reads reach the system
| Reader type | Who sees the read | Decision by | Offline autonomy |
| --- | --- | --- | --- |
| [[wiegand]] reader → UHPPOTE port | The controller | Controller (onboard card list) | **Yes** — works if host down |
| Pure TCP/IP reader | Host only | Host, then UDP `open` to relay | No — host on critical path |
| [[lpr-camera|LPR]] / QR scanner | Host only | Host | No |
## Key points
- **Pure network readers are invisible to the [[uhppote-controller]]** — it only generates events
for its own terminals. For a pure-TCP reader, only the host can listen/decide/command; the
controller is demoted to a commanded relay (onboard card DB + offline autonomy bypassed).
- **Check for a Wiegand output first** — many "network" readers have both; wiring Wiegand in
keeps autonomy + native event log.
- **Both models can share one relay** (valid Wiegand read **or** host `open` in "controlled"
mode), so one lane serves permit + casual.
- **Host-in-the-loop is good for fraud detection** — two independent records (host's signed
[[append-only-event-chain]] entry + the UHPPOTE remote-open event) should reconcile 1:1; any
mismatch is an anomaly.
Autonomy caveat: with remote-host control enabled, the controller expects host comms every ~30 s
or reverts to local control (Wiegand-on-board lanes only).
+26
View File
@@ -0,0 +1,26 @@
---
type: concept
tags: [parking, security, access-control, integrity]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Event-Log Ingestion (making the UHPPOTE log trustworthy)
The host-side discipline that turns the [[uhppote-controller]]'s log — undermined by the
[[uhppote-udp-protocol]] — into a solid detection/audit layer. (See
[[parking-system-architecture]] §6.)
- **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 (`set-event-index`).
- Walk **absolute** indices with `get-event <id>`. Treat three things as **alarms**:
1. a **gap** in the sequence,
2. an **"event has been overwritten" error** (you fell behind — data loss),
3. 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-event-chain]].
Net result: **tamper-evident, behind [[network-isolation]]** — a solid detection layer, but not
tamper-proof. Prevention requires the [[esp32-custom-controller]].
+21
View File
@@ -0,0 +1,21 @@
---
type: concept
tags: [parking, safety, devices]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Fail-State & Safety (custom controller)
For the [[esp32-custom-controller]], fail-state behaviour is to be treated **as seriously as the
crypto**. (See [[parking-system-architecture]] §7.)
- **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
([[barrier-not-a-door]]).
The general "fail-open on exit" principle is also an [[open-questions|open question]] (#2) for
the system as a whole, not just the custom controller.
+22
View File
@@ -0,0 +1,22 @@
---
type: concept
tags: [parking, security, network, access-control]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Network Isolation
**Mandatory** mitigation for the unauthenticated [[uhppote-udp-protocol]]: because the
[[uhppote-controller]] can't authenticate, the **network must be the security boundary** (the
[[trust-boundary]] = the network). (See [[parking-system-architecture]] §6.)
- Control devices go on **their own VLAN** with **no route** to the booth/office network and
**no wireless bridge**.
- Requires a **managed VLAN switch** (in the [[bom]]).
- Only when *only the host* can reach the controller does the controller's event log become a
trustworthy audit source (combined with [[event-log-ingestion]] + the
[[append-only-event-chain]]).
This makes the UHPPOTE setup **tamper-evident behind isolation** — but never tamper-*proof*;
that requires the [[esp32-custom-controller]].
+31
View File
@@ -0,0 +1,31 @@
---
type: concept
tags: [parking, constraint, foundational]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Offline-First
One of the **two foundational forces** shaping the whole system (the other is the
[[threat-model]]). (See [[parking-system-architecture]] §1.)
A park may have **no internet, intermittent connectivity, or be fully air-gapped**. **Nothing
in the core operation may depend on a network being present.**
## What it forces
- **Local auth** — no external identity provider; hence [[local-jwt-auth]] and the rejection of
[[logto-zitadel-oidc]].
- **Local database** — [[sqlite]] on-site; remote PostgreSQL is a *deferred*, optional sync
target, never a runtime dependency.
- **Autonomous device decisions** where possible — [[wiegand]]-into-controller lets the
[[uhppote-controller]] decide even if the host is down; [[lpr-camera]] uses edge AI so
recognition runs with no internet.
## What it does NOT mean
Offline-first does **not** mean "no [[reconciliation]]." It means **deferred, intermittent**
reconciliation — a manager's weekly USB stick, a daily phone hotspot, a monthly export. Only
design for "never, by anyone" if that's genuinely true (see [[reconciliation]] for the
network-free fallback controls).
+29
View File
@@ -0,0 +1,29 @@
---
type: concept
tags: [parking, security, anti-fraud, offline-first]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Reconciliation
**The real anti-fraud control.** Comparing local records against **an authority the operator
cannot alter**. This is what remote sync *really is* — a fraud-control mechanism, not just a
backup. (See [[parking-system-architecture]] §3.)
## Reconciliation when offline
[[offline-first]] does not mean no reconciliation — it means **deferred, intermittent**: a
manager visiting weekly with a USB stick, a phone hotspot once a day, a monthly export. Any of
these provides a path to compare local records against something outside the operator's reach.
## If it truly is "never, by anyone"
Only design for that if it's genuinely true. The network-free fallback controls are:
- the signed, hash-chained log ([[append-only-event-chain]]),
- physically **pre-numbered ticket stock**,
- **end-of-shift signed Z-reports**,
- **CCTV/LPR footage** as an independent record (see [[lpr-camera]]).
Establishing *some* periodic reconciliation channel is [[open-questions]] #4.
+37
View File
@@ -0,0 +1,37 @@
---
type: concept
tags: [parking, security, foundational]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Threat Model
The **second foundational force** (with [[offline-first]]). The central insight is a
**reframing of who the adversary is**. (See [[parking-system-architecture]] §3.)
## The key reframing
Early thinking focused on protecting the database **at rest** — SQLCipher, LUKS, 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**. The most likely adversary is the **legitimate operator at
the booth**. While the app runs, 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/delete the entry/exit record so the books balance.**
## Consequences
The controls that actually address insider/operator fraud are different in kind:
- **[[append-only-event-chain]]** — events appended, never edited/deleted; a "void" is itself a
recorded event, hash-chained, and **[[atecc608]]-signed** (unforgeable).
- **[[reconciliation]]** against an authority the operator can't alter — *this is what remote
sync really is*: a fraud-control mechanism, not just a backup.
- **[[disk-os-hardening]]** still worthwhile (defeats boot-from-USB) but **not the main event**;
with LUKS in place, SQLCipher is optional defence-in-depth.
The same reframing recurs at the device layer: the [[uhppote-controller]]'s real problem is
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
+20
View File
@@ -0,0 +1,20 @@
---
type: concept
tags: [parking, architecture, security, decision]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# The Core Fork: Where Is the Trust Boundary?
The pivotal device-layer decision. Two valid architectures, **chosen per deployment and mixable
per lane**. (See [[parking-system-architecture]] §5.)
| Trust boundary | Approach | Property |
| --- | --- | --- |
| **= the network** | Off-the-shelf controller ([[uhppote-controller]]/ZKTeco), contained by [[network-isolation]] | **Auditable** — tamper-evident, you don't own firmware |
| **= the device** | Custom controller whose firmware enforces auth ([[esp32-custom-controller]]) | **Unforgeable** — but you own the firmware |
This is the detection-vs-prevention choice. With the UHPPOTE path, trustworthiness comes from
[[event-log-ingestion]] + the [[append-only-event-chain]]. With the ESP32 path, it comes from
[[challenge-response-auth]]. See [[uhppote-vs-esp32]] for the head-to-head.
+38
View File
@@ -0,0 +1,38 @@
---
type: concept
tags: [parking, security, access-control, protocol]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# UHPPOTE UDP Protocol (the weakness)
The [[uhppote-controller]] 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 the barrier operator's, per
[[barrier-not-a-door]]). (See [[parking-system-architecture]] §6.)
**Mitigation: [[network-isolation]] is mandatory.** The security boundary is the network because
it cannot be the device.
## Why you can't fix it in firmware
The open-source `uhppoted` ecosystem is **protocol reverse-engineering only** — clients speaking
the existing UDP protocol. No source, SDK, schematic, or toolchain to build/flash custom
firmware. The controller accepts only the **manufacturer's official** firmware images. You
cannot configure or patch your way to authentication on this hardware.
## Unauthenticated commands that undermine the log
The record-level log is append-only, but these don't touch individual records:
| Vector | Command | Effect |
| --- | --- | --- |
| Blinding | `record-special-events false` | Stops logging door events going forward |
| Wipe | `restore-default-parameters` | Factory reset — clears config + event state |
| Rollover | (generate events / fall behind) | Finite circular buffer; old events overwritten |
| Time skew | `set-time` | Corrupts / backdates timestamps |
| Index desync | `set-event-index` | Moves the retrieval pointer; naive ingestion skips events |
The defensive response is [[event-log-ingestion]]. The preventive alternative is the
[[esp32-custom-controller]].
+31
View File
@@ -0,0 +1,31 @@
---
type: concept
tags: [parking, comparison, access-control, security]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# UHPPOTE vs. Custom ESP32 — Detection vs. Prevention
A head-to-head on the [[trust-boundary]] fork: the off-the-shelf [[uhppote-controller]] versus
the [[esp32-custom-controller]]. (Synthesized from [[parking-system-architecture]] §6–7.)
| | [[uhppote-controller]] | [[esp32-custom-controller]] |
| --- | --- | --- |
| **Trust boundary** | The network | The device |
| **Security posture** | Tamper-**evident** (detection) | Tamper-**proof** (prevention) |
| **Command auth** | None — [[uhppote-udp-protocol]] is open UDP | [[challenge-response-auth]] (asymmetric sigs) |
| **Key mitigation** | [[network-isolation]] (mandatory) + [[event-log-ingestion]] | [[atecc608]] holds non-extractable key; controller stores only a public key |
| **Firmware** | Manufacturer-only; not customizable | You own it (tiny + auditable) |
| **Cost / effort** | Cheap, off-the-shelf, available now | Build + firmware reliability, EMC/surge, field maintenance |
| **Replay/forgery on the wire** | Possible — contained only by isolation | Defeated by fresh per-command nonce |
| **Safety** | Barrier operator owns it ([[barrier-not-a-door]]) | Same + explicit [[fail-state-safety]] |
## Bottom line
- The UHPPOTE is the **current choice**: good enough as a detection/audit layer **when only the
host can reach it** (isolation) and every event lands in the [[append-only-event-chain]].
- The ESP32 is the **documented upgrade** when you need a control path that holds even against an
attacker on the wire. They're **mixable per lane**.
- Both still rely on host-side integrity ([[append-only-event-chain]]) and external
[[reconciliation]] as the ultimate anti-fraud control.
+30
View File
@@ -0,0 +1,30 @@
---
type: decision
tags: [parking, decisions, open]
sources: [parking-system-architecture]
updated: 2026-06-14
status: open
---
# Open Questions / Next Steps
**Not yet decided**, and they drive everything else — settle before procurement. (See
[[parking-system-architecture]] §10.)
1. **Lane topology.** One host per lane, or one central host driving networked devices in each
lane? 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. See
[[fail-state-safety]].
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
(see [[bom]]).
4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot,
manager visit) to reconcile the signed log against an external authority — the real anti-fraud
control. See [[reconciliation]].
5. **Durability / backup.** Backup strategy for the [[sqlite]] database + 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 — see
[[esp32-custom-controller]]).
+30
View File
@@ -0,0 +1,30 @@
---
type: decision
tags: [parking, decisions]
sources: [parking-system-architecture]
updated: 2026-06-14
status: settled
---
# Standing Decisions (settled)
The decisions treated as settled in the design notes. (See [[parking-system-architecture]]
"Summary of standing decisions".)
- **Stack:** [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · [[sqlite]] +
[[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — **no vendor lock, no rug-pull
risk** (see [[payload-cms]]). Full table in [[technology-stack]].
- **Platform:** a **dedicated, hardened Linux appliance** (LUKS + GRUB password + Secure Boot),
**not Windows/WSL** — see [[disk-os-hardening]].
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption
protects only at-rest (see [[threat-model]]).
- **Access control:** [[uhppote-controller]] for now, on an **isolated VLAN**
([[network-isolation]]); event log used as a tamper-evident audit source with host-side index
tracking ([[event-log-ingestion]]). The [[esp32-custom-controller]] is the documented
prevention-grade upgrade path (the [[trust-boundary]] fork).
- **Readers:** prefer [[wiegand]]-into-controller for permit holders (autonomous); host-in-the-loop
for [[lpr-camera|LPR]]/QR/pure-network readers; both can share a relay (see
[[entry-exit-readers]]).
Unsettled items live in [[open-questions]].
+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]].
+67
View File
@@ -0,0 +1,67 @@
---
type: overview
tags: [parking, index]
updated: 2026-06-14
---
# Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
## Overview & navigation
- [[overview]] — the top-level synthesis and entry point.
- [[index]] — this catalog.
- `log.md` — chronological record of ingests/queries/lints.
## Sources
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
## Entities — technology stack
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
- [[fastify]] — Node backend; hosts device-driver plugins + auth; serves the SPA.
- [[sqlite]] — local single-writer DB (WAL); limits & why it fits.
- [[drizzle-orm]] — ORM; schemas port to PostgreSQL for remote sync.
- [[turborepo]] — monorepo tool.
- [[react-vite-spa]] — React/Vite frontend served by Fastify.
- [[local-jwt-auth]] — fully local auth (JWT + bcrypt + role guard); forced by offline-first.
## Entities — rejected alternatives
- [[payload-cms]] — strong, but rejected over BSL license shift (the rug-pull cautionary case).
- [[refine]] — dropped for plain React; UI too simple to justify a framework.
- [[logto-zitadel-oidc]] — OIDC providers ruled out by offline-first.
## Entities — hardware & devices
- [[uhppote-controller]] — current access controller; cheap, tamper-evident, open-UDP, fixed firmware.
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
## Concepts — foundational forces
- [[offline-first]] — no network dependency in core operation; what it forces (and doesn't).
- [[threat-model]] — the operator-at-the-booth reframing; why encryption defends the wrong threat.
## Concepts — integrity & anti-fraud
- [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log.
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
- [[disk-os-hardening]] — LUKS/GRUB/Secure Boot; worthwhile but not the main event.
## Concepts — device architecture & safety
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
## Concepts — access control
- [[uhppote-udp-protocol]] — open UDP weakness + the unauthenticated commands that undermine the log.
- [[network-isolation]] — mandatory VLAN isolation; the network as security boundary.
- [[event-log-ingestion]] — host-side index tracking that makes the UHPPOTE log trustworthy.
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
## Decisions
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
- [[open-questions]] — 6 unsettled items that drive procurement.
+11
View File
@@ -0,0 +1,11 @@
# Wiki Log
Append-only chronological record. Each entry: `## [YYYY-MM-DD] <op> | <subject>`.
Query with `grep "^## \[" log.md | tail -5`.
## [2026-06-14] ingest | Parking System — Architecture & Design Notes
First source ingested. Bootstrapped wiki scaffolding (CLAUDE.md schema, index.md,
overview.md, log.md). Created source summary, 14 entity pages, 9 concept pages, and
decision records (settled decisions + 6 open questions). Source is a dense design
doc covering stack, threat model, device architecture, UHPPOTE access control, the
custom ESP32 controller alternative, readers, and a reference BOM.
+52
View File
@@ -0,0 +1,52 @@
---
type: overview
tags: [parking, overview, synthesis]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Parking System — Overview
The synthesis / entry point for this wiki. Start here, then follow links. Catalog of every page:
[[index]]. Source summaries: [[parking-system-architecture]].
## What it is
A **parking-management web application** running on a **dedicated, hardened Linux appliance**
deployed on-site at a parking facility. Two forces shape nearly every decision:
1. **[[offline-first]]** — a park may be air-gapped; nothing core may depend on a network.
2. **[[threat-model]]** — the primary adversary is the **legitimate operator at the booth**, not
an outsider. The classic fraud is *take the cash, delete the record.*
## The architecture in one pass
- **Stack** ([[technology-stack]] / [[standing-decisions]]): [[turborepo]] · [[fastify]] ·
[[react-vite-spa]] · [[sqlite]] + [[drizzle-orm]] · [[local-jwt-auth]] — all open-licensed to
avoid lock-in (cf. rejected [[payload-cms]], [[refine]], [[logto-zitadel-oidc]]).
- **Integrity** is the heart of it: an [[append-only-event-chain]] (hash-chained, [[atecc608]]-
signed) plus external [[reconciliation]] — *that's* what remote sync really is. Encryption at
rest ([[disk-os-hardening]]) defends a secondary threat.
- **Devices** sit behind a [[device-adapter-pattern]] (swap hardware → new adapter only), with
the [[barrier-not-a-door]] safety principle keeping physical safety in barrier-operator firmware.
- **Access control** hinges on the [[trust-boundary]] fork:
[[uhppote-vs-esp32|detection vs. prevention]]. Today: [[uhppote-controller]] behind
[[network-isolation]], its open [[uhppote-udp-protocol]] contained, its log made trustworthy by
[[event-log-ingestion]]. Upgrade path: the [[esp32-custom-controller]] with
[[challenge-response-auth]] and [[fail-state-safety]].
- **Readers** split two ways ([[entry-exit-readers]]): permit holders via [[wiegand]]
(autonomous), casual/transient via host-side [[lpr-camera]] / QR; both can share a relay.
- A reference [[bom]] lists recommended devices.
## Where it stands
6 [[open-questions]] still drive procurement — most critically **lane topology**, **failure
modes (fail-open on exit)**, the **reconciliation channel**, and **backup/durability**.
## Reading paths
- *Security-first:* [[threat-model]] → [[append-only-event-chain]] → [[reconciliation]] →
[[uhppote-vs-esp32]].
- *Hardware-first:* [[bom]] → [[uhppote-controller]] → [[entry-exit-readers]] →
[[esp32-custom-controller]].
- *Stack-first:* [[technology-stack]] → [[offline-first]] → [[device-adapter-pattern]].
+390
View File
@@ -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.
@@ -0,0 +1,51 @@
---
type: source
tags: [parking, architecture, source]
sources: [parking-system-architecture]
updated: 2026-06-14
---
# Source: Parking Management System — Architecture & Design Notes
A working design reference (not a final spec) recording architectural decisions, the
reasoning behind them, and rejected alternatives for a parking-management system. Several
items are explicitly open. Raw file: `raw/parking-system-architecture.md`.
## Key takeaways
- The system is a **web app on Linux, deployed on-site**. Two forces shape every decision:
[[offline-first]] operation and the **physical-security reality** of a machine in an exposed booth.
- **Stack** ([[technology-stack]]): [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] ·
[[sqlite]] + [[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — chosen for **no vendor
lock / no rug-pull risk** (the reason [[payload-cms]] was rejected).
- **The threat-model reframing** ([[threat-model]]): the primary adversary is the **legitimate
operator at the booth**, not an outsider stealing the machine. Encryption-at-rest defends the
wrong threat. The real controls are the [[append-only-event-chain]] (hash-chained,
[[atecc608]]-signed) plus [[reconciliation]] against an authority the operator can't alter.
- **Devices** go through a [[device-adapter-pattern]] so hardware swaps don't touch business logic.
Safety principle: [[barrier-not-a-door]] — physical safety lives in the barrier operator firmware.
- **Access control** today is the [[uhppote-controller]] on an isolated VLAN — tamper-*evident*,
not tamper-*proof*, because its [[uhppote-udp-protocol|UDP protocol]] is unauthenticated. The
[[esp32-custom-controller]] is documented as the prevention-grade upgrade (challenge–response
with asymmetric signatures).
- **Readers** split into two populations ([[entry-exit-readers]]): permit holders (best via
[[wiegand]] into the controller, autonomous) and casual/transient (host-side: [[lpr-camera]],
QR/ticket). Both can share one relay.
- A reference [[bom|BOM]] lists recommended devices; **6 open decisions** remain
([[open-questions]]) that drive procurement.
## Section map
| § | Topic | Wiki pages |
| --- | --- | --- |
| 1 | System context | [[offline-first]], [[threat-model]] |
| 2 | Technology stack | [[technology-stack]], [[fastify]], [[sqlite]], [[drizzle-orm]], [[turborepo]], [[react-vite-spa]], [[local-jwt-auth]], [[payload-cms]] |
| 3 | Data security & threat model | [[threat-model]], [[append-only-event-chain]], [[atecc608]], [[reconciliation]], [[disk-os-hardening]] |
| 4 | SQLite limits | [[sqlite]] |
| 5 | Device architecture | [[device-adapter-pattern]], [[barrier-not-a-door]], [[trust-boundary]] |
| 6 | UHPPOTE access control | [[uhppote-controller]], [[uhppote-udp-protocol]], [[network-isolation]], [[event-log-ingestion]] |
| 7 | Custom ESP32 controller | [[esp32-custom-controller]], [[challenge-response-auth]], [[atecc608]], [[fail-state-safety]] |
| 8 | Entry/exit readers | [[entry-exit-readers]], [[wiegand]], [[lpr-camera]] |
| 9 | Recommended devices (BOM) | [[bom]] |
| 10 | Open decisions / next steps | [[open-questions]] |
| — | Summary of standing decisions | [[standing-decisions]] |