From baa770f1b8d6b824a43d44e641e9743afd95560b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 16:07:21 +0700 Subject: [PATCH] feat(push): push marketplace events (offers/requests/confirms), not just DMs The service now also subscribes to marketplace events addressed to subscribers (kinds 30403/31872/31871) via #p filters built from the live subscription set, re-subscribed on every subscribe/unsubscribe. Payloads stay generic ('New offer on your listing', 'Your offer was accepted') - never the amount or item. Verified locally: subscribe -> offer to #p -> push attempt. --- AGENTS.md | 8 +++- push-service/README.md | 6 +++ push-service/server.ts | 99 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2e300c..6164255 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -697,8 +697,12 @@ session with **no other memory of this project**. So: 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 + the notify relay**. The service **also pushes marketplace events** addressed + to subscribers — offers (`30403`, the whole negotiation), requests (`31872`), + confirms (`31871`) — via `#p` filters built from the live subscription set + (so it only sees events for subscribers; the payload is a **generic body**, + never the amount/item). **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 diff --git a/push-service/README.md b/push-service/README.md index 4878dc0..6301550 100644 --- a/push-service/README.md +++ b/push-service/README.md @@ -13,6 +13,12 @@ only *"this pubkey has something, at time T."* 2. This service subscribes to `{ kinds: [1059] }` on the configured relays, and for each wrap: verifies the **outer** signature, requires exactly one `p` recipient, and checks the marker. + + It ALSO pushes **marketplace events** addressed to subscribers — offers + (`30403`, the whole negotiation), trade requests (`31872`) and confirmations + (`31871`) — via `#p` filters built from the current subscription set (so it + only receives events for subscribers; the payload is generic, never the + amount or item). Re-subscribed on every subscribe/unsubscribe. 3. For a marked wrap it looks up that recipient's subscriptions and sends a Web Push payload: `{ title, body, wrapId, url }` — **content-free**. 4. The service worker shows an OS notification. On tap, the app fetches the diff --git a/push-service/server.ts b/push-service/server.ts index dd1f799..a6b7694 100644 --- a/push-service/server.ts +++ b/push-service/server.ts @@ -113,12 +113,12 @@ function subsFor(pubkey: string): StoredSubscription[] { return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey); } -async function pushTo(pubkey: string, wrapId: string): Promise { +async function pushTo(pubkey: string, payload: Record): Promise { for (const s of subsFor(pubkey)) { try { await webpush.sendNotification( { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, - JSON.stringify({ title: 'SkateSpots', body: 'New message', wrapId, url: '/#/messages' }), + JSON.stringify(payload), ); } catch (err) { const status = (err as { statusCode?: number }).statusCode; @@ -136,6 +136,87 @@ async function pushTo(pubkey: string, wrapId: string): Promise { // ─── 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 MARKET_KINDS = [OFFER_KIND, TRADE_CONFIRM_KIND, TRADE_REQUEST_KIND]; + +function subscribedPubkeys(): string[] { + return [...new Set(Object.values(state.subscriptions).map((s) => s.pubkey))]; +} + +/** Content-free body — no amounts, no item text (that stays out of the push). */ +function marketBody(ev: NostrEvent): string { + if (ev.kind === TRADE_REQUEST_KIND) return 'asked you to confirm a trade'; + if (ev.kind === TRADE_CONFIRM_KIND) return 'confirmed your trade'; + 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' }); + 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 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] 🔔 market ${ev.kind} ${ev.id.slice(0, 12)}… → ${r.slice(0, 12)}…`); + void pushTo(r, { title: 'SkateSpots', body: marketBody(ev), wrapId: ev.id, url: '/#/trades' }); + } + } +} function watchRelay(url: string): void { relayStatus[url] = 'connecting'; @@ -144,7 +225,9 @@ function watchRelay(url: string): void { 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}`); }); @@ -152,16 +235,10 @@ function watchRelay(url: string): void { let parsed: unknown; try { parsed = JSON.parse(String(msg.data)); } catch { return; } if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return; - const ev = parsed[2] as NostrEvent; - if (ev?.kind !== 1059 || !verifyEvent(ev)) return; - 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)}… → recipient ${recipients[0].slice(0, 12)}…`); - void pushTo(recipients[0], ev.id); + handleEvent(parsed[2] as NostrEvent); }); - const retry = () => { relayStatus[url] = 'down'; setTimeout(() => watchRelay(url), 5000); }; + const retry = () => { relayStatus[url] = 'down'; relaySockets.delete(url); setTimeout(() => watchRelay(url), 5000); }; ws.addEventListener('close', retry); ws.addEventListener('error', () => { try { ws.close(); } catch { /* noop */ } }); } @@ -239,6 +316,7 @@ Bun.serve({ }; save(); console.log(`[push] ➕ subscribed ${body.proof.pubkey.slice(0, 12)}…`); + refreshMarketSubscription(); return json({ ok: true }, 200, headers); } @@ -249,6 +327,7 @@ Bun.serve({ if (!check.ok) return json({ error: check.error }, 401, headers); delete state.subscriptions[body.endpoint]; save(); + refreshMarketSubscription(); return json({ ok: true }, 200, headers); }