feat(push): client half - subscribe flow, service worker, DM marker

- public/push-sw.js: shows the content-free push; tap focuses/opens the app.
- src/lib/pushService.ts + usePushNotifications: register the SW, subscribe with
  the service's VAPID key, sign a NIP-98 challenge proof, POST /subscribe.
- NotificationSettingsCard: a 'Background notifications' toggle (opt-in,
  per-device) + honest metadata copy; corrected the now-stale 'we run no push
  server' note.
- useSendDirectMessage: marks the RECIPIENT wrap ["skatespots","push"] (never
  the self copy) and adds the community relay as the notify relay so the push
  service sees it.
- /privacy: discloses the push server sees metadata only.
This commit is contained in:
SkateSpots 2026-10-01 15:39:04 +07:00
parent 807037ee9b
commit 38317ee2a5
7 changed files with 330 additions and 11 deletions

View file

@ -691,11 +691,16 @@ session with **no other memory of this project**. So:
marked wrap must land on a relay the service watches — designate the
community relay as the **notify relay** and publish push-marked recipient
copies there. ⚠️ Privacy: the server sees **metadata only** (pubkey ↔ push
endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **STILL
TODO (client half of Phase 1):** the app service worker, the
subscribe/unsubscribe hook + Settings toggle, and adding the marker to
`useSendDirectMessage`'s recipient wraps. Android FCM is Phase 2.
(Approach + tradeoffs came from the Numo/Linky/BTCPay review this session.)
endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **Client
half done:** `public/push-sw.js` (service worker), `src/lib/pushService.ts` +
`src/hooks/usePushNotifications.ts` (register + subscribe with a NIP-98
challenge proof), a **Background notifications** toggle in
`NotificationSettingsCard`, and `useSendDirectMessage` now marks the
**recipient** wrap `["skatespots","push"]` **and adds the community relay as
the notify relay**. **Remaining:** deploy (`npm run deploy:push` + Caddy) and
Android FCM (Phase 2). ⚠️ Web push works in browsers; the Capacitor WebView
needs FCM for the APK. (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
(E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is

40
public/push-sw.js Normal file
View file

@ -0,0 +1,40 @@
/*
* SkateSpots push service worker.
*
* Receives a Web Push payload from `push-service/` — which is CONTENT-FREE
* ({ title, body, wrapId, url }) because the server never decrypts anything —
* and shows an OS notification. Tapping it focuses/opens the app, where the real
* message is fetched by wrapId and decrypted locally.
*
* Deliberately tiny: no caching, no offline logic.
*/
self.addEventListener('push', (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
/* non-JSON payload — fall back to defaults */
}
const title = data.title || 'SkateSpots';
const body = data.body || 'You have a new notification';
event.waitUntil(
self.registration.showNotification(title, {
body,
tag: data.wrapId || undefined,
data: { url: data.url || '/' },
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
for (const client of list) {
if ('focus' in client) return client.focus();
}
return self.clients.openWindow(target);
}),
);
});

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { Bell, Loader2, ShieldCheck, TriangleAlert } from 'lucide-react';
import { Bell, Loader2, Radio, ShieldCheck, TriangleAlert } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
import {
getNotificationPermission,
@ -23,6 +24,7 @@ export function NotificationSettingsCard() {
const [prefs, setPrefs] = useNotificationPrefs();
const [permission, setPermission] = useState<OsPermission | 'loading'>('loading');
const [busy, setBusy] = useState(false);
const push = usePushNotifications();
useEffect(() => {
let cancelled = false;
@ -120,6 +122,57 @@ export function NotificationSettingsCard() {
)}
</div>
{/* Background push (closed-app) */}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<div className="flex items-center gap-3 px-4 py-3.5">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-[#ccff00]/15">
<Radio className="h-4 w-4 text-[#ccff00]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">Background notifications</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
Get pinged for messages and trades even when SkateSpots is closed.
</p>
</div>
{push.state === 'working' ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={push.state === 'on'}
disabled={push.state === 'unsupported'}
onCheckedChange={(value) => {
if (value) void push.enable();
else void push.disable();
}}
/>
)}
</div>
{push.state === 'unsupported' && (
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
This browser doesn&apos;t support background push. On mobile, add SkateSpots to your home screen.
</p>
)}
{push.state === 'error' && push.error && (
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{push.error}
</p>
)}
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<span>
To reach you while closed, a SkateSpots notification server gets a ping from your
relay when something arrives for your key and passes it to your phone&apos;s push
service. It sees only <em>that</em> your key has something — never who sent it or
what it says, and it can&apos;t decrypt your messages. Opt in per device.
</span>
</p>
</div>
{/* Per-category toggles */}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<p className="border-b border-white/5 px-4 py-2.5 text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
@ -159,10 +212,10 @@ export function NotificationSettingsCard() {
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<span>
Notifications are built on your device from data the app already has. Nothing
extra is sent anywhere, and direct-message alerts never decrypt your messages —
so Amber is never asked to open one. While the app is fully closed Android and
iOS can't reach it, so those alerts arrive next time you open SkateSpots.
Alerts are built on your device from data the app already has, and direct-message
alerts never decrypt your messages — so Amber is never asked to open one. With{' '}
<strong className="text-white">Background notifications</strong> off, a fully closed
app can&apos;t be reached, so alerts arrive next time you open SkateSpots.
</span>
</p>
</div>

View file

@ -9,6 +9,7 @@ import { useCurrentUser } from './useCurrentUser';
import { useAppContext } from './useAppContext';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { useRelays } from '@/lib/relays';
import { SKATE_RELAY } from '@/lib/types';
import type { RelayMetadata } from '@/contexts/AppContext';
/**
@ -152,6 +153,13 @@ export function getGroupId(participants: string[]): string {
// Constants
const GIFT_WRAP_KIND = 1059;
/**
* Plaintext marker on the RECIPIENT copy of a gift wrap. The push service watches
* for it (see `push-service/`); it leaks only "this wrap wants a push", nothing
* about the content. Never added to the self copy.
*/
const PUSH_MARKER_TAG = 'skatespots';
const SEAL_KIND = 13;
const DM_KIND = 14;
const TWO_DAYS = 2 * 24 * 60 * 60;
@ -257,13 +265,18 @@ export function useSendDirectMessage() {
);
const encryptedSeal = nip44.v2.encrypt(sealJson, conversationKey);
// Mark the RECIPIENT copy (never the self copy) as push-worthy, so the
// push service can wake their device while the app is closed.
const wrapTags: string[][] = [['p', targetPubkey]];
if (targetPubkey !== user.pubkey) wrapTags.push([PUSH_MARKER_TAG, 'push']);
// Create gift wrap event and sign with ephemeral key.
// finalizeEvent takes an EventTemplate (no pubkey — it derives it from
// the secret key) and returns a fully-signed event.
return finalizeEvent({
created_at: randomPastTimestamp(),
kind: GIFT_WRAP_KIND,
tags: [['p', targetPubkey]],
tags: wrapTags,
content: encryptedSeal,
}, ephemeralSecretKey) as NostrEvent;
};
@ -322,6 +335,14 @@ export function useSendDirectMessage() {
const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase()));
let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase()));
if (publishRelays.length === 0) publishRelays = write;
// The push service watches the community relay for push-marked wraps, so the
// recipient copy must land there for a closed-app notification to fire.
if (
!blocked.has(SKATE_RELAY.toLowerCase()) &&
!publishRelays.some((u) => u.toLowerCase() === SKATE_RELAY.toLowerCase())
) {
publishRelays = [...publishRelays, SKATE_RELAY];
}
// 5. Publish in PARALLEL, each relay with its own timeout. People's lists
// commonly contain a dead relay or one that demands NIP-42 auth (e.g.

View file

@ -0,0 +1,141 @@
/**
* usePushNotifications — opt in/out of the SkateSpots push service (see
* `push-service/`) for this device.
*
* 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.
*/
import { useCallback, useEffect, useState } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
fetchVapidPublicKey,
requestPushChallenge,
subscribeToPush,
unsubscribeFromPush,
type PushSubscriptionJSON,
} from '@/lib/pushService';
export type PushState = 'unsupported' | 'off' | 'on' | 'working' | 'error';
function pushSupported(): boolean {
return (
typeof navigator !== 'undefined' &&
'serviceWorker' in navigator &&
typeof window !== 'undefined' &&
'PushManager' in window
);
}
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(normalized);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i);
return out;
}
export function usePushNotifications() {
const { user } = useCurrentUser();
const [state, setState] = useState<PushState>(() => (pushSupported() ? 'off' : 'unsupported'));
const [error, setError] = useState<string | null>(null);
// Reflect an existing subscription on mount / account switch.
useEffect(() => {
if (!pushSupported()) {
setState('unsupported');
return;
}
let cancelled = false;
void (async () => {
try {
const reg = await navigator.serviceWorker.getRegistration();
const sub = await reg?.pushManager.getSubscription();
if (!cancelled) setState(sub ? 'on' : 'off');
} catch {
if (!cancelled) setState('off');
}
})();
return () => {
cancelled = true;
};
}, [user?.pubkey]);
const signProof = useCallback(
async (challenge: string) => {
if (!user?.signer) throw new Error('Log in first.');
return user.signer.signEvent({
kind: 27235,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [['challenge', challenge]],
});
},
[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 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);
setState('on');
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not enable background notifications.');
setState('error');
}
}, [user, signProof]);
const disable = useCallback(async () => {
if (!pushSupported() || !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();
}
setState('off');
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not disable background notifications.');
setState('error');
}
}, [user, signProof]);
return { state, error, supported: pushSupported(), enable, disable };
}

