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:
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
Vendored
+33
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]]).
|
||||
@@ -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]].
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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]].
|
||||
@@ -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]].
|
||||
@@ -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]].
|
||||
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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]] |
|
||||
Reference in New Issue
Block a user