diff options
| author | Adam Soltys <[email protected]> | 2026-09-02 22:18:16 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-02 22:18:16 -0700 |
| commit | d28b716ae791f924d7dea901b9a9711e44bd7312 (patch) | |
| tree | ed3c2307ab1ee55a8e33fb7d873c5d521dd3ed87 /src/nostr-login.js | |
| parent | ea5bd5bfd7f6d41c64319847597894e61a61f126 (diff) | |
| download | coinosv3-d28b716ae791f924d7dea901b9a9711e44bd7312.tar.gz coinosv3-d28b716ae791f924d7dea901b9a9711e44bd7312.zip | |
The Amethyst handshake survives its own launch: opening the signer app freezes the tab (relay sockets die as the approval publishes) and its callbackUrl then RELOADS the page, discarding the client key mid-handshake — so the pending connect now persists to sessionStorage, the wait races the live subscription against a stored-event poller kicked on every return to visibility, and a fresh boot resumes the handshake instead of shrugging. Proven both ways with a scripted signer: approval after reload, and approval stored while the page was gone
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
Diffstat (limited to 'src/nostr-login.js')
| -rw-r--r-- | src/nostr-login.js | 114 |
1 files changed, 104 insertions, 10 deletions
diff --git a/src/nostr-login.js b/src/nostr-login.js index 86c6b79..56eb195 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -172,11 +172,33 @@ function bunkerAdapter(signer, pubkey, local) { // relay — no bunker URL to copy. Returns the URI (render it as a deep link // and a QR), a promise that resolves to a signer adapter when the app // answers, and a cancel. -export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000 } = {}) { +// +// SURVIVAL: launching the signer app on a phone FREEZES this tab — its relay +// sockets die exactly when the approval is being published, and a limit:0 +// subscription never replays what it slept through. Worse, a signer's +// callbackUrl can NAVIGATE the tab home, reloading the page and discarding +// the client key mid-handshake. So the pending handshake is persisted +// (sessionStorage) and the wait runs two paths: the library's live +// subscription, raced against a stored-event poller that re-queries the +// relays (kicked on every return to visibility) — whichever sees the +// approval first wins. `nostrConnectPending()` hands a fresh boot the +// persisted handshake so a reloaded page resumes instead of shrugging. +const NC_PENDING_KEY = 'nc-pending'; +export function nostrConnectPending(timeoutMs = 180_000) { + try { + const p = JSON.parse(sessionStorage.getItem(NC_PENDING_KEY) || 'null'); + if (p && p.local && p.secret && p.uri && Date.now() - p.ts < timeoutMs) return p; + if (p) sessionStorage.removeItem(NC_PENDING_KEY); + } catch {} + return null; +} +export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000, resume = null } = {}) { const { BunkerSigner, createNostrConnectURI } = await loadNip46(); - const local = generateSecretKey(); - const secret = hex.encode(crypto.getRandomValues(new Uint8Array(16))); - const uri = createNostrConnectURI({ + const local = resume ? hex.decode(resume.local) : generateSecretKey(); + const secret = resume ? resume.secret : hex.encode(crypto.getRandomValues(new Uint8Array(16))); + const startedAt = resume ? resume.ts : Date.now(); + if (resume && resume.relays) relays = resume.relays; + const uri = resume ? resume.uri : createNostrConnectURI({ clientPubkey: getPublicKey(local), relays, secret, @@ -184,15 +206,87 @@ export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://n url: 'https://v3.coinos.io', perms: ['get_public_key', 'sign_event', 'nip44_encrypt', 'nip44_decrypt'], }) + '&callbackUrl=' + encodeURIComponent('https://v3.coinos.io/'); + try { + sessionStorage.setItem(NC_PENDING_KEY, JSON.stringify({ + local: hex.encode(local), secret, relays, uri, ts: startedAt, + })); + } catch {} + const clientPub = getPublicKey(local); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')), timeoutMs); + const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')), + Math.max(5_000, timeoutMs - (Date.now() - startedAt))); + + // Path 1: the library's live wait — instant while the tab stays awake. + const live = (async () => BunkerSigner.fromURI(local, uri, {}, controller.signal))(); + live.catch(() => {}); + + // Path 2: the replay poller — finds an approval published while we slept. + let visKick = null; + const onVis = () => { if (document.visibilityState === 'visible' && visKick) visKick(); }; + document.addEventListener('visibilitychange', onVis); + const replay = (async () => { + const seen = new Set(); + while (!controller.signal.aborted) { + await new Promise((r) => { + visKick = r; + const t2 = setTimeout(r, 5000); + controller.signal.addEventListener('abort', () => { clearTimeout(t2); r(); }, { once: true }); + }); + visKick = null; + if (controller.signal.aborted) break; + const pool = new SimplePool(); + try { + const evs = await Promise.any(relays.map(async (url) => { + await pool.ensureRelay(url, { connectionTimeout: 4000 }); + const got = await pool.querySync([url], { + kinds: [24133], '#p': [clientPub], since: Math.floor(startedAt / 1000) - 120, + }, { maxWait: 4500 }); + if (!got || !got.length) throw new Error('nothing yet'); + return got; + })).catch(() => []); + for (const ev of evs || []) { + if (seen.has(ev.id)) continue; + seen.add(ev.id); + try { + const key = nip44.getConversationKey(local, ev.pubkey); + const resp = JSON.parse(nip44.decrypt(ev.content, key)); + // some signers echo the secret, others answer a bare "ack" + if (resp.result === secret || resp.result === 'ack') { + return BunkerSigner.fromBunker(local, { pubkey: ev.pubkey, relays, secret }); + } + } catch {} + } + } catch {} finally { try { pool.close(relays); } catch {} } + } + throw controller.signal.reason || new Error('cancelled'); + })(); + replay.catch(() => {}); + const ready = (async () => { - const signer = await BunkerSigner.fromURI(local, uri, {}, controller.signal); - const pubkey = await signer.getPublicKey(); - return bunkerAdapter(signer, pubkey, local); + try { + const signer = await Promise.any([live, replay]); + const pubkey = await signer.getPublicKey(); + try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {} + return bunkerAdapter(signer, pubkey, local); + } catch (e) { + // an aborted wait names its real cause (timeout, user cancel); a raw + // AggregateError from the race names nothing useful + throw (controller.signal.aborted && controller.signal.reason) + || (e && e.errors && e.errors[0]) || e; + } finally { + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVis); + controller.abort(new Error('settled')); + } })(); - ready.finally(() => clearTimeout(timer)).catch(() => {}); - return { uri, ready, cancel: (reason) => controller.abort(reason || new Error('cancelled')) }; + ready.catch(() => {}); + return { + uri, ready, + cancel: (reason) => { + try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {} + controller.abort(reason || new Error('cancelled')); + }, + }; } // A pasted key: signing happens locally, and the key is never persisted. |
