Push notification service for the Divine mobile app. It is a Rust service that watches Nostr relays for events that should notify a user — likes, comments, reposts, mentions — and delivers them to registered devices through Firebase Cloud Messaging (FCM). Devices register encrypted push tokens over Nostr, so the app never needs an always-on relay connection to receive alerts.
The service implements a draft push-notification protocol; see docs/nip-xx-push-notifications.md for the specification and docs/developer-guide.md for the delivery internals.
- Encrypted token registration — push tokens are carried in NIP-44 encrypted Nostr events; plaintext registrations are rejected.
- Notification delivery over FCM — one incoming Nostr event produces exactly one visible banner, with per-platform payloads for Android (data-only) and iOS (APNS
aps.alert). - User preferences — users can opt in or out of notification kinds with a preferences event; sensible defaults apply otherwise.
- Deduplication — atomic Redis
SET NX EXper-event locks ensure each event is delivered once, even across replicas. - Replay protection — a configurable processing window (7 days by default) ignores stale events.
- Token cleanup — a daily background task prunes tokens with no registration or successful delivery for 90 days by default.
- Prometheus metrics — relay ingestion, event processing, FCM delivery outcomes, and token pruning are exposed for monitoring.
- Optional allow-list —
allowed_pubkeyscan restrict delivery to a specific set of recipients.
Devices talk to the service entirely through Nostr events addressed to the service's public key. Every such event carries a p tag with the service pubkey and NIP-44 encrypted content.
| Kind | Purpose |
|---|---|
| 3079 | Register a push token (encrypted) |
| 3080 | Deregister a push token (encrypted) |
| 3083 | Update notification preferences (optional) |
Clients discover the service's public key from the /health endpoint (see API) and use it both as the p tag and as the NIP-44 encryption target.
The service subscribes to trigger events on its relay and notifies the tagged recipient:
| Type | Event kind | Trigger |
|---|---|---|
| Like | 7 | Reaction to a user's note (NIP-25) |
| Comment | 1111 | NIP-22 comment on a user's video or article |
| Mention | 30023, 34236 | Long-form content or video mentioning a user |
| Repost | 16 | Repost of a user's note (NIP-18) |
| New post | 34236 | A creator the user subscribed to ("belled") published a video |
Preference category 1 controls comment and mention delivery for these supported trigger kinds. The service does not subscribe to kind-1 text notes because Divine does not surface them in the notification Inbox.
New-post notifications are the one type not anchored to a p tag on the trigger event. Recipients come from the subscriber's own NIP-51 list (kind 30000, d=notify), so the service resolves them from a Redis reverse index rather than from the video. They are rate-limited to one push per (subscriber, creator) per hour, and fan-out is paged and delivered with bounded concurrency so one popular creator cannot force one unbounded Redis read or sequential delivery loop. The in-app feed is not throttled. See the protocol doc for the list shape.
Divine video comments are NIP-22 kind:1111 and notify both the root video author and the direct parent author (deduplicated when they coincide). Follow notifications are not handled by this service, so it does not subscribe to kind 3 contact lists.
Each FCM message carries a stable data payload with routing and presentation fields. Routing to the correct video uses the authoritative addressable coordinate from the triggering event, never a coordinate synthesized from the recipient's pubkey. The full payload contract is documented in the developer guide.
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Divine app │────▶│ Nostr relays │◀────│ Push service │
└─────────────┘ └──────────────┘ └──────┬───────┘
│
┌──────────────┐ ┌──────▼───────┐
│ Firebase │◀────│ Redis │
│ FCM │ │ (tokens, │
└──────┬───────┘ │ dedup) │
│ └──────────────┘
┌──────▼───────┐
│ Mobile device│
└──────────────┘
- The Divine app fetches its FCM token, NIP-44 encrypts it, and publishes a kind 3079 event tagged to the service.
- The push service receives the event over its relay subscription, decrypts the token, and stores it in Redis keyed by the user's pubkey.
- The service watches the relay for trigger events (likes, comments, reposts, mentions) referencing registered users.
- For each match it checks dedup and the recipient's preferences, then sends a data message to Firebase FCM.
- Firebase delivers the notification to the device.
The service is a single async binary running four cooperating tasks: a Nostr listener, an event handler, the token-cleanup service, and an HTTP server for health checks and metrics, plus an optional campaign delivery collector (off by default; see below). Those tasks are supervised: if one ends unexpectedly, the others are cancelled and the process exits non-zero, so a pod whose delivery pipeline has died is restarted instead of staying in service. It is single-app — one Firebase project, one relay — built with axum, tokio, nostr-sdk, and redis.
- Rust 1.85+
- Redis 6.2+
- A Firebase project with FCM enabled, and a service-account credentials file
# Start Redis
docker run -d -p 6379:6379 redis:7-alpine
# Provide the service's Nostr key (used for NIP-44 decryption)
export NOSTR_PUSH__SERVICE__PRIVATE_KEY_HEX="<service_private_key_hex>"
# Place Firebase credentials where the development config expects them
cp <your-service-account>.json firebase-service-account-divine.json
# Run (APP_ENV defaults to "development", loading config/settings.development.yaml)
cargo run# Set SERVICE_PRIVATE_KEY in your environment or a .env file first
docker compose up -ddocker compose runs the service (with APP_ENV=production) alongside a Redis instance and mounts firebase-service-account-divine.json for FCM credentials.
cargo test # integration and unit tests (some require a running Redis)
cargo clippy --all-targets --all-features
cargo fmt --all -- --checkConfiguration is layered: a YAML file selected by APP_ENV, then environment variables that override any value.
APP_ENV selects the file under config/:
APP_ENV=development(the default) loadsconfig/settings.development.yaml.APP_ENV=productionloadsconfig/settings.yaml.- Any other value loads
config/settings.<APP_ENV>.yaml.
These files set the relay (wss://relay.divine.video), profile relays, notification kinds, cleanup schedule, the Firebase project, the listen address (0.0.0.0:8000), and campaign delivery collection (off by default; see below).
Optional. When enabled, the service polls the campaign tool's internal delivery API for approved campaign notifications and delivers them over the existing FCM path, reporting each outcome back.
It is off by default, and even when enabled it does not poll unless a safe HTTPS api_base_url and both Access credentials are set. Normal delivery requires an explicit campaignsEnabled kind-3083 preference and a valid device UTC offset from kind 3079, and enforces 21:00–06:59 recipient-local quiet hours. allow_unverified_consent is retained only as an explicit staff/internal-test bridge.
Any setting can be overridden with the NOSTR_PUSH__ prefix and __ as the nesting separator (for example redis.url becomes NOSTR_PUSH__REDIS__URL).
| Variable | Required | Description |
|---|---|---|
NOSTR_PUSH__SERVICE__PRIVATE_KEY_HEX |
Yes | Service's Nostr private key (hex), used for NIP-44 decryption |
NOSTR_PUSH__REDIS__URL |
No | Redis connection URL (overrides the config file) |
NOSTR_PUSH__NOSTR__RELAY_URL |
No | Nostr relay to subscribe to |
NOSTR_PUSH__NOSTR__EVENT_SILENCE_TIMEOUT_SECS |
No | Quiet period before the listener resubscribes, and the window after any resubscribe before it fails health (default 300) |
NOSTR_PUSH__SERVER__INTERNAL_API_TOKEN |
No | Shared bearer token that enables authenticated internal push requests |
NOSTR_PUSH__CAMPAIGN_DELIVERY__ENABLED |
No | Turns campaign delivery collection on (default false) |
NOSTR_PUSH__CAMPAIGN_DELIVERY__API_BASE_URL |
No | Base URL of the campaign tool's delivery API. Must be a credential-free HTTPS origin: no path, query, userinfo, or fragment. |
NOSTR_PUSH__CAMPAIGN_DELIVERY__ACCESS_CLIENT_ID |
No | Cloudflare Access service token client id |
NOSTR_PUSH__CAMPAIGN_DELIVERY__ACCESS_CLIENT_SECRET |
No | Cloudflare Access service token secret |
NOSTR_PUSH__CAMPAIGN_DELIVERY__ALLOW_UNVERIFIED_CONSENT |
No | Staff/internal-test bridge that bypasses the kind-3083 consent and timezone checks (default false). Normal delivery requires an explicit campaignsEnabled opt-in and a valid device UTC offset. |
APP_ENV |
No | Selects the config file (default development) |
RUST_LOG |
No | Log level (default info) |
The service authenticates to FCM per the app's firebase config:
- Set
credentials_pathto a service-account JSON file (used in development and Docker). - Omit
credentials_pathto fall back to Application Default Credentials — for example GKE Workload Identity in production.
Production images are built and published by the Build, Test & Push GitHub Actions workflow (.github/workflows/publish-and-release.yml):
- On every push to
main, the workflow runs tests, builds a single Docker image, and pushes it to the POC and Staging Google Artifact Registry environments using Workload Identity federation. - Pushes to a
v*tag additionally publish and deploy to Production as an intentional automatic promotion. - A manual workflow dispatch publishes and deploys to POC and Staging by default. Selecting
include_productionadditionally publishes and deploys to Production as an intentional automatic promotion. - After publishing, the workflow dispatches an
image-deployevent todivinevideo/divine-iac-coreconfig, which creates and automatically merges the deployment PR before checking the ArgoCD sync. Production payloads useauto_promote: true; they are not labeled as emergency hotfixes.
Emergency production deployments are an out-of-band platform operation that uses a direct image-deploy dispatch with hotfix: true. Use that path only when an incident requires bypassing the normal release workflow; ordinary version tags and manual production promotions must use this repository's workflow.
The container is a multi-stage build on debian:bookworm-slim that bundles the release binary and the config/ directory, and exposes port 8000.
| Endpoint | Description |
|---|---|
GET /health |
Health check and service-key discovery. Clients read pubkey to discover the service key for registration and encryption. |
GET /metrics |
Prometheus metrics for relay ingestion and push delivery. Responses include Cache-Control: no-store. |
/health answers 200 while the delivery pipeline is alive:
{
"status": "ok",
"pubkey": "<hex>",
"tasks": { "nostr_listener": true, "event_handler": true }
}If the Nostr listener or the event handler has died it answers 503 with
"status": "degraded" and that task set to false. Both the liveness and the
readiness probe point here, so a dead pipeline fails its probes rather than
serving 200 behind a healthy-looking pod.
The metrics endpoint exposes:
| Metric | Type | Description |
|---|---|---|
push_events_received_total |
Counter | Events received from the Nostr relay. |
push_events_processed_total |
Counter | Events whose routing attempt completed, including attempts that ended in an error. |
push_fcm_sends_attempted_total |
Counter | FCM sends attempted per device token. |
push_fcm_sends_succeeded_total |
Counter | Successful FCM sends per device token. |
push_fcm_sends_failed_total{reason} |
Counter | Failed FCM sends by bounded failure reason. |
push_new_post_fanout_retries_total{reason,outcome} |
Counter | Durable new-post page retries by bounded failure reason and scheduled, preserved, exhausted, or expired outcome. |
push_tokens_pruned_total{reason} |
Counter | Tokens removed as invalid or stale. |
push_last_event_processed_timestamp_seconds |
Gauge | Unix timestamp of the last completed event routing attempt. Initialized at startup to give a new instance one alert window. |
The delivery deadman is based on the last-processed gauge:
absent(push_last_event_processed_timestamp_seconds)
or
time() - max by (pod) (push_last_event_processed_timestamp_seconds) > 900
This deadman detects an absent metric or a pod that stops completing
event-routing attempts. It does not claim that an attempt delivered a push: use
push_fcm_sends_succeeded_total and push_fcm_sends_failed_total{reason} to
alert on FCM rejecting every delivery. Use the deadman alongside task-health and
restart alerting; the startup timestamp avoids a premature deadman alert while a
new instance waits for its first event, while task-health alerting covers a
crash-looping instance that repeatedly resets that startup grace.
MIT
Part of Divine — your playground for human creativity · Brand guidelines