# OOTT Push Relay

A small, **stateless** relay that forwards OOTT push notifications to the OS push
gateways (FCM for Android, APNs for iOS via Firebase). It is deployed as a
**Firebase Cloud Function** (2nd gen), so it scales to zero — no idle cost and no
server/OS to patch — and the platform provides TLS and a stable HTTPS URL.

It is part of the push-notification feature described in
[`../push_notifications.md`](../push_notifications.md). See that document for the
full design and rationale.

## What it does (and does not do)

- **Forwards only.** It receives `{ tokens, notification: { title, body } }` from
  a self-hosted OOTT backend and fans the message out to FCM via the
  `firebase-admin` `sendEach` API. It returns a per-token result so the backend
  can prune dead tokens.
- **Keeps no state and no PII.** There is no database of users or tokens here; the
  self-hosted backend owns the tokens. The only thing the relay stores is a
  per-IP rate-limit counter in Firestore.
- **Never sees private data.** The payload carries only an already-sanitized
  title/body — no `data` payload, no MAC, no IP, no device identifiers.
- **Credentials never leave Google.** The function authenticates to FCM via its
  runtime service account; there is no service-account JSON to hold or rotate.

## Routes

- `POST /v1/push` — body `{ "tokens": ["..."], "notification": { "title": "...",
  "body": "..." } }`. Returns `{ "results": [{ "token": "...", "status":
  "ok" | "unregistered" | "invalid" | "error" }] }`. The backend prunes tokens
  reported `unregistered` or `invalid`.
- `GET /health` — liveness, returns `200 ok`. (Not `/healthz`: that path is
  reserved by Google Front End and never reaches the function.)

Protection (Phase 1, no shared secret): FCM project scoping (the relay can only
reach OOTT app installs), per-source-IP rate limiting, and a billing cap.

## Develop

Requires Node 22.

```sh
npm install
npm test          # unit tests (validation, FCM-response mapping, rate limiting)
npm run build     # type-check + emit to lib/
npm run serve     # run locally in the Firebase emulator
```

## One-time project-owned setup (manual — no secrets committed)

These steps are performed once by the project owner. **Do not commit any secrets,
service-account keys, or `google-services.json` / `GoogleService-Info.plist`**
(project rule).

1. **Create a Firebase project** and upgrade it to the **Blaze** plan (Cloud
   Functions require it). Set a **budget + billing cap** as the hard ceiling
   against cost runaway.
2. **Register the apps**: add the Android app and the iOS app to the Firebase
   project (their bundle/package ids must match the shipped OOTT app).
3. **APNs key**: in the Apple Developer portal create an APNs Auth Key (`.p8`)
   and upload it under *Project settings → Cloud Messaging → Apple app
   configuration*. This is what lets Firebase bridge to APNs for iOS.
4. **Enable Firestore** (Native mode) — used only for the rate-limit counter.
5. **Install the Firebase CLI** (`npm i -g firebase-tools`) and `firebase login`.
6. Select the project: `firebase use --add` (creates `.firebaserc`, which holds
   only the project id and is safe to commit, or keep local).

## Deploy

```sh
npm run deploy    # builds, then `firebase deploy --only functions`
```

After the first deploy, note the function's HTTPS URL (shown in the CLI output,
e.g. `https://us-central1-<project>.cloudfunctions.net/relay`). The backend's
push send endpoint is that URL plus `/v1/push`. Set this as the default
`relay_url` in the backend (`backend/src/settings.rs`, `default_relay_url`); a
self-hoster can always override it via `[notifications.push] relay_url`.
