aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 09:00:22 -0700
committerAdam Soltys <[email protected]>2026-10-18 09:00:22 -0700
commit2e897103836a6814a39dbb905d6a7ed1417344d7 (patch)
tree3d3c8d497682802508d524cd42bab43e61bc8a33
parent265fc554840455c87b7e46a2f42bcb51e22318c1 (diff)
downloadcoinosv3-server-2e897103836a6814a39dbb905d6a7ed1417344d7.tar.gz
coinosv3-server-2e897103836a6814a39dbb905d6a7ed1417344d7.zip
nwcpush: calls wake a closed app. Kind 21059 wraps to a registered pubkey are watched; one big enough to be a call offer (an SDP) pushes 'call' at high urgency, a minute's TTL, naming the wrap, which the device fetches from GET /wrap/<id> (kept two minutes; a push can't carry it) and opens with its own key. ICE candidates and hang-ups are too small to wake anyone. A 'call' opt-out like the other reasons.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
-rw-r--r--nwcpush/server.js45
-rw-r--r--tools/nwcpush-call-test.js87
2 files changed, 128 insertions, 4 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js
index 6d56f84..198757c 100644
--- a/nwcpush/server.js
+++ b/nwcpush/server.js
@@ -139,7 +139,8 @@ function resubscribeNotify() {
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) });
+ // ...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
@@ -152,7 +153,27 @@ function resubscribeNotify() {
// 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 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/<id>) 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) {
@@ -189,6 +210,14 @@ function onNotifyEvent(ev) {
}
return;
}
+ if (ev.kind === CALL_WRAP) {
+ if (!p || String(ev.content || '').length < CALL_OFFER_MIN) return;
+ keepCallWrap(ev);
+ for (const [id, r] of Object.entries(notifyRegs)) {
+ if ((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';
@@ -224,7 +253,8 @@ async function pushNotify(id, r, reason, extra) {
// 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' };
+ // 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);
@@ -384,6 +414,13 @@ const server = Bun.serve({
// 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.
@@ -463,7 +500,7 @@ const server = Bun.serve({
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']) {
+ 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] = {
diff --git a/tools/nwcpush-call-test.js b/tools/nwcpush-call-test.js
new file mode 100644
index 0000000..9145744
--- /dev/null
+++ b/tools/nwcpush-call-test.js
@@ -0,0 +1,87 @@
+// nwcpush and calls: a device registered for its pubkey is woken for a call
+// offer (a kind 21059 wrap big enough to carry an SDP), with a push naming
+// the wrap, which it then fetches from /wrap/<id>; the small wraps that
+// follow an offer (ICE candidates, a hang-up) wake nobody; a device that
+// turned calls off isn't woken.
+// The device here is a UnifiedPush endpoint (the notifier POSTs the payload
+// as is), so the test can read what it was sent.
+// Run: bun tools/nwcpush-call-test.js
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import webpush from 'web-push';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+// a relay: every event to every subscription it matches
+const subs = new Set();
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+const relay = Bun.serve({ port: 0, hostname: '127.0.0.1',
+ fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response('ws', { status: 426 })),
+ websocket: {
+ open(ws) { ws.data = new Map(); },
+ message(ws, raw) {
+ const m = JSON.parse(raw);
+ if (m[0] === 'REQ') { const s = { ws, id: m[1], filters: m.slice(2) }; ws.data.set(m[1], s); subs.add(s); ws.send(JSON.stringify(['EOSE', m[1]])); }
+ if (m[0] === 'CLOSE') { subs.delete(ws.data.get(m[1])); }
+ if (m[0] === 'EVENT') {
+ ws.send(JSON.stringify(['OK', m[1].id, true, '']));
+ for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, m[1]))) s.ws.send(JSON.stringify(['EVENT', s.id, m[1]]));
+ }
+ },
+ close(ws) { for (const s of ws.data.values()) subs.delete(s); },
+ } });
+// the devices: what each was pushed
+const got = { on: [], off: [] };
+const devices = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: async (req) => { got[new URL(req.url).pathname.slice(1)].push(await req.json()); return new Response('ok'); } });
+
+const dir = mkdtempSync(tmpdir() + '/nwcpush-call-');
+const port = 18000 + Math.floor(Math.random() * 1000);
+writeFileSync(dir + '/config.json', JSON.stringify({ port, relays: [`ws://127.0.0.1:${relay.port}`], vapid: { ...webpush.generateVAPIDKeys(), subject: 'mailto:[email protected]' }, stateFile: dir + '/state.json' }));
+const proc = Bun.spawn(['bun', 'nwcpush/server.js'], { env: { ...process.env, NWCPUSH_CONFIG: dir + '/config.json' }, stdout: 'pipe', stderr: 'pipe' });
+const N = `http://127.0.0.1:${port}`;
+const ME = getPublicKey(generateSecretKey()), OTHER = getPublicKey(generateSecretKey());
+// a call wrap as an app sends one: the throwaway key, p = who it's for, NIP-44 content of some size
+const wrap = (to, size) => finalizeEvent({ kind: 21059, created_at: Math.floor(Date.now() / 1000), tags: [['p', to]], content: 'A'.repeat(size) }, generateSecretKey());
+const publish = (ev) => new Promise((resolve) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${relay.port}`);
+ ws.onopen = () => ws.send(JSON.stringify(['EVENT', ev]));
+ ws.onmessage = () => { ws.close(); resolve(); };
+});
+
+try {
+ for (let i = 0; i < 40; i++) { try { if ((await fetch(N + '/health')).ok) break; } catch {} await sleep(150); }
+ const reg = (path, reasons) => fetch(N + '/register', { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ subscription: { endpoint: `http://127.0.0.1:${devices.port}/${path}`, unifiedpush: true }, notify: { ptags: [ME], reasons } }) });
+ check('a device registers for its pubkey', (await reg('on', {})).ok);
+ check('...another with calls turned off', (await reg('off', { call: false })).ok);
+ await sleep(1000); // the notifier resubscribes
+
+ console.log('\n[an offer]');
+ const offer = wrap(ME, 6000);
+ await publish(offer);
+ await sleep(800);
+ const push = got.on.find((p) => p.reason === 'call');
+ check('the device is woken: a call, naming the wrap', !!push && push.type === 'notify' && push.wrapId === offer.id, JSON.stringify(got.on));
+ check('...not the one that turned calls off', !got.off.some((p) => p.reason === 'call'));
+ const fetched = await (await fetch(N + '/wrap/' + offer.id)).json();
+ check('the wrap can be fetched from the notifier, as sent', fetched.id === offer.id && fetched.content === offer.content);
+ check('...from any page (CORS)', (await fetch(N + '/wrap/' + offer.id)).headers.get('access-control-allow-origin') === '*');
+
+ console.log('\n[what follows an offer]');
+ const before = got.on.length;
+ for (let i = 0; i < 5; i++) await publish(wrap(ME, 900)); // candidates
+ await publish(wrap(ME, 500)); // a hang-up
+ await sleep(800);
+ check('ICE candidates and a hang-up wake nobody', got.on.length === before, (got.on.length - before) + ' pushes');
+ await publish(wrap(OTHER, 6000));
+ await sleep(800);
+ check('an offer for someone else wakes nobody here', got.on.length === before);
+ check('a wrap it never saw: 404', (await fetch(N + '/wrap/' + 'f'.repeat(64))).status === 404);
+} catch (e) { console.error(e); ok = false; } finally {
+ proc.kill(); relay.stop(true); devices.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ nwcpush wakes a device for a call' : '\n❌ failed');
+process.exit(ok ? 0 : 1);