58
src/lib/pushService.ts Normal file
View file

@ -0,0 +1,58 @@
/**
* Client for the SkateSpots push service (`push-service/`).
*
* The service watches relays for push-marked gift wraps and sends a CONTENT-FREE
* Web Push. It never decrypts; the app fetches + decrypts the named wrap itself.
*
* Base URL mirrors `sasquatchAiBase()`: production (and native builds) use the
* deployed host; dev points at the local service. `VITE_PUSH_SERVICE_BASE`
* overrides both.
*/
import { isNativePlatform } from '@/lib/platform';
const PRODUCTION_PUSH_BASE = 'https://push.skate-spots.org';
export function pushServiceBase(): string {
const override = (import.meta.env as Record<string, string | undefined>).VITE_PUSH_SERVICE_BASE;
if (override) return override.replace(/\/+$/, '');
if (import.meta.env.PROD || isNativePlatform()) return PRODUCTION_PUSH_BASE;
return 'http://localhost:8788';
}
/** Standard `PushSubscription.toJSON()` shape the service expects. */
export interface PushSubscriptionJSON {
endpoint: string;
keys: { p256dh: string; auth: string };
}
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${pushServiceBase()}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
if (!res.ok) throw new Error(data.error || `Push service error (${res.status})`);
return data;
}
export async function fetchVapidPublicKey(): Promise<string> {
const res = await fetch(`${pushServiceBase()}/vapid-public-key`);
if (!res.ok) throw new Error('Could not reach the notification service.');
const data = (await res.json()) as { key?: string };
if (!data.key) throw new Error('Notification service returned no key.');
return data.key;
}
export async function requestPushChallenge(pubkey: string): Promise<string> {
const data = await post<{ challenge: string }>('/auth/challenge', { pubkey });
return data.challenge;
}
export async function subscribeToPush(subscription: PushSubscriptionJSON, proof: unknown): Promise<void> {
await post('/subscribe', { subscription, proof });
}
export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {
await post('/unsubscribe', { endpoint, proof });
}

