// NWC push notifier — step 1 of making hal answer wallet-connect requests // while it is closed. // // hal is a web wallet: a service worker cannot hold a relay socket open, so // something has to tell the browser "a request arrived, wake up". That is all // this does. It watches the relays for NIP-47 requests (kind 23194) addressed // to the wallet-service pubkeys a device has registered, and sends that device // a Web Push. The service worker then does the actual work — fetch, decrypt, // pay, reply — in the browser, with the keys never leaving it. // // WHAT THIS SERVICE LEARNS: the wallet-service pubkeys you register, and the // timing of requests to them. That is a real metadata leak and is the price of // push. It never sees request contents (encrypted to keys it does not have), // never sees your wallet seed, and can never move funds. The worst it can do // is fail to wake you, or lie about a wake-up — both of which cost nothing. // // Deliberately dumb: no database, no accounts. Registrations live in one JSON // file and expire if a device stops refreshing them. import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { SimplePool } from 'nostr-tools/pool'; import { verifyEvent } from 'nostr-tools/pure'; import webpush from 'web-push'; // crude per-IP publish limiter: 20/min is far above a wallet's real reply rate const pubRate = new Map(); function rateOk(ip) { const now = Date.now(); const arr = (pubRate.get(ip) || []).filter((t) => t > now - 60_000); if (arr.length >= 20) return false; arr.push(now); pubRate.set(ip, arr); if (pubRate.size > 500) for (const [k, v] of pubRate) { if (!v.some((t) => t > now - 60_000)) pubRate.delete(k); } return true; } const CFG = JSON.parse(readFileSync(process.env.NWCPUSH_CONFIG || join(import.meta.dir, 'config.json'), 'utf8')); // { port, relays: [...], vapid: { publicKey, privateKey, subject }, // stateFile, maxPubkeysPerDevice, staleDays } const RELAYS = CFG.relays || ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol']; const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'registrations.json'); const MAX_PK = CFG.maxPubkeysPerDevice || 20; const STALE_MS = (CFG.staleDays || 30) * 86400_000; const REQ_KIND = 23194; const OFFER_KIND = 21001; webpush.setVapidDetails(CFG.vapid.subject, CFG.vapid.publicKey, CFG.vapid.privateKey); // Two ways to reach a device. // // Web Push is the browser's, and on Android it is Firebase's — a phone // without Google Play Services (GrapheneOS and friends) can't subscribe at // all. UnifiedPush is the other way: the user runs a distributor, the app // gets an endpoint, and anyone wanting to reach the device POSTs the payload // to it. No encryption envelope and no VAPID — the endpoint is the secret, // which is why we never log one in full. // // A subscription marked `unifiedpush` is the second kind; everything else is // a browser's. One function, so every send path serves both. const isUP = (sub) => !!(sub && sub.unifiedpush && typeof sub.endpoint === 'string'); async function deliver(sub, payload, opts = {}) { if (!isUP(sub)) return webpush.sendNotification(sub, payload, opts); const res = await fetch(sub.endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: payload, signal: AbortSignal.timeout(8000), }); if (!res.ok) { // shaped like web-push's errors so the callers' 404/410 pruning works const err = new Error(`unifiedpush ${res.status}`); err.statusCode = res.status; throw err; } return res; } const log = (...a) => console.log(new Date().toISOString(), ...a); // --------------------------------------------------------------------------- // registrations: { [servicePubkey]: [{ sub, id, updated }] } // --------------------------------------------------------------------------- let regs = {}; // user-facing notifications: { [endpointId]: { sub, ptags: [], authors: [], updated } } // ptags → DMs / zap receipts p-tagged at the user; authors → community // stream pubkeys (kind 1059 wraps authored by a channel's derived key). let notifyRegs = {}; (() => { try { const raw = JSON.parse(readFileSync(STATE, 'utf8')); if (raw && raw.nwc) { regs = raw.nwc; notifyRegs = raw.notify || {}; } else regs = raw || {}; // pre-notify state file } catch {} })(); function persist() { mkdirSync(dirname(STATE), { recursive: true }); const tmp = STATE + '.tmp'; writeFileSync(tmp, JSON.stringify({ nwc: regs, notify: notifyRegs })); renameSync(tmp, STATE); } // A device that stops refreshing drops off, so a stale endpoint can't keep us // subscribed to a pubkey forever. function prune() { const cutoff = Date.now() - STALE_MS; let dropped = 0; for (const pk of Object.keys(regs)) { regs[pk] = (regs[pk] || []).filter((r) => (r.updated || 0) > cutoff); if (!regs[pk].length) { delete regs[pk]; dropped++; } } for (const id of Object.keys(notifyRegs)) { if ((notifyRegs[id].updated || 0) <= cutoff) { delete notifyRegs[id]; dropped++; } } if (dropped) { persist(); log(`pruned ${dropped} stale registration(s)`); resubscribe(); } } const watchedPubkeys = () => Object.keys(regs); // --------------------------------------------------------------------------- // relay watcher // --------------------------------------------------------------------------- const pool = new SimplePool(); let sub = null; let lastResub = 0; const notifyPtags = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.ptags || []))]; const notifyAuthors = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.authors || []))]; let notifySub = null; function resubscribeNotify() { try { notifySub?.close(); } catch {} notifySub = null; const ptags = notifyPtags(); const authors = notifyAuthors(); if (!ptags.length && !authors.length) return; const filters = []; // DMs / direct invites / zap receipts land p-tagged at the user // ...as do replies and mentions (kind 1 naming the user) and reactions (7) // ...and calls ringing in (NIP-AC: ephemeral gift wraps, kind 21059) if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7, CALL_WRAP], '#p': ptags, since: Math.floor(Date.now() / 1000) }); // community chat: wraps authored by a channel's derived stream key if (authors.length) filters.push({ kinds: [1059], authors, since: Math.floor(Date.now() / 1000) }); // one sub per filter — the pool API takes a single filter object notifySub = filters.map((f) => pool.subscribeMany(RELAYS, f, { onevent: onNotifyEvent })); notifySub.close = function () { for (const x of this) { try { x.close(); } catch {} } }; log(`notify: watching ${ptags.length} ptag(s), ${authors.length} stream author(s)`); } // throttle: chat bursts collapse to one push per device per window // DMs are throttled barely at all now: the device drops the ones it decides // are noise (a stranger, or our own sent-copy), and a server-side cooldown // would let a discarded push swallow the friend's message that followed it. const COOLDOWN = { payment: 10_000, zap: 10_000, dm: 1_000, chat: 90_000, mention: 5_000, reaction: 30_000, call: 3_000 }; // Calls (NIP-AC). Every setup message of a call is a kind 21059 wrap to the // person — the offer that rings them, then a stream of ICE candidates and // the hang-up — and none can be read here. An offer carries a whole SDP, so // its wrap is several kilobytes; candidates and hang-ups are a few hundred // bytes. Only a wrap big enough to be an offer wakes the device (a push the // device then shows nothing for counts against the site in Chrome), and a // whole offer is too big for a push (~4 KB), so the push says which wrap and // the device fetches it from here (GET /wrap/) and opens it itself. const CALL_WRAP = 21059; const CALL_OFFER_MIN = 2000; // wrap content length: an offer's is ~4-10 KB, a candidate's ~1 KB const CALL_KEEP_MS = 120_000; const callWraps = new Map(); // wrap id -> { ev, at } function keepCallWrap(ev) { callWraps.set(ev.id, { ev, at: Date.now() }); if (callWraps.size > 500) { const cut = Date.now() - CALL_KEEP_MS; for (const [k, v] of callWraps) if (v.at < cut || callWraps.size > 500) callWraps.delete(k); } } const lastPush = new Map(); // endpointId:reason -> ts const seenNotifyEvents = new Map(); // event id -> ts function onNotifyEvent(ev) { if (seenNotifyEvents.has(ev.id)) return; seenNotifyEvents.set(ev.id, Date.now()); if (seenNotifyEvents.size > 4000) { const cut = Date.now() - 600_000; for (const [k, t] of seenNotifyEvents) if (t < cut) seenNotifyEvents.delete(k); } const p = ev.tags?.find((t) => t[0] === 'p')?.[1]; // A public reply or mention: the note is readable here, so the device // gets what it needs to say who and what — an excerpt, and whether it // answers a post (e tag) or just names the person. if (ev.kind === 1) { const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]); const reply = (ev.tags || []).some((t) => t[0] === 'e' && t[1]); const extra = { from: ev.pubkey, reply, text: String(ev.content || '').replace(/\s+/g, ' ').slice(0, 140) }; for (const [id, r] of Object.entries(notifyRegs)) { const mine = r.ptags || []; if (mine.includes(ev.pubkey)) continue; // their own post, not news if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'mention', extra).catch(() => {}); } return; } // A like or emoji on the user's post. Opt-in by an explicit flag: an app // too old to send one has no words for it and would call it chat. if (ev.kind === 7) { const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]); const extra = { from: ev.pubkey, emoji: String(ev.content || '+').slice(0, 16) }; for (const [id, r] of Object.entries(notifyRegs)) { const mine = r.ptags || []; if (r.reasons?.reaction !== true || mine.includes(ev.pubkey)) continue; if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'reaction', extra).catch(() => {}); } return; } if (ev.kind === CALL_WRAP) { if (!p || String(ev.content || '').length < CALL_OFFER_MIN) return; keepCallWrap(ev); // only to an app that says it rings for calls (reasons.call): an older // one's worker has no words for a call and would call it chat for (const [id, r] of Object.entries(notifyRegs)) { if (r.reasons?.call === true && (r.ptags || []).includes(p)) pushNotify(id, r, 'call', { wrapId: ev.id }).catch(() => {}); } return; } const zap = ev.kind === 9735 || ev.kind === 9737; const reason = zap ? 'payment' : p && notifyPtags().includes(p) ? 'dm' : 'chat'; // A wrapped DM hides its sender from us by design, so we can't decide here // whether it's from a friend or a stranger — we hand the whole wrap to the // device, which holds the only key that can open it. Web Push tops out // around 4KB after encryption; anything larger goes as a bare nudge and the // device shows its generic notification. const extra = reason === 'dm' && JSON.stringify(ev).length < 3500 ? { wrap: ev } : {}; // a coinos zap receipt says how much in the clear const amt = ev.kind === 9737 ? parseInt(ev.tags?.find((t) => t[0] === 'amount')?.[1], 10) : 0; if (amt > 0) extra.amountSat = amt; for (const [id, r] of Object.entries(notifyRegs)) { const hit = reason === 'chat' ? (r.authors || []).includes(ev.pubkey) : p && (r.ptags || []).includes(p); // a zap is its own category for apps that know one; older apps keep // getting it as a payment if (hit) pushNotify(id, r, zap && typeof r.reasons?.zap === 'boolean' ? 'zap' : reason, extra).catch(() => {}); } } async function pushNotify(id, r, reason, extra) { if (r.reasons && r.reasons[reason] === false) return; // opted out of this category const key = id + ':' + reason; const last = lastPush.get(key) || 0; if (Date.now() - last < (COOLDOWN[reason] || 10_000)) return; lastPush.set(key, Date.now()); const payload = JSON.stringify({ type: 'notify', reason, ...extra }); // Android holds a 'normal' push while the phone dozes and batches it into // the next maintenance window — and drops it once TTL runs out first. A // phone in a pocket missed most zaps and mentions this way (a laptop never // dozes, so it got them all). Money and people talking to you go 'high' // and keep for a day; reactions and community chatter can wait. const loud = ['payment', 'zap', 'dm', 'mention'].includes(reason); // a call rings now or not at all: a minute, then it's a missed call const opts = reason === 'call' ? { TTL: 60, urgency: 'high' } : loud ? { TTL: 86400, urgency: 'high' } : { TTL: 3600, urgency: 'normal' }; try { try { await deliver(r.sub, payload, opts); } catch (e) { // A payload the push service won't take (413, or the library refusing // the size) must not cost the notification itself — send the bare nudge // and let the device show its generic message. if (!extra.wrap || (e.statusCode && e.statusCode !== 413)) throw e; log(`notify(dm): payload rejected (${e.statusCode || e.message}), retrying bare`); await deliver(r.sub, JSON.stringify({ type: 'notify', reason }), opts); } log(`notify(${reason}) -> device ${id}`); } catch (e) { // 404/410 = gone; 403 = the sub was made against a different VAPID key // and can NEVER receive a push (a stale key rotation), so prune it too — // the client re-subscribes fresh against the current key on next open. if (e.statusCode === 404 || e.statusCode === 410 || e.statusCode === 403) { delete notifyRegs[id]; persist(); resubscribeNotify(); log(`dropped a dead notify endpoint ${id} (${e.statusCode})`); } else { log(`notify(${reason}) push failed for ${id}: ${e.statusCode || e.message}`); } } } function resubscribe() { resubscribeNotify(); const pks = watchedPubkeys(); try { sub?.close(); } catch {} sub = null; if (!pks.length) { log('nothing registered for nwc; not subscribed'); return; } lastResub = Date.now(); sub = pool.subscribeMany( RELAYS, // only live traffic: a replayed old request must not wake devices. // NOTE: nostr-tools ≥2.23 wants a single filter object, NOT an array — // an array serializes as an invalid REQ that relays reject with CLOSED. { kinds: [REQ_KIND, OFFER_KIND], '#p': pks, since: Math.floor(Date.now() / 1000) }, { onevent: (ev) => { const target = ev.tags?.find((t) => t[0] === 'p')?.[1]; if (target) { noteRequest(ev.id); wake(target, ev.id, ev).catch(() => {}); } }, }, ); // Track replies for the requests we forwarded, so an auto-answering worker // can ask "was this already answered by an open device elsewhere?" before // it pays. Only e-tags of requests we have seen are recorded. try { answeredSub?.close(); } catch {} answeredSub = pool.subscribeMany( RELAYS, { kinds: [RES_KIND, OFFER_KIND], since: Math.floor(Date.now() / 1000) }, { onevent: (ev) => { for (const t of ev.tags || []) { if (t[0] === 'e' && recentReqs.has(t[1])) answeredIds.set(t[1], Date.now()); } }, }, ); log(`subscribed for ${pks.length} service pubkey(s) on ${RELAYS.length} relays`); } // requests we pushed recently, and which of them got a reply const RES_KIND = 23195; let answeredSub = null; const recentReqs = new Map(); // request event id -> ts const answeredIds = new Map(); // request event id -> ts a 23195 e-tagged it // First come, first pays: request event id -> { by, ts }. Every awake device // of a wallet sees every pay request, and two spending at once each lock an // HTLC for the same invoice — the loser's refusal then reached the client as // a failure. A device claims a request before spending; a claim held by // someone else means stand down. Memory only, minutes of life, no keys. const claims = new Map(); function noteRequest(id) { recentReqs.set(id, Date.now()); if (recentReqs.size > 2000) pruneRequests(); } function pruneRequests() { const cut = Date.now() - 300_000; for (const [k, t] of recentReqs) if (t < cut) recentReqs.delete(k); for (const [k, t] of answeredIds) if (t < cut) answeredIds.delete(k); for (const [k, c] of claims) if (c.ts < cut) claims.delete(k); } // De-dupe: the same event arrives from several relays. const recentlyWoken = new Map(); async function wake(servicePk, eventId, ev) { const key = servicePk + ':' + eventId; if (recentlyWoken.has(key)) return; recentlyWoken.set(key, Date.now()); if (recentlyWoken.size > 2000) { const cut = Date.now() - 300_000; for (const [k, t] of recentlyWoken) if (t < cut) recentlyWoken.delete(k); } const targets = regs[servicePk] || []; if (!targets.length) return; // The payload carries the (still encrypted, still client-signed) request // event so an auto-answering service worker can act on it without holding // a relay socket. Web Push payloads cap around 4KB — an oversized event is // omitted and the worker falls back to waking the user. const clean = ev && { id: ev.id, pubkey: ev.pubkey, created_at: ev.created_at, kind: ev.kind, tags: ev.tags, content: ev.content, sig: ev.sig, }; let payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk, event: clean }); if (payload.length > 3800) payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk }); let ok = 0; for (const r of targets) { try { // Ten minutes at APNs: a phone on a patchy signal (PullCap's 2026-10-26 // run lost two pushes in a row on mobile data) gets the wake late // rather than never. The request itself is only good for two minutes // (the worker ignores an older one quietly), but a late wake still // finishes sends an earlier wake left in flight. await deliver(r.sub, payload, { TTL: 600, urgency: 'high' }); ok++; } catch (e) { // 404/410 mean the browser dropped the subscription: forget it. if (e.statusCode === 404 || e.statusCode === 410) { regs[servicePk] = (regs[servicePk] || []).filter((x) => x.id !== r.id); if (!regs[servicePk].length) delete regs[servicePk]; persist(); log(`dropped an expired push endpoint for ${servicePk.slice(0, 12)}`); } else { log('push failed:', e.statusCode || e.message); } } } log(`woke ${ok}/${targets.length} device(s) for ${servicePk.slice(0, 12)}`); } // --------------------------------------------------------------------------- // scheduled posts // --------------------------------------------------------------------------- // A post written now to go out later. The app signs it now, while the user // (and their signer) is there, with created_at set to the time it should // appear — so it is exactly the note it will be, and we can't change a word // of it. Relays refuse notes from the future, so we hold it and publish it // at that time to the relays the author asked for. Cancelling is a kind 5 // deletion signed by the same key; we drop the note and it never reaches a // relay. What this learns: a public post a little before everyone else. const SCHED_FILE = join(dirname(STATE), 'scheduled.json'); const SCHED_MAX_PER_PK = 50; const SCHED_MAX_AHEAD = 90 * 86400; const SCHED_GIVE_UP = 86400; // a note no relay would take for a day is dropped let scheduled = {}; // { [eventId]: { ev, relays, tries } } try { scheduled = JSON.parse(readFileSync(SCHED_FILE, 'utf8')) || {}; } catch {} function persistScheduled() { mkdirSync(dirname(SCHED_FILE), { recursive: true }); writeFileSync(SCHED_FILE + '.tmp', JSON.stringify(scheduled)); renameSync(SCHED_FILE + '.tmp', SCHED_FILE); } const okRelay = (u) => typeof u === 'string' && u.length < 200 && /^wss:\/\/[a-z0-9.-]+(:\d+)?(\/[\w./-]*)?$/i.test(u); let schedBusy = false; async function publishDue() { if (schedBusy) return; schedBusy = true; try { const now = Math.floor(Date.now() / 1000); for (const [id, s] of Object.entries(scheduled)) { if (s.ev.created_at > now || (s.next || 0) > now) continue; const relays = [...new Set([...(s.relays || []), ...RELAYS])]; let sent = 0; try { sent = (await Promise.allSettled(pool.publish(relays, s.ev))).filter((r) => r.status === 'fulfilled').length; } catch {} if (sent) { delete scheduled[id]; log(`published scheduled post ${id.slice(0, 12)} by ${s.ev.pubkey.slice(0, 12)} to ${sent}/${relays.length} relays`); } else if (now - s.ev.created_at > SCHED_GIVE_UP) { delete scheduled[id]; log(`gave up on scheduled post ${id.slice(0, 12)}: no relay took it in a day`); } else { s.tries = (s.tries || 0) + 1; s.next = now + Math.min(3600, 30 * 2 ** s.tries); } persistScheduled(); } } finally { schedBusy = false; } } // --------------------------------------------------------------------------- // HTTP // --------------------------------------------------------------------------- const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', }, }); const server = Bun.serve({ port: CFG.port || 8797, async fetch(req) { const url = new URL(req.url); if (req.method === 'OPTIONS') return json({}); if (url.pathname === '/health') { return json({ ok: true, relays: RELAYS, watching: watchedPubkeys().length, subscribed: !!sub, lastResub, }); } // The browser needs this to create a PushSubscription. if (url.pathname === '/vapid') return json({ publicKey: CFG.vapid.publicKey }); // a call's offer wrap, for the device a push woke (see CALL_WRAP above): // still encrypted to them, as every relay that carried it saw it const wm = /^\/wrap\/([0-9a-f]{64})$/.exec(url.pathname); if (wm && req.method === 'GET') { const w = callWraps.get(wm[1]); return w && Date.now() - w.at < CALL_KEEP_MS ? json(w.ev) : json({ error: 'gone' }, 404); } // Publish-back for the auto-answering service worker: it cannot hold a // relay socket, so it hands us its (encrypted, service-key-signed) reply. // Strictly kind 23195, size-capped, signature-verified, rate-limited — // this is a reply pipe, not an open relay proxy. if (url.pathname === '/publish' && req.method === 'POST') { const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); const body = await req.json().catch(() => null); const ev = body?.event; if (!ev || ![RES_KIND, OFFER_KIND].includes(ev.kind)) return json({ error: 'kind 23195/21001 events only' }, 400); if (JSON.stringify(ev).length > 4096) return json({ error: 'event too large' }, 400); if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400); const eTag = (ev.tags || []).find((t) => t[0] === 'e')?.[1]; try { await Promise.allSettled(pool.publish(RELAYS, ev)); } catch {} if (eTag) answeredIds.set(eTag, Date.now()); log(`published a reply for request ${(eTag || '?').slice(0, 12)}`); return json({ ok: true }); } // Scheduled posts (see above): hold a signed kind-1 note until its // created_at, then publish it; status by id (the id is the capability: // only its author knows it before it goes out); cancel with a kind 5. if (url.pathname === '/schedule' && req.method === 'POST') { const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); const body = await req.json().catch(() => null); const ev = body?.event; if (!ev || ev.kind !== 1) return json({ error: 'kind 1 notes only' }, 400); if (JSON.stringify(ev).length > 65536) return json({ error: 'note too large' }, 400); const now = Math.floor(Date.now() / 1000); if (!(ev.created_at > now + 30)) return json({ error: 'that time has passed' }, 400); if (ev.created_at > now + SCHED_MAX_AHEAD) return json({ error: 'at most 90 days ahead' }, 400); if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400); if (!scheduled[ev.id] && Object.values(scheduled).filter((s) => s.ev.pubkey === ev.pubkey).length >= SCHED_MAX_PER_PK) { return json({ error: `at most ${SCHED_MAX_PER_PK} scheduled posts` }, 400); } const relays = [...new Set((Array.isArray(body.relays) ? body.relays : []).filter(okRelay))].slice(0, 20); scheduled[ev.id] = { ev, relays, tries: 0 }; persistScheduled(); log(`scheduled post ${ev.id.slice(0, 12)} by ${ev.pubkey.slice(0, 12)} for ${new Date(ev.created_at * 1000).toISOString()}`); return json({ ok: true, id: ev.id, at: ev.created_at }); } if (url.pathname === '/schedule/status' && req.method === 'GET') { const ids = (url.searchParams.get('ids') || '').split(',').filter((x) => /^[0-9a-f]{64}$/.test(x)).slice(0, 100); return json(Object.fromEntries(ids.map((id) => [id, scheduled[id] ? 'pending' : 'gone']))); } if (url.pathname === '/schedule/cancel' && req.method === 'POST') { const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); const body = await req.json().catch(() => null); const del = body?.event; if (!del || del.kind !== 5 || JSON.stringify(del).length > 8192) return json({ error: 'a kind 5 deletion' }, 400); if (!verifyEvent(del)) return json({ error: 'bad signature' }, 400); let n = 0; for (const [k, id] of del.tags || []) { if (k === 'e' && scheduled[id] && scheduled[id].ev.pubkey === del.pubkey) { delete scheduled[id]; n++; } } if (n) { persistScheduled(); log(`cancelled ${n} scheduled post(s) by ${del.pubkey.slice(0, 12)}`); } return json({ ok: true, cancelled: n }); } // Server-to-server: the names registrar reports a settled receive for a // pubkey. Token-gated; pushes 'payment received' to that user's devices. if (url.pathname === '/notify' && req.method === 'POST') { const body = await req.json().catch(() => null); if (!CFG.notifyToken || body?.token !== CFG.notifyToken) return json({ error: 'forbidden' }, 403); const pk = body?.pubkey; if (!/^[0-9a-f]{64}$/.test(pk || '')) return json({ error: 'pubkey required' }, 400); // Network scoping: the same pubkey exists on mainnet and staging, so a // payment goes only to devices on its own network. A device that hasn't // re-registered since this shipped has net=null and still gets it (so // no one goes dark mid-rollout); once both sides send a net, mismatches // are filtered. A caller that omits net pushes to everyone (legacy). const wantNet = typeof body.net === 'string' ? body.net : null; let n = 0; for (const [id, r] of Object.entries(notifyRegs)) { if (!(r.ptags || []).includes(pk)) continue; if (wantNet && r.net && r.net !== wantNet) continue; pushNotify(id, r, 'payment', { amountSat: Number(body.amountSat) || undefined }).catch(() => {}); n++; } return json({ ok: true, devices: n }); } // Claim a pay request before spending on it. The first claimant (or the // same one again) gets ok:true; anyone else ok:false and stands down. if (url.pathname === '/claim' && req.method === 'POST') { const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); const body = await req.json().catch(() => null); const id = body?.event, by = String(body?.by || '').slice(0, 64); if (!/^[0-9a-f]{64}$/.test(id || '') || !by) return json({ error: 'event and by required' }, 400); if (claims.size > 5000) pruneRequests(); const cur = claims.get(id); if (cur && cur.by !== by && Date.now() - cur.ts < 300_000) return json({ ok: false, by: cur.by }); claims.set(id, { by, ts: Date.now() }); return json({ ok: true }); } // A worker that can't act on a request (its mirror is short of keys or // coins, say) wakes the user — and leaves this reply here, an error that // tells the other app to wait, which goes out only if nobody answers // within afterMs. A sibling device with the wallet open, or the woken // user, may still pay inside that window; their real reply wins, and // the client never sees an error over a payment. if (url.pathname === '/decline' && req.method === 'POST') { const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); const body = await req.json().catch(() => null); const ev = body?.event; if (!ev || ev.kind !== RES_KIND) return json({ error: 'kind 23195 events only' }, 400); if (JSON.stringify(ev).length > 4096) return json({ error: 'event too large' }, 400); if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400); const eTag = (ev.tags || []).find((t) => t[0] === 'e')?.[1]; if (!eTag) return json({ error: 'reply must name its request (e tag)' }, 400); if (answeredIds.has(eTag)) return json({ ok: true, scheduled: false }); const afterMs = Math.min(60_000, Math.max(5_000, Number(body?.afterMs) || 20_000)); setTimeout(async () => { if (answeredIds.has(eTag)) return; try { await Promise.allSettled(pool.publish(RELAYS, ev)); } catch {} answeredIds.set(eTag, Date.now()); log(`published a wait reply for request ${eTag.slice(0, 12)} (nobody answered in ${afterMs} ms)`); }, afterMs); return json({ ok: true, scheduled: true }); } // Was a request we pushed already answered by someone? Lets a worker // avoid double-paying (or shouting an error over) another device's reply. if (url.pathname === '/answered') { const id = url.searchParams.get('event') || ''; return json({ answered: answeredIds.has(id) }); } // A device registers its push endpoint plus the wallet-service pubkeys it // wants woken for. Re-post periodically to stay alive. if (url.pathname === '/register' && req.method === 'POST') { const body = await req.json().catch(() => null); const sub_ = body?.subscription; const hasNwc = Array.isArray(body?.servicePubkeys); const pks = (body?.servicePubkeys || []).filter((p) => /^[0-9a-f]{64}$/.test(p)); // user-facing notification watch (messages + payments) — independent of // the NWC registration so either can refresh without clobbering the other if (body?.notify && sub_?.endpoint) { const hex = (a, cap) => [...new Set((a || []).filter((p) => /^[0-9a-f]{64}$/.test(p)))].slice(0, cap); const id = Bun.hash(sub_.endpoint).toString(36); // per-category opt-outs; absent means everything on (older clients) const reasons = {}; for (const k of ['payment', 'zap', 'dm', 'chat', 'mention', 'reaction', 'call']) { if (typeof body.notify.reasons?.[k] === 'boolean') reasons[k] = body.notify.reasons[k]; } notifyRegs[id] = { sub: sub_, updated: Date.now(), ptags: hex(body.notify.ptags, 8), authors: hex(body.notify.authors, 64), reasons, // the network this device is on: the same seed yields the same // pubkey on mainnet and staging, so a payment push is delivered // only to devices on the payment's network (see /notify) net: typeof body.notify.net === 'string' ? body.notify.net.slice(0, 16) : null, }; persist(); resubscribeNotify(); if (!hasNwc) return json({ ok: true, notify: true }); } if (!sub_?.endpoint || !pks.length) return json({ error: 'subscription and servicePubkeys required' }, 400); if (pks.length > MAX_PK) return json({ error: `at most ${MAX_PK} pubkeys` }, 400); // one id per endpoint so re-registering replaces rather than duplicates const id = Bun.hash(sub_.endpoint).toString(36); const known = new Set(pks); for (const pk of pks) { regs[pk] = (regs[pk] || []).filter((r) => r.id !== id); regs[pk].push({ sub: sub_, id, updated: Date.now() }); } // drop this device from pubkeys it no longer cares about for (const pk of Object.keys(regs)) { if (known.has(pk)) continue; const before = regs[pk].length; regs[pk] = regs[pk].filter((r) => r.id !== id); if (!regs[pk].length) delete regs[pk]; if (before !== (regs[pk]?.length ?? 0)) { /* changed */ } } persist(); resubscribe(); return json({ ok: true, watching: pks.length }); } if (url.pathname === '/register' && req.method === 'DELETE') { const body = await req.json().catch(() => null); if (!body?.endpoint) return json({ error: 'endpoint required' }, 400); const id = Bun.hash(body.endpoint).toString(36); for (const pk of Object.keys(regs)) { regs[pk] = regs[pk].filter((r) => r.id !== id); if (!regs[pk].length) delete regs[pk]; } delete notifyRegs[id]; persist(); resubscribe(); return json({ ok: true }); } return json({ error: 'not found' }, 404); }, }); log(`nwc push notifier on :${server.port}`); log(` relays: ${RELAYS.join(' ')}`); log(` watching ${watchedPubkeys().length} service pubkey(s)`); resubscribe(); setInterval(prune, 3600_000); setInterval(publishDue, 15_000); publishDue(); log(` holding ${Object.keys(scheduled).length} scheduled post(s)`); // relays drop long-lived subs; re-arm periodically setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000);