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 marked wrap must land on a relay the service watches — designate the
community relay as the **notify relay** and publish push-marked recipient community relay as the **notify relay** and publish push-marked recipient
copies there. ⚠️ Privacy: the server sees **metadata only** (pubkey ↔ push copies there. ⚠️ Privacy: the server sees **metadata only** (pubkey ↔ push
endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **STILL endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **Client
TODO (client half of Phase 1):** the app service worker, the half done:** `public/push-sw.js` (service worker), `src/lib/pushService.ts` +
subscribe/unsubscribe hook + Settings toggle, and adding the marker to `src/hooks/usePushNotifications.ts` (register + subscribe with a NIP-98
`useSendDirectMessage`'s recipient wraps. Android FCM is Phase 2. challenge proof), a **Background notifications** toggle in
(Approach + tradeoffs came from the Numo/Linky/BTCPay review this session.) `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 - **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
(E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is (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 { 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 { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs'; import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs'; import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
import { import {
getNotificationPermission, getNotificationPermission,
@ -23,6 +24,7 @@ export function NotificationSettingsCard() {
const [prefs, setPrefs] = useNotificationPrefs(); const [prefs, setPrefs] = useNotificationPrefs();
const [permission, setPermission] = useState<OsPermission | 'loading'>('loading'); const [permission, setPermission] = useState<OsPermission | 'loading'>('loading');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const push = usePushNotifications();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -120,6 +122,57 @@ export function NotificationSettingsCard() {
)} )}
</div> </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 */} {/* Per-category toggles */}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2"> <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"> <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"> <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]" /> <ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<span> <span>
Notifications are built on your device from data the app already has. Nothing Alerts are built on your device from data the app already has, and direct-message
extra is sent anywhere, and direct-message alerts never decrypt your messages — alerts never decrypt your messages — so Amber is never asked to open one. With{' '}
so Amber is never asked to open one. While the app is fully closed Android and <strong className="text-white">Background notifications</strong> off, a fully closed
iOS can't reach it, so those alerts arrive next time you open SkateSpots. app can&apos;t be reached, so alerts arrive next time you open SkateSpots.
</span> </span>
</p> </p>
</div> </div>

View file

@ -9,6 +9,7 @@ import { useCurrentUser } from './useCurrentUser';
import { useAppContext } from './useAppContext'; import { useAppContext } from './useAppContext';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { useRelays } from '@/lib/relays'; import { useRelays } from '@/lib/relays';
import { SKATE_RELAY } from '@/lib/types';
import type { RelayMetadata } from '@/contexts/AppContext'; import type { RelayMetadata } from '@/contexts/AppContext';
/** /**
@ -152,6 +153,13 @@ export function getGroupId(participants: string[]): string {
// Constants // Constants
const GIFT_WRAP_KIND = 1059; 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 SEAL_KIND = 13;
const DM_KIND = 14; const DM_KIND = 14;
const TWO_DAYS = 2 * 24 * 60 * 60; const TWO_DAYS = 2 * 24 * 60 * 60;
@ -257,13 +265,18 @@ export function useSendDirectMessage() {
); );
const encryptedSeal = nip44.v2.encrypt(sealJson, conversationKey); 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. // Create gift wrap event and sign with ephemeral key.
// finalizeEvent takes an EventTemplate (no pubkey — it derives it from // finalizeEvent takes an EventTemplate (no pubkey — it derives it from
// the secret key) and returns a fully-signed event. // the secret key) and returns a fully-signed event.
return finalizeEvent({ return finalizeEvent({
created_at: randomPastTimestamp(), created_at: randomPastTimestamp(),
kind: GIFT_WRAP_KIND, kind: GIFT_WRAP_KIND,
tags: [['p', targetPubkey]], tags: wrapTags,
content: encryptedSeal, content: encryptedSeal,
}, ephemeralSecretKey) as NostrEvent; }, ephemeralSecretKey) as NostrEvent;
}; };
@ -322,6 +335,14 @@ export function useSendDirectMessage() {
const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase())); const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase()));
let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase())); let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase()));
if (publishRelays.length === 0) publishRelays = write; 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 // 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. // 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"> <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">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">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">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">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> <li><strong className="text-white">Cashu mints</strong> — see your IP whenever your wallet sends, receives or melts.</li>