Files
botfights/frontend/src/composables/useNostr.ts
T

497 lines
13 KiB
TypeScript
Raw Normal View History

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<string>
signEvent(event: Record<string, unknown>): Promise<Record<string, unknown>>
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>
}
declare global {
interface Window {
nostr?: NostrWindow
}
}
// Persist auth state across page navigations and HMR reloads
function loadStored<T>(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<string, unknown>): 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<string | null>(loadStored('bf_pubkey'))
const bot = ref<BotData | null>(loadStored('bf_bot'))
const profilePicUrl = ref<string | null>(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<BotData> {
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<void> {
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<BotData> {
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,
2026-03-08 20:07:09 +00:00
fetchNostrProfile,
}
}
2026-03-08 20:07:09 +00:00
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<string, { data: NostrProfile; ts: number }>()
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<string | null> {
2026-03-08 20:07:09 +00:00
const profile = await fetchNostrProfile(pk)
return profile?.picture || null
}
2026-03-08 20:07:09 +00:00
/** Fetch full Nostr profile (kind:0 metadata) with caching */
async function fetchNostrProfile(pk: string): Promise<NostrProfile | null> {
const cached = profileCache.get(pk)
if (cached && Date.now() - cached.ts < PROFILE_TTL) return cached.data
for (const relay of RELAYS) {
try {
2026-03-08 20:07:09 +00:00
const profile = await queryRelayProfile(relay, pk)
if (profile) {
profileCache.set(pk, { data: profile, ts: Date.now() })
return profile
}
} catch {
continue
}
}
return null
}
2026-03-08 20:07:09 +00:00
function queryRelayProfile(url: string, pk: string): Promise<NostrProfile | 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()
2026-03-08 20:07:09 +00:00
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)
}
})
}