import { ref, readonly, computed } from 'vue' 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 eloRating: number wins: number losses: number winStreak: number bestStreak: number tier: number } 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)) } const pubkey = ref(loadStored('bf_pubkey')) const bot = ref(loadStored('bf_bot')) const profilePicUrl = ref(loadStored('bf_pic')) const isLoading = ref(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 if (pubkey.value && !bot.value) { fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value }), }).then(r => r.json()).then(data => { if (data.exists) { bot.value = data.bot store('bf_bot', data.bot) } }).catch(() => {}) } async function login(): Promise<{ pubkey: string; bot: BotData | null }> { if (!window.nostr) { throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.') } isLoading.value = true try { const pk = await window.nostr.getPublicKey() pubkey.value = pk store('bf_pubkey', pk) // Fetch Nostr profile pic from relay const pic = await fetchNostrProfilePic(pk) if (pic) { profilePicUrl.value = pic; store('bf_pic', pic) } // 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 = data.bot store('bf_bot', data.bot) return { pubkey: pk, bot: data.bot } } } 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) throw new Error(data.error || 'Registration failed') 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, } 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) } } function logout() { pubkey.value = null bot.value = null profilePicUrl.value = null store('bf_pubkey', null) store('bf_bot', null) store('bf_pic', null) } return { pubkey: readonly(pubkey), bot: readonly(bot), profilePicUrl: readonly(profilePicUrl), isLoggedIn, isLoading: readonly(isLoading), hasExtension, login, registerBot, updateCustomization, logout, } } // Fetch profile picture from a Nostr relay async function fetchNostrProfilePic(pk: string): Promise { const relays = [ 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', ] for (const relay of relays) { try { const pic = await queryRelay(relay, pk) if (pic) return pic } catch { continue } } return null } function queryRelay(url: string, pk: string): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => { ws.close() resolve(null) }, 3000) const ws = new WebSocket(url) const subId = Math.random().toString(36).slice(2, 10) ws.onopen = () => { // Request kind 0 (metadata) for this pubkey 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(meta.picture || null) } else if (data[0] === 'EOSE') { clearTimeout(timeout) ws.close() resolve(null) } } catch { // ignore parse errors } } ws.onerror = () => { clearTimeout(timeout) resolve(null) } }) }