import { ref, readonly, computed } from 'vue' import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools' import { bytesToHex, hexToBytes } from 'nostr-tools/utils' import { nsecEncode } from 'nostr-tools/nip19' 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 */ function clearAllState() { pubkey.value = null bot.value = null profilePicUrl.value = null store('bf_pubkey', null) store('bf_bot', null) store('bf_pic', null) } 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 across all component mounts let autoRestoreRan = false // Flag: skip relay pic fetch for freshly generated keys (no profile exists) let freshlyGenerated = false export function useNostr() { const isLoggedIn = computed(() => !!pubkey.value && !!bot.value) const hasExtension = computed(() => !!window.nostr) // Restore session on first load — re-verify with server (once only) const autoRestoreController = new AbortController() if (!autoRestoreRan && pubkey.value && !bot.value) { autoRestoreRan = true fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value }), signal: autoRestoreController.signal, }).then(r => r.json()).then(data => { if (data.exists) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) } }).catch(() => {}) } async function login(): Promise<{ pubkey: string; bot: BotData | null }> { let pk: string if (window.nostr) { try { pk = await window.nostr.getPublicKey() } catch { throw new Error('Nostr extension denied access. Approve the request and try again.') } } else { const storedNsec = localStorage.getItem('bf_nsec') if (storedNsec) { try { const secretKey = hexToBytes(storedNsec) pk = getPublicKey(secretKey) } catch { throw new Error('Stored key is corrupt. Generate a new identity.') } } else { throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.') } } isLoading.value = true try { pubkey.value = pk store('bf_pubkey', pk) // Fetch Nostr profile pic from relay (skip for freshly generated keys — no profile exists) if (freshlyGenerated) { freshlyGenerated = false profilePicUrl.value = null store('bf_pic', null) } else { // Non-blocking: start fetch but don't block login on it fetchNostrProfilePic(pk).then(pic => { profilePicUrl.value = pic store('bf_pic', pic) }).catch(() => {}) } // Check if this pubkey has a bot const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pk }), }) if (res.ok) { const data = await res.json() if (data.exists) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) return { pubkey: pk, bot: bot.value } } } // No bot for this key — clear stale state bot.value = null store('bf_bot', null) return { pubkey: pk, bot: null } } 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 // Store new key localStorage.setItem('bf_nsec', nsecHex) pubkey.value = pk store('bf_pubkey', pk) return { pubkey: pk, nsec: nsecBech32 } } /** Login with an existing nsec (hex) */ async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> { let secretKey: Uint8Array try { secretKey = hexToBytes(nsecHex) } catch { throw new Error('Invalid secret key format.') } const pk = getPublicKey(secretKey) // Clear stale state before switching identity bot.value = null profilePicUrl.value = null store('bf_bot', null) store('bf_pic', null) localStorage.setItem('bf_nsec', nsecHex) pubkey.value = pk store('bf_pubkey', pk) isLoading.value = true try { // Fetch Nostr profile pic (non-blocking) fetchNostrProfilePic(pk).then(pic => { profilePicUrl.value = pic store('bf_pic', pic) }).catch(() => {}) const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pk }), }) if (res.ok) { const data = await res.json() if (data.exists) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) return { pubkey: pk, bot: bot.value } } } return { pubkey: pk, bot: null } } finally { isLoading.value = false } } async function registerBot(name: string, webhookUrl: string, archetype: string): Promise { if (!pubkey.value) throw new Error('Not logged in') const res = await fetch('/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) { // Surface detailed error info from webhook verification failures const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed') 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) return bot.value } async function updateCustomization(customization: BotCustomization): Promise { if (!pubkey.value) throw new Error('Not logged in') const res = await fetch('/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 fetch('/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 registerHuman(name: string, avatarSeed?: string): Promise { if (!pubkey.value) throw new Error('Not logged in') const res = await fetch('/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) throw new Error(data.error || 'Registration failed') 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) return bot.value } function logout() { autoRestoreController.abort() clearAllState() // Don't clear bf_nsec on logout — user may want to log back in } /** Check if user has a locally stored key (no extension needed) */ const hasStoredKey = computed(() => !!localStorage.getItem('bf_nsec')) /** Get the stored nsec hex for backup display */ function getStoredNsec(): string | null { return localStorage.getItem('bf_nsec') } return { pubkey: readonly(pubkey), bot: readonly(bot), profilePicUrl: readonly(profilePicUrl), isLoggedIn, isLoading: readonly(isLoading), hasExtension, hasStoredKey, login, generateLogin, loginWithNsec, registerBot, registerHuman, updateCustomization, updateWebhook, getStoredNsec, logout, fetchNostrProfile, } } 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 */ async function fetchNostrProfile(pk: string): Promise { const cached = profileCache.get(pk) if (cached && Date.now() - cached.ts < PROFILE_TTL) return cached.data for (const relay of RELAYS) { try { const profile = await queryRelayProfile(relay, pk) if (profile) { profileCache.set(pk, { data: profile, ts: Date.now() }) return profile } } catch { continue } } return null } function queryRelayProfile(url: string, pk: string): Promise { 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({ displayName: meta.display_name || meta.name || null, about: meta.about || null, picture: meta.picture || null, banner: meta.banner || null, nip05: meta.nip05 || null, }) } else if (data[0] === 'EOSE') { clearTimeout(timeout) ws.close() resolve(null) } } catch { // ignore parse errors } } ws.onerror = () => { clearTimeout(timeout) resolve(null) } }) }