Initial scaffold: Turborepo monorepo + design wiki
Turborepo (pnpm workspaces) with all dependencies pinned to latest mutually-compatible versions: turbo 2.9, TypeScript 6, Fastify 5, React 19, Vite 8, better-sqlite3 12 + Drizzle ORM 0.45. Layout: - apps/server Fastify backend (local JWT auth + role guard, /health) - apps/web React 19 + Vite 8 operator SPA - packages/db Drizzle schema on SQLite/WAL; append-only events + users - packages/devices reader/printer/relay adapter interfaces (intent-only relay) - packages/shared shared domain types Architecture constraints from the design wiki are encoded in the scaffold: append-only hash-chained + signed event log, device-agnostic adapters, "a barrier is not a door" (relay expresses intent only), fully-local offline-first auth. wiki/ is an LLM-maintained Obsidian knowledge base (28 pages) ingested from the architecture & design notes, with its own maintenance schema. Verified: pnpm install, full turbo build (5/5), server boots and serves /health, drizzle-kit generates the initial migration.
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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]]).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]].)
|
||||
@@ -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).
|
||||
@@ -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]].
|
||||
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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]]).
|
||||
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user