Multi-Tenant RFID Attendance & Presence Platform
A two-board ESP32 system that reads RFID cards, captures a photo per event, and syncs to a multi-tenant Supabase backend — built to demonstrate how embedded firmware, cloud architecture, and SaaS billing come together into one deployable, sellable product.
An RFID tap-in/tap-out attendance logger with photo capture, offline queueing, QR-code device provisioning, and a multi-tenant web dashboard — built solo, currently deployed to production on a real VPS, and now brought through a formal security-hardening and documentation pass ahead of its first tagged release.
- Dashboard: https://gateman.89.167.93.25.sslip.io (static file, Caddy on Hetzner VPS
89.167.93.25— seedocs/WIKI.md§8.3 for the deploy procedure) - Hardware wiring physically verified (continuity-tested wire by wire):
docs/WIRING.md— pinouts, wire colours, circuit diagram, power topology, rebuild checklist. Circuit diagram images inpublic/ - Full pipeline proven: RFID tap → user lookup → CAM photo → Supabase insert → photo in Storage → live dashboard feed
- Observability added: dashboard shows a global health banner (subscription blocking / device offline), per-device last-seen, unknown-card alerts, and a rejection log fed by
submit-logaudit events — added after a trial-expiry silently rejected all device logs for 4 months - Server-side queue self-cleaning:
submit-logaccepts-and-discards stale or unparseable timestamps (auditable) so devices purge their own offline queues — seedocs/WIKI.md§3.2 and thetimestampToISOfirmware bug documented in §9
| Role | Founder & Sole Engineer (firmware, backend, dashboard, security, documentation) |
| Duration | February 2026 – present (ongoing) |
| Team Size | 1 (solo) |
| Type | Independent commercial product (not academic) |
| Focus | Embedded IoT + Multi-Tenant SaaS |
git log traces the tracked history back to 2026-02-27 (the Supabase-backed rewrite); no earlier version-controlled history exists for this repository, so that date marks the start of this architecture, not necessarily the first line of code ever written for the idea.
- ✓ Solo-built, end-to-end: firmware, multi-tenant backend, billing, dashboard, security review, documentation
- ✓ Two-board ESP32 firmware (WROOM + CAM) with an offline-tolerant sync queue
- ✓ Multi-tenant Postgres schema with Row Level Security on every table
- ✓ QR-code device provisioning with single-use, time-limited tokens
- ✓ Paystack subscription billing with signature-verified, idempotent webhooks
- ✓ Deployed to production on a real VPS, serving a real customer site
- ✓ Found and fixed three critical/moderate cross-tenant security vulnerabilities as part of preparing this v1.0.0 release
- ✓ 17-document engineering documentation set, every claim traced to source
TODO:this section is intentionally left for the actual story — the specific need, the specific frustration, the specific moment this became worth building. Everything else in this README is verifiable against the code orgit log; personal motivation isn't, so it isn't invented here.
Manual attendance and access logging — paper sign-in sheets, standalone card readers with no central record, or enterprise access-control platforms priced and scoped for large facilities — leaves small and mid-sized organizations without a reliable, affordable attendance record:
- Paper and standalone readers don't produce queryable data. No dashboard, no export, no photo record for accountability.
- Enterprise access-control platforms are priced for enterprises. SMEs that outgrew a sign-in sheet aren't the target market for a Verkada-scale deployment.
- Connectivity isn't always guaranteed. A system that only works with a live internet connection fails exactly when a small site's WiFi is flakiest.
flowchart LR
A[Tap RFID card] --> B[Capture photo<br/>on-device]
A --> C[Queue locally<br/>if offline]
C --> D[Sync to cloud<br/>when connected]
B --> D
D --> E[Live dashboard<br/>+ multi-tenant billing]
Two ESP32 boards per site read the card, capture a photo, and queue the event locally if the network is down — syncing automatically once connectivity returns. A multi-tenant Supabase backend turns that stream of events into a live dashboard, CSV exports, and subscription billing, so one deployable hardware+software product serves many independent customer organizations from one codebase.
Firmware changes are gated behind physical hardware acceptance testing (see docs/TESTING.md) — a change that looks obviously correct in code review still doesn't ship to the fleet without that validation gate. This governed the entire v1.0.0 hardening pass: security fixes that touched dashboard-only code shipped immediately; a known plaintext-secret issue that touches the device authentication path was deliberately deferred rather than bundled in. See docs/SECURITY.md.
Anything that bypasses Postgres Row Level Security — a SECURITY DEFINER function, a service-role Edge Function — gets the same scrutiny RLS would have provided automatically. This principle exists because of a real bug, not as a theoretical ideal: one RPC was written outside the pattern the others used and shipped without the membership check every other RPC had. See docs/SECURITY.md fix #2 and docs/LESSONS_LEARNED.md §5.
The two-board split exists because of real GPIO constraints on the ESP32-CAM, not novelty — and any future hardware consolidation is scoped around off-the-shelf modules (e.g. a single ESP32-S3 camera board), not a custom PCB, until volume justifies the tooling cost. See docs/ROADMAP.md.
Every real limitation in this system — the disabled hardware watchdog, the lack of OTA updates, a confirmed enrollment-photo bug, plaintext device secrets — is written down in docs/SECURITY.md and docs/FIRMWARE.md rather than glossed over. Anything that couldn't be verified against the actual source is marked TODO: Needs verification instead of guessed, across every document in docs/.
Tap-in/tap-out events with duplicate-tap suppression and idempotent sync — a retried upload can never create a duplicate record. Details →
A second ESP32 board captures a photo per event; if it's unreachable, the event still logs without blocking the primary flow. Details →
Both boards queue locally (SPIFFS / SD card) and sync automatically once connectivity returns. Details →
A single-use, 10-minute token generated from the dashboard, scanned by the device's own captive portal — no manual credential entry beyond WiFi. Details →
Live attendance feed (Supabase Realtime), employee management, remote card enrollment, CSV export, device management — one dashboard serving many organizations with Postgres RLS enforcing isolation. Details →
Tiered plans (Starter/Growth/Enterprise) with device and user limits, enforced server-side, billed through Paystack with signature-verified, idempotent webhooks. Details →
flowchart TB
subgraph Site["Customer Site"]
RFID["MFRC522 RFID Reader"] -->|SPI| Brain["ESP32-WROOM \"Brain\""]
Brain <-->|UART| Cam["ESP32-CAM \"Slave\""]
end
Brain -->|HTTPS + device_secret| EdgeFn["Supabase Edge Functions"]
Dashboard["Dashboard (single-file JS)"] -->|HTTPS + user JWT| EdgeFn
Dashboard <-->|Realtime| DB[("Postgres + RLS")]
EdgeFn --> DB
EdgeFn --> Storage["Storage: attendance photos"]
Paystack["Paystack"] <-->|webhook + verify| EdgeFn
Full detail, including why each piece is shaped the way it is: docs/ARCHITECTURE.md and docs/DESIGN_DECISIONS.md.
- There is no relay, lock, or door-strike anywhere in the firmware — despite the product's "access control" framing, v1.0.0 is a presence/attendance logger, not a physical access system.
- Device authentication (
device_uid+device_secret) lives in the HTTPS request body, not the transport-layerAuthorizationheader — that header only carries the shared public anon key to satisfy the Supabase gateway. Seedocs/DESIGN_DECISIONS.mdADR-4. - Both firmware images run a single-threaded, blocking control loop — RFID scanning, camera round-trips, and HTTP calls all share one loop with multi-second timeouts.
- The dashboard is one static HTML file with no build step or framework — trivial to deploy, at the cost of no component structure as the UI grows. See ADR-3.
| Component | Purpose |
|---|---|
| ESP32-WROOM-32 ("Brain") | WiFi, RFID reading, HTTPS calls to Supabase, NVS credential storage |
| ESP32-CAM w/ OV2640 ("Slave") | Photo capture, SD-card offline queue — no network stack |
| MFRC522 RFID reader | Card UID reads over SPI |
| MicroSD card | Local photo + attendance-log storage on the CAM board |
No relay/lock hardware exists yet (see Architecture Notes above). Full pinout tables, UART wiring between the two boards, and a partial BOM (with explicit TODO markers where component specs aren't recoverable from the repo — no invented part numbers or prices): docs/HARDWARE.md.
The Brain firmware runs three mutually-exclusive modes:
stateDiagram-v2
[*] --> Provisioning: no device_secret in NVS
Provisioning --> Normal: device-provision succeeds
Normal --> EnrollMode: button held 2s, or admin-initiated via dashboard
EnrollMode --> Normal: card tapped, or 60s timeout
Normal --> Normal: RFID scan / duplicate check / sync loop
- Provisioning mode: open WiFi AP + captive portal, QR/token entry.
docs/PROVISIONING.md - Normal mode: RFID scan → duplicate-tap check → CAM photo capture → local queue → periodic sync.
docs/FIRMWARE.md - Enroll mode: rapid LED pattern, waits up to 60s for a card tap to link it to an employee.
docs/FIRMWARE.md
Backend: 13 Deno Edge Functions on Supabase, grouped by caller (firmware-facing, dashboard-facing, webhook, unused/orphaned). Full contracts: docs/EDGE_FUNCTIONS.md and docs/API.md.
- A debugging workaround that removed authentication shipped to production and stayed there for months. Found and fixed as part of preparing this release. See
docs/SECURITY.mdanddocs/LESSONS_LEARNED.md§1. - Three provisioning UX designs were built within 48 hours, two of them discarded — leaving orphaned Edge Functions and a database table that had to be inventoried, not silently deleted, before cleanup. See
docs/PROVISIONING.mdanddocs/CLEANUP_REPORT.md. - A cross-tenant RPC bypassed Row Level Security by design (
SECURITY DEFINER) and simply never got the same membership check every other RPC had — a real gap in an otherwise sound multi-tenant model. Seedocs/SECURITY.mdfix #2. - Splitting camera and RFID/WiFi across two physical boards to work around ESP32-CAM's limited free GPIOs, accepting an informal, unversioned UART protocol between them as the cost. See
docs/DESIGN_DECISIONS.mdADR-1.
As the sole engineer on this project, direct responsibilities across every layer:
- System architecture — designed the two-board firmware split, the Supabase multi-tenant backend, and the migration away from an earlier Node.js/SQLite backend
- Embedded firmware — wrote both ESP32 firmware images (RFID/WiFi "Brain", camera "Slave"), the UART bridge protocol between them, and the WiFi captive-portal provisioning flow
- Backend engineering — built all 13 Supabase Edge Functions, the Postgres schema with Row Level Security, and the Paystack billing integration
- Dashboard — built the multi-tenant web dashboard (employee management, live feed, enrollment, billing, device management)
- Security remediation — audited every authentication path, RPC, and RLS policy; found and fixed three cross-tenant vulnerabilities ahead of the v1.0.0 release
- Documentation — produced the full 17-document engineering reference set in
docs/, every claim traced to source rather than asserted
Firmware
- C++ (Arduino framework)
- ESP32-WROOM-32, ESP32-CAM
- MFRC522, ArduinoJson, esp_camera
Backend
- Supabase (Postgres, Row Level Security, Auth, Storage, Realtime)
- Deno Edge Functions (TypeScript)
Dashboard
- Vanilla JavaScript, single HTML file, no build step
- Supabase JS client, Chart.js, QR code generation
Billing
- Paystack (subscriptions, HMAC-signature-verified webhooks)
Hosting
- Hetzner VPS + Caddy (dashboard static files, auto-TLS)
- Supabase-hosted backend (not self-hosted)
gateman/
├── README.md The only markdown file at repo root, by design
├── firmware/
│ ├── wroom_brain/ ESP32-WROOM firmware ("Brain")
│ └── esp32cam_slave/ ESP32-CAM firmware ("Slave")
├── supabase/
│ ├── functions/ 13 Deno Edge Functions
│ └── migrations/ Postgres schema migrations
├── dashboard/ Single-file web dashboard
└── docs/ Every other document lives here — current reference
set AND historical/working documents side by side.
See docs/PROJECT_STRUCTURE.md for the map and
docs/CLEANUP_REPORT.md for what's current vs. superseded.
Every markdown file in this repository lives in docs/ except this README — the one standard exception, since GitHub only renders a repo's landing page from a root-level README.md. See docs/PROJECT_STRUCTURE.md for what's in there.
Start here: docs/ARCHITECTURE.md → docs/SECURITY.md → docs/ROADMAP.md.
| Category | Documents |
|---|---|
| Engineering reference | ARCHITECTURE.md · FIRMWARE.md · HARDWARE.md · DATABASE.md · EDGE_FUNCTIONS.md · API.md · DESIGN_DECISIONS.md |
| Operating the system | INSTALLATION.md · PROVISIONING.md · DEPLOYMENT.md · TESTING.md · SECURITY.md · WIRING.md (physically verified 2026-07-18) |
| Release & planning | CHANGELOG.md · RELEASE_NOTES_v1.0.0.md · VERSIONING.md · ROADMAP.md · CLEANUP_REPORT.md |
| Business & retrospective | BUSINESS_MODEL.md · LESSONS_LEARNED.md · PROJECT_STRUCTURE.md |
Historical / superseded (kept for reference, see CLEANUP_REPORT.md) |
plan.md · audit.md · FIX.MD · homepage.md · deployment_guide.md · AUTO_DISCOVERY_PROVISIONING.md · SIMPLIFIED_PROVISIONING.md · FINAL_PROVISIONING_FLOW.md · ESP32-WROOM BRAIN FIRMWARE.md · ESP32-CAM SLAVE FIRMWARE.md · deploy_quick_commands.md |
| Still-current operational docs (updated 2026-07-19) | WIKI.md (deep technical reference incl. current VPS deployment + submit-log contract) · VPS_RECOVERY_GUIDE.md (server rebuild; header notes current IP 89.167.93.25) |
Full map with per-file status: docs/PROJECT_STRUCTURE.md.
- Tap a card. The Brain reads the RFID UID and checks it against a 5-second duplicate-tap window.
- Capture a photo. The Brain asks the CAM board to capture; if it's unreachable after two attempts, the event proceeds without a photo rather than blocking.
- Queue and sync. The event is queued locally and synced to Supabase, idempotently — a retried sync can never create a duplicate record.
- Dashboard updates live. Supabase Realtime pushes the new event to any connected dashboard session immediately.
- Billing and limits enforce themselves. Every write is scoped to the device's organization; subscription tier gates device count, user count, and retention automatically.
Full sequence diagrams: docs/ARCHITECTURE.md.
| Capability | Status |
|---|---|
| RFID attendance logging + photo capture | Completed |
| Offline queueing + sync | Completed |
| Multi-tenant dashboard + RLS | Completed |
| QR-code device provisioning | Completed |
| Paystack subscription billing | Completed |
| Critical security review + fixes | Completed (this release) |
| Full documentation set | Completed (this release) |
| Device-secret hashing | Planned — Phase 4.1, next |
| Hardware watchdog re-enablement | Planned |
| OTA firmware updates | Planned |
| Firmware version reporting | Planned |
| Automated test suite | Planned |
| Single-board hardware consolidation (V2) | Under consideration, not designed |
| Multi-vertical expansion (hotel access, etc.) | Under consideration, not designed |
Full detail and what's explicitly not being pursued right now: docs/ROADMAP.md.
Dates below come directly from git log — this is the tracked history of the current Supabase-based architecture, not necessarily the first line of code ever written for the idea (no earlier version-controlled history exists in this repository).
flowchart LR
A["Feb 2026<br/>Supabase rewrite begins"] --> B["Firmware + dashboard +<br/>provisioning iteration"]
B --> C["Jun 2026<br/>VPS rebuild / v2.0 tag"]
C --> D["Jul 2026<br/>Security review +<br/>full documentation pass"]
D --> E["v1.0.0<br/>pending hardware validation"]
Full write-up: docs/LESSONS_LEARNED.md. In short: the individual pieces here — RFID reading, RLS, webhook verification — are each well-understood problems; the actual engineering challenge was keeping a solo-maintained, multi-tenant, hardware-connected system coherent across a real architecture migration and multiple provisioning-flow rewrites, without losing track of what was live versus abandoned.
TODO:no product/hardware photos exist yet. Physical hardware is in hand; photos and a demo video are planned post-validation — seedocs/ROADMAP.md.
# Dashboard (local dev)
npm install && npm start # http://localhost:3000
# Firmware: flash esp32cam_slave first, then wroom_brainFull library list, wiring, and provisioning steps: docs/INSTALLATION.md. Bringing a device online after flashing: docs/PROVISIONING.md.
TODO:no license has been chosen yet — a deliberate open decision, not an oversight. Gateman is moving toward a commercial hardware+subscription business (seedocs/BUSINESS_MODEL.md), which has real implications for whether and how the source should be publicly licensed versus kept proprietary.