Case Study
MnB Access & Video Platform
Offline-first access control and managed video for commercial buildings: local SQLite authorization, hardware-timed relock, a cloud that only projects.

A door in a commercial building has one requirement that outranks every other: a valid card has to open it whether or not the internet is up. I'm the sole engineer on the software behind MnB Security's managed access-control and video platform for small commercial buildings, and the idea I keep coming back to is that the cloud is a convenience, not an authority.
Each building runs a small Go site computer, and access decisions happen there, against a local SQLite database. The cloud (a React Router portal, a Hono device gateway, Postgres, NATS JetStream, MinIO) manages the sites and projects back whatever they report.
0
Inbound ports at the site
4
Doors on the validation bench
15
Cameras on the simulated site
The site is the source of truth
packages/site-core/CONTEXT.md states the rule: the site's SQLite database is the authoritative store for people, credentials, doors, and access levels; the cloud's Postgres view is a projection. When the internet drops, management degrades: no new cards until the link is back. Enforcement doesn't degrade at all, because nothing in the tap path leaves the building.
apps/web is the React Router v7 SSR portal; it queries Postgres directly through Drizzle and holds no NATS client. apps/site-gateway is the Hono service that talks to appliances and projects what they send into Postgres. Tenancy is in the schema: every site-scoped table in packages/db/src/schema/access.ts carries a composite (site_id, id) key, and child tables reference their parent on both columns, so a credential can't reference a person in another site.
Retiring the vendor controller
The first version of the access layer drove an undocumented vendor controller over TCP. It kept its own card database in flash, so the orchestrator spent its time reconciling drift instead of deciding anything. I replaced it with OSDP card readers on RS-485, a Modbus TCP relay and input module, and the Go site computer making every decision. Commodity relays and readers instead of proprietary controllers, at a fraction of the per-door cost, and no credential data on any edge module, so a failed module is a swap rather than a database restore.
The native stack landed on June 12 in packages/board-native: modbusio is the Modbus TCP client, osdpframe handles OSDP framing and checksums, osdpreader runs the poll loop, and NativeAdapter ties them together behind access.Adapter. Authorization is in packages/site-core/nativeauth/authorizer.go: a card number and door index are looked up against credentials, access_levels, and site_doors, and a lookup error denies rather than defaulting open. The vendor controller was retired on July 7.
- Card tap
- OSDP read
- SQLite check
- Modbus flash-on
- Relay relocks
I validated the design on a 4-door bench before committing to driver work, with pass and fail gates in docs/hardware/waveshare-bench.md: relock when the cable is pulled mid-pulse, every relay off after a power cycle, tap-to-unlock at or under 500 ms at p99, and a 72-hour soak.
Relock happens in hardware
The step I'm most careful about is the last one. NativeAdapter.handleTap writes one Modbus coil, the module's flash-on register, with the pulse duration in it, and the relay module times the unlock and relocks on its own. Nothing after that single write is needed for the door to relock.
Lock power, the fire-alarm release loop, and exit devices are wired outside software; the site computer sees a request-to-exit as a logging tap, not as the thing that opens the door. The simulator honours this too. nativesim's Modbus server re-closes a flashed relay on its own timer with no further command, so the 42 adapter tests and the full in-process tap test in packages/site-core/nativeintegration/e2e_test.go exercise the real semantics without a board on the desk.
Durable commands and live actions
A configuration change and a door-open request have opposite requirements. Adding a person should be delivered eventually and applied exactly once, even if the site was offline for a week. Opening a door should happen now or not at all; a queued open that fires an hour late is a security hole. The repo's glossaries insist on the distinction: a durable command can be replayed; a live door action never is.
Durable commands go through packages/site-commands into Postgres and a JetStream subject per site, delivered by the dispatcher in apps/site-gateway/src/sync/command-delivery.ts, which retries with exponential backoff and tracks delivery and on-site application as separate states, so the portal shows a change as queued until the building confirms it. Live door opens take a 3-second request/reply path instead. The web app never touches NATS; it POSTs to the gateway's /internal/live/open-door bridge with an Ed25519-signed JWT that expires within ten seconds. On the site, packages/site-core/livedoor claims a receipt before touching hardware, so the same request can't actuate twice, and if the adapter call fails after the claim the result is indeterminate rather than a guess.
- Portal action
- Signed JWT to gateway
- NATS request/reply
- Receipt claimed
- Flash-on or indeterminate

