# UlHub UlHub is a web platform for utility-locating field data. Locator receivers with high-precision GPS report their position and telemetry over MQTT; the backend ingests, stores, and streams that data live to a per-job map in the browser. Core domain: **organizations** have **users** (with roles) and **devices** (locator receivers). Users create **jobs** ("tickets") — a utility to be located — either in the web app or from a field device. Locators post **points** along the located utility to a job, plus a live **status** feed of their current position. Everything is scoped to an organization; **API keys** grant scoped, org-limited programmatic access. ## Status This is an early-stage internal build (not yet hardened for production exposure — see [Known gaps](#known-gaps)). The core domain, auth, MQTT ingest, realtime map, and a device simulator are all implemented and end-to-end verified. Google Maps is the only map provider; the codebase is structured so an Esri implementation can be added alongside it later. ## Architecture ``` ┌─────────────┐ Browser ───────► │ web (Next) │ ── same-origin proxy ──► backend (Nest) :3000 └─────────────┘ /api/* , /api/ws :3001 │ ┌───────┴────────┐ │ │ Postgres+PostGIS Mosquitto (data) (MQTT broker) ▲ ▲ └── backend subscribes to devices/# and ingests ▲ Locator receivers / gateways / the /sim tool ``` - **backend/** — NestJS 10 API (REST + WebSocket), Prisma/PostGIS data layer, MQTT ingest pipeline. Everything lives under the `/api` prefix. - **web/** — Next.js 14 (pages router). Talks to the backend only through a same-origin rewrite (`/api/*` → `http://backend:3001/api/*`), so cookies and the WebSocket upgrade work without CORS. - **postgres** — `postgis/postgis:17-3.5`. Schema is managed by Prisma migrations (`backend/prisma/migrations/`), applied automatically on backend container boot (`prisma migrate deploy`). - **mosquitto** — MQTT broker. Devices publish telemetry; the backend is itself an MQTT client (subscribes to `devices/#`, publishes acks and simulator messages). - **pgadmin** — optional DB inspection UI. - **nginx/** — example reverse-proxy config for a real deployment (terminates TLS, proxies `/` to the web container). Not used in local dev. ### Data model Defined in `backend/prisma/schema.prisma`: | Model | Purpose | |---|---| | `Organization` | Tenant boundary. Everything else hangs off an org. | | `User` / `OrgMembership` | Users can belong to multiple orgs, with a role per org: `ORG_ADMIN`, `MEMBER`, `VIEWER`. | | `Job` | A locate ticket: ticket number (unique per org), status, address, assignment, source (`WEB` or `DEVICE`). | | `Device` | A locator receiver and/or MQTT publisher. Identified by a globally-unique `serialNumber`, an `mqttUsername`, or both. Can be remotely disabled with a reason. | | `LocatePoint` | One recorded reading: lat/lng (high-precision decimals), altitude, GPS quality (fix type, accuracy, satellites, HDOP), and locator telemetry (depth, frequency, current, signal, gain, locate mode, phase, compass, distortion). A generated PostGIS `geometry(Point,4326)` column (`geom`, GIST-indexed) is derived from lat/lng for spatial queries. | | `ApiKey` | Scoped (`jobs:read`, `points:write`, etc.), org-limited, sha256-hashed, shown once at creation. | | `DeviceCertificate` | An mTLS client certificate issued to a device (`CN` = serial number) for the broker's 8883 listener. One per device; the CA/server keys themselves live only on disk, never in this table. | | `DeviceEvent` | Raw log of every MQTT message on `devices/#`, matched or not — an audit/debug trail. | ### Auth - Email/password, bcrypt-hashed, JWT in an httpOnly `ulhub_token` cookie (7-day expiry). Registration is public and creates a new org with the registrant as `ORG_ADMIN`. - Every domain route is nested under `/api/orgs/:orgId/...` and guarded by `OrgRolesGuard` (role/membership check) plus, for API-key callers, `ScopesGuard`. - API keys authenticate via `X-API-Key: ulh_` and carry a scope list instead of a role. ### MQTT ingest Devices publish under `devices//...`. Two identity schemes coexist: 1. **`devices//points`** and **`.../jobs`** — the publisher (a gateway/app, possibly relaying several locators) is a pre-registered `Device` with broker credentials. Points name a `ticket` or `jobId`; locators within the batch are attributed by an optional `serial` in the payload. Unknown tickets auto-create a stub job (`source: DEVICE`) so field data is never dropped ahead of the ticket being opened in the app. 2. **`devices//log`** — a locator identifies itself by serial number directly in the topic (no pre-provisioned broker credential needed); the org is resolved from the `jobId` in the payload instead. Unknown serials are auto-registered. Every message carries `"type": "log" | "status"`: - `log` persists a `LocatePoint` (same shape as above). - `status` is the same reading shape but is broadcast live over the job's realtime channel and **never persisted** — it drives the "current position" blue dot on the map, not the historical point trail. Every message on `devices/#` is written to `DeviceEvent` regardless of whether it's understood, for audit purposes. Disabled devices (see below) are rejected on both ingest paths. The broker (`mosquitto/config/`) uses a `pattern readwrite devices/%u/#` ACL so each device's own MQTT username scopes its access; the backend connects as a dedicated `backend` user with read access to `devices/#` and write access to ack/log topics. ### Device certificate authentication (mTLS) Devices identified by serial number can authenticate to a dedicated TLS listener (port 8883) with a client certificate instead of a shared broker password. `backend/src/certificates/` acts as a small CA: it shells out to `openssl` to generate a root CA (once), a broker server certificate, and per-device client certificates (`CN` = serial number, signed by the CA). Mosquitto's `use_identity_as_username` turns that `CN` directly into the MQTT username, so the existing `pattern readwrite devices/%u/#` ACL scopes the device exactly as it would for a password-authenticated user — no separate ACL logic needed. Manage it from the "MQTT Certs" settings page (CA init/broker cert) and each device's "Certificate" action (issue/download/ revoke). See [Known gaps](#known-gaps) for the CA's on-disk storage and revocation caveats. ### BLE-relayed devices: short-lived session certs Some locators are BLE-only and have no network stack of their own — a phone app relays their data, so it's the phone, not the locator, that would open the MQTT/TLS connection. Handing the phone the locator's *permanent* client-cert private key (as the mTLS flow above allows any ORG_ADMIN to download) would export that device's identity to every phone it ever pairs with, so `backend/src/device-mqtt-auth/` instead uses a challenge-response handshake that never moves the permanent key off the device: 1. `POST /api/devices/:serial/mqtt-session/challenge` (public, unauthenticated — same trust model as the device-status check below) returns a one-time `nonce` and the exact `payload` string (`ulhub-mqtt-auth-v1::`) the locator's firmware must sign with its permanent private key (RSA-SHA256, PKCS#1v1.5) over BLE. 2. `POST /api/devices/:serial/mqtt-session` with `{ nonce, signature }` (base64) verifies that signature against the device's stored permanent certificate. On success it mints a **short-lived** client certificate (`MQTT_SESSION_CERT_HOURS`, default 24h) — same `CN`, so the existing ACL applies unchanged — and returns it plus the CA cert, for the phone to connect to the *same* 8883 listener with. On failure: 400 for an invalid/expired/reused nonce, 403 for a disabled device, 404 for an unknown serial or a device with no permanent cert yet, 401 for a bad signature. Nonces live in memory only (single-use, `MQTT_CHALLENGE_TTL_SECONDS`, default 120s) and session certs are never persisted — Mosquitto validates any CA-signed cert at connect time regardless of whether the backend remembers issuing it. Both endpoints are rate-limited (`@nestjs/throttler`, 10 requests/min) since, unlike the read-only device-status check, each one does real work (an openssl signature verification and/or a fresh cert issuance). ### Realtime A plain WebSocket gateway at `/api/ws` (not socket.io) authenticates off the same `ulhub_token` cookie on the upgrade request. Clients subscribe to per-job channels (`{"type":"subscribe","channel":"job:"}`) and receive `points` (new logged points) or `status` (live position update) messages, gated by the same org-membership check as the REST API. ### Device remote disable A device can be marked disabled (with a free-text reason) from the org's Devices settings page. Disabled devices' MQTT messages are dropped on ingest. A field device can self-check via an unauthenticated `GET /api/devices/:serial/status` — returns whether it's registered, disabled, and why, so it can show that on its own screen before anyone logs into the app. (Deliberately public: it reveals nothing beyond a boolean and a short string, mirroring the serial-based trust model already used for `.../log` ingestion.) ### Frontend Next.js pages router, no UI framework (inline styles). Key pieces: - `lib/auth-context.tsx` — bootstraps session from `/api/auth/me`, tracks the active org (persisted in `localStorage`), exposes `login`/`register`/`logout`. - `lib/use-job-stream.ts` — the `/api/ws` client hook (reconnects with backoff, dispatches `points` vs `status` messages). - `components/map/` — the map is behind a provider-neutral interface (`JobMapProps`, `MapPoint`, `LiveStatus`) so a future Esri implementation is a new component, not a rewrite. `components/map/google/GoogleJobMap.tsx` is the only file that imports the Google Maps SDK: colored markers + polylines per utility type (APWA color code), a blue "current position" marker with an accuracy halo for live status, and click-to-inspect detail popups. - Pages: `login`/`register`, `/` (job list with search/status filter), `/jobs/new`, `/jobs/[jobId]` (detail + live map), `/settings/{members, devices,api-keys}`. ### Simulator (`/sim`) A standalone page (own header, outside the main app nav, but same Next app — no extra infra) for testing without real hardware: pick an open job or create one, set a serial number and telemetry defaults, and send points one at a time or on a timer along a simulated walking path. It calls an authenticated backend endpoint (`POST /api/orgs/:orgId/sim/publish`) that **publishes onto the real MQTT broker** rather than writing the database directly — so it exercises the actual ingest pipeline, not a shortcut around it. A message-type toggle lets you send either `log` (persisted) or `status` (live-only) readings. A second toggle picks the **transport**: - **HTTP relay** (default) — the backend forwards the message over its own already-open, privileged broker connection. No device provisioning needed; good for quick payload testing. - **MQTTS** — the backend instead opens a real TLS connection to port 8883 and authenticates as the serial's own issued client certificate (see `SimMqttsService`, `backend/src/sim/sim-mqtts.service.ts`), so the message is subject to the exact same mTLS handshake and `devices/%u/#` ACL a real field device would be. Requires a device with that serial number to exist in the org and have a certificate issued from `/settings/devices` first; connections are cached per serial so an auto-send session reuses one TLS connection rather than reconnecting on every publish. ## Repository layout ``` backend/ src/ auth/ JWT + API-key auth, guards, decorators orgs/ org CRUD, membership management jobs/ job/ticket CRUD devices/ device CRUD, remote disable device-status/ public GET /api/devices/:serial/status points/ point query/creation REST API ingest/ MQTT client + routing + point/job/log ingest services realtime/ WebSocket gateway + pub/sub service api-keys/ API key issuance/revocation certificates/ MQTT device mTLS CA (openssl-backed) device-mqtt-auth/ BLE challenge-response -> short-lived MQTT session certs sim/ simulator's publish-to-broker endpoint (HTTP relay + real MQTTS/mTLS transport) prisma/ PrismaService/PrismaModule prisma/ schema.prisma migrations/ seed.ts demo org/user/device/job/points web/ pages/ routes (see above) components/ Layout, map abstraction lib/ api client, auth context, WS hook mosquitto/config/ broker config, passwd, ACL mosquitto/certs/ CA/server/device certs (gitignored, generated at runtime) nginx/ example reverse-proxy config for real deployment test/ Python MQTT test scripts (see test/README.md) docker-compose.yml .env.example ``` ## Running locally Requires Docker (no local Node install needed — the containers do everything). ```bash cp .env.example .env # fill in JWT_SECRET, MQTT_BACKEND_PASSWORD, NEXT_PUBLIC_GOOGLE_MAPS_API_KEY docker compose up -d --build docker compose exec backend npm run db:seed # optional demo data ``` - Web: http://localhost:3000 (simulator at `/sim`) - Backend: http://localhost:3001/api - pgAdmin: http://localhost:5050 Seeded login (if you ran the seed): `brent.perteet@gmail.com` / `changeme123`, org "umagul", demo device `testuser` / job `TKT-2026-0001`. Useful commands: ```bash # create a new Prisma migration after editing schema.prisma docker compose exec backend npx prisma migrate dev --name # tail backend logs docker compose logs -f backend # publish sample MQTT data from the CLI (alternative to /sim) python3 test/publish_sample.py ``` ## Known gaps - **Device MQTT credential provisioning is manual for `mqttUsername` devices.** Creating a device with an `mqttUsername` in the UI doesn't create real broker credentials — that's still a manual `mosquitto_passwd` on the mosquitto container. Devices identified by serial number can instead use the mTLS client-certificate flow below, which is fully self-service. - **No certificate revocation enforcement at the broker.** The `certificates` module (`backend/src/certificates/`) acts as a CA for MQTT device client certs (port 8883, `CN` = serial number, scoped by the existing `pattern readwrite devices/%u/#` ACL). Revoking a device certificate (`DELETE .../devices/:deviceId/certificate`) deletes its DB row only — there's no CRL/OCSP, so the same certificate still authenticates until its 10-year expiry. Short-lived certs or a CRL/OCSP setup would close this. - **No automatic reload of Mosquitto config or certs.** Initializing the CA, provisioning the broker's server certificate, or editing `mosquitto.conf` all require a manual `docker compose restart mosquitto` — there's no hot-reload. - **Device private keys are stored in Postgres in plaintext** (`device_certificates.privateKeyPem`) — DB access is effectively key access, same tradeoff as most self-hosted device-cert setups without an HSM. - **No password reset or org-invite email flow.** Adding a member requires they've already registered themselves. - **Public registration.** Anyone can self-register and create a new org; there's no invite-only mode. - **`GET /api/devices/:serial/status` is unauthenticated** by design (see above) — worth revisiting if device identity ever needs to be harder to spoof. - No automated test suite yet; `test/` is manual/interactive MQTT scripts.