Low Energy Bluetooth QR Code Proximity Validator — prove a person's phone is physically on premises before allowing a QR-code action, using a BLE link between the phone and an organization-controlled host machine, with a validation server deciding and logging every attempt.
Proxies has grown past the QR product into its intended shape: a real-time sensing platform where presence is the first stream among many — the same pipeline (enrolled endpoints → signed readings → time-series store → rules → alerts → dashboards) that agriculture, waste management, factory, and water deployments need. The platform slice is live and smoke-tested end to end (sensor enrollment → signed batches → Timescale hypertable → rule firing → alert → Grafana). The living plan is docs/ROADMAP.md, updated in every PR that changes status.
mobile (BLE central) sensors / gateways
│ nonce + signed envelope │ signed telemetry batches
▼ ▼
host (Electron, BLE peripheral) HTTPS POST /telemetry or MQTT proxies/telemetry/<id>
│ measures RSSI (median), │
│ counter-signs + relays │
▼ ▼
server (Express + zod, TypeScript) — one transport-agnostic ingest pipeline
│ enrollment (Ed25519) · single-use nonces · host attestation ·
│ same-network proof · assurance tiers · threshold rules → alerts
▼
TimescaleDB (hypertable telemetry + audit tables) ──► Grafana dashboard
The whole codebase is TypeScript (strict), Apache-2.0 licensed, and CI-checked
(lint, typecheck, tests against Timescale-enabled Postgres, npm audit) on
every push. deploy/ boots the whole stack with one docker compose up.
| Feature | Status |
|---|---|
| BLE proximity link (host advertises, phone submits metrics, verdict notified back) | Implemented — hardware smoke test pending |
| Threshold validation with strict input validation (no bypass-by-omission) | Implemented, unit-tested |
| Validation audit logging to Postgres | Implemented (DATABASE_URL) |
| Device enrollment: per-device Ed25519 keys, one-time codes, signed requests | Implemented (P1.2) |
| Host attestation: envelopes must cross an enrolled host's radio; host-measured RSSI is authoritative when present | Implemented (P1.4) |
| Same-network proof: host-signed token served on its LAN-only listener | Implemented (P1.7) |
| QR scanning gated on validation: single-use session QR on the host, redeemed once | Implemented (P1.8) |
| Assurance tiers (A radio-measured · B same-network · C relay-only) with per-site minimum policy and per-site thresholds | Implemented (P1.11) |
| Structured error codes on every denial, logged with the achieved tier | Implemented (P1.11) |
| Telemetry platform: canonical signed envelope, Timescale hypertable storage, org/site/device attribution | Implemented (P2.1/P2.4) — live-verified |
| Sensor/gateway enrollment (site-bound) + HTTPS batch ingest with seq replay protection | Implemented (P2.9) |
MQTT transport sharing the same ingest pipeline (proxies/telemetry/<id> + acks) |
Implemented (P2.2/P2.3 v1) |
| Threshold rules → persisted alerts with pluggable delivery (webhook/console) | Implemented (P2.5 v1) |
Fleet health (/admin/fleet): last-seen, battery, staleness for every device and host |
Implemented (P2.6) |
| Presence as telemetry — validations land in the same stream as sensor data | Implemented (P2.10) |
| One-box deploy: Timescale + server + mosquitto + provisioned Grafana dashboard | Implemented (P2.8) — first boot verified |
Vertical kits (agriculture · waste · factory · water): metric catalogs, labeled rule packs, apply-kit, simulator CLI, templated dashboard |
Implemented (P3.0) |
| Waste pilot (chosen vertical): provisioning CLI, collection-route endpoint, field runbook (docs/PILOTS/waste.md) | Activation ready (P3.W1); field deployment next |
| Waste bin-node reference firmware (firmware/waste-bin): ESP32, full signed envelope, bench mode, CI-compiled | Implemented (P3.W2 ◐); hardware + field install remain |
| Gateway hardware, BLE payload crypto, retention/aggregates, SMS/WhatsApp | Remaining — see roadmap |
server/ Validation + telemetry platform — Express, zod, pg, mqtt, vitest
host/ Desktop host — Electron, @stoprocent/bleno (BLE peripheral)
mobile/ Phone app — Capacitor + Vite (BLE central)
shared/ Cross-component constants (BLE service/characteristic UUIDs)
deploy/ One-box Docker Compose: Timescale, server, mosquitto, Grafana
docs/ ROADMAP.md (living plan) · TELEMETRY.md (canonical envelope)
Node.js ≥ 20 everywhere.
cd server
npm install
cp .env.example .env # adjust thresholds / site coords / DATABASE_URL
npm run dev # or: npm run build && npm start
npm testTo persist validation logs, create a Postgres database (≥ 13), set
DATABASE_URL in .env, and run npm run db:migrate — migrations live in
server/migrations and are applied in order, tracked in
schema_migrations.
Validation requires an enrolled device and an enrolled host: the server
only accepts envelopes wrapped in a host's signed attestation, so a validation
that never crossed an organization-controlled radio is rejected outright. With
ADMIN_TOKEN set, bootstrap in order — user, site, host, device:
curl -s -X POST localhost:3000/admin/users -H "x-admin-token: $ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{"organizationName":"Acme","email":"someone@acme.test","displayName":"Someone"}'curl -s -X POST localhost:3000/admin/sites -H "x-admin-token: $ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{"organizationName":"Acme","name":"HQ","latitude":6.5244,"longitude":3.3792}'curl -s -X POST localhost:3000/admin/hosts -H "x-admin-token: $ADMIN_TOKEN" \
-H 'content-type: application/json' -d '{"siteId":1,"name":"front-desk"}'Start the host once with HOST_ENROLLMENT_CODE=<code> in host/.env — it
generates its keypair, enrolls, and stores its identity in the Electron user
data directory; the code is single-use and can be removed afterwards.
curl -s -X POST localhost:3000/admin/devices -H "x-admin-token: $ADMIN_TOKEN" \
-H 'content-type: application/json' -d '{"userEmail":"someone@acme.test"}'The response contains a 24-hour, single-use enrollmentCode. In the mobile
app, open Enrollment, point it at the server's LAN URL, and enter the code
— the phone generates a non-extractable keypair and registers its public key.
From then on every validation is a three-step protocol: the phone requests a
single-use nonce (signed request, ±5 min timestamp window), collects a
same-network token from the host's LAN-only listener (advertised over BLE;
reachability of a LAN-bound address is the network proof), and submits a
signed envelope {deviceId, nonce, lanToken?, signature, metrics} over BLE.
The host wraps it in its own attestation. Nonces expire after 2 minutes and
die on first use; an invalid LAN token is a hard failure while an absent one
is recorded and scored by policy. Unsigned requests are rejected (dev-only
escape hatch: ALLOW_UNSIGNED_VALIDATION=true with no database).
On approval the server mints a single-use QR session (2 min TTL); the host displays it, and the organization's scanning system redeems it exactly once:
curl -s -X POST localhost:3000/sessions/redeem -H "x-admin-token: $ADMIN_TOKEN" \
-H 'content-type: application/json' -d '{"sessionId":"<uuid from the QR>"}'cd host
npm install # builds bleno's native module; needs Xcode CLT on macOS
npm start # compiles TypeScript, then launches the tray appGrant the app Bluetooth permission when prompted. Configuration via host/.env:
SERVER_URL (default http://localhost:3000), HOST_NAME (advertised BLE
name), HOST_LAN_PORT (same-network token listener, default 47814),
HOST_ENROLLMENT_CODE (one-time, first start only).
BLE peripheral support in desktop Node is the known-weakest link (we use
@stoprocent/bleno, the actively maintained bleno fork); the roadmap moves the
radio to dedicated gateway hardware in Phase 2.
cd mobile
npm install
npm run dev # web preview of the UI (BLE needs a real device)To run on a device: npm run build, npx cap add android (or ios),
npx cap sync, then open the native project. Android requires the
BLUETOOTH_SCAN/BLUETOOTH_CONNECT and location permissions; iOS requires
NSBluetoothAlwaysUsageDescription.
deploy/ has a Docker Compose scaffold — Timescale-enabled Postgres, the server (migrations applied on boot), and Grafana pre-provisioned with the Proxies Overview dashboard. See deploy/README.md.
| Variable | Default | Meaning |
|---|---|---|
PORT |
3000 |
API port (host's SERVER_URL must match) |
RSSI_FLOOR_DBM |
-70 |
Weakest acceptable Bluetooth signal |
WIFI_FLOOR_DBM |
-60 |
Weakest acceptable Wi-Fi signal (checked only if reported) |
GPS_MAX_METERS |
50 |
Max distance from site (checked only if site is configured) |
SITE_LATITUDE / SITE_LONGITUDE |
unset | Site coordinates; unset disables the GPS check |
DATABASE_URL |
unset | Postgres connection; required for enrollment + audit logging |
ADMIN_TOKEN |
unset | Gates /admin/* (user + device bootstrap); unset disables them |
ALLOW_UNSIGNED_VALIDATION |
false |
Dev-only: unsigned validation when no DB is configured |
TIMESTAMP_TOLERANCE_MS |
300000 |
Max signed-timestamp age on nonce requests |
NONCE_TTL_MS |
120000 |
Validity window of a single-use validation nonce |
LAN_TOKEN_TTL_MS |
120000 |
Max age of a host-served same-network token |
SESSION_TTL_MS |
120000 |
Lifetime of a minted QR session before redemption |
RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX |
900000 / 300 |
Per-IP request budget |
RATE_LIMIT_ENROLL_MAX |
10 |
Stricter per-IP budget on the enroll endpoints |
TRUST_PROXY |
false |
Set behind a reverse proxy so limits see real client IPs |
TLS_CERT_PATH / TLS_KEY_PATH |
unset | Serve HTTPS directly (dev: mkcert); otherwise terminate TLS at a proxy |
MQTT_URL |
unset | Broker URL; when set, the bridge subscribes to proxies/telemetry/+ |
MQTT_USERNAME / MQTT_PASSWORD |
unset | Broker credentials when it requires them |
The current implementation validates phone-reported signals. That is honest telemetry but not proof: a device can misreport its own measurements. Treat the current build as a demo of the pipeline, not a security control. Phase 1 of the roadmap inverts the trust model (server-issued nonce delivered over BLE, host-measured RSSI, per-device enrollment keys, TLS) — that is the point at which a validation becomes evidence.
Delivery convention: one PR per feature, per phase, scoped to a single roadmap item.
- Pick an item from docs/ROADMAP.md (e.g.
P1.3) and branch asp1.3-nonce-issuance. npm run lint && npm run typecheck && npm testin the packages you touched.- In the same PR, cite the finding IDs the change closes and flip their status in the roadmap.
- Open the pull request — one feature, no drive-by scope.
Apache License 2.0 — see LICENSE.