Add jest + ts-jest to the NestJS backend with an app.service smoke spec that exercises Nest DI. `npm test` is the documented command the QA gate runs. Trace: SRS §6 gate, NFR-8. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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). The core domain, auth, MQTT ingest, realtime map, and a device simulator are all implemented and end-to-end verified. Esri (ArcGIS) is the map provider, behind a provider-neutral interface so a different one could be swapped in 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
/apiprefix. - 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_tokencookie (7-day expiry). Registration is public and creates a new org with the registrant asORG_ADMIN. - Every domain route is nested under
/api/orgs/:orgId/...and guarded byOrgRolesGuard(role/membership check) plus, for API-key callers,ScopesGuard. - API keys authenticate via
X-API-Key: ulh_<random>and carry a scope list instead of a role.
MQTT ingest
Devices publish under devices/<id>/.... Two identity schemes coexist:
-
devices/<mqttUsername>/pointsand.../jobs— the publisher (a gateway/app, possibly relaying several locators) is a pre-registeredDevicewith broker credentials. Points name aticketorjobId; locators within the batch are attributed by an optionalserialin 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. -
devices/<serial>/log— a locator identifies itself by serial number directly in the topic (no pre-provisioned broker credential needed); the org is resolved from thejobIdin the payload instead. Unknown serials are auto-registered. Every message carries"type": "log" | "status":logpersists aLocatePoint(same shape as above).statusis 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 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:
POST /api/devices/:serial/mqtt-session/challenge(public, unauthenticated — same trust model as the device-status check below) returns a one-timenonceand the exactpayloadstring (ulhub-mqtt-auth-v1:<serial>:<nonce>) the locator's firmware must sign with its permanent private key (RSA-SHA256, PKCS#1v1.5) over BLE.POST /api/devices/:serial/mqtt-sessionwith{ 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) — sameCN, 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:<id>"}) 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 inlocalStorage), exposeslogin/register/logout.lib/use-job-stream.ts— the/api/wsclient hook (reconnects with backoff, dispatchespointsvsstatusmessages).components/map/— the map is behind a provider-neutral interface (JobMapProps,MapPoint,LiveStatus) so swapping providers is a new component, not a rewrite.components/map/esri/EsriJobMap.tsxis the only file that imports the ArcGIS Maps SDK (@arcgis/core): a hybrid (satellite + labels) basemap, 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 (Esri's built-inPopup, opened natively via each graphic'spopupTemplate).- 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 anddevices/%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/devicesfirst; 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).
cp .env.example .env # fill in JWT_SECRET, MQTT_BACKEND_PASSWORD, NEXT_PUBLIC_ARCGIS_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:
# create a new Prisma migration after editing schema.prisma
docker compose exec backend npx prisma migrate dev --name <description>
# 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
mqttUsernamedevices. Creating a device with anmqttUsernamein the UI doesn't create real broker credentials — that's still a manualmosquitto_passwdon 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
certificatesmodule (backend/src/certificates/) acts as a CA for MQTT device client certs (port 8883,CN= serial number, scoped by the existingpattern 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.confall require a manualdocker 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/statusis 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.