aboutsummaryrefslogtreecommitdiffstats
path: root/nwcpush/server.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-28 00:22:16 -0700
committerAdam Soltys <[email protected]>2026-10-28 00:22:16 -0700
commit587a0f3df38884d7916b02cc16a080ad3f1905cc (patch)
tree99e7e27880b2e6456924b107e2d91cb541744951 /nwcpush/server.js
parentf6658f0ef551b34339844d035dd2efdec5661a84 (diff)
downloadcoinosv3-server-587a0f3df38884d7916b02cc16a080ad3f1905cc.tar.gz
coinosv3-server-587a0f3df38884d7916b02cc16a080ad3f1905cc.zip
nwcpush: POST /decline holds a worker's "wait" reply and sends it only if nobody answers the request in afterMs (5–60 s); NWC wake pushes live 600 s at APNs instead of 120 so a phone on a patchy signal gets the wake late rather than never
A worker that can't act on a request (mirror short of keys or coins) wakes the user and, through this, tells the other app to look the request up later — PullCap read the silence as "no answer" and suspended the subscription (2026-10-26). A sibling device or the woken user may still pay inside the window; their real reply wins and the error never lands on a payment. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'nwcpush/server.js')
-rw-r--r--nwcpush/server.js34
1 files changed, 33 insertions, 1 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js
index a6fd7c7..1ad0ad2 100644
--- a/nwcpush/server.js
+++ b/nwcpush/server.js
@@ -370,7 +370,12 @@ async function wake(servicePk, eventId, ev) {
let ok = 0;
for (const r of targets) {
try {
- await deliver(r.sub, payload, { TTL: 120, urgency: 'high' }); // the request window; a phone in deep sleep gets the wake late rather than never
+ // 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.
@@ -481,6 +486,33 @@ const server = Bun.serve({
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') {