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