/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

Notification Service

Implementation reference for the notification. For its architectural role, see Notification Service Architecture; for the domain model, see Alerting.

A Rust / Axum service in Ports & Adapters shape. It receives candidate alert events, debounces them, resolves recipients, and delivers through two channels: an in-app event over the Redis broker and a Web Push to registered devices.


Source Layout

PathResponsibility
src/domain/Pure types and message formats: notification (payloads, cooldown key, ISO timestamp), preference, subscription, identity (belongs_to/domains/is_system over the claims-schema payload), error.
src/service/ports.rsOutbound port traits: SubscriptionStore, PreferenceStore, PushSender, NotificationBus, Cooldown, DomainDirectory, Clock.
src/service/alerts.rsAlert use cases: broker breach, manual /trigger. Owns the breach throttle.
src/service/push.rsRecipient resolution and fan-out; deletes gone subscriptions.
src/service/preferences.rsDevice registration and per-domain opt-in writes.
src/adapters/driving/http_api/Axum handlers, GatewayClaims extractor, DomainError responses.
src/adapters/driving/alert_listener.rsKafka consumer on the alerts topic.
src/adapters/driven/Mongo stores, Redis bus + cooldown, web-push sender, digital-twin domain lookup.
graph TD
    HTTP["adapters/driving/http_api"] --> ALERTS["service/alerts.rs"]
    HTTP --> PREFS["service/preferences.rs"]
    LISTEN["adapters/driving/alert_listener.rs"] --> ALERTS
    ALERTS --> PUSH["service/push.rs"]
    ALERTS --> PORTS["service/ports.rs"]
    PREFS --> PORTS
    PUSH --> PORTS
    PORTS --> MONGO["adapters/driven/persistence"]
    PORTS --> REDIS["adapters/driven/redis_bus.rs"]
    PORTS --> WEBPUSH["adapters/driven/push.rs"]
    PORTS --> TWIN["adapters/driven/twin.rs"]

Configuration

VariableDefaultPurpose
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYemptyVAPID key pair. Both must be set or every push fails and is logged.
DIGITAL_TWIN_URLhttp://localhost:3000digital-twin base; resolves a building’s domains.
MONGO_URImongodb://localhost:27017/notificationdbDatabase; the name is taken from the URI path.
REDIS_URLredis://localhost:6379Broker for publishing notifications and the cooldown keys; breaches are consumed from Kafka.
KAFKA_BROKERSkafka:9092Source of the alerts topic. Unreachable at boot is retried, not fatal: the consumer reconnects every 5s.
PORT3000HTTP listen port.

Data Model

