Files
oott/push_notifications.md
T
rzuastiandClaude Opus 4.8 fa54361198 Refine push notifications plan: privacy, paths, and naming
- Drop all private data from push payloads: send only the already-sanitized
  title/body, no data field, no MAC/IP, and no deep-link (tap just opens the app)
- Correct backend integration points: sender at src/notifications/push.rs and a
  "push" arm in deliver() (not events.rs); add module registrations and an HTTP
  client (reqwest) dependency
- Unify nomenclature on "push": method value "push", relay under push_relay/,
  config section [notifications.push], sender module push.rs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:33:33 -04:00

284 lines
16 KiB
Markdown

# Push notifications via FCM + project-operated relay
Implementation plan for delivering push notifications to OOTT's own iOS and
Android apps, triggered from the backend, as an alternative to Pushover.
Status: **planned, not started.** Development begins after this document is
agreed. No code has been written yet.
## Goal
Deliver notifications (new device, device back online, device changed) to a
user's phone running the OOTT mobile app, even when the app is backgrounded or
closed, on both iOS and Android. This is a new `notifications.method` value,
`push`, alongside the existing `pushover` method — it does not replace Pushover,
it sits next to it.
## Background constraint
To reach a backgrounded/closed app you must go through the OS push gateways:
APNs (iOS) and FCM (Android). There is no way around them. We use **Firebase
Cloud Messaging (FCM HTTP v1)** as a single integration: it delivers to Android
natively and bridges to APNs for iOS (our APNs key is uploaded into Firebase).
Because OOTT ships a single store-distributed app, the Firebase project and APNs
key are **project-owned**, not per-self-hoster. We therefore route sends through
a small **project-operated relay** so we never distribute project secrets to
self-hosters and so self-hosters need zero push credentials of their own.
## Locked decisions
1. **The push relay lives in this monorepo**, under a new top-level
`push_relay/` directory. It is implemented in **TypeScript** and deployed as a
**Firebase Cloud Function** (scale-to-zero). The `push_relay/` dir holds the
source; only the deploy target differs from an always-on server.
2. **Phase 1 is the shipped feature.** It needs no shared secret: protection
rests on FCM project scoping + per-IP rate limiting + a billing cap.
Attestation hardening (Play Integrity / App Attest) is **deferred, optional
future work** — built only if abuse signals appear (see "Optional future
hardening"). The architecture leaves room to layer it on without reworking
Phase 1.
3. **Push is opt-in per device** via a toggle in the app's settings (not
auto-enabled on permission grant).
## Architecture
```
Flutter app ──register FCM token──► OOTT backend (self-hosted, LAN)
│ stores tokens in its SQLite DB
new-device event ─────────────────────┤
POST /v1/push {[tokens], notification:{title,body}}
OOTT Push Relay (Firebase Cloud Function, stateless)
FCM creds never leave Google (runtime SA)
FCM HTTP v1 (Google)
├──────────► Android devices
└──► APNs ──► iOS devices
```
Key properties:
- **The relay is stateless** — no database, no accounts, no PII at rest. The
self-hosted backend already owns SQLite, an API, and a LAN channel to the app,
so it stores the device tokens. The relay only forwards.
- **FCM credentials never leave Google.** As a Cloud Function, the relay
authenticates to FCM via its runtime service account through the
`firebase-admin` SDK — there is no service-account JSON for us to hold, store,
or rotate. The "keep project secrets off self-hosters" goal becomes "Google
keeps the secret."
- **"Only our app" is guaranteed by FCM project scoping**: the relay sends
through our single Firebase project, so tokens belonging to any other app are
rejected by FCM. No caller can make the relay push to a different app.
- **No private data in payloads**: the relay only ever sees a short title/body
and nothing else — no `data` payload, no MAC, no IP, no device identifiers.
The existing notification copy (`notifications/render.rs`) is already built to
exclude private data: it masks the MAC to a 2-octet suffix in titles, omits IP
addresses entirely, and its tests assert that no full MAC or IP appears. That
same already-sanitized title/body is all that travels to the relay, so network
metadata never reaches project servers — privacy and reduced liability.
- **No deep-link**: tapping a push simply opens the app; it does not carry an
identifier or navigate to a specific device. The in-app notification list is
the durable, on-LAN record the user consults for detail. This removes the only
reason the payload would have needed a `notification_id` or `mac`.
## Phase 1 — working end-to-end push
### Component 1: push relay service (`push_relay/`, new)
- **TypeScript Firebase Cloud Function**, stateless. Scales to zero — no idle
cost, no server/OS to patch, TLS and a stable HTTPS URL provided by the
platform.
- Endpoints (HTTP function routes):
- `POST /v1/push``{ tokens: [...], notification: {title, body} }` (no
`data` field — see "No private data in payloads") → send via the
`firebase-admin` SDK
(`messaging().sendEach(...)`, batched multicast) → return per-token results
(`ok` / `unregistered` / `invalid`) so the caller can prune dead tokens.
- `GET /healthz` — liveness.
- FCM client: the `firebase-admin` SDK authenticates via the function's runtime
service account — **no service-account JSON to manage, no OAuth-token minting
or caching code**. `sendEach` returns per-token success/error, giving us
pruning for free.
- **No auth secret in Phase 1.** Protection rests on three layers that depend
on no shared secret: FCM project scoping (can only reach OOTT installs),
per-source-IP rate limiting, and the billing cap below. This keeps
self-hosters at zero configuration and avoids committing a secret to the
open-source repo. (If drive-by invocation noise ever becomes an issue, a
non-secret client key can be added later without design changes.)
- **Rate limiting per source IP** (one home/deployment ≈ one public IP) via a
lightweight Firestore counter, so one abuser cannot starve other users. Use
generous limits to tolerate the occasional CGNAT-shared IP.
- **Billing cap + budget alert** on the project — the hard ceiling against cost
runaway, the one risk the per-invocation model adds. Together with rate
limiting and FCM project scoping, this removes cost as a reason to need
attestation.
- Config via Cloud Function env: rate-limit params, log level. No secrets
required in Phase 1.
- Tests: rate limiting, payload validation (reject malformed/oversized
requests), FCM-response → result mapping (mock the `firebase-admin` messaging
call).
- Deployment: `firebase deploy` of the function. Cost ~$0 within the free tier
(see Cost summary).
### Component 2: backend changes (existing Rust service)
- **Migration** `database_migrations/10-add_push_tokens/up.sql`: `push_tokens`
table — `id`, `token` (unique), `platform` (`android`/`ios`), `created_on`,
`last_seen` (RFC3339, matching the existing datetime convention). A `voucher`
column would be added later only if attestation is built (see "Optional future
hardening").
- `src/db/push_tokens.rs``upsert`, `list`, `delete`, `delete_many` (prune
dead tokens). Unit tests mirroring `db/notifications.rs`. Register the module
by adding `pub mod push_tokens;` to the `mod` list at the top of `db.rs`. (No
migration registration needed: migrations are auto-discovered from the
directory via `include_dir!` in `db.rs`.)
- `src/model/push_tokens.rs``PushToken` + payload structs, deriving
`ToSchema`. Add the matching `pub mod push_tokens;` to `model.rs`.
- `src/web_server/push_tokens.rs` — handlers behind the existing bearer `auth`
middleware:
- `PUT /api/push_tokens` — register/refresh `{token, platform}`.
- `DELETE /api/push_tokens/{token}` — unregister.
Wired into the router and into `ApiDoc` `paths(...)` + `components(schemas())`
in `web_server.rs`, plus a new `push_tokens` OpenAPI tag.
- `settings.rs` — support `method = "push"` and a `[notifications.push]`
section with a single `relay_url` that **defaults to the project's deployed
push relay**, so enabling push needs only `method = "push"` and nothing to
paste — consistent with "zero push credentials for self-hosters". Add parse
tests.
- `src/notifications/push.rs` — sender mirroring `notifications/pushover.rs`:
load tokens from DB, POST to the relay, prune `unregistered`/`invalid` tokens
from the response. Register it with `mod push;` in `notifications.rs`.
- **HTTP client dependency.** The relay POST needs an HTTP client; `Cargo.toml`
has none today (Pushover brings its own blocking client via the `pushover`
crate). Add `reqwest` (the service already runs on `tokio`, so use its async
client with the `json` feature). Unlike `pushover::send_message` — which the
delivery loop runs via `spawn_blocking` — the `reqwest` call is async and can
be `await`ed directly in `deliver()`.
- `src/notifications/delivery.rs` — add a `"push"` arm to the method match
in `deliver()` (not `events.rs`, which only records device events and has no
`send_notification`). The arm forwards only the already-sanitized title/body
(no `data`, no MAC, no IP). Because no per-notification identifiers travel with
the push, the existing delivery channel (`DeliveryRequest`/`enqueue`) needs no
new fields — it already carries title/body.
- Tests: sender against a mocked relay endpoint, DB CRUD/prune, settings
parsing, endpoint API tests. Run `./run_tests.sh` and `./lint.sh`.
### Component 3: Flutter app changes
- Deps: `firebase_core`, `firebase_messaging`, `flutter_local_notifications`.
- Platform config: `google-services.json` (Android), `GoogleService-Info.plist`
(iOS); iOS Push Notifications + Background Modes capabilities. APNs key lives
only in the Firebase console.
- `lib/utils/push_service.dart` — init Firebase, request permission, get token,
register via API, handle token refresh, and handle taps → simply bring the app
to the foreground (no deep-link, no identifier to route on; the user reads
detail from the in-app notification list). The `notification` payload is shown
by the OS directly when the app is backgrounded/terminated;
`flutter_local_notifications` is used to display alerts while the app is in the
**foreground** (and for Android channel configuration).
- `lib/utils/api/oott_api_push.dart``registerPushToken` /
`unregisterPushToken`, following the existing `oott_api` split.
- Settings UI: per-device "Enable push on this device" toggle (calls
register/unregister, shows permission state). Uses theme colors and
`UISnackbars` per project rules.
- Tests: push_service logic (mocked messaging), API tests via the Dio-adapter
mock seam, widget test for the toggle. Run `./run_tests.sh` and
`dart analyze`.
### One-time project-owned setup (manual, documented, no secrets committed)
- Firebase project + Android/iOS apps registered.
- Apple Developer APNs `.p8` key uploaded into Firebase.
- Cloud Function deployed; its runtime service account grants FCM access (no
service-account JSON to generate or store).
- Steps captured in `push_relay/README.md`. Secrets never committed (project
rule).
## Optional future hardening (deferred) — attestation
**Not part of the shipped feature.** In the Cloud Function context, Phase 1's
rate limiting + billing cap already cover cost-runaway, and FCM project scoping
already prevents wrong-app targeting. The *only* residual threat attestation
addresses is **spam to harvested genuine OOTT tokens** (an attacker who has
collected real tokens from their own installs or by breaching self-hosted
backend DBs pushing to those specific users). For OOTT's threat profile — a
niche, self-hosted, low-value target with bounded blast radius — that is a
low-probability, low-impact risk, and attestation carries real cost (uneven
Flutter coverage, server-side Play Integrity + App Attest verification, voucher
issuance/refresh/storage, added complexity to an otherwise trivial stateless
function).
**Build this only if a trigger appears:**
- Evidence of token harvesting or relay abuse.
- An actual spam incident through the relay.
- Significant user growth that raises the target's value.
If built, it proves each token came from a genuine instance of our shipped app:
- **Android → Play Integrity API**, **iOS → App Attest (DCAppAttest)**.
- Relay gains `POST /v1/attest`: verify the platform proof + FCM token → return
a **signed voucher** for that token. Vouchers are **HMAC-signed JWTs** (relay
both issues and verifies them, so symmetric signing is sufficient — no keypair
distribution). Voucher carries `{fcm_token_hash, platform, exp}`, short TTL,
refreshed on token refresh. Relay stays stateless: it just verifies its own
signature.
- `/v1/push` additionally requires a valid voucher per token.
- Backend: add `voucher` column to `push_tokens`; store and forward it.
- App: obtain attestation, exchange for voucher at registration, send voucher to
backend.
- New Cloud Function secret: HMAC signing secret.
- Flutter attestation coverage is uneven; evaluate platform-channel vs. a
maintained package during this phase.
## Cost summary
- FCM messages and APNs: **$0**.
- Apple Developer ($99/yr) and Google Play ($25 one-time): already required for
the app, not incremental to push.
- **Relay (Cloud Function): ~$0.** Scales to zero, and the free tier (~2M
invocations/mo) comfortably covers OOTT at realistic scale; only pennies if it
is ever exceeded. No idle cost, no host, no domain/TLS to buy. A small
Firestore counter for rate limiting also stays within free tier at this
volume.
- Real cost is operational: the relay is still a shared dependency and a single
point of failure for everyone's notifications; budget for monitoring and
backward-compatible relay API versioning across many deployed backend
versions. (Cloud Functions removes the OS-patching burden of a VPS.)
## Security / abuse notes
- FCM project scoping bounds the blast radius to OOTT app installs only — no
caller can target arbitrary people or other apps.
- **No private data leaves the LAN.** Pushes carry only an already-sanitized
title/body (no MAC, no IP, no `data` payload), so the relay and the OS push
gateways (FCM/APNs) never see network metadata. A compromised or subpoenaed
relay exposes nothing beyond the short event copy.
- Phase 1 has no shared secret to leak: defense is FCM project scoping + per-IP
rate limiting + billing cap. Deferred attestation would add the strong
"genuine app instance" guarantee if ever needed.
- Token pruning loop (relay reports dead tokens → backend deletes) must be
implemented end-to-end or tokens accumulate.
- **Push is best-effort.** `persist_and_deliver` (in `notifications.rs`) records
the notification in the backend DB *before* enqueuing it for delivery (as it
already does for Pushover), so
a relay or network failure never loses the event — only that one push. The
in-app notification list is the durable record; there is deliberately no push
retry queue in v1.
## Build & verification order
1. Migration + DB layer + model (tests).
2. Backend endpoints + OpenAPI wiring (API tests).
3. Relay Cloud Function Phase 1 (tests) + `firebase deploy`.
4. Backend `push` sender + settings (tests against mock relay).
5. Flutter integration + settings toggle (tests).
6. Manual end-to-end on real Android + iOS devices.
7. (Deferred, only if triggered) attestation + vouchers across relay, backend,
and app.
Each step ends with the relevant `run_tests.sh` / `lint.sh` / `dart analyze`
green before moving to the next, per project rules.