import { ref, readonly, computed } from 'vue' import { generateSecretKey, getPublicKey } from 'nostr-tools' import { bytesToHex, hexToBytes } from 'nostr-tools/utils' import { nsecEncode } from 'nostr-tools/nip19' import { buildNip98Token, setToken, getToken, isTokenExpired, authFetch } from '../lib/nostr-auth' /** NIP-55: Build a nostrsigner: URI for sign_event (inline since nostr-tools doesn't export nip55) */ function buildSignEventUri(eventJson: Record, callbackUrl: string): string { const params = new URLSearchParams({ type: 'sign_event', callbackUrl, returnType: 'event', compressionType: 'none', }) return `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}?${params.toString()}` } interface BotCustomization { archetype?: string primaryColor?: string secondaryColor?: string forceVisor?: boolean forceMohawk?: boolean forceHorns?: boolean } interface BotData { id: string name: string avatarSeed: string archetype: string profilePicUrl: string | null customization: BotCustomization | null isHuman?: boolean eloRating: number wins: number losses: number winStreak: number bestStreak: number tier: number satsWon: number satsWagered: number hasWallet: boolean } interface NostrWindow { getPublicKey(): Promise signEvent(event: Record): Promise> getRelays?(): Promise> } declare global { interface Window { nostr?: NostrWindow } } // Persist auth state across page navigations and HMR reloads function loadStored(key: string): T | null { try { const raw = localStorage.getItem(key) return raw ? JSON.parse(raw) : null } catch { return null } } function store(key: string, value: unknown) { if (value == null) localStorage.removeItem(key) else localStorage.setItem(key, JSON.stringify(value)) } /** Normalize server bot data to fill missing fields */ function normalizeBotData(data: Record): BotData { return { satsWon: 0, satsWagered: 0, hasWallet: false, ...data, } as BotData } /** Clear all auth-related state including JWT and nsec */ function clearAllState() { pubkey.value = null bot.value = null profilePicUrl.value = null store('bf_pubkey', null) store('bf_bot', null) store('bf_pic', null) setToken(null) sessionNsec = null sessionStorage.removeItem('bf_nsec') } const pubkey = ref(loadStored('bf_pubkey')) const bot = ref(loadStored('bf_bot')) const profilePicUrl = ref(loadStored('bf_pic')) const isLoading = ref(false) // Guard: only auto-restore once — survives Vite HMR module re-evaluation let autoRestoreRan = (globalThis as any).__bf_autoRestoreRan ?? false // Flag: skip relay pic fetch for freshly generated keys (no profile exists) let freshlyGenerated = false // In-memory nsec for current session (never auto-persisted to localStorage) let sessionNsec: string | null = null // Sync in-memory auth state when tab regains focus (handles external localStorage clearing) if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return const storedPubkey = loadStored('bf_pubkey') if (!storedPubkey && pubkey.value) { // localStorage was cleared externally — wipe in-memory state to match clearAllState() } }) } // Auto-restore session from JWT on first load. // Identity comes from the token alone — GET /api/auth/me derives the // pubkey server-side via extractPubkeyFromAuth, so no bare pubkey is // ever sent to claim a session (D-01/BOT-01). if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) { autoRestoreRan = true; (globalThis as any).__bf_autoRestoreRan = true authFetch('/api/auth/me').then(r => r.json()).then(data => { if (data.exists) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) } }).catch(err => console.warn('[Nostr] auto-restore failed:', err)) } else if (!autoRestoreRan && pubkey.value && !getToken()) { // No JWT — clear stale pubkey from before the JWT migration autoRestoreRan = true; (globalThis as any).__bf_autoRestoreRan = true pubkey.value = null store('bf_pubkey', null) } export function useNostr() { const isLoggedIn = computed(() => !!pubkey.value && !!bot.value) const hasExtension = computed(() => !!window.nostr) /** Wait for window.nostr to appear (mobile signers inject late) */ async function waitForSigner(timeoutMs = 3000): Promise { if (window.nostr) return true const start = Date.now() while (Date.now() - start < timeoutMs) { await new Promise(r => setTimeout(r, 200)) if (window.nostr) return true } return false } /** * Authenticate with the server using NIP-98 signed request. * Works with NIP-07 extension, Amber signer, or local nsec. * Returns JWT session token + bot data. */ async function authenticateSession(secretKeyHex?: string | null): Promise<{ pubkey: string; bot: BotData | null }> { const sessionUrl = new URL('/api/auth/nostr/session', window.location.origin).toString() const nip98Token = await buildNip98Token(sessionUrl, 'POST', secretKeyHex) const res = await fetch('/api/auth/nostr/session', { method: 'POST', headers: { 'Authorization': `Nostr ${nip98Token}`, }, }) if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Authentication failed' })) throw new Error(data.error || 'NIP-98 authentication failed') } const data = await res.json() // Store JWT setToken(data.token) // Store pubkey pubkey.value = data.pubkey store('bf_pubkey', data.pubkey) if (data.exists && data.bot) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) return { pubkey: data.pubkey, bot: bot.value } } bot.value = null store('bf_bot', null) return { pubkey: data.pubkey, bot: null } } /** * Sign in using NIP-07 extension or Amber signer (window.nostr). * Authenticates via NIP-98 and receives a JWT session. */ async function login(): Promise<{ pubkey: string; bot: BotData | null }> { if (!window.nostr) { // Mobile signers (Amber) inject window.nostr late — poll for up to 3s const found = await waitForSigner(3000) if (!found) { // Fall back to session or persisted nsec if available const storedNsec = sessionNsec || sessionStorage.getItem('bf_nsec') if (storedNsec) { return loginWithNsec(storedNsec) } throw new Error('No Nostr signer found. Install a browser extension or use "Generate New Identity".') } } // Verify extension is responsive try { await window.nostr!.getPublicKey() } catch { throw new Error('Nostr signer denied access. Approve the request and try again.') } isLoading.value = true try { const result = await authenticateSession() // Fetch Nostr profile pic (non-blocking) if (!freshlyGenerated) { fetchNostrProfilePic(result.pubkey).then(pic => { profilePicUrl.value = pic store('bf_pic', pic) }).catch(err => console.warn('[Nostr] relay profile fetch failed:', err)) } else { freshlyGenerated = false profilePicUrl.value = null store('bf_pic', null) } return result } finally { isLoading.value = false } } /** Generate a fresh Nostr keypair locally — no extension needed */ function generateLogin(): { pubkey: string; nsec: string } { const secretKey = generateSecretKey() const pk = getPublicKey(secretKey) const nsecHex = bytesToHex(secretKey) const nsecBech32 = nsecEncode(secretKey) // Clear all stale state from previous identity clearAllState() // Mark as freshly generated so login() skips relay pic fetch freshlyGenerated = true // Hold key in session memory only — user must opt in to persist sessionNsec = nsecHex pubkey.value = pk store('bf_pubkey', pk) return { pubkey: pk, nsec: nsecBech32 } } /** Login with an existing nsec (hex). Signs NIP-98 locally. If persist=true, saves to localStorage. */ async function loginWithNsec(nsecHex: string, persist = false): Promise<{ pubkey: string; bot: BotData | null }> { let secretKey: Uint8Array try { secretKey = hexToBytes(nsecHex) } catch { throw new Error('Invalid secret key format.') } getPublicKey(secretKey) // validate key // Clear stale state before switching identity bot.value = null profilePicUrl.value = null store('bf_bot', null) store('bf_pic', null) sessionNsec = nsecHex if (persist) sessionStorage.setItem('bf_nsec', nsecHex) isLoading.value = true try { const result = await authenticateSession(nsecHex) // Fetch Nostr profile pic (non-blocking) fetchNostrProfilePic(result.pubkey).then(pic => { profilePicUrl.value = pic store('bf_pic', pic) }).catch(err => console.warn('[Nostr] profile pic fetch failed:', err)) return result } finally { isLoading.value = false } } async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<{ bot: BotData; secret: string; mode: string }> { if (!pubkey.value) throw new Error('Not logged in') const res = await authFetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value, name, webhookUrl, archetype, profilePicUrl: profilePicUrl.value, }), }) const data = await res.json() if (!res.ok) { let msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed') if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)` throw new Error(msg) } bot.value = { id: data.id, name: data.name, avatarSeed: data.name, archetype: data.archetype, profilePicUrl: profilePicUrl.value, customization: data.customization || null, eloRating: 1200, wins: 0, losses: 0, winStreak: 0, bestStreak: 0, tier: 0, satsWon: 0, satsWagered: 0, hasWallet: false, } store('bf_bot', bot.value) // Re-authenticate to get fresh JWT with botId try { await authenticateSession(sessionNsec) } catch { // Non-critical: existing JWT still works, just missing botId } return { bot: bot.value, secret: data.secret, mode: data.mode || 'webhook' } } async function updateCustomization(customization: BotCustomization): Promise { if (!pubkey.value) throw new Error('Not logged in') const res = await authFetch('/api/auth/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value, customization }), }) if (!res.ok) { const data = await res.json() throw new Error(data.error || 'Update failed') } if (bot.value) { bot.value = { ...bot.value, customization: { ...(bot.value.customization || {}), ...customization }, archetype: customization.archetype || bot.value.archetype, } store('bf_bot', bot.value) } } async function updateWebhook(newUrl: string): Promise<{ latencyMs: number }> { if (!pubkey.value) throw new Error('Not logged in') const res = await authFetch('/api/auth/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }), }) const data = await res.json() if (!res.ok) { const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Update failed') throw new Error(msg) } return { latencyMs: data.latencyMs || 0 } } async function regenerateSecret(): Promise<{ botId: string; secret: string }> { if (!pubkey.value) throw new Error('Not logged in') const res = await authFetch('/api/auth/regenerate-secret', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) const data = await res.json() if (!res.ok) { throw new Error(data.error || 'Failed to regenerate secret') } return { botId: data.botId, secret: data.secret } } async function registerHuman(name: string, avatarSeed?: string): Promise { if (!pubkey.value) throw new Error('Not logged in') const res = await authFetch('/api/auth/register-human', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value, name, avatarSeed, profilePicUrl: profilePicUrl.value, }), }) const data = await res.json() if (!res.ok) { let msg = data.error || 'Registration failed' if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)` throw new Error(msg) } bot.value = { id: data.id, name: data.name, avatarSeed: avatarSeed || data.name, archetype: 'human', profilePicUrl: profilePicUrl.value, customization: null, isHuman: true, eloRating: 1200, wins: 0, losses: 0, winStreak: 0, bestStreak: 0, tier: 0, satsWon: 0, satsWagered: 0, hasWallet: false, } store('bf_bot', bot.value) // Re-authenticate to get fresh JWT with botId try { await authenticateSession(sessionNsec) } catch { // Non-critical } return bot.value } /** Sign out — clears everything including JWT, nsec, and all stored state */ function logout() { clearAllState() } /** Check if user has a locally stored key (no extension needed) */ const hasStoredKey = computed(() => !!sessionStorage.getItem('bf_nsec')) /** Get the current nsec hex (session memory first, then localStorage) */ function getStoredNsec(): string | null { return sessionNsec || sessionStorage.getItem('bf_nsec') } /** Persist the current session key to localStorage (opt-in) */ function persistKey(): void { if (sessionNsec) sessionStorage.setItem('bf_nsec', sessionNsec) } /** * NIP-55: Initiate login via Android signer app (Amber/Primal). * Builds an unsigned NIP-98 event and navigates to a nostrsigner: URI. * The signer app signs the event and redirects back to callbackUrl. */ function initiateNip55Login() { const sessionUrl = new URL('/api/auth/nostr/session', window.location.origin).toString() const unsignedEvent = { kind: 27235, tags: [['u', sessionUrl], ['method', 'POST']], content: '', created_at: Math.floor(Date.now() / 1000), } const callbackUrl = new URL('/join-bout', window.location.origin).toString() const uri = buildSignEventUri(unsignedEvent, callbackUrl) window.location.href = uri } /** * NIP-55: Process the callback after returning from an Android signer. * Parses the signed event from the URL and authenticates with the server. * Returns null if no NIP-55 callback params are present. */ async function processNip55Return(): Promise<{ pubkey: string; bot: BotData | null } | null> { const params = new URLSearchParams(window.location.search) // Look for the signed event in common NIP-55 callback param names const eventStr = params.get('event') || params.get('result') || params.get('signed_event') if (!eventStr) return null // Clean the URL so the params don't persist window.history.replaceState({}, '', window.location.pathname) try { const signedEvent = JSON.parse(decodeURIComponent(eventStr)) const nip98Token = btoa(JSON.stringify(signedEvent)) const res = await fetch('/api/auth/nostr/session', { method: 'POST', headers: { 'Authorization': `Nostr ${nip98Token}` }, }) if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Authentication failed' })) throw new Error(data.error || 'NIP-98 authentication failed') } const data = await res.json() setToken(data.token) pubkey.value = data.pubkey store('bf_pubkey', data.pubkey) if (data.exists && data.bot) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) return { pubkey: data.pubkey, bot: bot.value } } bot.value = null store('bf_bot', null) return { pubkey: data.pubkey, bot: null } } catch (e) { console.error('[NIP-55] Failed to process signer callback:', e) throw e } } /** Detect if Android signer apps (Amber/Primal) might be available */ const hasAndroidSigner = computed(() => /android/i.test(navigator.userAgent)) return { pubkey: readonly(pubkey), bot: readonly(bot), profilePicUrl: readonly(profilePicUrl), isLoggedIn, isLoading: readonly(isLoading), hasExtension, hasStoredKey, login, waitForSigner, generateLogin, loginWithNsec, registerBot, registerHuman, updateCustomization, updateWebhook, regenerateSecret, getStoredNsec, persistKey, logout, fetchNostrProfile, initiateNip55Login, processNip55Return, hasAndroidSigner, } } export interface NostrProfile { displayName: string | null about: string | null picture: string | null banner: string | null nip05: string | null } // Profile cache with 5-min TTL const profileCache = new Map() const PROFILE_TTL = 5 * 60 * 1000 const RELAYS = [ 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', ] // Fetch profile picture from a Nostr relay async function fetchNostrProfilePic(pk: string): Promise { const profile = await fetchNostrProfile(pk) return profile?.picture || null } /** Fetch full Nostr profile (kind:0 metadata) with caching. * Queries all relays in parallel, picks result with latest created_at. */ async function fetchNostrProfile(pk: string): Promise { const cached = profileCache.get(pk) if (cached && Date.now() - cached.ts < PROFILE_TTL) return cached.data const results = await Promise.allSettled( RELAYS.map(relay => queryRelayProfile(relay, pk)) ) // Pick the profile with the latest created_at (newest wins) let best: { profile: NostrProfile; createdAt: number } | null = null for (const r of results) { if (r.status !== 'fulfilled' || !r.value) continue if (!best || r.value.createdAt > best.createdAt) { best = r.value } } if (best) { profileCache.set(pk, { data: best.profile, ts: Date.now() }) return best.profile } return null } function queryRelayProfile(url: string, pk: string): Promise<{ profile: NostrProfile; createdAt: number } | null> { return new Promise((resolve) => { let timedOut = false const timeout = setTimeout(() => { timedOut = true if (ws.readyState === WebSocket.OPEN) ws.close() resolve(null) }, 3000) const ws = new WebSocket(url) const subId = Math.random().toString(36).slice(2, 10) ws.onopen = () => { if (timedOut) { ws.close(); return } ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }])) } ws.onmessage = (msg) => { try { const data = JSON.parse(msg.data) if (data[0] === 'EVENT' && data[2]?.kind === 0) { const meta = JSON.parse(data[2].content) clearTimeout(timeout) ws.close() resolve({ profile: { displayName: meta.display_name || meta.name || null, about: meta.about || null, picture: meta.picture || null, banner: meta.banner || null, nip05: meta.nip05 || null, }, createdAt: data[2].created_at || 0, }) } else if (data[0] === 'EOSE') { clearTimeout(timeout) ws.close() resolve(null) } } catch { // ignore parse errors } } ws.onerror = () => { clearTimeout(timeout) resolve(null) } }) }