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 `https://push.skate-spots.org/health` → 200. ⚠️ 000 from this box = a **stale
local resolver cache** (NXDOMAIN); confirm with local resolver cache** (NXDOMAIN); confirm with
`curl --resolve push.skate-spots.org:443:185.18.221.2 …`. **Remaining:** `curl --resolve push.skate-spots.org:443:185.18.221.2 …`. **Remaining:**
Android FCM (Phase 2). ⚠️ Web push works in browsers; the Capacitor WebView Android FCM (**Phase 2 — code landed 2026-10-01**): the service has
needs FCM for the APK. (Approach + tradeoffs came from the `/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.) Numo/Linky/BTCPay review this session.)
- **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS - **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS
delivery coordinator** exposed as a **ContextVM server** over Nostr relays 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/core": "^8.4.2",
"@capacitor/geolocation": "^8.2.0", "@capacitor/geolocation": "^8.2.0",
"@capacitor/local-notifications": "^8.3.1", "@capacitor/local-notifications": "^8.3.1",
"@capacitor/push-notifications": "^8.1.2",
"@cashu/cashu-ts": "2.8.1", "@cashu/cashu-ts": "2.8.1",
"@contextvm/sdk": "^0.12.0", "@contextvm/sdk": "^0.12.0",
"@fontsource-variable/inter": "^5.2.6", "@fontsource-variable/inter": "^5.2.6",
@ -391,6 +392,15 @@
"@capacitor/core": ">=8.0.0" "@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": { "node_modules/@capacitor/synapse": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",

View file

@ -32,6 +32,7 @@
"@capacitor/core": "^8.4.2", "@capacitor/core": "^8.4.2",
"@capacitor/geolocation": "^8.2.0", "@capacitor/geolocation": "^8.2.0",
"@capacitor/local-notifications": "^8.3.1", "@capacitor/local-notifications": "^8.3.1",
"@capacitor/push-notifications": "^8.1.2",
"@cashu/cashu-ts": "2.8.1", "@cashu/cashu-ts": "2.8.1",
"@contextvm/sdk": "^0.12.0", "@contextvm/sdk": "^0.12.0",
"@fontsource-variable/inter": "^5.2.6", "@fontsource-variable/inter": "^5.2.6",

View file

@ -24,3 +24,8 @@ PUSH_LOOKBACK_SECONDS=259200
# Origins allowed to call /subscribe (comma-separated). # Origins allowed to call /subscribe (comma-separated).
PUSH_ALLOWED_ORIGINS=https://skate-spots.org,http://localhost:8080 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`. 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 ## Privacy
The service is **opt-in per device**. It stores a pubkey ↔ push-endpoint map and 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. * State is a small JSON file (subscriptions + challenges). Env + deploy: README.md.
*/ */
import { readFileSync, writeFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
import { createSign } from 'node:crypto';
import webpush from 'web-push'; import webpush from 'web-push';
import { verifyEvent, type NostrEvent } from 'nostr-tools'; import { verifyEvent, type NostrEvent } from 'nostr-tools';
@ -55,20 +56,39 @@ if (!vapidPublic || !vapidPrivate) {
} }
webpush.setVapidDetails(vapidSubject, 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) ─────────────────────────────────────────────────────── // ─── State (JSON file) ───────────────────────────────────────────────────────
interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string } 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 { interface State {
subscriptions: Record<string, StoredSubscription>; // endpoint → sub subscriptions: Record<string, StoredSubscription>; // endpoint → sub
/** FCM token → device (native push). */
devices: Record<string, StoredDevice>;
challenges: Record<string, { pubkey: string; expiresAt: number }>; challenges: Record<string, { pubkey: string; expiresAt: number }>;
} }
function load(): State { function load(): State {
try { try {
const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial<State>; 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 { } catch {
return { subscriptions: {}, challenges: {} }; return { subscriptions: {}, devices: {}, challenges: {} };
} }
} }
const state = load(); const state = load();
@ -107,6 +127,92 @@ function verifyProof(proof: unknown, expectedPubkey: string): { ok: boolean; err
return { ok: true }; 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 ──────────────────────────────────────────────────────────────── // ─── Web Push ────────────────────────────────────────────────────────────────
function subsFor(pubkey: string): StoredSubscription[] { 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) ────────────────────────────────── // ─── Relay watcher (no keys, no decryption) ──────────────────────────────────
@ -285,6 +401,8 @@ Bun.serve({
return json({ return json({
ok: true, ok: true,
subscriptions: Object.keys(state.subscriptions).length, subscriptions: Object.keys(state.subscriptions).length,
devices: Object.keys(state.devices).length,
native: !!fcmServiceAccount,
relays: relayStatus, relays: relayStatus,
}, 200, headers); }, 200, headers);
} }
@ -328,6 +446,35 @@ Bun.serve({
return json({ ok: true }, 200, headers); 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') { if (req.method === 'POST' && url.pathname === '/unsubscribe') {
const body = (await req.json().catch(() => null)) as { endpoint?: string; proof?: NostrEvent } | null; 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); 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 * usePushNotifications — opt in/out of the SkateSpots push service
* `push-service/`) for this device. * (`push-service/`) for this device. Two paths:
* *
* Enabling: register the push service worker, ask for permission, subscribe with * - **Web / PWA** — service worker + VAPID Web Push.
* the service's VAPID key, sign a NIP-98 (kind 27235) challenge proof, and POST * - **Native (Android/iOS)** — FCM via `@capacitor/push-notifications`
* it to the service. Disabling reverses it. Nothing is sent anywhere except the * (`/native/subscribe` + `/native/unsubscribe`; the plugin is imported lazily
* pubkey + this device's push endpoint. * 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 { useCallback, useEffect, useState } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isNativePlatform, getPlatform } from '@/lib/platform';
import { import {
fetchVapidPublicKey, fetchVapidPublicKey,
requestPushChallenge, requestPushChallenge,
subscribeToPush, subscribeToPush,
unsubscribeFromPush, unsubscribeFromPush,
subscribeNativePush,
unsubscribeNativePush,
type PushSubscriptionJSON, type PushSubscriptionJSON,
} from '@/lib/pushService'; } from '@/lib/pushService';
export type PushState = 'unsupported' | 'off' | 'on' | 'working' | 'error'; 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 ( return (
typeof navigator !== 'undefined' && typeof navigator !== 'undefined' &&
'serviceWorker' in navigator && 'serviceWorker' in navigator &&
@ -39,12 +48,18 @@ function urlBase64ToUint8Array(base64: string): Uint8Array {
export function usePushNotifications() { export function usePushNotifications() {
const { user } = useCurrentUser(); 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); const [error, setError] = useState<string | null>(null);
// Reflect an existing subscription on mount / account switch. // Reflect an existing subscription on mount / account switch.
useEffect(() => { useEffect(() => {
if (!pushSupported()) { if (native) {
setState(localStorage.getItem(FCM_TOKEN_KEY) ? 'on' : 'off');
return;
}
if (!webPushSupported()) {
setState('unsupported'); setState('unsupported');
return; return;
} }
@ -61,7 +76,7 @@ export function usePushNotifications() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [user?.pubkey]); }, [user?.pubkey, native]);
const signProof = useCallback( const signProof = useCallback(
async (challenge: string) => { async (challenge: string) => {
@ -76,8 +91,54 @@ export function usePushNotifications() {
[user], [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<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 () => { const enable = useCallback(async () => {
if (!pushSupported()) { if (!supported) {
setState('unsupported'); setState('unsupported');
return; return;
} }
@ -89,53 +150,53 @@ export function usePushNotifications() {
setState('working'); setState('working');
setError(null); setError(null);
try { try {
const permission = await Notification.requestPermission(); if (native) await enableNative();
if (permission !== 'granted') throw new Error('Notification permission was not granted.'); else await enableWeb();
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);
setState('on'); setState('on');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Could not enable background notifications.'); setError(err instanceof Error ? err.message : 'Could not enable background notifications.');
setState('error'); setState('error');
} }
}, [user, signProof]); }, [supported, user, native, enableNative, enableWeb]);
const disable = useCallback(async () => { const disable = useCallback(async () => {
if (!pushSupported() || !user?.pubkey) { if (!supported || !user?.pubkey) {
setState('off'); setState('off');
return; return;
} }
setState('working'); setState('working');
setError(null); setError(null);
try { try {
const reg = await navigator.serviceWorker.getRegistration(); if (native) {
const sub = await reg?.pushManager.getSubscription(); const token = localStorage.getItem(FCM_TOKEN_KEY);
if (sub) { if (token) {
const challenge = await requestPushChallenge(user.pubkey); const challenge = await requestPushChallenge(user.pubkey);
const proof = await signProof(challenge); const proof = await signProof(challenge);
await unsubscribeFromPush(sub.endpoint, proof).catch(() => { await unsubscribeNativePush(token, proof).catch(() => {
/* best effort — the local unsubscribe below still runs */ /* best effort */
}); });
await sub.unsubscribe(); 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'); setState('off');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Could not disable background notifications.'); setError(err instanceof Error ? err.message : 'Could not disable background notifications.');
setState('error'); 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> { export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {
await post('/unsubscribe', { endpoint, proof }); 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 });
}