diff options
| -rw-r--r-- | nwcpush/server.js | 34 |
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') { |
