/** * SkateSpots push service * ───────────────────────────────────────────────────────────────────────────── * A keyless Bun service that delivers **closed-app notifications** for NIP-17 * gift wraps. It watches relays for kind-1059 wraps carrying the plaintext push * marker `["", "push"]`, extracts ONLY the recipient pubkey and the * wrap id, and sends a Web Push. It **never decrypts** anything — the client * fetches + decrypts the named wrap itself. * * Protocol (ours — independent of, though inspired by, Linky): * GET /health → { ok, subscriptions, relays } * GET /vapid-public-key → { key } * POST /auth/challenge {pubkey} → { challenge, expiresAt } * POST /subscribe { subscription, proof } → { ok } * POST /unsubscribe { endpoint, proof } → { ok } * * `proof` is a NIP-98 kind-27235 event signed by the subscribing pubkey, * carrying ["challenge", ] from /auth/challenge — it proves ownership * of that pubkey. A challenge is one-time (consumed on use). * * State is a small JSON file (subscriptions + challenges). Env + deploy: README.md. */ import { readFileSync, writeFileSync } from 'node:fs'; import webpush from 'web-push'; import { verifyEvent, type NostrEvent } from 'nostr-tools'; // Bun provides this at runtime; declare it so the standalone tsc check passes // without pulling all of @types/bun into the app's toolchain. declare const Bun: { serve(options: { port: number; fetch: (req: Request) => Response | Promise }): unknown; }; // ─── Config ────────────────────────────────────────────────────────────────── const env = process.env; const PORT = Number(env.PORT ?? 8788); const MARKER = (env.PUSH_MARKER ?? 'skatespots').trim(); const STORAGE_PATH = env.PUSH_STORAGE_PATH ?? './push-state.json'; const RELAYS = (env.PUSH_RELAYS ?? 'wss://relay.skate-spots.org,wss://goskatespots.nostr1.com') .split(',').map((s) => s.trim()).filter(Boolean); const LOOKBACK_SECONDS = Number(env.PUSH_LOOKBACK_SECONDS ?? 3 * 24 * 60 * 60); const CHALLENGE_TTL_SECONDS = Number(env.PUSH_CHALLENGE_TTL_SECONDS ?? 5 * 60); const ORIGINS = (env.PUSH_ALLOWED_ORIGINS ?? 'https://skate-spots.org,http://localhost:8080') .split(',').map((s) => s.trim()).filter(Boolean); let vapidPublic = env.PUSH_VAPID_PUBLIC_KEY ?? ''; let vapidPrivate = env.PUSH_VAPID_PRIVATE_KEY ?? ''; const vapidSubject = env.PUSH_VAPID_SUBJECT ?? 'mailto:owner@skate-spots.org'; if (!vapidPublic || !vapidPrivate) { const generated = webpush.generateVAPIDKeys(); vapidPublic = generated.publicKey; vapidPrivate = generated.privateKey; console.warn('[push] ⚠️ PUSH_VAPID_* not set — generated EPHEMERAL keys (subscriptions break on restart).'); } webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate); // ─── State (JSON file) ─────────────────────────────────────────────────────── interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string; /** Which categories this device wants. Empty/absent = all (back-compat). */ categories?: string[]; } /** * Event kind → notification category, mirroring the app's `NotificationCategory`. * `newsletter` has no push channel (readers poll it); reactions/reposts are * deliberately excluded (too noisy). */ const CATEGORY_BY_KIND: Record = { 1059: 'dms', // NIP-17 gift wrap 30403: 'trades', // marketplace offer / counter / accept / decline / withdraw 31871: 'trades', // trade attestation (sold / bought) 31872: 'trades', // confirm-request 34967: 'trades', // shipment status 36229: 'crew', // crew invitation 31969: 'crew', // crew join request 9735: 'social', // zap receipt 1: 'social', // reply / mention 1111: 'social', // comment }; /** Where a tap should land, per category. */ const URL_BY_CATEGORY: Record = { trades: '/#/trades', crew: '/#/crews', social: '/#/', dms: '/#/messages', }; function allows(categories: string[] | undefined, category: string): boolean { // ABSENT = legacy subscriber → allow everything. An EMPTY array = the user // explicitly wants no push categories. if (categories === undefined) return true; return categories.includes(category); } interface State { subscriptions: Record; // endpoint → sub /** Poll token → the pubkey + categories it may read activity for (native polling). */ pollTokens: Record; /** * Per-pubkey activity cursor with a per-category breakdown, so a native poller * can ask "anything new since N?" for ONLY the categories the user wants — * WITHOUT the server ever revealing *what* (content-free, like web push). */ activity: Record }>; challenges: Record; } function load(): State { try { const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial; return { subscriptions: parsed.subscriptions ?? {}, pollTokens: parsed.pollTokens ?? {}, activity: parsed.activity ?? {}, challenges: parsed.challenges ?? {}, }; } catch { return { subscriptions: {}, pollTokens: {}, activity: {}, challenges: {} }; } } const state = load(); function save(): void { try { writeFileSync(STORAGE_PATH, JSON.stringify(state)); } catch (err) { console.warn('[push] could not persist state:', (err as Error).message); } } function sweepChallenges(): void { const now = Math.floor(Date.now() / 1000); for (const [id, c] of Object.entries(state.challenges)) if (c.expiresAt < now) delete state.challenges[id]; } // ─── NIP-98 proof ──────────────────────────────────────────────────────────── function tagValue(ev: NostrEvent, name: string): string | undefined { return ev.tags.find(([n]) => n === name)?.[1]; } /** Verify a kind-27235 ownership proof for `expectedPubkey`, consuming its challenge. */ function verifyProof(proof: unknown, expectedPubkey: string): { ok: boolean; error?: string } { if (!proof || typeof proof !== 'object') return { ok: false, error: 'missing proof' }; const ev = proof as NostrEvent; if (ev.kind !== 27235) return { ok: false, error: 'proof must be kind 27235' }; if (ev.pubkey !== expectedPubkey) return { ok: false, error: 'proof pubkey mismatch' }; const challenge = tagValue(ev, 'challenge'); if (!challenge) return { ok: false, error: 'proof missing challenge' }; if (!verifyEvent(ev)) return { ok: false, error: 'bad proof signature' }; const row = state.challenges[challenge]; delete state.challenges[challenge]; // one-time, even on failure save(); if (!row) return { ok: false, error: 'unknown challenge' }; if (row.pubkey !== expectedPubkey) return { ok: false, error: 'challenge/pubkey mismatch' }; if (row.expiresAt < Math.floor(Date.now() / 1000)) return { ok: false, error: 'challenge expired' }; return { ok: true }; } /** * Reject endpoints that could make the server POST to internal infrastructure * (SSRF via a forged Web Push endpoint). Real push services are public HTTPS * hosts; anything loopback/private/link-local is refused. */ function isSafePushEndpoint(endpoint: string): boolean { let url: URL; try { url = new URL(endpoint); } catch { return false; } if (url.protocol !== 'https:') return false; const host = url.hostname.toLowerCase(); if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) { return false; } const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (v4) { const a = Number(v4[1]); const b = Number(v4[2]); if (a === 0 || a === 10 || a === 127) return false; if (a === 172 && b >= 16 && b <= 31) return false; if (a === 192 && b === 168) return false; if (a === 169 && b === 254) return false; if (a === 100 && b >= 64 && b <= 127) return false; } if (host === '[::1]' || host === '::1') return false; if (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) return false; return true; } // ─── Web Push ──────────────────────────────────────────────────────────────── function subsFor(pubkey: string): StoredSubscription[] { return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey); } /** Bump the content-free activity cursor for a pubkey + category. */ function recordActivity(pubkey: string, category: string): void { const cur = state.activity[pubkey] ?? { seq: 0, at: 0, byCategory: {} }; const seq = cur.seq + 1; state.activity[pubkey] = { seq, at: Math.floor(Date.now() / 1000), byCategory: { ...cur.byCategory, [category]: seq }, }; save(); } async function pushTo(pubkey: string, payload: Record, category: string): Promise { recordActivity(pubkey, category); for (const s of subsFor(pubkey)) { // Respect the device's category choices. if (!allows(s.categories, category)) continue; try { await webpush.sendNotification( { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, JSON.stringify(payload), ); } catch (err) { const status = (err as { statusCode?: number }).statusCode; if (status === 404 || status === 410) { console.log(`[push] dropping dead subscription for ${pubkey.slice(0, 12)}…`); delete state.subscriptions[s.endpoint]; save(); } else { console.warn(`[push] send failed (${status ?? 'error'}) for ${pubkey.slice(0, 12)}…`); } } } } // ─── Relay watcher (no keys, no decryption) ────────────────────────────────── const relayStatus: Record = {}; const relaySockets = new Map(); /** Marketplace kinds we push for. Offers carry the whole negotiation state. */ const OFFER_KIND = 30403; // offer / counter / accepted / declined / withdrawn const TRADE_CONFIRM_KIND = 31871; // counterparty recorded the trade const TRADE_REQUEST_KIND = 31872; // "please confirm our trade" const SHIPMENT_STATUS_KIND = 34967; // shipped / received / issue (self-reported) const CREW_INVITE_KIND = 36229; // crew invitation const CREW_JOIN_REQUEST_KIND = 31969; // crew join request const ZAP_RECEIPT_KIND = 9735; // zap const TEXT_NOTE_KIND = 1; // reply / mention const COMMENT_KIND = 1111; // comment const MARKET_KINDS = [ OFFER_KIND, TRADE_CONFIRM_KIND, TRADE_REQUEST_KIND, SHIPMENT_STATUS_KIND, CREW_INVITE_KIND, CREW_JOIN_REQUEST_KIND, ZAP_RECEIPT_KIND, TEXT_NOTE_KIND, COMMENT_KIND, ]; function subscribedPubkeys(): string[] { // Web subscribers AND native poll-token holders — we watch for both. const web = Object.values(state.subscriptions).map((s) => s.pubkey); const poll = Object.values(state.pollTokens).map((t) => t.pubkey); return [...new Set([...web, ...poll])]; } /** Content-free body — no amounts, no item text (that stays out of the push). */ function marketBody(ev: NostrEvent): string { if (ev.kind === CREW_INVITE_KIND) return 'invited you to a crew'; if (ev.kind === CREW_JOIN_REQUEST_KIND) return 'asked to join your crew'; if (ev.kind === ZAP_RECEIPT_KIND) return 'You got a zap ⚡'; if (ev.kind === TEXT_NOTE_KIND || ev.kind === COMMENT_KIND) return 'New reply or mention'; if (ev.kind === TRADE_REQUEST_KIND) return 'asked you to confirm a trade'; if (ev.kind === TRADE_CONFIRM_KIND) return 'confirmed your trade'; if (ev.kind === SHIPMENT_STATUS_KIND) { const status = ev.tags.find(([n]) => n === 'status')?.[1]; if (status === 'shipped') return 'Your item shipped'; if (status === 'received') return 'Item marked received'; if (status === 'issue') return 'There’s an issue with a shipment'; return 'Shipment update'; } const status = ev.tags.find(([n]) => n === 'status')?.[1] ?? 'open'; if (status === 'accepted') return 'Your offer was accepted'; if (status === 'declined') return 'Your offer was declined'; if (status === 'withdrawn') return 'An offer was withdrawn'; const role = ev.tags.find(([n]) => n === 'role')?.[1]; return role === 'offer' ? 'New offer on your listing' : 'You got a counter-offer'; } let marketSubSeq = 0; let currentMarketSubId: string | null = null; /** Ask ONE relay for marketplace events addressed to the CURRENT subscribers. */ function sendMarketSubscription(ws: WebSocket, since: number): void { if (currentMarketSubId) { try { ws.send(JSON.stringify(['CLOSE', currentMarketSubId])); } catch { /* socket gone */ } } const pubkeys = subscribedPubkeys(); if (pubkeys.length === 0) return; const id = `skatespots-push-mkt-${++marketSubSeq}`; // `#p` filters so we only receive marketplace events addressed to subscribers, // chunked (a REQ carries multiple filters). const filters: unknown[] = []; for (let i = 0; i < pubkeys.length; i += 50) { filters.push({ kinds: MARKET_KINDS, '#p': pubkeys.slice(i, i + 50), since }); } ws.send(JSON.stringify(['REQ', id, ...filters])); currentMarketSubId = id; } let marketRefreshTimer: ReturnType | null = null; /** Rebuild the marketplace subscription after a subscribe/unsubscribe (debounced). */ function refreshMarketSubscription(): void { if (marketRefreshTimer) clearTimeout(marketRefreshTimer); marketRefreshTimer = setTimeout(() => { const since = Math.floor(Date.now() / 1000) - LOOKBACK_SECONDS; for (const ws of relaySockets.values()) { if (ws.readyState === 1) sendMarketSubscription(ws, since); } }, 1500); } function handleEvent(ev: NostrEvent): void { if (!verifyEvent(ev)) return; // 1) NIP-17 gift wraps: push-marked, exactly one recipient, never decrypted. if (ev.kind === 1059) { const recipients = [...new Set(ev.tags.filter(([n]) => n === 'p').map(([, v]) => v).filter(Boolean))]; if (recipients.length !== 1) return; // fan-out wraps aren't pushable if (!ev.tags.some(([n, v]) => n === MARKER && v === 'push')) return; console.log(`[push] 🔔 marked wrap ${ev.id.slice(0, 12)}… → ${recipients[0].slice(0, 12)}…`); void pushTo(recipients[0], { title: 'SkateSpots', body: 'New message', wrapId: ev.id, url: '/#/messages' }, 'dms'); return; } // 2) Marketplace events addressed to a subscriber (public — metadata only; // we only ever send a generic body, never the amount or item). if (MARKET_KINDS.includes(ev.kind)) { const category = CATEGORY_BY_KIND[ev.kind] ?? 'trades'; const recipients = [...new Set(ev.tags.filter(([n]) => n === 'p').map(([, v]) => v).filter(Boolean))]; for (const r of recipients) { if (r === ev.pubkey) continue; // never notify the author about their own message console.log(`[push] 🔔 ${category} ${ev.kind} ${ev.id.slice(0, 12)}… → ${r.slice(0, 12)}…`); void pushTo( r, { title: 'SkateSpots', body: marketBody(ev), wrapId: ev.id, url: URL_BY_CATEGORY[category] ?? '/#/' }, category, ); } } } function watchRelay(url: string): void { relayStatus[url] = 'connecting'; const ws = new WebSocket(url); const since = Math.floor(Date.now() / 1000) - LOOKBACK_SECONDS; ws.addEventListener('open', () => { relayStatus[url] = 'live'; relaySockets.set(url, ws); ws.send(JSON.stringify(['REQ', 'skatespots-push', { kinds: [1059], since }])); sendMarketSubscription(ws, since); console.log(`[push] watching ${url}`); }); ws.addEventListener('message', (msg: MessageEvent) => { let parsed: unknown; try { parsed = JSON.parse(String(msg.data)); } catch { return; } if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return; handleEvent(parsed[2] as NostrEvent); }); const retry = () => { relayStatus[url] = 'down'; relaySockets.delete(url); setTimeout(() => watchRelay(url), 5000); }; ws.addEventListener('close', retry); ws.addEventListener('error', () => { try { ws.close(); } catch { /* noop */ } }); } for (const url of RELAYS) watchRelay(url); // ─── HTTP ──────────────────────────────────────────────────────────────────── function cors(origin: string | null): Record { if (origin && ORIGINS.includes(origin)) return { 'access-control-allow-origin': origin, vary: 'Origin' }; return {}; } function json(body: unknown, status = 200, extra: Record = {}): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...extra } }); } Bun.serve({ port: PORT, async fetch(req: Request) { const url = new URL(req.url); const headers = cors(req.headers.get('origin')); if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: { ...headers, 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400', }, }); } if (url.pathname === '/health') { return json({ ok: true, subscriptions: Object.keys(state.subscriptions).length, relays: relayStatus, }, 200, headers); } if (url.pathname === '/vapid-public-key') return json({ key: vapidPublic }, 200, headers); if (req.method === 'POST' && url.pathname === '/auth/challenge') { const body = (await req.json().catch(() => null)) as { pubkey?: string } | null; if (!body?.pubkey || !/^[0-9a-f]{64}$/i.test(body.pubkey)) { return json({ error: 'valid hex pubkey required' }, 400, headers); } sweepChallenges(); const id = crypto.randomUUID(); const expiresAt = Math.floor(Date.now() / 1000) + CHALLENGE_TTL_SECONDS; state.challenges[id] = { pubkey: body.pubkey.toLowerCase(), expiresAt }; save(); return json({ challenge: id, expiresAt }, 200, headers); } if (req.method === 'POST' && url.pathname === '/subscribe') { const body = (await req.json().catch(() => null)) as { subscription?: { endpoint?: string; keys?: { p256dh?: string; auth?: string } }; proof?: NostrEvent; /** The device's chosen categories (only dms/trades are pushed). */ categories?: string[]; } | null; const sub = body?.subscription; if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) { return json({ error: 'subscription { endpoint, keys{ p256dh, auth } } 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); if (!isSafePushEndpoint(sub.endpoint)) { return json({ error: 'invalid push endpoint' }, 400, headers); } const ownerPubkey = body.proof.pubkey.toLowerCase(); const existing = state.subscriptions[sub.endpoint]; if (existing && existing.pubkey !== ownerPubkey) { return json({ error: 'endpoint already registered to another account' }, 409, headers); } state.subscriptions[sub.endpoint] = { endpoint: sub.endpoint, pubkey: ownerPubkey, p256dh: sub.keys.p256dh, auth: sub.keys.auth, categories: Array.isArray(body.categories) ? body.categories : undefined, }; save(); console.log(`[push] ➕ subscribed ${body.proof.pubkey.slice(0, 12)}…`); refreshMarketSubscription(); 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); const check = verifyProof(body.proof, body.proof.pubkey); if (!check.ok) return json({ error: check.error }, 401, headers); const owned = state.subscriptions[body.endpoint]; // Only the owner may remove a subscription. A non-owner gets an idempotent // OK without deleting anything, so this can't be used to disable another // account's notifications or probe which endpoints exist. if (owned && owned.pubkey !== body.proof.pubkey.toLowerCase()) { return json({ ok: true }, 200, headers); } delete state.subscriptions[body.endpoint]; save(); refreshMarketSubscription(); return json({ ok: true }, 200, headers); } // ── Native background polling (no Google) ───────────────────────────── // The device registers once (NIP-98 proof) and receives an unguessable poll // token; it then polls `/pending` and shows a LOCAL notification when the // content-free activity cursor moves. The server never reveals what arrived. if (req.method === 'POST' && url.pathname === '/native/poll/register') { const body = (await req.json().catch(() => null)) as | { pubkey?: string; proof?: NostrEvent; categories?: string[] } | null; if (!body?.pubkey || !body?.proof) return json({ error: 'pubkey and proof required' }, 400, headers); const check = verifyProof(body.proof, body.pubkey); if (!check.ok) return json({ error: check.error }, 401, headers); // One token per pubkey: re-registering (e.g. after a settings change) // replaces the old one so nothing is orphaned. for (const [t, e] of Object.entries(state.pollTokens)) { if (e.pubkey === body.pubkey.toLowerCase()) delete state.pollTokens[t]; } const token = crypto.randomUUID(); state.pollTokens[token] = { pubkey: body.pubkey.toLowerCase(), categories: Array.isArray(body.categories) ? body.categories : undefined, createdAt: Math.floor(Date.now() / 1000), }; save(); refreshMarketSubscription(); console.log(`[push] 🔁 poll token for ${body.pubkey.slice(0, 12)}…`); return json({ token, seq: state.activity[body.pubkey.toLowerCase()]?.seq ?? 0 }, 200, headers); } if (req.method === 'POST' && url.pathname === '/native/poll/unregister') { 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 entry = state.pollTokens[body.token]; if (!entry) return json({ ok: true }, 200, headers); const check = verifyProof(body.proof, entry.pubkey); if (!check.ok) return json({ error: check.error }, 401, headers); delete state.pollTokens[body.token]; save(); refreshMarketSubscription(); return json({ ok: true }, 200, headers); } if (req.method === 'GET' && url.pathname === '/pending') { const token = url.searchParams.get('token') ?? ''; const entry = state.pollTokens[token]; if (!entry) return json({ error: 'unknown token' }, 401, headers); const since = Number(url.searchParams.get('since') ?? 0); const activity = state.activity[entry.pubkey] ?? { seq: 0, at: 0, byCategory: {} }; // Only the categories THIS token asked for can move `changed`. const allowed = Array.isArray(entry.categories) ? entry.categories : Object.keys(activity.byCategory); const cutoff = Number.isFinite(since) ? since : 0; const changed = allowed.some((c) => (activity.byCategory[c] ?? 0) > cutoff); return json({ seq: activity.seq, at: activity.at, changed }, 200, headers); } return json({ error: 'not found' }, 404, headers); }, }); console.log(`[push] listening on :${PORT} — marker "${MARKER}", relays: ${RELAYS.join(', ')}`);