aboutsummaryrefslogtreecommitdiffstats
path: root/nwcpush/server.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 11:16:56 -0700
committerAdam Soltys <[email protected]>2026-08-01 11:16:56 -0700
commitca76accb5ccbe0ffcd68c97da2e9609f923df8f1 (patch)
treec54196c14de4f0b3b1f6bb0149d90a9d5a4e2228 /nwcpush/server.js
parent0519a79ac05ec983ae7ad8d4292f5cfa9fa321a9 (diff)
downloadcoinosv3-ca76accb5ccbe0ffcd68c97da2e9609f923df8f1.tar.gz
coinosv3-ca76accb5ccbe0ffcd68c97da2e9609f923df8f1.zip
Add the NWC push notifier (step 1 of background wallet-connect)
A service worker cannot hold a relay socket open, so something must tell a closed browser "a request arrived". This watches the relays for NIP-47 requests addressed to the wallet-service pubkeys a device registers, and sends that device a Web Push. The service worker will do the actual work in the browser; keys never leave it. Scoped to leak as little as possible: the notifier learns the service pubkeys registered and the timing of requests to them, and nothing else. Request contents are encrypted to keys it doesn't hold, it never sees the wallet seed, and it cannot move funds — the worst it can do is fail to wake a device. No database or accounts: registrations live in one JSON file, expire if a device stops refreshing, and push endpoints that return 404/410 are dropped. Wake-ups are de-duplicated across relays and only fire for live traffic, so a replayed old request can't wake anyone. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'nwcpush/server.js')
-rw-r--r--nwcpush/server.js211
1 files changed, 211 insertions, 0 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js
new file mode 100644
index 0000000..feca109
--- /dev/null
+++ b/nwcpush/server.js
@@ -0,0 +1,211 @@
+// 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 webpush from 'web-push';
+
+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;
+
+webpush.setVapidDetails(CFG.vapid.subject, CFG.vapid.publicKey, CFG.vapid.privateKey);
+const log = (...a) => console.log(new Date().toISOString(), ...a);
+
+// ---------------------------------------------------------------------------
+// registrations: { [servicePubkey]: [{ sub, id, updated }] }
+// ---------------------------------------------------------------------------
+
+let regs = (() => {
+ try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return {}; }
+})();
+function persist() {
+ mkdirSync(dirname(STATE), { recursive: true });
+ const tmp = STATE + '.tmp';
+ writeFileSync(tmp, JSON.stringify(regs));
+ 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++; }
+ }
+ if (dropped) { persist(); log(`pruned ${dropped} stale pubkey(s)`); resubscribe(); }
+}
+
+const watchedPubkeys = () => Object.keys(regs);
+
+// ---------------------------------------------------------------------------
+// relay watcher
+// ---------------------------------------------------------------------------
+
+const pool = new SimplePool();
+let sub = null;
+let lastResub = 0;
+
+function resubscribe() {
+ const pks = watchedPubkeys();
+ try { sub?.close(); } catch {}
+ sub = null;
+ if (!pks.length) { log('nothing registered; not subscribed'); return; }
+ lastResub = Date.now();
+ sub = pool.subscribeMany(
+ RELAYS,
+ // only live traffic: a replayed old request must not wake devices
+ [{ kinds: [REQ_KIND], '#p': pks, since: Math.floor(Date.now() / 1000) }],
+ {
+ onevent: (ev) => {
+ const target = ev.tags?.find((t) => t[0] === 'p')?.[1];
+ if (target) wake(target, ev.id).catch(() => {});
+ },
+ },
+ );
+ log(`subscribed for ${pks.length} service pubkey(s) on ${RELAYS.length} relays`);
+}
+
+// De-dupe: the same event arrives from several relays.
+const recentlyWoken = new Map();
+async function wake(servicePk, eventId) {
+ 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 no request content — only enough for the service
+ // worker to know which connection to go look at.
+ const payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk });
+ let ok = 0;
+ for (const r of targets) {
+ try {
+ await webpush.sendNotification(r.sub, payload, { TTL: 60, 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)}`);
+}
+
+// ---------------------------------------------------------------------------
+// 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 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 pks = (body?.servicePubkeys || []).filter((p) => /^[0-9a-f]{64}$/.test(p));
+ 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];
+ }
+ 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);
+// relays drop long-lived subs; re-arm periodically
+setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000);