diff --git a/TODO.md b/TODO.md index 94ced83..a23fba2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,7 @@ # OOTT ToDo list -- [ ] Add support for push notifications to the app (iOS and Android) +- [x] Add support for push notifications to the app (iOS and Android) +- [x] Remove push notifications plan ## Release plan for 0.2.0 - [x] Test Android UI on emulator @@ -26,12 +27,12 @@ - [x] In the status screen, the "listening for" property of passive scanners should change to days and months (now its always minutes) - [ ] Check for potential dependency upgrades -- [ ] In settings, figure out automatic save -- [ ] The permissions dialog for push notifications on android says "Allow frontend to send you notifications" +- [ ] In settings, move the backend configuration to a popup dialog that asks for the URL and API key and allows the user to test and save it. In the main screen it should present both items as read only and allow the user to change the theme and push notifications and both should trigger the change immediately (ie. without a save button) +- [x] The permissions dialog for push notifications on android says "Allow frontend to send you notifications" ## Push relay -- [ ] Add NPM to the devShell to be able to run tests +- [x] Add NPM to the devShell to be able to run tests ## Improve engine diff --git a/backend/src/model.rs b/backend/src/model.rs index cf0f550..3a6cca6 100644 --- a/backend/src/model.rs +++ b/backend/src/model.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod device_events; pub mod devices; pub mod notifications; diff --git a/backend/src/model/config.rs b/backend/src/model/config.rs new file mode 100644 index 0000000..7400c7b --- /dev/null +++ b/backend/src/model/config.rs @@ -0,0 +1,17 @@ +use serde::Serialize; +use utoipa::ToSchema; + +// Front-end-facing view of the backend configuration. Only the settings the UI +// needs to adapt itself are exposed here, grouped by area so the shape can grow +// without breaking existing fields. Today it carries just the notification +// method, which gates the per-device push toggle in the settings screen. +#[derive(Clone, Serialize, ToSchema)] +pub struct Config { + pub notifications: NotificationConfig, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct NotificationConfig { + // The configured delivery method (e.g. "push", "pushover", "none"). + pub method: String, +} diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index 25264dc..6417594 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -1,6 +1,7 @@ use std::error::Error; use std::path::{Path, PathBuf}; +use crate::model::config::{Config, NotificationConfig}; use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType}; use crate::model::devices::{Device, DeviceListResponse, DeviceSummary}; use crate::model::notifications::{Notification, NotificationListResponse, NotificationType}; @@ -29,6 +30,7 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa_swagger_ui::SwaggerUi; pub mod arp_scanner; +pub mod config; pub mod device_events; pub mod devices; pub mod dhcp_scanner; @@ -50,6 +52,7 @@ pub mod utils; ), paths( test_api, + config::read, devices::list, devices::summary, devices::read, @@ -71,6 +74,8 @@ pub mod utils; snmp_scanner::status, ), components(schemas( + Config, + NotificationConfig, Device, DeviceListResponse, DeviceSummary, @@ -90,6 +95,7 @@ pub mod utils; )), modifiers(&SecurityAddon), tags( + (name = "config", description = "Front-end configuration"), (name = "devices", description = "Device management"), (name = "notifications", description = "Notification management"), (name = "push_tokens", description = "Push notification token registration"), @@ -163,6 +169,7 @@ pub async fn serve() -> Result<(), Box> { let router = Router::new() .route("/api/test", get(test_api)) + .route("/api/config", get(config::read)) .route("/api/devices", get(devices::list)) .route("/api/devices", put(devices::register)) .route("/api/devices/summary", get(devices::summary)) diff --git a/backend/src/web_server/config.rs b/backend/src/web_server/config.rs new file mode 100644 index 0000000..8cb0711 --- /dev/null +++ b/backend/src/web_server/config.rs @@ -0,0 +1,41 @@ +use axum::Json; + +use crate::model::config::{Config, NotificationConfig}; +use crate::settings::get_settings; + +#[utoipa::path( + get, + path = "/api/config", + tag = "config", + responses( + (status = 200, description = "Front-end configuration", body = Config), + ), + security(("bearer_auth" = [])) +)] +pub async fn read() -> Json { + let settings = get_settings(); + Json(Config { + notifications: NotificationConfig { + method: settings.notifications.method.clone(), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests_common; + + #[tokio::test] + async fn config_reports_the_configured_notification_method() { + tests_common::setup().await; + + let Json(config) = read().await; + + assert_eq!( + config.notifications.method, + get_settings().notifications.method, + "The endpoint should echo the backend's configured notification method" + ); + } +} diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index 0fd500e..f63e377 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ json) { + final notifications = json['notifications'] as Map; + return AppConfig(notificationMethod: notifications['method'] as String); + } +} diff --git a/frontend/lib/settings/settings.dart b/frontend/lib/settings/settings.dart index 2c174d6..e07c88b 100644 --- a/frontend/lib/settings/settings.dart +++ b/frontend/lib/settings/settings.dart @@ -32,6 +32,9 @@ class _SettingsState extends State { late final PushService _pushService; bool _pushEnabled = false; bool _pushBusy = false; + // Whether the backend delivers notifications via push. The per-device push + // toggle only makes sense then, so it stays hidden until this is confirmed. + bool _pushMethodActive = false; final _formKey = GlobalKey(); @@ -46,6 +49,20 @@ class _SettingsState extends State { _selectedTheme = context.read().themeKey; _pushService = widget.pushService ?? FirebasePushService(); _pushEnabled = PrefUtil.getValue('push_enabled', false) as bool; + _loadConfig(); + } + + // Learn the backend's notification method so the push toggle is only shown + // when the backend actually delivers via push. Failures (e.g. the backend is + // unreachable, as on first run) just leave the toggle hidden. + Future _loadConfig() async { + try { + final config = await BackendAPI.instance.getConfig(); + if (!mounted) return; + setState(() => _pushMethodActive = config.notificationMethod == 'push'); + } catch (e) { + debugPrint('Failed to load backend config: $e'); + } } @override @@ -245,7 +262,7 @@ class _SettingsState extends State { if (value != null) setState(() => _selectedTheme = value); }, ), - if (_pushService.isSupported) ...[ + if (_pushService.isSupported && _pushMethodActive) ...[ const SizedBox(height: Insets.sm), SwitchListTile( contentPadding: EdgeInsets.zero, diff --git a/frontend/lib/utils/api/oott_api_config.dart b/frontend/lib/utils/api/oott_api_config.dart new file mode 100644 index 0000000..54f033b --- /dev/null +++ b/frontend/lib/utils/api/oott_api_config.dart @@ -0,0 +1,9 @@ +part of '../oott_api.dart'; + +/// Front-end configuration endpoint: the subset of backend settings the UI needs +/// to adapt itself (currently the notification delivery method). +extension ConfigApi on BackendAPI { + /// Fetches the backend's UI-facing configuration. Used by the settings screen + /// to show push controls only when the backend delivers via push. + Future getConfig() => _getModel('/config', AppConfig.fromJson); +} diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 447f6cb..c5dbee0 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -9,6 +9,7 @@ import 'api/dio_config.dart'; import 'backend_reachability.dart'; import 'pref_utils.dart'; import '../model/active_scanner_status.dart'; +import '../model/app_config.dart'; import '../model/passive_scanner_status.dart'; import '../model/device.dart'; import '../model/device_event.dart'; @@ -18,6 +19,7 @@ import '../model/notification.dart'; export 'api/api_error.dart'; +part 'api/oott_api_config.dart'; part 'api/oott_api_devices.dart'; part 'api/oott_api_scanners.dart'; part 'api/oott_api_notifications.dart'; diff --git a/frontend/test/api/config_api_test.dart b/frontend/test/api/config_api_test.dart new file mode 100644 index 0000000..97bb503 --- /dev/null +++ b/frontend/test/api/config_api_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/oott_api.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../helpers/backend_test_harness.dart'; + +void main() { + late DioAdapter adapter; + + setUp(() async { + adapter = await setUpBackendForTest(); + }); + + test('getConfig GETs /config and decodes the notification method', () async { + adapter.onGet( + '/config', + (server) => server.reply(200, { + 'notifications': {'method': 'push'}, + }), + ); + + final config = await BackendAPI.instance.getConfig(); + + expect(config.notificationMethod, 'push'); + }); +} diff --git a/frontend/test/widget/settings_push_test.dart b/frontend/test/widget/settings_push_test.dart index 669092f..788f196 100644 --- a/frontend/test/widget/settings_push_test.dart +++ b/frontend/test/widget/settings_push_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:frontend/settings/settings.dart'; import 'package:frontend/utils/pref_utils.dart'; import 'package:frontend/utils/push_service.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; import '../helpers/backend_test_harness.dart'; import '../helpers/pump_app.dart'; @@ -35,8 +36,10 @@ class _FakePushService implements PushService { const _toggleText = 'Push notifications on this device'; void main() { + late DioAdapter adapter; + setUp(() async { - await setUpBackendForTest( + adapter = await setUpBackendForTest( prefs: { 'base_url': 'http://my.server/api', 'api_key': XOR().xorEncode('topsecret'), @@ -48,24 +51,59 @@ void main() { await PrefUtil.setValue('push_enabled', false); }); - testWidgets('shows the push toggle on a supported platform', (tester) async { + // Stubs the backend config endpoint the settings screen reads on load. + void stubNotificationMethod(String method) { + adapter.onGet( + '/config', + (server) => server.reply(200, { + 'notifications': {'method': method}, + }), + ); + } + + // Pumps frames so the async config load (resolved via Dio's zero-duration + // timer) settles, for assertions that expect the toggle to be absent. + Future settleConfig(WidgetTester tester) async { + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + } + + testWidgets('shows the push toggle when the backend method is push', ( + tester, + ) async { + stubNotificationMethod('push'); await pumpScreen(tester, Settings(pushService: _FakePushService())); + await pumpUntilFound(tester, find.text(_toggleText)); expect(find.text(_toggleText), findsOneWidget); }); testWidgets('hides the push toggle when push is unsupported', (tester) async { + stubNotificationMethod('push'); await pumpScreen( tester, Settings(pushService: _FakePushService(supported: false)), ); + await settleConfig(tester); + expect(find.text(_toggleText), findsNothing); + }); + + testWidgets('hides the push toggle when the backend method is not push', ( + tester, + ) async { + stubNotificationMethod('pushover'); + await pumpScreen(tester, Settings(pushService: _FakePushService())); + await settleConfig(tester); expect(find.text(_toggleText), findsNothing); }); testWidgets('enabling push registers and shows a success message', ( tester, ) async { + stubNotificationMethod('push'); final service = _FakePushService(enableResult: true); await pumpScreen(tester, Settings(pushService: service)); + await pumpUntilFound(tester, find.byType(SwitchListTile)); await tester.tap(find.byType(SwitchListTile)); await pumpUntilFound( @@ -75,31 +113,38 @@ void main() { expect(service.enableCalls, 1); expect(PrefUtil.getValue('push_enabled', false), isTrue); - expect(tester.widget(find.byType(SwitchListTile)).value, isTrue); + expect( + tester.widget(find.byType(SwitchListTile)).value, + isTrue, + ); }); testWidgets('a declined permission leaves the toggle off with an error', ( tester, ) async { + stubNotificationMethod('push'); final service = _FakePushService(enableResult: false); await pumpScreen(tester, Settings(pushService: service)); + await pumpUntilFound(tester, find.byType(SwitchListTile)); await tester.tap(find.byType(SwitchListTile)); - await pumpUntilFound( - tester, - find.textContaining('Could not enable push'), - ); + await pumpUntilFound(tester, find.textContaining('Could not enable push')); expect(service.enableCalls, 1); - expect(tester.widget(find.byType(SwitchListTile)).value, isFalse); + expect( + tester.widget(find.byType(SwitchListTile)).value, + isFalse, + ); }); testWidgets('disabling push unregisters and shows a success message', ( tester, ) async { + stubNotificationMethod('push'); await PrefUtil.setValue('push_enabled', true); final service = _FakePushService(); await pumpScreen(tester, Settings(pushService: service)); + await pumpUntilFound(tester, find.byType(SwitchListTile)); // Starts on because the stored intent is enabled. expect( diff --git a/frontend/test/widget/settings_test.dart b/frontend/test/widget/settings_test.dart index c95aafa..84cbbb7 100644 --- a/frontend/test/widget/settings_test.dart +++ b/frontend/test/widget/settings_test.dart @@ -9,17 +9,27 @@ import '../helpers/pump_app.dart'; void main() { setUp(() async { - await setUpBackendForTest( + final adapter = await setUpBackendForTest( prefs: { 'base_url': 'http://my.server/api', 'api_key': XOR().xorEncode('topsecret'), 'theme': 'catppuccin_mocha', }, ); + // Settings loads the backend config on init; these tests don't exercise the + // push toggle, so report a non-push method to keep it hidden. + adapter.onGet( + '/config', + (server) => server.reply(200, { + 'notifications': {'method': 'none'}, + }), + ); }); testWidgets('prefills the form from stored preferences', (tester) async { await pumpScreen(tester, const Settings()); + // Let the on-init config request resolve so its timer doesn't leak. + await tester.pump(const Duration(milliseconds: 10)); expect(find.text('http://my.server/api'), findsOneWidget); expect(find.text('Catppuccin Mocha'), findsOneWidget); @@ -78,6 +88,7 @@ void main() { ) async { await PrefUtil.setValue('base_url', ''); await pumpScreen(tester, const Settings()); + await tester.pump(const Duration(milliseconds: 10)); expect(find.textContaining('Welcome to OOTT'), findsOneWidget); }); @@ -87,6 +98,7 @@ void main() { ) async { await PrefUtil.setValue('base_url', 'http://my.server/api'); await pumpScreen(tester, const Settings()); + await tester.pump(const Duration(milliseconds: 10)); expect(find.textContaining('Welcome to OOTT'), findsNothing); }); diff --git a/push_notifications.md b/push_notifications.md deleted file mode 100644 index ef9f40e..0000000 --- a/push_notifications.md +++ /dev/null @@ -1,284 +0,0 @@ -# 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 /health` — liveness. (Not `/healthz`: Google Front End reserves that - path and returns its own 404 before the request reaches the function.) -- 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.