# 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//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/`. - 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 | S–M | 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`