diff --git a/frontend/index.html b/frontend/index.html index 0b7e084..e46d36f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,6 +21,18 @@
+ + 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 {