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.
This commit is contained in:
SkateSpots 2026-10-01 19:28:54 +07:00
parent 4b831ebcc9
commit 5f0a9537b0
8 changed files with 308 additions and 44 deletions

View file

@ -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

10
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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=

View file

@ -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/<project_id>/messages:send`; an `UNREGISTERED`
token is dropped.
## Privacy
The service is **opt-in per device**. It stores a pubkey ↔ push-endpoint map and

View file

@ -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<string, StoredSubscription>; // endpoint → sub
/** FCM token → device (native push). */
devices: Record<string, StoredDevice>;
challenges: Record<string, { pubkey: string; expiresAt: number }>;
}
function load(): State {
try {
const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial<State>;
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<string> {
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<string, string> },
): Promise<void> {
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<string, unknown>): 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);

View file

@ -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<PushState>(() => (pushSupported() ? 'off' : 'unsupported'));
const native = isNativePlatform();
const supported = native || webPushSupported();
const [state, setState] = useState<PushState>(() => (supported ? 'off' : 'unsupported'));
const [error, setError] = useState<string | null>(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,19 +91,7 @@ export function usePushNotifications() {
[user],
);
const enable = useCallback(async () => {
if (!pushSupported()) {
setState('unsupported');
return;
}
if (!user?.pubkey || !user.signer) {
setError('Log in to enable background notifications.');
setState('error');
return;
}
setState('working');
setError(null);
try {
const enableWeb = useCallback(async () => {
const permission = await Notification.requestPermission();
if (permission !== 'granted') throw new Error('Notification permission was not granted.');
@ -101,41 +104,99 @@ export function usePushNotifications() {
applicationServerKey: urlBase64ToUint8Array(vapid),
});
const challenge = await requestPushChallenge(user.pubkey);
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<string>((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<string, string> | 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 (!supported) {
setState('unsupported');
return;
}
if (!user?.pubkey || !user.signer) {
setError('Log in to enable background notifications.');
setState('error');
return;
}
setState('working');
setError(null);
try {
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 {
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 — the local unsubscribe below still runs */
/* 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 };
}

View file

@ -56,3 +56,12 @@ export async function subscribeToPush(subscription: PushSubscriptionJSON, proof:
export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {
await post('/unsubscribe', { endpoint, proof });
}
/** Native (FCM) device registration — Android/iOS. */
export async function subscribeNativePush(token: string, platform: string, proof: unknown): Promise<void> {
await post('/native/subscribe', { token, platform, proof });
}
export async function unsubscribeNativePush(token: string, proof: unknown): Promise<void> {
await post('/native/unsubscribe', { token, proof });
}