From 2c039f2af3d37d3e6bf474f382f53f39b57bb628 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 31 Jul 2026 06:58:53 -0400 Subject: [PATCH] fix(nostr): reactive extension detection + native Archipelago signer bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes found during live signer-login verification: 1. hasExtension was `computed(() => !!window.nostr)` — window.nostr is a plain global with no Vue reactivity, so this evaluated once, lazily, on first read and cached forever. If the extension's content script hadn't injected yet at that moment (common — extensions often inject slightly after page scripts start), "SIGN IN WITH EXTENSION" disappeared permanently, even once the extension finished injecting moments later. Reported live as "no browser extension or signer option ever shows". Fixed: hasExtension is now backed by a real ref, seeded from the current value and upgraded by a short poll (existing waitForSigner() precedent, same 200ms/timeout shape) so the UI reacts when the extension actually appears. 2. Added Archipelago's native NIP-07 signer bridge (frontend/public/ nostr-provider.js, copied verbatim from neode-ui/public/nostr-provider.js — the canonical source) via a diff --git a/frontend/public/nostr-provider.js b/frontend/public/nostr-provider.js new file mode 100644 index 0000000..fc124bd --- /dev/null +++ b/frontend/public/nostr-provider.js @@ -0,0 +1,160 @@ +/** + * NIP-07 Nostr Provider Shim — Archipelago + * + * Provides window.nostr (NIP-07) for iframe apps. + * Auto sign-in: does NIP-98 auth directly then reloads so the app + * picks up the valid session. Shows a loading overlay during auth. + */ +(function () { + 'use strict'; + if (window.__archipelagoNostr) return; + window.__archipelagoNostr = true; + if (window === window.top) return; + + var pending = {}, nextId = 1; + + function request(method, params) { + return new Promise(function (resolve, reject) { + var id = nextId++; + pending[id] = { resolve: resolve, reject: reject }; + window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*'); + setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000); + }); + } + + window.addEventListener('message', function (e) { + if (!e.data || e.data.type !== 'nostr-response') return; + var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id]; + e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result); + }); + + window.nostr = { + getPublicKey: function () { return request('getPublicKey'); }, + signEvent: function (ev) { return request('signEvent', { event: ev }); }, + sign: function (ev) { return request('signEvent', { event: ev }); }, + getRelays: function () { return request('getRelays'); }, + nip04: { + encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); }, + decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); }, + }, + nip44: { + encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); }, + decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); }, + }, + }; + + // --- Loading Overlay --- + var overlay = null; + + function showLoader(message) { + if (overlay) return; + overlay = document.createElement('div'); + overlay.id = 'archipelago-auth-overlay'; + overlay.innerHTML = + '
' + + '' + + '' + + '' + + '' + + '
' + (message || 'Signing in...') + '
' + + '
'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);'; + var style = document.createElement('style'); + style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}'; + document.head.appendChild(style); + document.body.appendChild(overlay); + } + + function updateLoader(message) { + if (!overlay) return; + var txt = overlay.querySelector('div > div'); + if (txt) txt.textContent = message; + } + + function hideLoader() { + if (overlay) { overlay.remove(); overlay = null; } + } + + // --- Direct NIP-98 Auth --- + var authDone = false; + + function doNip98Auth(pubkey) { + if (authDone) return; + authDone = true; + + var apiBase = '/api'; + var healthUrl = window.location.origin + apiBase + '/nostr-auth/health'; + var sessionUrl = window.location.origin + apiBase + '/auth/nostr/session'; + + // 1. Check if API backend is reachable (3s timeout) + var hc = new AbortController(); + var ht = setTimeout(function () { hc.abort(); }, 3000); + + fetch(healthUrl, { signal: hc.signal }).then(function (r) { + clearTimeout(ht); + if (!r.ok) throw new Error('Health ' + r.status); + + // 2. API is up — show loader and do NIP-98 + showLoader('Signing in with Nostr...'); + var now = Math.floor(Date.now() / 1000); + var event = { + kind: 27235, created_at: now, content: '', pubkey: pubkey, + tags: [['u', sessionUrl], ['method', 'POST']] + }; + console.log('[nostr-provider] NIP-98: signing for', sessionUrl); + return window.nostr.signEvent(event); + + }).then(function (signed) { + updateLoader('Creating session...'); + var ac = new AbortController(); + setTimeout(function () { ac.abort(); }, 10000); + return fetch(sessionUrl, { + method: 'POST', + headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) }, + signal: ac.signal + }); + + }).then(function (res) { + console.log('[nostr-provider] NIP-98: response', res.status); + if (!res.ok) throw new Error('Auth failed: ' + res.status); + return res.json(); + + }).then(function (data) { + if (data.accessToken) { + sessionStorage.setItem('nostr_token', data.accessToken); + sessionStorage.setItem('nostr_pubkey', pubkey); + if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken); + updateLoader('Signed in! Loading...'); + console.log('[nostr-provider] NIP-98: success, reloading...'); + setTimeout(function () { window.location.reload(); }, 400); + } else { + hideLoader(); authDone = false; + } + + }).catch(function (err) { + hideLoader(); authDone = false; + var msg = err.message || String(err); + if (msg.indexOf('abort') > -1) msg = 'API timeout'; + console.warn('[nostr-provider] NIP-98 skipped:', msg); + }); + } + + // Listen for identity from parent Archipelago frame + window.addEventListener('message', function (e) { + if (!e.data || e.data.type !== 'archipelago:identity') return; + var pk = e.data.nostr_pubkey; + console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none'); + if (!pk) return; + + // Skip if already signed in with a real token (not mock) + try { + var token = sessionStorage.getItem('nostr_token'); + if (token && token.indexOf('mock-') === -1) { + console.log('[nostr-provider] Already signed in with real token'); + return; + } + } catch (x) {} + + setTimeout(function () { doNip98Auth(pk); }, 1500); + }); +})(); diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index c9c6923..86a975a 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -102,6 +102,35 @@ let freshlyGenerated = false // In-memory nsec for current session (never auto-persisted to localStorage) let sessionNsec: string | null = null +// window.nostr is injected by a browser extension's content script, which +// often runs AFTER this module's own top-level code (extension content +// scripts commonly fire at document_idle, sometimes with an extra delay for +// slower extensions). A plain `computed(() => !!window.nostr)` has no +// reactive dependency to track (window.nostr is a bare global, not a Vue +// ref) — Vue evaluates it once, lazily, on first read and then caches that +// result forever. If the extension hasn't injected yet at that first read, +// the "SIGN IN WITH EXTENSION" button (gated on this value) disappears +// permanently for the rest of the page's life, even once the extension +// finishes injecting moments later — this was a real reported bug: "no +// browser extension or signer option ever shows". Fix: track it in a real +// ref, seeded from the current value, and poll briefly for late injection +// so the UI updates reactively when the extension actually shows up. +const hasExtensionRef = ref(typeof window !== 'undefined' && !!window.nostr) +let extensionPollStarted = (globalThis as any).__bf_extensionPollStarted ?? false +if (typeof window !== 'undefined' && !hasExtensionRef.value && !extensionPollStarted) { + extensionPollStarted = true; + (globalThis as any).__bf_extensionPollStarted = true + const pollStart = Date.now() + const pollTimer = setInterval(() => { + if (window.nostr) { + hasExtensionRef.value = true + clearInterval(pollTimer) + } else if (Date.now() - pollStart > 5000) { + clearInterval(pollTimer) + } + }, 200) +} + // Sync in-memory auth state when tab regains focus (handles external localStorage clearing) if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { @@ -137,7 +166,7 @@ if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpir export function useNostr() { const isLoggedIn = computed(() => !!pubkey.value && !!bot.value) - const hasExtension = computed(() => !!window.nostr) + const hasExtension = computed(() => hasExtensionRef.value) /** Wait for window.nostr to appear (mobile signers inject late) */ async function waitForSigner(timeoutMs = 3000): Promise {