Gate push toggle on backend notification method

Add a GET /api/config endpoint exposing the front-end-facing backend
configuration (currently the notification delivery method, grouped under
a nested "notifications" object so the shape can grow). The settings
screen fetches it on init and only shows the per-device push toggle when
the backend method is "push" (and the platform supports push, which keeps
it off the browser).

Also fold in related push-notifications cleanups: fix the Android app
label ("frontend" -> "OOTT") so the notification permission dialog reads
correctly, remove the completed push_notifications.md plan, and update
TODO.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-08 17:26:53 -04:00
co-authored by Claude Opus 4.8
parent bef1a2987d
commit 1d85ec6f83
14 changed files with 209 additions and 299 deletions
+5 -4
View File
@@ -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
+1
View File
@@ -1,3 +1,4 @@
pub mod config;
pub mod device_events;
pub mod devices;
pub mod notifications;
+17
View File
@@ -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,
}
+7
View File
@@ -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<dyn Error>> {
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))
+41
View File
@@ -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<Config> {
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"
);
}
}
@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="frontend"
android:label="OOTT"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
+16
View File
@@ -0,0 +1,16 @@
/// Front-end-facing backend configuration, served by `GET /api/config`.
///
/// Only the settings the UI needs to adapt itself are exposed. Today that is the
/// notification delivery method, which the settings screen uses to decide
/// whether the per-device push toggle applies.
class AppConfig {
AppConfig({required this.notificationMethod});
/// The backend's configured delivery method (e.g. `push`, `pushover`, `none`).
final String notificationMethod;
factory AppConfig.fromJson(Map<String, dynamic> json) {
final notifications = json['notifications'] as Map<String, dynamic>;
return AppConfig(notificationMethod: notifications['method'] as String);
}
}
+18 -1
View File
@@ -32,6 +32,9 @@ class _SettingsState extends State<Settings> {
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<FormState>();
@@ -46,6 +49,20 @@ class _SettingsState extends State<Settings> {
_selectedTheme = context.read<AppState>().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<void> _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<Settings> {
if (value != null) setState(() => _selectedTheme = value);
},
),
if (_pushService.isSupported) ...[
if (_pushService.isSupported && _pushMethodActive) ...[
const SizedBox(height: Insets.sm),
SwitchListTile(
contentPadding: EdgeInsets.zero,
@@ -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<AppConfig> getConfig() => _getModel('/config', AppConfig.fromJson);
}
+2
View File
@@ -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';
+26
View File
@@ -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');
});
}
+53 -8
View File
@@ -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<void> 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<SwitchListTile>(find.byType(SwitchListTile)).value, isTrue);
expect(
tester.widget<SwitchListTile>(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<SwitchListTile>(find.byType(SwitchListTile)).value, isFalse);
expect(
tester.widget<SwitchListTile>(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(
+13 -1
View File
@@ -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);
});
-284
View File
@@ -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.