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>
This commit is contained in:
rzuasti
2026-06-08 11:33:33 -04:00
co-authored by Claude Opus 4.8
parent ed04a9bc89
commit fa54361198
+61 -35
View File
@@ -10,9 +10,9 @@ agreed. No code has been written yet.
Deliver notifications (new device, device back online, device changed) to a 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 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` alongside closed, on both iOS and Android. This is a new `notifications.method` value,
the existing `pushover` method — it does not replace Pushover, it sits next to `push`, alongside the existing `pushover` method — it does not replace Pushover,
it. it sits next to it.
## Background constraint ## Background constraint
@@ -28,10 +28,10 @@ self-hosters and so self-hosters need zero push credentials of their own.
## Locked decisions ## Locked decisions
1. **Relay lives in this monorepo**, under a new top-level `relay/` directory. 1. **The push relay lives in this monorepo**, under a new top-level
It is implemented in **TypeScript** and deployed as a **Firebase Cloud `push_relay/` directory. It is implemented in **TypeScript** and deployed as a
Function** (scale-to-zero). The `relay/` dir holds the source; only the **Firebase Cloud Function** (scale-to-zero). The `push_relay/` dir holds the
deploy target differs from an always-on server. source; only the deploy target differs from an always-on server.
2. **Phase 1 is the shipped feature.** It needs no shared secret: protection 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. rests on FCM project scoping + per-IP rate limiting + a billing cap.
Attestation hardening (Play Integrity / App Attest) is **deferred, optional Attestation hardening (Play Integrity / App Attest) is **deferred, optional
@@ -49,7 +49,7 @@ Flutter app ──register FCM token──► OOTT backend (self-hosted, LAN)
new-device event ─────────────────────┤ new-device event ─────────────────────┤
POST /v1/push {[tokens], payload} POST /v1/push {[tokens], notification:{title,body}}
OOTT Push Relay (Firebase Cloud Function, stateless) OOTT Push Relay (Firebase Cloud Function, stateless)
FCM creds never leave Google (runtime SA) FCM creds never leave Google (runtime SA)
@@ -72,24 +72,29 @@ Key properties:
- **"Only our app" is guaranteed by FCM project scoping**: the relay sends - **"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 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. rejected by FCM. No caller can make the relay push to a different app.
- **Minimal payloads**: the relay only ever sees a short title/body plus - **No private data in payloads**: the relay only ever sees a short title/body
`data: {notification_id, mac}`. Full device detail (MAC/IP/vendor) is fetched and nothing else — no `data` payload, no MAC, no IP, no device identifiers.
from the **local** backend when the user taps the notification. This keeps The existing notification copy (`notifications/render.rs`) is already built to
network metadata off project servers — privacy and reduced liability. Because exclude private data: it masks the MAC to a 2-octet suffix in titles, omits IP
that tap-through fetch only works when the phone can reach the local backend addresses entirely, and its tests assert that no full MAC or IP appears. That
(on-LAN or via the user's own remote access), the title/body must stay same already-sanitized title/body is all that travels to the relay, so network
self-sufficient — the existing event copy already summarizes the event. 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 ## Phase 1 — working end-to-end push
### Component 1: relay service (`relay/`, new) ### Component 1: push relay service (`push_relay/`, new)
- **TypeScript Firebase Cloud Function**, stateless. Scales to zero — no idle - **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 cost, no server/OS to patch, TLS and a stable HTTPS URL provided by the
platform. platform.
- Endpoints (HTTP function routes): - Endpoints (HTTP function routes):
- `POST /v1/push` — `{ tokens: [...], notification: {title, - `POST /v1/push``{ tokens: [...], notification: {title, body} }` (no
body}, data: {...} }` → send via the `firebase-admin` SDK `data` field — see "No private data in payloads") → send via the
`firebase-admin` SDK
(`messaging().sendEach(...)`, batched multicast) → return per-token results (`messaging().sendEach(...)`, batched multicast) → return per-token results
(`ok` / `unregistered` / `invalid`) so the caller can prune dead tokens. (`ok` / `unregistered` / `invalid`) so the caller can prune dead tokens.
- `GET /healthz` — liveness. - `GET /healthz` — liveness.
@@ -126,24 +131,38 @@ Key properties:
column would be added later only if attestation is built (see "Optional future column would be added later only if attestation is built (see "Optional future
hardening"). hardening").
- `src/db/push_tokens.rs``upsert`, `list`, `delete`, `delete_many` (prune - `src/db/push_tokens.rs``upsert`, `list`, `delete`, `delete_many` (prune
dead tokens). Unit tests mirroring `db/notifications.rs`. 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 - `src/model/push_tokens.rs``PushToken` + payload structs, deriving
`ToSchema`. `ToSchema`. Add the matching `pub mod push_tokens;` to `model.rs`.
- `src/web_server/push_tokens.rs` — handlers behind the existing bearer `auth` - `src/web_server/push_tokens.rs` — handlers behind the existing bearer `auth`
middleware: middleware:
- `PUT /api/push_tokens` — register/refresh `{token, platform}`. - `PUT /api/push_tokens` — register/refresh `{token, platform}`.
- `DELETE /api/push_tokens/{token}` — unregister. - `DELETE /api/push_tokens/{token}` — unregister.
Wired into the router and into `ApiDoc` `paths(...)` + `components(schemas())` Wired into the router and into `ApiDoc` `paths(...)` + `components(schemas())`
in `web_server.rs`, plus a new `push_tokens` OpenAPI tag. in `web_server.rs`, plus a new `push_tokens` OpenAPI tag.
- `settings.rs` — support `method = "fcm_relay"` and a `[notifications.fcm_relay]` - `settings.rs` — support `method = "push"` and a `[notifications.push]`
section with a single `relay_url` that **defaults to the project's deployed section with a single `relay_url` that **defaults to the project's deployed
relay**, so enabling push needs only `method = "fcm_relay"` and nothing to push relay**, so enabling push needs only `method = "push"` and nothing to
paste — consistent with "zero push credentials for self-hosters". Add parse paste — consistent with "zero push credentials for self-hosters". Add parse
tests. tests.
- `src/events/fcm_relay.rs` — sender mirroring `pushover.rs`: load tokens from - `src/notifications/push.rs` — sender mirroring `notifications/pushover.rs`:
DB, POST to relay, prune `unregistered`/`invalid` tokens from the response. load tokens from DB, POST to the relay, prune `unregistered`/`invalid` tokens
- `src/events.rs` — add an `"fcm_relay"` arm in `send_notification` using the from the response. Register it with `mod push;` in `notifications.rs`.
minimal-payload design above. - **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 - Tests: sender against a mocked relay endpoint, DB CRUD/prune, settings
parsing, endpoint API tests. Run `./run_tests.sh` and `./lint.sh`. parsing, endpoint API tests. Run `./run_tests.sh` and `./lint.sh`.
@@ -154,11 +173,12 @@ Key properties:
(iOS); iOS Push Notifications + Background Modes capabilities. APNs key lives (iOS); iOS Push Notifications + Background Modes capabilities. APNs key lives
only in the Firebase console. only in the Firebase console.
- `lib/utils/push_service.dart` — init Firebase, request permission, get token, - `lib/utils/push_service.dart` — init Firebase, request permission, get token,
register via API, handle token refresh, and handle taps → deep-link to the register via API, handle token refresh, and handle taps → simply bring the app
device/notification via `go_router`. The `notification` payload is shown by the to the foreground (no deep-link, no identifier to route on; the user reads
OS directly when the app is backgrounded/terminated; `flutter_local_notifications` detail from the in-app notification list). The `notification` payload is shown
is used to display alerts while the app is in the **foreground** (and for by the OS directly when the app is backgrounded/terminated;
Android channel configuration). `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` / - `lib/utils/api/oott_api_push.dart``registerPushToken` /
`unregisterPushToken`, following the existing `oott_api` split. `unregisterPushToken`, following the existing `oott_api` split.
- Settings UI: per-device "Enable push on this device" toggle (calls - Settings UI: per-device "Enable push on this device" toggle (calls
@@ -174,7 +194,8 @@ Key properties:
- Apple Developer APNs `.p8` key uploaded into Firebase. - Apple Developer APNs `.p8` key uploaded into Firebase.
- Cloud Function deployed; its runtime service account grants FCM access (no - Cloud Function deployed; its runtime service account grants FCM access (no
service-account JSON to generate or store). service-account JSON to generate or store).
- Steps captured in `relay/README.md`. Secrets never committed (project rule). - Steps captured in `push_relay/README.md`. Secrets never committed (project
rule).
## Optional future hardening (deferred) — attestation ## Optional future hardening (deferred) — attestation
@@ -231,13 +252,18 @@ If built, it proves each token came from a genuine instance of our shipped app:
- FCM project scoping bounds the blast radius to OOTT app installs only — no - FCM project scoping bounds the blast radius to OOTT app installs only — no
caller can target arbitrary people or other apps. 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 - 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 rate limiting + billing cap. Deferred attestation would add the strong
"genuine app instance" guarantee if ever needed. "genuine app instance" guarantee if ever needed.
- Token pruning loop (relay reports dead tokens → backend deletes) must be - Token pruning loop (relay reports dead tokens → backend deletes) must be
implemented end-to-end or tokens accumulate. implemented end-to-end or tokens accumulate.
- **Push is best-effort.** `send_notification` records the notification in the - **Push is best-effort.** `persist_and_deliver` (in `notifications.rs`) records
backend DB *before* contacting the relay (as it already does for Pushover), so 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 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 in-app notification list is the durable record; there is deliberately no push
retry queue in v1. retry queue in v1.
@@ -247,7 +273,7 @@ If built, it proves each token came from a genuine instance of our shipped app:
1. Migration + DB layer + model (tests). 1. Migration + DB layer + model (tests).
2. Backend endpoints + OpenAPI wiring (API tests). 2. Backend endpoints + OpenAPI wiring (API tests).
3. Relay Cloud Function Phase 1 (tests) + `firebase deploy`. 3. Relay Cloud Function Phase 1 (tests) + `firebase deploy`.
4. Backend `fcm_relay` sender + settings (tests against mock relay). 4. Backend `push` sender + settings (tests against mock relay).
5. Flutter integration + settings toggle (tests). 5. Flutter integration + settings toggle (tests).
6. Manual end-to-end on real Android + iOS devices. 6. Manual end-to-end on real Android + iOS devices.
7. (Deferred, only if triggered) attestation + vouchers across relay, backend, 7. (Deferred, only if triggered) attestation + vouchers across relay, backend,