Two collections, kept separate by design (see Alerting.

// webpushsubscriptions — "where can I reach this account?"
struct WebPushSubscription {
    account_name: String,   // indexed
    endpoint: String,       // unique
    keys: SubscriptionKeys, // p256dh, auth
}
// compound unique index { accountName, endpoint }

// notificationsubscriptions — "what does this account want?"
struct AccountPreferences {
    account_name: String,
    domain_name: String,
    preferences: Vec<Preference>, // notificationType, isSubscribed
    created_at: String,
}
// compound unique index { accountName, domainName }

Collection names and field names are the mongoose ones — the store is shared with the pre-Rust data. Notification type is a metric key from telemetry_schema::ALERTABLE_METRICS: "temperature", "airQuality" or "peopleCount".

On startup, switch_on_new_metrics_once switches each alertable metric on for every account that has temperature on — once per metric, recorded as a switched-on:<metric> marker in the migrations collection, and never over a choice already stored. Those accounts subscribed before the metric existed; /subscribe now switches every metric on by itself.

A single account may own several WebPushSubscription records (one per device) but exactly one preferences document per domain.


HTTP API

Method · PathAuthDescription
GET /public-keyPublicReturns { publicVapidKey } for the browser to create a push subscription.
POST /subscribeJWTUpserts a device subscription for the authenticated account (bound from the token) and, when a domain is supplied, the matching preferences — with no type named, every alertable metric switched on. 201.
GET /preferences/:accountNameJWTReturns the caller’s own NotificationSubscription records. The :accountName param is ignored (identity from the token).
POST /preferencesJWTSets one or more per-domain, per-type opt-in flags for the caller. Every form names its type, which must be in ALERTABLE_METRICS (temperature, airQuality, peopleCount); otherwise 400. 200.
POST /triggerJWTManual alert for a building (no cooldown). Resolves domains via twin (forwarding the caller’s token), publishes, and pushes. type is a severity — info, warning or danger (default); anything else is a 400. 200.

Request bodies accept the canonical domainName field and the deprecated alias domainId. The account is always taken from the verified claims, never from a body accountName/userId or the URL param — this closed an IDOR on GET /preferences/:accountName. Preferences may be supplied as a single { type, enabled }, a types[] array with one enabled, or a granular preferences[] array — every accepted and rejected form is pinned in schemas/fixtures/notification-preferences.json.

Auth boundary

Only /health, /metrics, and /public-key (the non-secret VAPID key) stay public; the GatewayClaims extractor guards everything else and rejects claims without an accountName. /trigger fans out push notifications to a whole domain, so leaving it open was an abuse vector — it requires valid claims like the rest.

Domain membership

Authentication is not enough: every route that names a domain also checks that the caller is a member of it, from memberships in the claims. Without this, any authenticated user of any tenant could subscribe themselves to another tenant’s alert feed, or push arbitrary text to every opted-in user of a domain they have nothing to do with.

Audience::of decides what a caller may reach: Unrestricted for a system: subject or a global admin, Domains(...) for everyone else. That mirrors policy.cedar’s ReadWithAdminBypass — domain membership or maxRoleWeight >= 100 — so alerting is never narrower than digital-twin’s own read rule, where an admin may read any building.

RouteRuleOn failure
POST /subscribeclaims.belongs_to(domainName) — only when a domain is supplied403; the device subscription is still stored
POST /preferencesclaims.belongs_to(domainName)403
POST /triggerTwin’s domains for the building intersected with claims.domains()Fans out to the intersection; 403 only when the building has domains and none are the caller’s

GET /preferences needs no check — it reads by authenticated account and cannot name a domain at all.

Why the intersection, not a plain check

digital-twin’s GET /domain/:building authenticates the caller but does not scope the result (_claims: GatewayClaims, deliberately unused). Forwarding the caller’s header therefore buys no authorization, so notification filters twin’s answer itself rather than trusting it. A building spanning two tenants fans out only to the caller’s own.

The admin bypass is a role check, not Cedar

Unlike digital-twin, this service does not embed the Cedar bundle — its Docker build context is its own directory, so include_str! cannot reach auth-policy/. ReadWithAdminBypass’s gate is maxRoleWeight >= 100, and admin tops auth-contracts/roles.json at exactly 100, so holding the admin role anywhere is equivalent. the_admin_role_is_the_top_of_the_shared_ladder reads roles.json at compile time and fails if a heavier role is ever added — that is the drift alarm. If one is, switch this service to Cedar rather than bumping a constant.

The broker path is exempt

on_breach takes no claims — there is no user behind a Redis message. It resolves domains with the system identity, whose memberships is []. That is why the membership filter lives in trigger and the handlers, never in the shared fan_out: pushing it down one level would silently disable every broker-driven alert.


The Alert Throttle

The telemetry fires on every reading (~every 10 s). To avoid a storm of identical alerts, Alerts debounces per room and per breached field using a Redis key with a fixed TTL. It is also what absorbs the duplicates that the alerts topic’s at-least-once delivery allows.

sequenceDiagram
    autonumber
    participant SS as telemetry
    participant NS as notification
    participant R as Redis
    participant TW as digital-twin
    SS->>NS: AlertEvent on the alerts topic { type, field, buildingId, roomId, ... }
    NS->>R: GET alert:{type}:{field}:{buildingId}:{roomId}
    alt cooldown active
        R-->>NS: "1"
        Note over NS: suppressed, record settled
    else first alert / expired
        NS->>TW: GET /twin/domain/:buildingId
        TW-->>NS: domain names
        Note over NS: publish "notifications" + Web Push to opted-in recipients
        NS->>R: SET alert:{type}:{field}:{buildingId}:{roomId} "1" EX 300
    end

Recipient Resolution and Delivery

service/push.rs separates who to notify from how to reach them.

Preference upserts

PreferenceStore::set is idempotent per type: it first $pulls any existing entry for that notificationType, then $pushes the new { notificationType, isSubscribed }, using $setOnInsert to create the document on first write. Two writes, not one.

Stale-subscription cleanup

When the push endpoint answers 410 Gone or 403 Forbidden, it is permanently invalid and the subscription is deleted automatically. All other errors (network, 5xx) are logged but leave the subscription intact, as they may be transient.


Error Handling

DomainError maps to the same { type, message } body the Node service returned. Missing accountName/domainName and malformed push payloads are Validation Error (400); a failed twin lookup surfaces as Internal Server Error (500). The broker path never returns an error — a malformed alert or a failed lookup is logged and the cooldown is still armed.