d3ro-voice/docs/deployment/push-transport-without-firebase.md
Yun Chan c3ddd36c6f
Some checks failed
deploy-site / deploy (push) Failing after 40s
docs: record the 1.1.0 release and add the infrastructure map
Release notes for 1.1.0 were split between an Unreleased section and the
version section, so the published notes would have omitted the update-feed
and desktop changes. Everything shipping in this version now sits under one
`## [1.1.0]` heading.

`docs/map/` becomes the entry point for what infrastructure exists per
platform and how far each feature is developed, with a documented update
protocol so feature work and this map do not drift apart again. The release
guide now states that installer binaries live in the update feed rather than
the repository.
2026-09-16 23:27:52 +09:00

126 lines
8.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Push transport without Firebase — analysis
> Status: decision note (2026-09-13)
> Scope: `send-push` Edge Function, `server/supabase/migrations` push outbox, `apps/mobile-rn` notification stack
> Question: we already pay for Supabase. Can it replace Firebase for notifications?
---
## 0. TL;DR
- **Supabase already is the push backend.** Tokens, devices, durable outbox, leases, retries, and delivery audit all live in Supabase (`push_tokens`, `devices`, `push_deliveries`, `push_dispatch_attempts`, `send-push`). Firebase is only the **last hop** to the device.
- **Web and iOS are now Firebase-free on the server side.** Web Push (VAPID + RFC 8291 aes128gcm) and Apple APNs (`.p8` token) transports are implemented in `send-push` (`_shared/webpush.ts`, `_shared/apns.ts`, 10 unit tests). Client registration for those providers is the remaining work.
- **Android cannot drop Firebase without changing transport.** Google Play Services only delivers push through FCM, and the FCM registration token is minted by the Firebase Messaging SDK, which requires `google-services.json` (a Firebase project). Supabase cannot substitute for that hop.
- **The scheduled drain was missing and is now added.** Nothing in the repo triggered `send-push?mode=drain`, so transactionally enqueued notifications never left the outbox. A Cloudflare Cron Trigger now drains it every minute.
- **Firebase-free Android is possible but is a different product decision:** UnifiedPush + a self-hosted distributor (e.g. ntfy). Third-party push services (OneSignal, Pusher) do **not** remove the Firebase dependency; they wrap FCM.
---
## 1. What exists today
| Layer | Owned by | Notes |
|---|---|---|
| Token + device registry | Supabase | `push_tokens`, `devices`; writes via `register_push_registration` RPC; RLS denies direct access |
| Durable outbox + retry/lease | Supabase | `push_deliveries`, `push_dispatch_attempts`, `claim_due_push_dispatches`, `finalize_push_delivery` |
| Dispatch orchestration | Supabase Edge `send-push` | Auth, event resolution, batching, stale-token handling |
| **Transport** | **FCM** | `_shared/push-contract.ts``https://fcm.googleapis.com/v1/projects/<id>/messages:send`, OAuth2 via `FCM_SERVICE_ACCOUNT_JSON` |
| Android client | Firebase Messaging SDK | `com.google.firebase:firebase-messaging`, `D3ROFirebaseMessagingService`, `google-services.json`; `FIREBASE_CONFIGURED` build flag |
Provider values accepted but unimplemented (`send-push/index.ts`):
```
const unsupported = deliveries.filter((delivery) => delivery.provider !== 'fcm')
→ finalizeDelivery(..., 'permanent_failure', 'push_provider_not_supported')
```
So: the only Firebase-specific pieces are (a) the FCM HTTP v1 send + service account, and (b) the Android client token source.
---
## 2. Platform-by-platform answer
### Desktop — no change needed
In-app events use IPC/EventEmitter and Supabase Realtime. No push transport, no Firebase.
### Web — Firebase-free, implementable now
- Standard **Web Push** with **VAPID** keys. The browser Push API + service worker handles delivery; the server signs with a VAPID private key and POSTs to the subscription endpoint.
- Supabase plan: store `push_subscriptions` (endpoint + keys per browser), send from `send-push` with a VAPID JWT (ES256).
- No Firebase, no Google account. APNs not involved.
### iOS — Firebase-free, implementable now
- **APNs** directly: `.p8` key + Key ID + Team ID → JWT (ES256) → `https://api.push.apple.com/3/device/<token>`.
- Needs an Apple Developer membership (already needed for App Store), **not** a Firebase project.
- Supabase Edge Function can hold the `.p8` as a secret and sign the JWT with WebCrypto.
### Android — Firebase-free only via UnifiedPush
- Google Play Services delivers notifications through FCM. The official `FirebaseMessaging` SDK mints the registration token, and it requires `google-services.json`. There is no supported way to obtain an FCM token without a Firebase project.
- FCM HTTP v1 also requires an OAuth2 service account with the `firebase.messaging` scope, which means an FCM-enabled Google Cloud/Firebase project (the Firebase *console* is not strictly required, but the project is).
- Firebase-free options:
1. **UnifiedPush + self-hosted distributor (ntfy)** — open standard; the app registers with a distributor (`ntfy`, NextPush) which holds a persistent connection; the server POSTs to the distributor. No Google. Requires the user to have a distributor installed (or the app to bundle one) and adds a background-connection battery cost.
2. **Own persistent WebSocket / foreground service** — free of Google but unreliable: Android Doze/App Standby kills sockets, so notifications cannot wake a killed app. Acceptable only for in-app live updates while running.
3. **Third-party (OneSignal, Pusher Beams, Expo Push)** — still FCM/APNs underneath. Does not remove Firebase for Android.
---
## 3. What "Firebase" actually costs us today
- A Firebase project + `google-services.json` committed for the Android build (build blocks release if missing).
- A service account JSON in Supabase secrets (`FCM_SERVICE_ACCOUNT_JSON`) and `FCM_PROJECT_ID`.
- No Firebase database/auth/storage/analytics is used. It is a push-only dependency.
If the goal is "no Firebase at all", the decision reduces to: replace Android transport (UnifiedPush) or accept FCM on Android only.
---
## 4. Recommended path
1. **Keep Supabase as the SSOT/outbox** (already true). Do not move token/outbox logic.
2. **[done] Implement `webpush` + `apns` transports** in `send-push` (`_shared/webpush.ts`, `_shared/apns.ts`).
3. **For Android, offer two modes behind config:**
- `fcm` (default): minimal Firebase project, push-only usage.
- `unifiedpush` (opt-in): self-hosted ntfy distributor; documented UX and battery caveats.
4. **Do not adopt OneSignal/Pusher** as a "Firebase replacement" — it does not remove the dependency.
### Effort sketch
| Item | Effort | Risk |
|---|---|---|
| Web Push (VAPID) transport + tests | SM | low — **done** |
| APNs (JWT ES256 `.p8`) transport + tests | M | medium — **server done**, Apple key + client registration pending |
| Cloudflare cron drain | S | low — **done** |
| UnifiedPush Android (client lib + distributor + server transport) | L | high (UX, battery, distributor dependency) |
| Minimal Firebase project for Android FCM | S (external action) | low |
---
## 5. Implemented (2026-09-13)
| Piece | Where | Notes |
|---|---|---|
| Web Push transport | `_shared/webpush.ts` | VAPID ES256 JWT + RFC 8291 `aes128gcm` encryption; strict config/subscription validation; 200/404/410/413 mapping |
| APNs transport | `_shared/apns.ts` | Token-based `.p8` ES256 JWT (cached ~50 min); production/sandbox hosts; stale (`BadDeviceToken`/`Unregistered`) and 403 handling |
| Provider routing | `send-push/index.ts` | `fcm` / `webpush` / `apns` dispatched with per-delivery stale → `stale`, payload → `permanent_failure`, else retryable; unknown providers stay `push_provider_not_supported` |
| Cloudflare cron drain | `server/cloudflare-worker/src/push-drain.ts` + `scheduled()` + `wrangler.toml [triggers] crons` | POSTs `send-push?mode=drain&limit=…` with the service-role bearer every minute |
| Tests | `_shared/webpush.test.ts`, `_shared/apns.test.ts`, `push-drain.test.ts`, `push-contract.test.ts` | 10 new tests incl. an RFC 8291 decrypt round-trip; CI runs the edge suite and the worker drain test |
### Required configuration
| Secret / var | Consumer | Purpose |
|---|---|---|
| `WEBPUSH_VAPID_PUBLIC_KEY` / `WEBPUSH_VAPID_PRIVATE_KEY` / `WEBPUSH_SUBJECT` | Supabase Edge Function | VAPID signing; subject must be `mailto:` or `https:` |
| `APNS_KEY_ID` / `APNS_TEAM_ID` / `APNS_PRIVATE_KEY` / `APNS_TOPIC` / `APNS_ENVIRONMENT` | Supabase Edge Function | APNs token auth; topic is the bundle id (`com.d3ro.voice`) |
| `SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `PUSH_DRAIN_BATCH_LIMIT` | Cloudflare Worker | Cron drain target + auth (service role key as `wrangler secret`) |
### Remaining work
- **Client registration:** the mobile app currently registers only `fcm`. Web needs a service worker + `pushManager.subscribe` → store the JSON subscription as the registration id; iOS needs an APNs device token and `provider: 'apns'`.
- **Android decision:** keep FCM or adopt UnifiedPush (see §2/§3).
---
## 6. Related
- `server/supabase/functions/_shared/push-contract.ts` — FCM transport + provider types
- `server/supabase/functions/_shared/webpush.ts` — Web Push (VAPID + RFC 8291)
- `server/supabase/functions/_shared/apns.ts` — APNs (.p8 token)
- `server/supabase/functions/send-push/index.ts` — dispatch orchestration
- `server/cloudflare-worker/src/push-drain.ts` — cron drain
- `apps/mobile-rn/src/features/notifications/*` — client token/provider contract
- `docs/map/11-gap-backlog.md` `GAP-PUSH-01` / `GAP-PUSH-02` / `EXT-FIREBASE-01`
- `docs/v3/MOBILE_APP_COMPLETION_SSOT.md` `EXT-011`