aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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') {