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
+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.