View file

@ -149,6 +149,7 @@ export default function PrivacyPage() {
<ul className="list-disc pl-5 space-y-1 text-gray-400">
<li><strong className="text-white">Map tiles</strong> (OpenStreetMap) — see your IP and which area of the map you look at.</li>
<li><strong className="text-white">Spot/shop geocoding</strong> — when you <em>create or edit</em> a spot or shop, its coordinates go to OpenStreetMap <strong className="text-white">Nominatim</strong> to fill in the city/country tag. When you open <strong className="text-white">Community Stats</strong>, the coordinates of pins that have no location tag go to <strong className="text-white">BigDataCloud</strong> so they can be grouped by region (cached on your device — at most once per pin). Both see your IP. These are the <em>pin you placed / public pin coordinates</em>, never your device's live GPS.</li>
<li><strong className="text-white">Background notifications</strong> (opt-in) — if you enable them, a SkateSpots push server sees that your key received something and when (never who sent it or what it says — messages stay encrypted), and your OS push service (Google/Mozilla/Apple) delivers the alert.</li>
<li><strong className="text-white">Bitcoin places</strong> (BTCMap, when you switch that layer on) — see your IP and the area you're viewing.</li>
<li><strong className="text-white">Nostr relays</strong> — see your IP when the app reads or publishes.</li>
<li><strong className="text-white">Cashu mints</strong> — see your IP whenever your wallet sends, receives or melts.</li>