orama/core/docs/PUSH_NOTIFICATIONS.md
anonpenguin23 07638354d2 feat(#72): full-privacy push — self-hosted ntfy + APNs-direct provider
Migration 028: namespace_push_credentials
- Per-(namespace, provider) AES-256-GCM encrypted credential blob.
- Generic schema — apns/ntfy/expo/future plug in with zero migration.
- Separated from migration 026's namespace_push_config (preferences vs
  credentials, different access patterns).

pkg/push/credentials
- Manager + Registry + RQLite store; HKDF purpose "namespace-push-credentials"
  via pkg/secrets. Provider Validator interface for per-provider schema.

pkg/push/providers/apns
- Apple Push Notification service direct provider (no Expo proxy).
- Validator + dispatcher; credentials are p8 signing key + key_id + team_id.

pkg/push/providers/ntfy/credentials.go
- ntfy credential schema (auth_token + default topic). Used both with
  the public ntfy.sh and our self-hosted instance.

pkg/environments/production/installers/ntfy.go
- Self-hosted ntfy server installer. Binary, system user, hardened
  /etc/ntfy/server.yml, systemd unit. Listens on 127.0.0.1:NtfyListenPort
  only — Caddy is the only public path.

pkg/environments/production/installers/caddy.go
- Emit reverse_proxy block for push.<dnsZone> -> 127.0.0.1:NtfyListenPort
  when operator enables ntfy on a node.

CLI: install/upgrade orchestrators learn a new "ntfy" install/preserve
phase; flag gating in install/flags.go + upgrade/flags.go.

Gateway handlers/push/credentials_handler.go
- GET/PUT/DELETE /v1/namespace/push-credentials/{provider}.
- PUT validates against provider Validator before encrypting and storing.
- GET returns a redacted view (booleans + non-secret fields only).

Push manager: provider resolution now also consults
namespace_push_credentials before falling back to YAML defaults.

Docs: core/docs/PUSH_NOTIFICATIONS.md walks through end-to-end setup.

VERSION bumped to 0.122.14.
2026-05-14 10:48:00 +03:00

12 KiB

Push Notifications — Tenant Guide

This guide explains how a tenant app (any namespace on the Orama Network) configures push notifications end-to-end. The platform is bring-your-own-credentials: you control your Apple Developer account, your push keys, and your topic format. The platform provides delivery infrastructure (an APNs HTTP/2 client pool, a self-hosted ntfy server, and storage for your encrypted credentials).

Feature #72 implements this. Closes the "tenants must file an ops ticket to get push enabled" workflow that bug #220 partially fixed for ntfy/expo.


Provider matrix

Platform Provider Privacy Setup
iOS (production) apns (direct) Full — no proxies Apple Developer account + p8 key
iOS (TestFlight) apns (sandbox env) Full — no proxies Same key, "environment": "sandbox"
Android (FCM) expo (legacy) Routes via Expo+FCM Expo access token
Android (no FCM) ntfy Full — self-hosted ntfy topic (no Google Play Services required)
Web / push API ntfy Full — self-hosted Web Push protocol against push.<dnsZone>

Pick apns + ntfy for full-privacy stacks (recommended for privacy-focused apps, GrapheneOS, etc.). Pick expo if you'd rather not run your own Android push infrastructure and your users are on Google Play Services.


Step 1 — Generate Apple Push credentials (iOS only)

You need an active Apple Developer Program membership for the team that owns your iOS app's bundle ID.

  1. Go to https://developer.apple.com/account/resources/authkeys/list.
  2. Click + to create a new key.
  3. Check "Apple Push Notifications service (APNs)".
  4. Name it (e.g. Orama Push - myapp prod) and continue.
  5. Download the .p8 file IMMEDIATELY — Apple does NOT let you download it again later. Lose it = generate a new key.
  6. Note the Key ID (10 chars, alphanumeric).
  7. Note your Team ID from the top-right of the page.
  8. Confirm the Bundle ID that matches your iOS app (Xcode → Project → Signing).

You should now have:

  • AuthKey_<KeyID>.p8 file
  • Key ID (e.g. ABC123DEFG)
  • Team ID (e.g. 1234567890)
  • Bundle ID (e.g. com.example.myapp)

The same key signs for all apps under the same Apple Developer team — one key per team is enough.


