diff options
| author | Adam Soltys <[email protected]> | 2026-10-29 09:00:50 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-29 09:00:50 -0700 |
| commit | 344ff3f24cad678b3121e6c257e1a412e9efd3d6 (patch) | |
| tree | e3d3d762872af20f9643263a2d0ffed2d142b1a9 /nwcpush | |
| parent | 587a0f3df38884d7916b02cc16a080ad3f1905cc (diff) | |
| download | coinosv3-server-344ff3f24cad678b3121e6c257e1a412e9efd3d6.tar.gz coinosv3-server-344ff3f24cad678b3121e6c257e1a412e9efd3d6.zip | |
nwcpush: scheduled posts. POST /schedule holds a signed kind-1 note whose created_at is in the future (2 min to 90 days, 50 per author, 64 KB) and publishes it at that time to the author's relays plus ours, retrying with backoff for up to a day; GET /schedule/status?ids= says which are still held; POST /schedule/cancel takes a kind 5 by the same key and drops them before they reach any relay. Kept in data/scheduled.json
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6TUeqTyZB8eXLGymUY
Diffstat (limited to 'nwcpush')
| -rw-r--r-- | nwcpush/server.js | 94 |
1 files changed, 94 insertions, 0 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js index 1ad0ad2..b8c1ecd 100644 --- a/nwcpush/server.js +++ b/nwcpush/server.js @@ -393,6 +393,55 @@ async function wake(servicePk, eventId, ev) { } // --------------------------------------------------------------------------- +// scheduled posts +// --------------------------------------------------------------------------- +// A post written now to go out later. The app signs it now, while the user +// (and their signer) is there, with created_at set to the time it should +// appear — so it is exactly the note it will be, and we can't change a word +// of it. Relays refuse notes from the future, so we hold it and publish it +// at that time to the relays the author asked for. Cancelling is a kind 5 +// deletion signed by the same key; we drop the note and it never reaches a +// relay. What this learns: a public post a little before everyone else. + +const SCHED_FILE = join(dirname(STATE), 'scheduled.json'); +const SCHED_MAX_PER_PK = 50; +const SCHED_MAX_AHEAD = 90 * 86400; +const SCHED_GIVE_UP = 86400; // a note no relay would take for a day is dropped +let scheduled = {}; // { [eventId]: { ev, relays, tries } } +try { scheduled = JSON.parse(readFileSync(SCHED_FILE, 'utf8')) || {}; } catch {} +function persistScheduled() { + mkdirSync(dirname(SCHED_FILE), { recursive: true }); + writeFileSync(SCHED_FILE + '.tmp', JSON.stringify(scheduled)); + renameSync(SCHED_FILE + '.tmp', SCHED_FILE); +} +const okRelay = (u) => typeof u === 'string' && u.length < 200 && /^wss:\/\/[a-z0-9.-]+(:\d+)?(\/[\w./-]*)?$/i.test(u); +let schedBusy = false; +async function publishDue() { + if (schedBusy) return; + schedBusy = true; + try { + const now = Math.floor(Date.now() / 1000); + for (const [id, s] of Object.entries(scheduled)) { + if (s.ev.created_at > now || (s.next || 0) > now) continue; + const relays = [...new Set([...(s.relays || []), ...RELAYS])]; + let sent = 0; + try { sent = (await Promise.allSettled(pool.publish(relays, s.ev))).filter((r) => r.status === 'fulfilled').length; } catch {} + if (sent) { + delete scheduled[id]; + log(`published scheduled post ${id.slice(0, 12)} by ${s.ev.pubkey.slice(0, 12)} to ${sent}/${relays.length} relays`); + } else if (now - s.ev.created_at > SCHED_GIVE_UP) { + delete scheduled[id]; + log(`gave up on scheduled post ${id.slice(0, 12)}: no relay took it in a day`); + } else { + s.tries = (s.tries || 0) + 1; + s.next = now + Math.min(3600, 30 * 2 ** s.tries); + } + persistScheduled(); + } + } finally { schedBusy = false; } +} + +// --------------------------------------------------------------------------- // HTTP // --------------------------------------------------------------------------- @@ -448,6 +497,48 @@ const server = Bun.serve({ return json({ ok: true }); } + // Scheduled posts (see above): hold a signed kind-1 note until its + // created_at, then publish it; status by id (the id is the capability: + // only its author knows it before it goes out); cancel with a kind 5. + if (url.pathname === '/schedule' && 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 !== 1) return json({ error: 'kind 1 notes only' }, 400); + if (JSON.stringify(ev).length > 65536) return json({ error: 'note too large' }, 400); + const now = Math.floor(Date.now() / 1000); + if (!(ev.created_at > now + 30)) return json({ error: 'that time has passed' }, 400); + if (ev.created_at > now + SCHED_MAX_AHEAD) return json({ error: 'at most 90 days ahead' }, 400); + if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400); + if (!scheduled[ev.id] && Object.values(scheduled).filter((s) => s.ev.pubkey === ev.pubkey).length >= SCHED_MAX_PER_PK) { + return json({ error: `at most ${SCHED_MAX_PER_PK} scheduled posts` }, 400); + } + const relays = [...new Set((Array.isArray(body.relays) ? body.relays : []).filter(okRelay))].slice(0, 20); + scheduled[ev.id] = { ev, relays, tries: 0 }; + persistScheduled(); + log(`scheduled post ${ev.id.slice(0, 12)} by ${ev.pubkey.slice(0, 12)} for ${new Date(ev.created_at * 1000).toISOString()}`); + return json({ ok: true, id: ev.id, at: ev.created_at }); + } + if (url.pathname === '/schedule/status' && req.method === 'GET') { + const ids = (url.searchParams.get('ids') || '').split(',').filter((x) => /^[0-9a-f]{64}$/.test(x)).slice(0, 100); + return json(Object.fromEntries(ids.map((id) => [id, scheduled[id] ? 'pending' : 'gone']))); + } + if (url.pathname === '/schedule/cancel' && 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 del = body?.event; + if (!del || del.kind !== 5 || JSON.stringify(del).length > 8192) return json({ error: 'a kind 5 deletion' }, 400); + if (!verifyEvent(del)) return json({ error: 'bad signature' }, 400); + let n = 0; + for (const [k, id] of del.tags || []) { + if (k === 'e' && scheduled[id] && scheduled[id].ev.pubkey === del.pubkey) { delete scheduled[id]; n++; } + } + if (n) { persistScheduled(); log(`cancelled ${n} scheduled post(s) by ${del.pubkey.slice(0, 12)}`); } + return json({ ok: true, cancelled: n }); + } + // 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') { @@ -596,5 +687,8 @@ log(` relays: ${RELAYS.join(' ')}`); log(` watching ${watchedPubkeys().length} service pubkey(s)`); resubscribe(); setInterval(prune, 3600_000); +setInterval(publishDue, 15_000); +publishDue(); +log(` holding ${Object.keys(scheduled).length} scheduled post(s)`); // relays drop long-lived subs; re-arm periodically setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000); |
