Developer Guide
CrowdVision uses three communication styles, chosen deliberately per relationship:
This page maps all three, then walks five end-to-end flows.
There is no central session store and no per-request call back to an identity authority. Two distinct mechanisms carry trust, and which one applies depends on whether a human is behind the request:
| Mechanism | Used when | How it works |
|---|---|---|
x-gateway-claims header, forwarded verbatim | The call happens on behalf of an end user (a service calling another service mid-request, to fulfil something a user asked for). | claims-gateway verifies the user once, mints a signed token; the mesh (Istio RequestAuthentication in k8s, Caddy forward_auth in compose) verifies it once more at the edge and injects the verified claims as one header. Every service downstream decodes that header instead of re-verifying a JWT, and if it calls another service, it forwards the same header unchanged — mTLS already authenticates the network hop itself, so the header only needs to carry who the original caller was, not prove the hop is legitimate. |
HMAC X-Signature, shared secret | The call is control-plane, backend-only, with no end user in the loop (organization provisioning). | Caller computes HMAC-SHA256(sharedSecret, requestBody), sends it as X-Signature; callee recomputes and compares with hmac.Equal (constant-time). There is no user identity to forward — this authenticates the calling service itself. |
Both patterns exist for the same reason: nobody re-implements JWT verification or invents a second signing scheme per pair of services. Every mesh-claims consumer and every HMAC consumer reuses one shared convention, respectively — see Identity & Tenancy Architecture and Service Mesh Architecture for the full verification mechanics.
A third category — no authentication at all — is used only where forwarding an identity would be meaningless or actively wrong: device-facing ingestion (a sensor has no user token to present) and a small number of internal bootstrap calls that write nothing sensitive (flagged individually in the table below).
All external traffic enters through the single gateway (Caddy in compose, Istio Gateway API in k8s), which routes by URL prefix. See the routing table in the Overview — not repeated here.
| Caller → Callee | Method + Path | Auth | Purpose |
|---|---|---|---|
| twin-service → sensor-service | PUT /thresholds/buildings/{id} | x-gateway-claims forwarded | On building creation/update, push a threshold baseline for the building’s rooms. |
| twin-service → sensor-service | PATCH /thresholds/peopleCount/buildings/{id}/rooms/{id} | x-gateway-claims forwarded | Seed a new room’s people-count threshold. |
| twin-service → contracts-service | POST /preferences/init/{buildingId} | None | Best-effort seed of a new building’s default display preferences; failure is logged, never surfaced to the caller. |
| notification-service → twin-service | GET /domain/{buildingName} | x-gateway-claims forwarded if present | Resolve which domain(s) a building belongs to, to select alert recipients. |
| contracts-service → sensor-service / twin-service | GET /contracts (target from *_METRICS_URL env, not hardcoded) | None | Discover each service’s self-describing metric catalog. |
| chat-service → agent-service | POST /ask | x-gateway-claims forwarded | Forward a user’s chat message to the assistant, under the user’s own identity. |
| agent-service → twin-service | GET /buildings/{domain} | x-gateway-claims forwarded | Tool call: read live building data to answer a question. |
| agent-service → sensor-service | GET /{metric}/latest, /{metric}/entireBuilding, /{metric}/dashboard, /sensors/buildings/{id}[/rooms/{id}] | x-gateway-claims forwarded | Tool calls: read live sensor readings to answer a question. |
| claims-gateway → tenancy-service | POST /internal/provision | HMAC X-Signature | Once per login (never per request): first-login-provision the caller’s membership from their verified IdP claim. |
| claims-gateway → tenancy-service | GET /internal/memberships | HMAC X-Signature | Resolve the caller’s existing memberships before minting the internal token. |
| provisioner → registry-service | GET /internal/organizations/pending | HMAC X-Signature | Reconcile loop: list organizations awaiting provisioning. |
| provisioner → registry-service | POST /internal/organizations/{id}/status | HMAC X-Signature | Report an organization back to ready/failed. |
| provisioner → tenancy-service | POST /internal/domains | HMAC X-Signature | Create the tenancy domain for a newly-approved organization (idempotent — tolerates 409 as success). |
These calls are intentionally few. The design keeps the request path short and avoids synchronous chains on the hot path; anything high-frequency goes over the broker instead.
contracts-service’s own outbound discovery calls send no header of any kind: they only read a metric catalog from twin-service and sensor-service’s public /contracts endpoints, with no sensitive side effect for an unauthenticated caller to exploit. Every call that reads or writes something a specific user’s authorization should gate carries x-gateway-claims — including the twin-service → contracts-service preferences-init call, which forwards the building creator’s claims verbatim, the same as twin’s other outbound calls. contracts-service now requires that header on all of its own inbound user-facing routes (dashboard schema and preferences), so those are no longer reachable anonymously.
Both are pure receivers of the calls above — neither has an outbound HTTP client for any peer service. Only claims-gateway talks to Keycloak (Admin API for user management, OIDC endpoints for token exchange/verification); no other service holds Keycloak credentials or configuration.
POST /ingest on sensor-service — no authentication (a physical sensor or simulator has no user token to present), and exempt from the gateway’s auth gate for the same reason. The endpoint responds 202 Accepted immediately and validates/persists/evaluates thresholds in the background (setImmediate), so a slow downstream step never makes an IoT device wait or retry.
Redis is the publish/subscribe backbone for everything high-frequency. Four channels and one stream carry the event flow:
| Channel / Stream | Publisher | Subscriber | Carries |
|---|---|---|---|
telemetry:raw (pub/sub) | sensor-service | contracts-service | Every accepted reading, as a normalized event. |
telemetry:filtered:{buildingId} (pub/sub) | contracts-service | socket-service | Every reading for that building, routed by the event’s own buildingId. |
alerts:temperature (pub/sub) | sensor-service | notification-service | A temperature reading that crossed a configured threshold — the trigger for the alert path, not yet a user-facing notification. |
notifications (pub/sub) | notification-service | socket-service | The resolved, recipient-scoped alert event (carries domainName), ready for in-app delivery. |
account.deleted (stream, consumer group) | (no publisher found in this codebase — see the gap note below) | tenancy-service | {accountId} — triggers membership cleanup for a deleted Keycloak account. |
Publishers never know who (if anyone) is listening, and the broker absorbs bursts, so a slow or restarting consumer cannot back-pressure a sensor.
tenancy-service’s consumer (internal/events/account_deleted.go) is fully implemented — consumer group, at-least-once redelivery on processing failure, idempotent reap — but no component in this codebase ever calls XAdd on this stream. It is presumably meant to come from a Keycloak event listener that does not yet exist. As things stand, deleting a Keycloak account does not currently clean up its memberships. See claude/issues/issues.md for tracking.
sensor-service also uses Redis as a 10-second TTL cache for each room’s latest temperature reading (GET/SETEX), independent of the pub/sub channels above. It exists purely to serve fast reads for agent-service’s dashboard tool calls without re-querying the primary datastore, not to communicate between services.
| Direction | Event | Detail |
|---|---|---|
| Client → Server | subscribe_building / unsubscribe_building (buildingId) | Join/leave the building:{id} room. |
| Server → Client | telemetry | Relayed from the Redis telemetry:filtered:{buildingId} channel to everyone in that building’s room. |
| Server → Client | notification | Relayed from the Redis notifications channel to everyone in the resolved domain:{name} room; authenticated clients auto-join their domain room on connection. |
The client fetches the server’s public VAPID key (GET /notification/public-key), calls the browser’s pushManager.subscribe(), and registers the resulting subscription (POST /notification/subscribe). When an alert fires, notification-service sends a push payload ({title, message, icon}) via the web-push package directly to the browser’s push service — this path does not go through Redis or socket-service, and works even if the user has no tab open. A service worker (frontend/public/service-worker.js) handles the push event to show an OS-level notification and notificationclick to focus/open the app.
sequenceDiagram
participant Sensor as Sensor source
participant SS as sensor-service
participant R as Redis
participant CS as contracts-service
participant WS as socket-service
participant UI as Vue client
Sensor->>SS: POST /ingest (no auth)
SS-->>Sensor: 202 Accepted
Note over SS: background: validate, persist, normalize
SS->>R: PUBLISH telemetry:raw
R-->>CS: telemetry:raw event
Note over CS: read the event's own buildingId → route
CS->>R: PUBLISH telemetry:filtered:{buildingId}
R-->>WS: telemetry:filtered:{buildingId} event
WS->>UI: emit "telemetry" to room building:{id}contracts-service is the routing stage: it republishes each reading to its own building’s channel (an O(1) lookup on the event’s buildingId), so a reading reaches exactly one building’s feed and never leaks into another’s. It does not filter by metric type — every metric a building produces is forwarded, and the dashboard’s column preferences only decide what the table displays, client-side.
sequenceDiagram
participant SS as sensor-service
participant R as Redis
participant NS as notification-service
participant TW as twin-service
participant WS as socket-service
participant Push as Browser push service
participant U as User
Note over SS: background: reading crosses a configured bound
SS->>R: PUBLISH alerts:temperature
R-->>NS: alerts:temperature event
Note over NS: throttle repeats (per-room cooldown)
NS->>TW: GET /domain/{buildingName}
TW-->>NS: domain name(s)
Note over NS: select opted-in recipients
NS->>R: PUBLISH notifications {domainName}
NS->>Push: web-push send (VAPID)
R-->>WS: notifications event
WS->>U: emit "notification" to room domain:{name}
Push-->>U: service worker "push" → OS notificationUnlike Path 1, this is a two-hop async chain, not one publish: sensor-service never calls notification-service directly — the threshold trigger and the resolved, recipient-scoped notification are two distinct Redis events, with notification-service sitting in between doing the one synchronous call it needs (resolving domains from twin-service) and fanning out over both delivery paths — in-app (broker + socket) and Web Push (direct, works with no tab open).
sequenceDiagram
participant U as Browser
participant Chat as chat-service
participant Agent as agent-service
participant Twin as twin-service
participant Sensor as sensor-service
U->>Chat: POST /chat/... (x-gateway-claims via gateway)
Chat->>Agent: POST /ask {question}, x-gateway-claims forwarded
Note over Agent: LLM decides which tool(s) to call
Agent->>Twin: GET /buildings/{domain}, x-gateway-claims forwarded
Twin-->>Agent: building data
Agent->>Sensor: GET /{metric}/latest, x-gateway-claims forwarded
Sensor-->>Agent: reading
Agent-->>Chat: answer
Chat-->>U: chat responseEvery tool call agent-service makes carries the original user’s x-gateway-claims header, forwarded across two hops (chat-service → agent-service → twin-service/sensor-service) — so twin-service and sensor-service apply the same Cedar authorization to an assistant-driven read as they would to a direct API call from that user. chat-service persists the conversation in its own MongoDB independently of this flow.
sequenceDiagram
participant U as User
participant CG as claims-gateway
participant KC as Keycloak
participant TS as tenancy-service
U->>CG: POST /login (credentials or IdP redirect)
CG->>KC: verify credentials / verify token
KC-->>CG: verified identity + Organization claim
CG->>TS: GET /internal/memberships (HMAC)
alt no membership yet
CG->>TS: POST /internal/provision (HMAC)
TS-->>CG: membership created from the Organization claim
end
CG-->>U: signed internal token (StandardClaims)This is the only point where tenancy-service is consulted for identity — once per login, never per request. Every subsequent request in a session is authorized locally, from the token/header, with zero further network hops for auth. See Identity & Tenancy Architecture for how the membership itself is derived, and Service Mesh Architecture for how the resulting token becomes the x-gateway-claims header at the mesh edge.
sequenceDiagram
participant C as New customer
participant RS as registry-service
participant P as provisioner
participant TS as tenancy-service
C->>RS: POST /organizations (no auth — no credentials exist yet)
RS-->>C: 201, status=provisioning
loop every 15s
P->>RS: GET /internal/organizations/pending (HMAC)
end
P->>TS: POST /internal/domains (HMAC, idempotent)
TS-->>P: 201 or 409 (both = success)
P->>RS: POST /internal/organizations/{id}/status (HMAC)This is the control-plane path: no end user is authenticated mid-flow, so every internal hop uses the HMAC X-Signature scheme rather than a forwarded identity header. See Overview for what registry-service/provisioner/tenancy-service each own.
| Concern | Style | Reason |
|---|---|---|
| User actions (login, CRUD, assistant queries) | Synchronous, identity forwarded | The caller needs an immediate, ordered result, and downstream services need to know who’s asking. |
| Organization provisioning | Synchronous, HMAC-signed | No user identity exists to carry; the calling service authenticates itself instead. Failures are tolerated and retried on the next reconcile tick. |
| Telemetry & alerts | Asynchronous | High volume, many consumers, and producers that must never block on a slow subscriber. |