Files
botfights/frontend/src/composables/useNostr.ts
T
DorianandClaude Fable 5 2a343ac746
CI / check (push) Failing after 6m11s
feat(09-02): retire the bare-pubkey session path (BOT-01)
Client: useNostr.ts's auto-restore now calls GET /api/auth/me (a plain
authFetch, no body) instead of POSTing {pubkey} to /api/auth/login —
identity is derived server-side from the JWT alone, never claimed by
the client.

Server: POST /login is reduced to a pure, documented-deprecated read.
Removed the creator auto-create branch and the creator auto-upgrade
db.update block — an unauthenticated request can no longer mutate the
database via this endpoint. The identical creator auto-create/upgrade
logic already exists, correctly gated behind NIP-98 verification, in
POST /nostr/session, so a creator signing in with a real signer still
gets the same row created/upgraded. Added a handler doc comment plus a
new auth.test.ts case asserting an unregistered creator pubkey now
returns exists:false and leaves the bots table row count unchanged.

e2e/helpers/auth.ts: doc comments updated to describe loginWithPubkey
as a read-only test lookup helper, not a login; request/signature
unchanged so existing e2e specs keep working.

Verification: auth.test.ts + auth-edge.test.ts + auth-audit.test.ts +
auth-me.test.ts = 56/56 pass. Full server suite (bypassing pnpm's
install-gate via ./node_modules/.bin/vitest, since this environment's
pnpm needs an interactive build-approval step unrelated to this task)
= 810/817 pass, remaining 7 are pre-existing timing/perf flakes under
CPU load (lifecycle/speed-meta/tier-balance/bot-auth constant-time),
none touching auth. tsc (server) and vue-tsc (frontend) both exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:31:26 -04:00

676 lines
20 KiB
TypeScript

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<string, unknown>, 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<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 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<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 — 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<string>('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<boolean> {
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<void> {
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<BotData> {
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<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> {
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<NostrProfile | null> {
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)
}
})
}