Step 2 — Choose an ntfy topic mode (Android / Web only)

When using ntfy, the gateway and your client must agree on the topic URL each device subscribes to. Three modes:

Mode Topic format Privacy Notes
opaque sha256(namespace + userId + secret) Best Recommended default
path ns/<namespace>/<userId> Readable Anyone enumerating topics sees IDs
user <userId> Reveals user IDs Minimal — rarely useful

For opaque, you generate a topic_secret once and bake it into both your gateway credential record AND your client's signed app config. Both sides hash the same triple to get the topic. Rotate the secret by:

  1. PUT new topic_secret (clients keep computing old topic against their config until the app updates).
  2. Ship a new client build with the new secret.
  3. After all clients update, the old topic stops receiving sends.

Step 3 — Store credentials via the API

All credentials live encrypted in your namespace's row in the gateway's RQLite cluster. Stored credentials are NEVER returned by any GET endpoint — responses report has_<field>: true/false only.

Auth: every request requires a JWT issued for your wallet, scoped to your namespace.

APNs (iOS)

PUT /v1/namespace/push-credentials/apns
Authorization: Bearer <your wallet JWT>
Content-Type: application/json

{
  "team_id":     "1234567890",
  "key_id":      "ABC123DEFG",
  "bundle_id":   "com.example.myapp",
  "p8_key":      "-----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----",
  "environment": "production"
}

environment must be "sandbox" (Xcode / TestFlight builds) or "production" (App Store builds). A mismatch produces BadDeviceToken at send time, not at PUT time — match your build channel.

Response on success:

{
  "namespace": "myapp-prod",
  "provider":  "apns",
  "configured": true,
  "updated_at": 1700000000,
  "updated_by": "0xWalletAddress…",
  "redacted": {
    "team_id":     "1234567890",
    "key_id":      "ABC123DEFG",
    "bundle_id":   "com.example.myapp",
    "environment": "production",
    "has_p8_key":  true
  }
}

ntfy (Android / Web)

PUT /v1/namespace/push-credentials/ntfy
Authorization: Bearer <your wallet JWT>
Content-Type: application/json

{
  "base_url":     "https://push.dbrs.space",
  "auth_token":   "tk_…",
  "topic_mode":   "opaque",
  "topic_secret": "<32-byte random secret, base64 OK>"
}

base_url and auth_token are both optional:

  • Leave base_url empty to use the platform's self-hosted ntfy.
  • Leave auth_token empty when using the platform ntfy (no auth needed for opaque topics) or pointing at a public ntfy server.

Expo (legacy, optional)

Same shape via the older endpoint:

PUT /v1/push/config
{ "expo_access_token": "…" }

This is the pre-#72 path; new code should prefer apns + ntfy.


Step 4 — Verify what's configured

Per-provider GET

GET /v1/namespace/push-credentials/apns

Returns the redacted view (has_p8_key: true/false etc.) but never the secret material. Use this to confirm what you PUT.

Summary (what providers do I have?)

GET /v1/namespace/push-credentials
{
  "namespace":  "myapp-prod",
  "configured": ["apns", "ntfy"],
  "supported":  ["apns", "ntfy", "fcm"]
}
  • configured is what your namespace has stored credentials for.
  • supported is what this gateway knows how to deliver to (provider packages are compiled in and Register()-ed at startup).

Step 5 — Register devices from your client

The client-side flow is unchanged from before #72:

POST /v1/push/devices
{
  "device_id":   "<unique per-device ID>",
  "provider":    "apns",           // or "ntfy" / "expo"
  "token":       "<hex APNs token | ntfy topic | Expo token>",
  "platform":    "ios",            // or "android" / "web"
  "app_version": "1.2.3"
}

For apns, the token is the hex string Apple gives your iOS app at launch (UIApplication.didRegisterForRemoteNotificationsWithDeviceToken).

For ntfy with topic_mode=opaque, the token is the sha256 hex digest your client computes locally from (namespace, userId, topic_secret).

For ntfy with topic_mode=path, the token is ns/<namespace>/<userId>.


Step 6 — Send pushes

Two paths, depending on whether the push originates from your serverless function or an external system:

From a serverless function

import { push } from "@orama/sdk";

await push.send({
  user_id: "<wallet or user ID>",
  title:   "New message",
  body:    "Hello from %1",
  channel: "messages",
  priority: "high",
});

