aboutsummaryrefslogtreecommitdiffstats
path: root/nwcpush/server.js
diff options
context:
space:
mode:
Diffstat (limited to 'nwcpush/server.js')
-rw-r--r--nwcpush/server.js529
1 files changed, 0 insertions, 529 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js
deleted file mode 100644
index 6d56f84..0000000
--- a/nwcpush/server.js
+++ /dev/null
@@ -1,529 +0,0 @@
-// 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)
- if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7], '#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 };
-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;
- }
- 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);
- const opts = 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 {
- await deliver(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 });
-
- // 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 });
- }
-
- // 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 });
- }
-
- // 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']) {
- 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);
-// relays drop long-lived subs; re-arm periodically
-setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000);