Rebuilding a site from its command log
Because durable commands are replayable and every configuration aggregate carries a cloud-assigned ID, a wiped or replaced appliance can rebuild itself. packages/site-core/siterecovery takes a recovery assignment from the cloud, replays the site's full command history through the normal executor with board writes suppressed, then reconciles the hardware once at the end. Live actions were never on that stream, so a replay can't pulse a door. A dead mini PC becomes a cold-spare swap, not an afternoon of re-entering people and cards.
Sync that survives outages and duplicates
Events flow the other way through transactional outboxes in SQLite. packages/site-core/sitesync/archive_worker.go groups access events and state changes into zstd-compressed archive batches, each with a manifest describing its sequence range and a SHA-256 of the data, and uploads them to a per-site MinIO prefix. Sequence numbers are qualified by a producer epoch, so a rebuilt appliance can't collide with its predecessor. The gateway's batch projector checks that each range fits what it has already committed and quarantines anything impossible instead of dropping it. Object storage is the path that has to work after a long outage, since a JetStream stream has bounded retention.
Device identity and least privilege
Gate A, July 14 to 17, replaced a shared site secret with real identities. An appliance now redeems a one-time activation token for a credential bundle sealed with HPKE (X25519, HKDF-SHA256, AES-256-GCM) in apps/site-gateway/src/security/bundle.ts and opened in packages/site-core/devicecred/hpke.go, and rotates through a challenge flow with a bounded overlap window. The cloud services run as three least-privilege identities with their own NATS NKeys (command dispatcher, live-action bridge, event projector), each site gets its own MinIO policy, NATS is TLS, MinIO is HTTPS with server-side encryption through KES, and the site reaches all of it through an outbound WireGuard tunnel, with no inbound ports.
The design is checked, not just described. tools/gate-a-conformance hashes infra/gate-a/topology-manifest.json as RFC 8785 canonical JSON and compares deployed NATS permissions, MinIO policies, and GitHub branch protection against it. In verify mode its probes attempt the writes each role should be denied, and the run fails if any succeed.
Managed Video
Managed Video, built September 2 to 6, reuses the same shape. apps/site-video-agent is a Go daemon beside a Frigate recorder that reports node liveness, recorder health, and per-camera health as three independent signals, so an agent outage is never displayed as a camera failure. The cloud derives offline status from server receipt time, not device clocks, and a background sweep appends exactly one offline transition per outage.

Live viewing is a chain of narrow authorizations. The web app approves a time-limited session for one user, one camera, one profile. apps/video-relay, about 300 lines of Go, claims that session, dials the site over WireGuard, and copies bytes without decoding or storing them. The site agent's edgegrant.go verifies a single-use Ed25519 grant bound to node, session, camera, profile, and expiry before it reaches go2rtc.
To test it I built a simulated site: a real Frigate 0.17.2 recorder plus 15 ONVIF camera simulators, each with its own IP and credentials and capped at 128 MiB and 0.25 CPU, commissioned through WS-Discovery and authenticated ONVIF calls by infra/simulated-video/onvif-commission.py. Recording continued through a 90-second cloud outage, with segments climbing from 28 to 37 while the agent's uploads failed. The rig also found a real bug: Chromium's native player retried with Range requests, which the single-use session correctly rejected. The player now makes one authenticated fetch into MediaSource, and verify-browser.mjs checks advancing decoded frames on every camera, exactly one media request, and a 401 when an ended session is reused.

The release gate
Delivery is seven services as rootless Podman Quadlet containers under systemd behind Caddy, with tagged rollback images for each release. CI is four required jobs in .github/workflows/gate-a.yml with SHA-pinned actions: unit and static checks; gitleaks plus govulncheck; tests against real TLS NATS, HTTPS MinIO with KES, and Postgres using a CA the repo generates; and Trivy scans of the container images. The Go side is 289 tests in 56 files (site-core 121, video-agent 59, board-native 42, gate-a 32, orchestrator 28); the TypeScript side is around 300 test cases in 77 files, including integration tests against a disposable Postgres, and the portal runs Playwright at six widths plus axe. Each of the six subsystems has a glossary and an invariants file. It came to 189 commits between April 17 and September 7, solo.
What I'd revisit
The bench topology is not the field product. It proves the software and safety model with three networked devices in the cabinet, and installer feedback was direct: too many network interfaces. ADR-0004 proposes an integrated cabinet with a custom USB I/O controller and one network port, keeping the same authorization and relock invariants. The Go driver is TCP-oriented today and needs a transport seam before that controller can ship.
The native adapter and the SQLite door table both cap a controller at four doors, and docs/hardware/eight-door-assessment.md lays out how an eight-door, two-hub site would need the adapter, contract, registry, and database limits extended together. I'd rather lift those limits once the hub layout is chosen than guess at an eight-bit mask now.
OSDP is plaintext on the bench. Secure Channel is a prerequisite for any field design, and it's the piece of the reader path I'd build next.
Keep going
Want the parts I left out?
There's more behind every decision here than fits on a page. If something caught your attention, I'm happy to walk through it.