From 5f0a9537b02bac0d02a3ec33414dd66681515f2f Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 19:28:54 +0700 Subject: [PATCH] feat(push): Android FCM (Phase 2) - native device support Server: /native/subscribe + /native/unsubscribe (NIP-98 proof), FCM device store, and FCM HTTP v1 sending (service-account OAuth2, cached ~1h); /health reports native + device count. 503 without PUSH_FIREBASE_SERVICE_ACCOUNT_JSON. Client: @capacitor/push-notifications (lazily imported, native-only) in usePushNotifications - request permission, register, store the FCM token, and subscribe via /native/subscribe; tap deep-links into the app. Needs a Firebase project: google-services.json + the Gradle plugin (android/), and the service-account JSON in the host .env. --- AGENTS.md | 12 ++- package-lock.json | 10 ++ package.json | 1 + push-service/.env.example | 5 + push-service/README.md | 23 +++++ push-service/server.ts | 151 +++++++++++++++++++++++++++++- src/hooks/usePushNotifications.ts | 141 ++++++++++++++++++++-------- src/lib/pushService.ts | 9 ++ 8 files changed, 308 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f75995a..d563215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -786,8 +786,16 @@ session with **no other memory of this project**. So: `https://push.skate-spots.org/health` → 200. ⚠️ 000 from this box = a **stale local resolver cache** (NXDOMAIN); confirm with `curl --resolve push.skate-spots.org:443:185.18.221.2 …`. **Remaining:** - Android FCM (Phase 2). ⚠️ Web push works in browsers; the Capacitor WebView - needs FCM for the APK. (Approach + tradeoffs came from the + Android FCM (**Phase 2 — code landed 2026-10-01**): the service has + `/native/subscribe` + `/native/unsubscribe` (same NIP-98 proof) and sends via + **FCM HTTP v1** (service-account OAuth2, token cached ~1h); client uses + `@capacitor/push-notifications` (lazily imported, native-only) in + `usePushNotifications`, storing the FCM token in `skatespots:fcm-token`. + ⚠️ **Needs a Firebase project** (user-only): `google-services.json` → + `android/app/` + the Gradle google-services plugin, and the **service-account + JSON** in the host's `.env` as `PUSH_FIREBASE_SERVICE_ACCOUNT_JSON` (single + line) — without it `/native/subscribe` → 503 and web push still works. Not + testable without a device. (Approach + tradeoffs came from the Numo/Linky/BTCPay review this session.) - **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS delivery coordinator** exposed as a **ContextVM server** over Nostr relays diff --git a/package-lock.json b/package-lock.json index d93029d..6362671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@capacitor/core": "^8.4.2", "@capacitor/geolocation": "^8.2.0", "@capacitor/local-notifications": "^8.3.1", + "@capacitor/push-notifications": "^8.1.2", "@cashu/cashu-ts": "2.8.1", "@contextvm/sdk": "^0.12.0", "@fontsource-variable/inter": "^5.2.6", @@ -391,6 +392,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/push-notifications": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.2.tgz", + "integrity": "sha512-TQl4XxqJfNF+KbSYBFMXYYT5WXUaleVKXlByhh/8+KySMVNjp+l5OqAUoGe/sY1IAnGXEuUeCjA/WLZEVvGwrA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/synapse": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", diff --git a/package.json b/package.json index 210925c..d20f98c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@capacitor/core": "^8.4.2", "@capacitor/geolocation": "^8.2.0", "@capacitor/local-notifications": "^8.3.1", + "@capacitor/push-notifications": "^8.1.2", "@cashu/cashu-ts": "2.8.1", "@contextvm/sdk": "^0.12.0", "@fontsource-variable/inter": "^5.2.6", diff --git a/push-service/.env.example b/push-service/.env.example index af6c279..fb552f4 100644 --- a/push-service/.env.example +++ b/push-service/.env.example @@ -24,3 +24,8 @@ PUSH_LOOKBACK_SECONDS=259200 # Origins allowed to call /subscribe (comma-separated). PUSH_ALLOWED_ORIGINS=https://skate-spots.org,http://localhost:8080 + +# Native (FCM) push. A SINGLE-LINE Firebase service-account JSON (project_id, +# client_email, private_key). Without it, /native/subscribe answers 503 and they +# native path is disabled — web push is unaffected. +PUSH_FIREBASE_SERVICE_ACCOUNT_JSON= diff --git a/push-service/README.md b/push-service/README.md index c9c6a51..68f7e20 100644 --- a/push-service/README.md +++ b/push-service/README.md @@ -80,6 +80,29 @@ Caddy — copy `Caddyfile` to `/etc/caddy/Caddyfile` and reload once. Logs: `npm run deploy:push -- --logs`. +## Native push (FCM, Android/iOS) + +The same service also delivers **FCM** notifications to the native app, so +closed-app alerts work in the APK too. Setup (one-time, needs a Google account): + +1. **Firebase project** → add an Android app with the package id + `org.skatespots.app`. Download **`google-services.json`** into + `android/app/` (gitignored — never commit it). +2. **Project settings → Service accounts → Generate new private key** → a + service-account JSON. Put it, single-line, in the server's `.env`: + ``` + PUSH_FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"…","client_email":"…","private_key":"…"} + ``` +3. Rebuild the APK (`npm run android:build`) and add the Gradle google-services + plugin (the Capacitor push plugin requires it). + +Endpoints (native): `POST /native/subscribe` `{ token, platform, proof }` and +`POST /native/unsubscribe` `{ token, proof }` — same NIP-98 challenge proof as +web. `/health` reports `native: true|false` and the device count. The service +mints a Google OAuth2 token from the service account (cached ~1h) and posts to +`fcm.googleapis.com/v1/projects//messages:send`; an `UNREGISTERED` +token is dropped. + ## Privacy The service is **opt-in per device**. It stores a pubkey ↔ push-endpoint map and diff --git a/push-service/server.ts b/push-service/server.ts index a42b2ab..a58169c 100644 --- a/push-service/server.ts +++ b/push-service/server.ts @@ -21,6 +21,7 @@ * State is a small JSON file (subscriptions + challenges). Env + deploy: README.md. */ import { readFileSync, writeFileSync } from 'node:fs'; +import { createSign } from 'node:crypto'; import webpush from 'web-push'; import { verifyEvent, type NostrEvent } from 'nostr-tools'; @@ -55,20 +56,39 @@ if (!vapidPublic || !vapidPrivate) { } webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate); +// ─── FCM (native push) ─────────────────────────────────────────────────────── +// Absent service account → /native/subscribe answers 503 and no native sends. +interface FcmServiceAccount { project_id: string; client_email: string; private_key: string } +let fcmServiceAccount: FcmServiceAccount | null = null; +try { + const raw = (env.PUSH_FIREBASE_SERVICE_ACCOUNT_JSON ?? '').trim(); + if (raw) fcmServiceAccount = JSON.parse(raw) as FcmServiceAccount; +} catch { + console.warn('[push] PUSH_FIREBASE_SERVICE_ACCOUNT_JSON is not valid JSON — native push disabled.'); +} + // ─── State (JSON file) ─────────────────────────────────────────────────────── interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string } +/** A native (FCM) device token — Android APK / iOS. */ +interface StoredDevice { pubkey: string; platform: string; updatedAt: number } interface State { subscriptions: Record; // endpoint → sub + /** FCM token → device (native push). */ + devices: Record; challenges: Record; } function load(): State { try { const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial; - return { subscriptions: parsed.subscriptions ?? {}, challenges: parsed.challenges ?? {} }; + return { + subscriptions: parsed.subscriptions ?? {}, + devices: parsed.devices ?? {}, + challenges: parsed.challenges ?? {}, + }; } catch { - return { subscriptions: {}, challenges: {} }; + return { subscriptions: {}, devices: {}, challenges: {} }; } } const state = load(); @@ -107,6 +127,92 @@ function verifyProof(proof: unknown, expectedPubkey: string): { ok: boolean; err return { ok: true }; } +// ─── FCM send (OAuth2 service-account) ─────────────────────────────────────── + +/** FCM device tokens registered for a pubkey. */ +const devicesFor = (pubkey: string): string[] => + Object.entries(state.devices) + .filter(([, d]) => d.pubkey === pubkey) + .map(([token]) => token); + +let cachedAccessToken: { token: string; expiresAt: number } | null = null; + +const base64url = (data: string | Buffer): string => Buffer.from(data).toString('base64url'); + +/** Mint (and cache) a Google OAuth2 access token from the service account. */ +async function getFcmAccessToken(sa: FcmServiceAccount): Promise { + const now = Math.floor(Date.now() / 1000); + if (cachedAccessToken && cachedAccessToken.expiresAt > now + 60) return cachedAccessToken.token; + + const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const claim = base64url( + JSON.stringify({ + iss: sa.client_email, + scope: 'https://www.googleapis.com/auth/firebase.messaging', + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600, + }), + ); + const signingInput = `${header}.${claim}`; + const signer = createSign('RSA-SHA256'); + signer.update(signingInput); + signer.end(); + const assertion = `${signingInput}.${signer.sign(sa.private_key).toString('base64url')}`; + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${assertion}`, + }); + if (!res.ok) throw new Error(`FCM token ${res.status}`); + const data = (await res.json()) as { access_token: string; expires_in: number }; + cachedAccessToken = { token: data.access_token, expiresAt: now + (data.expires_in ?? 3600) }; + return data.access_token; +} + +/** Send a notification to native device tokens (best-effort, never throws). */ +async function sendFcm( + tokens: string[], + payload: { title: string; body: string; data?: Record }, +): Promise { + if (!fcmServiceAccount || tokens.length === 0) return; + let access: string; + try { + access = await getFcmAccessToken(fcmServiceAccount); + } catch (err) { + console.warn('[push] FCM auth failed:', (err as Error).message); + return; + } + for (const token of tokens) { + try { + const res = await fetch( + `https://fcm.googleapis.com/v1/projects/${fcmServiceAccount.project_id}/messages:send`, + { + method: 'POST', + headers: { authorization: `Bearer ${access}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + message: { + token, + notification: { title: payload.title, body: payload.body }, + data: payload.data ?? {}, + }, + }), + }, + ); + if (res.status === 404 || res.status === 400) { + // UNREGISTERED / invalid token — drop it. + delete state.devices[token]; + save(); + } else if (!res.ok) { + console.warn(`[push] FCM send failed (${res.status})`); + } + } catch (err) { + console.warn('[push] FCM send error:', (err as Error).message); + } + } +} + // ─── Web Push ──────────────────────────────────────────────────────────────── function subsFor(pubkey: string): StoredSubscription[] { @@ -131,6 +237,16 @@ async function pushTo(pubkey: string, payload: Record): Promise } } } + + // Native (FCM) devices registered for the same pubkey. + await sendFcm(devicesFor(pubkey), { + title: String(payload.title ?? 'SkateSpots'), + body: String(payload.body ?? 'New notification'), + data: { + ...(payload.wrapId ? { wrapId: String(payload.wrapId) } : {}), + ...(payload.url ? { url: String(payload.url) } : {}), + }, + }); } // ─── Relay watcher (no keys, no decryption) ────────────────────────────────── @@ -285,6 +401,8 @@ Bun.serve({ return json({ ok: true, subscriptions: Object.keys(state.subscriptions).length, + devices: Object.keys(state.devices).length, + native: !!fcmServiceAccount, relays: relayStatus, }, 200, headers); } @@ -328,6 +446,35 @@ Bun.serve({ return json({ ok: true }, 200, headers); } + if (req.method === 'POST' && url.pathname === '/native/subscribe') { + if (!fcmServiceAccount) return json({ error: 'native_push_unavailable' }, 503, headers); + const body = (await req.json().catch(() => null)) as + | { token?: string; platform?: string; proof?: NostrEvent } + | null; + if (!body?.token) return json({ error: 'token required' }, 400, headers); + if (!body?.proof) return json({ error: 'proof required' }, 400, headers); + const check = verifyProof(body.proof, body.proof.pubkey); + if (!check.ok) return json({ error: check.error }, 401, headers); + state.devices[body.token] = { + pubkey: body.proof.pubkey.toLowerCase(), + platform: body.platform ?? 'android', + updatedAt: Math.floor(Date.now() / 1000), + }; + save(); + console.log(`[push] 📱 native device for ${body.proof.pubkey.slice(0, 12)}…`); + return json({ ok: true }, 200, headers); + } + + if (req.method === 'POST' && url.pathname === '/native/unsubscribe') { + const body = (await req.json().catch(() => null)) as { token?: string; proof?: NostrEvent } | null; + if (!body?.token || !body?.proof) return json({ error: 'token and proof required' }, 400, headers); + const check = verifyProof(body.proof, body.proof.pubkey); + if (!check.ok) return json({ error: check.error }, 401, headers); + delete state.devices[body.token]; + save(); + return json({ ok: true }, 200, headers); + } + if (req.method === 'POST' && url.pathname === '/unsubscribe') { const body = (await req.json().catch(() => null)) as { endpoint?: string; proof?: NostrEvent } | null; if (!body?.endpoint || !body?.proof) return json({ error: 'endpoint and proof required' }, 400, headers); diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts index dab4a18..d76c4d1 100644 --- a/src/hooks/usePushNotifications.ts +++ b/src/hooks/usePushNotifications.ts @@ -1,25 +1,34 @@ /** - * usePushNotifications — opt in/out of the SkateSpots push service (see - * `push-service/`) for this device. + * usePushNotifications — opt in/out of the SkateSpots push service + * (`push-service/`) for this device. Two paths: * - * Enabling: register the push service worker, ask for permission, subscribe with - * the service's VAPID key, sign a NIP-98 (kind 27235) challenge proof, and POST - * it to the service. Disabling reverses it. Nothing is sent anywhere except the - * pubkey + this device's push endpoint. + * - **Web / PWA** — service worker + VAPID Web Push. + * - **Native (Android/iOS)** — FCM via `@capacitor/push-notifications` + * (`/native/subscribe` + `/native/unsubscribe`; the plugin is imported lazily + * so the web bundle never includes it). + * + * Both prove ownership of the pubkey with a NIP-98 challenge proof, and the + * payload is content-free — the server never decrypts anything. */ import { useCallback, useEffect, useState } from 'react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { isNativePlatform, getPlatform } from '@/lib/platform'; import { fetchVapidPublicKey, requestPushChallenge, subscribeToPush, unsubscribeFromPush, + subscribeNativePush, + unsubscribeNativePush, type PushSubscriptionJSON, } from '@/lib/pushService'; export type PushState = 'unsupported' | 'off' | 'on' | 'working' | 'error'; -function pushSupported(): boolean { +/** The FCM token of this device (native only), so we can unsubscribe later. */ +const FCM_TOKEN_KEY = 'skatespots:fcm-token'; + +function webPushSupported(): boolean { return ( typeof navigator !== 'undefined' && 'serviceWorker' in navigator && @@ -39,12 +48,18 @@ function urlBase64ToUint8Array(base64: string): Uint8Array { export function usePushNotifications() { const { user } = useCurrentUser(); - const [state, setState] = useState(() => (pushSupported() ? 'off' : 'unsupported')); + const native = isNativePlatform(); + const supported = native || webPushSupported(); + const [state, setState] = useState(() => (supported ? 'off' : 'unsupported')); const [error, setError] = useState(null); // Reflect an existing subscription on mount / account switch. useEffect(() => { - if (!pushSupported()) { + if (native) { + setState(localStorage.getItem(FCM_TOKEN_KEY) ? 'on' : 'off'); + return; + } + if (!webPushSupported()) { setState('unsupported'); return; } @@ -61,7 +76,7 @@ export function usePushNotifications() { return () => { cancelled = true; }; - }, [user?.pubkey]); + }, [user?.pubkey, native]); const signProof = useCallback( async (challenge: string) => { @@ -76,8 +91,54 @@ export function usePushNotifications() { [user], ); + const enableWeb = useCallback(async () => { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') throw new Error('Notification permission was not granted.'); + + const reg = await navigator.serviceWorker.register('/push-sw.js'); + await navigator.serviceWorker.ready; + + const vapid = await fetchVapidPublicKey(); + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapid), + }); + + const challenge = await requestPushChallenge(user!.pubkey); + const proof = await signProof(challenge); + await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof); + }, [user, signProof]); + + const enableNative = useCallback(async () => { + const { PushNotifications } = await import('@capacitor/push-notifications'); + const permission = await PushNotifications.requestPermissions(); + if (permission.receive !== 'granted') throw new Error('Notification permission was not granted.'); + + await PushNotifications.removeAllListeners(); + + const token = await new Promise((resolve, reject) => { + void (async () => { + await PushNotifications.addListener('registration', (t) => resolve(t.value)); + await PushNotifications.addListener('registrationError', (e) => + reject(new Error(String((e as { error?: string }).error ?? 'Registration failed'))), + ); + // Tapping the notification deep-links into the app (HashRouter path). + await PushNotifications.addListener('pushNotificationActionPerformed', (action) => { + const url = (action.notification.data as Record | undefined)?.url; + if (url) window.location.href = url; + }); + await PushNotifications.register(); + })().catch(reject); + }); + + localStorage.setItem(FCM_TOKEN_KEY, token); + const challenge = await requestPushChallenge(user!.pubkey); + const proof = await signProof(challenge); + await subscribeNativePush(token, getPlatform(), proof); + }, [user, signProof]); + const enable = useCallback(async () => { - if (!pushSupported()) { + if (!supported) { setState('unsupported'); return; } @@ -89,53 +150,53 @@ export function usePushNotifications() { setState('working'); setError(null); try { - const permission = await Notification.requestPermission(); - if (permission !== 'granted') throw new Error('Notification permission was not granted.'); - - const reg = await navigator.serviceWorker.register('/push-sw.js'); - await navigator.serviceWorker.ready; - - const vapid = await fetchVapidPublicKey(); - const sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapid), - }); - - const challenge = await requestPushChallenge(user.pubkey); - const proof = await signProof(challenge); - - await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof); + if (native) await enableNative(); + else await enableWeb(); setState('on'); } catch (err) { setError(err instanceof Error ? err.message : 'Could not enable background notifications.'); setState('error'); } - }, [user, signProof]); + }, [supported, user, native, enableNative, enableWeb]); const disable = useCallback(async () => { - if (!pushSupported() || !user?.pubkey) { + if (!supported || !user?.pubkey) { setState('off'); return; } setState('working'); setError(null); try { - const reg = await navigator.serviceWorker.getRegistration(); - const sub = await reg?.pushManager.getSubscription(); - if (sub) { - const challenge = await requestPushChallenge(user.pubkey); - const proof = await signProof(challenge); - await unsubscribeFromPush(sub.endpoint, proof).catch(() => { - /* best effort — the local unsubscribe below still runs */ - }); - await sub.unsubscribe(); + if (native) { + const token = localStorage.getItem(FCM_TOKEN_KEY); + if (token) { + const challenge = await requestPushChallenge(user.pubkey); + const proof = await signProof(challenge); + await unsubscribeNativePush(token, proof).catch(() => { + /* best effort */ + }); + localStorage.removeItem(FCM_TOKEN_KEY); + } + const { PushNotifications } = await import('@capacitor/push-notifications'); + await PushNotifications.removeAllListeners(); + } else { + const reg = await navigator.serviceWorker.getRegistration(); + const sub = await reg?.pushManager.getSubscription(); + if (sub) { + const challenge = await requestPushChallenge(user.pubkey); + const proof = await signProof(challenge); + await unsubscribeFromPush(sub.endpoint, proof).catch(() => { + /* best effort */ + }); + await sub.unsubscribe(); + } } setState('off'); } catch (err) { setError(err instanceof Error ? err.message : 'Could not disable background notifications.'); setState('error'); } - }, [user, signProof]); + }, [supported, user, native, signProof]); - return { state, error, supported: pushSupported(), enable, disable }; + return { state, error, supported, enable, disable }; } diff --git a/src/lib/pushService.ts b/src/lib/pushService.ts index b0bba17..afc8161 100644 --- a/src/lib/pushService.ts +++ b/src/lib/pushService.ts @@ -56,3 +56,12 @@ export async function subscribeToPush(subscription: PushSubscriptionJSON, proof: export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise { await post('/unsubscribe', { endpoint, proof }); } + +/** Native (FCM) device registration — Android/iOS. */ +export async function subscribeNativePush(token: string, platform: string, proof: unknown): Promise { + await post('/native/subscribe', { token, platform, proof }); +} + +export async function unsubscribeNativePush(token: string, proof: unknown): Promise { + await post('/native/unsubscribe', { token, proof }); +}