The hostfunc fans out to every registered device for the user, using each device's recorded provider.

From outside (admin/internal scope)

POST /v1/push/send
Authorization: Bearer <your wallet JWT>
{
  "user_id": "0xUser...",
  "title":   "New message",
  "body":    "Hello",
  "channel": "messages",
  "priority": "high"
}

This endpoint is JWT-gated and scoped to your namespace. Add a finer allow-list / admin-scope check at your gateway layer before exposing it to untrusted callers — see security note in pkg/gateway/handlers/push/handlers.go.


Removing credentials

DELETE /v1/namespace/push-credentials/apns

Idempotent — returns 200 even if nothing was stored. Subsequent push sends for that provider become no-ops (devices registered with the removed provider are skipped with a warning log).


Platform-operator notes

These bits are for whoever runs the Orama gateway cluster, NOT tenants.

Enabling self-hosted ntfy

The gateway installer takes a --with-ntfy flag (install + upgrade commands). When set on a node, that node:

  • Installs the ntfy binary at /usr/local/bin/ntfy.
  • Runs ntfy as a ntfy system user with restricted privileges.
  • Listens on 127.0.0.1:8090 (Caddy fronts it for public TLS).
  • Persists message cache at /var/lib/ntfy/cache.db.
  • Generates a Caddy reverse-proxy block for push.<dnsZone> → localhost:8090, with Let's Encrypt cert via the orama ACME DNS-01 flow.

For devnet, enable on ns1 (already runs Caddy):

orama node install --with-ntfy --nameserver  # (other flags omitted)

For production, you can either colocate with ns1 or run a dedicated node. The installer is identical either way.

The preference persists in /opt/orama/.orama/preferences.yaml so subsequent orama node upgrade runs keep it on without re-passing the flag.

How the gateway handles credentials

  • pkg/push/credentials/ — generic per-(namespace, provider) store with LRU+TTL cache (mirrors pkg/ratelimit).
  • AES-256-GCM at rest via pkg/secrets using HKDF-derived key under purpose string namespace-push-credentials.
  • Provider packages register a Validator at gateway startup; the HTTP handler dispatches to that Validator for schema validation and redaction. Adding a new provider (FCM, SMS, …) is one new package + one pushcreds.Register(...) call.

Backward-compat with bug #220's /v1/push/config

The legacy /v1/push/config endpoint still works for ntfy_base_url and ntfy_auth_token / expo_access_token. Field-by-field semantics:

  • If a tenant has a row in namespace_push_credentials (the new #72 table) for ntfy, that record's base_url / auth_token / topic config takes precedence.
  • Otherwise the gateway reads from namespace_push_config (the 026 table).

This lets tenants migrate at their own pace. A future migration will drop the legacy ntfy credential columns once all known tenants have moved over.


FAQ

Q. Does the platform hold my Apple p8 key? The platform stores it encrypted in your namespace's RQLite row. The key is derived from the cluster secret and is unique per cluster. Operators with cluster-secret access can decrypt the key (the encryption is to protect against database-dump exfiltration, not against the platform operators themselves). Treat the platform operators with the same trust level you'd treat a hosting provider.

Q. Can two tenants share Apple credentials? Apple's APNs token-auth model lets one Apple Developer team sign for all bundle IDs registered under that team. So if two of your apps live under the same Apple Developer team, they can use the same p8 key — but you still PUT to each namespace separately (one PUT per namespace).

Q. What if my p8 key leaks? Generate a new one in the Apple Developer dashboard, PUT it to the gateway. The old key keeps working until you revoke it on Apple's side; the new key starts working as soon as the gateway's credential cache TTL expires (30 s) on every gateway in the cluster.

Q. How do I rotate the ntfy topic_secret? See "Step 2" — two-phase: ship a new client first that knows BOTH secrets, then PUT the new secret, then ship a final client that drops the old. Or accept a short message-loss window during cutover.

Q. Can I use my own ntfy server instead of the platform's? Yes. PUT a base_url pointing at your ntfy server. The platform's ntfy is just a convenience default.

Q. Are pushes rate-limited? The gateway-level per-namespace rate limit (feature #69) applies to the POST /v1/push/send endpoint. Per-provider send rate limits at the dispatcher level are not yet implemented — track as a follow-up feature.