diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index 1ef4884..2c6bce7 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -55,40 +55,73 @@ function store(key: string, value: unknown) { 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 - if (pubkey.value && !bot.value) { + // Restore session on first load — re-verify with server (once only) + 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 }), }).then(r => r.json()).then(data => { if (data.exists) { - bot.value = data.bot - store('bf_bot', data.bot) + bot.value = normalizeBotData(data.bot) + store('bf_bot', bot.value) } }).catch(() => {}) } async function login(): Promise<{ pubkey: string; bot: BotData | null }> { - // Try NIP-07 extension first, fall back to stored nsec let pk: string if (window.nostr) { - pk = await window.nostr.getPublicKey() + 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) { - const secretKey = hexToBytes(storedNsec) - pk = getPublicKey(secretKey) + 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.') } @@ -99,10 +132,18 @@ export function useNostr() { pubkey.value = pk store('bf_pubkey', pk) - // Fetch Nostr profile pic from relay - const pic = await fetchNostrProfilePic(pk) - profilePicUrl.value = pic - store('bf_pic', pic) + // 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', { @@ -114,9 +155,9 @@ export function useNostr() { 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 } + bot.value = normalizeBotData(data.bot) + store('bf_bot', bot.value) + return { pubkey: pk, bot: bot.value } } } @@ -137,13 +178,13 @@ export function useNostr() { const nsecHex = bytesToHex(secretKey) const nsecBech32 = nsecEncode(secretKey) - // Clear stale state from previous identity - bot.value = null - profilePicUrl.value = null - store('bf_bot', null) - store('bf_pic', null) + // Clear all stale state from previous identity + clearAllState() - // Store locally + // 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) @@ -153,15 +194,32 @@ export function useNostr() { /** Login with an existing nsec (hex) */ async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> { - const secretKey = hexToBytes(nsecHex) + 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' }, @@ -171,9 +229,9 @@ export function useNostr() { 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 } + bot.value = normalizeBotData(data.bot) + store('bf_bot', bot.value) + return { pubkey: pk, bot: bot.value } } } @@ -199,7 +257,11 @@ export function useNostr() { }) const data = await res.json() - if (!res.ok) throw new Error(data.error || 'Registration failed') + 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, @@ -247,6 +309,24 @@ export function useNostr() { } } + 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') @@ -288,12 +368,7 @@ export function useNostr() { } function logout() { - pubkey.value = null - bot.value = null - profilePicUrl.value = null - store('bf_pubkey', null) - store('bf_bot', null) - store('bf_pic', null) + clearAllState() // Don't clear bf_nsec on logout — user may want to log back in } @@ -319,6 +394,7 @@ export function useNostr() { registerBot, registerHuman, updateCustomization, + updateWebhook, getStoredNsec, logout, } diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue index 6c58fdc..1dc632f 100644 --- a/frontend/src/pages/BotProfilePage.vue +++ b/frontend/src/pages/BotProfilePage.vue @@ -9,7 +9,7 @@ import type { SpriteCustomization } from '../game/sprites' const route = useRoute() const router = useRouter() -const { bot: nostrBot, isLoggedIn, logout, updateCustomization } = useNostr() +const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook } = useNostr() const botName = route.params.name as string interface BotCustomization { @@ -34,6 +34,7 @@ interface BotStats { winStreak: number bestStreak: number tier: number + isActive: boolean tierName: string tierColor: string winRate: number @@ -44,6 +45,10 @@ interface BotStats { satsWagered: number hasWallet: boolean createdAt: string + // Owner-only webhook fields + webhookUrl?: string + consecutiveErrors?: number + isHuman?: boolean recentFights: { id: string opponent: string @@ -86,6 +91,15 @@ const showCustomize = ref(false) const isSaving = ref(false) const custError = ref('') +// Webhook management +const showWebhook = ref(false) +const webhookInput = ref('') +const isTestingWebhook = ref(false) +const isUpdatingWebhook = ref(false) +const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; latencyMs: number; error?: string; message?: string } | null>(null) +const webhookError = ref('') +const webhookSuccess = ref('') + const ARCHETYPES = [ 'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton', @@ -210,9 +224,49 @@ async function saveCustomization() { isSaving.value = false } +async function testWebhook() { + if (!stats.value || isTestingWebhook.value) return + isTestingWebhook.value = true + webhookTestResult.value = null + webhookError.value = '' + try { + const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/test`, { method: 'POST' }) + webhookTestResult.value = await res.json() + if (webhookTestResult.value?.validResponse && stats.value) { + stats.value = { ...stats.value, consecutiveErrors: 0, isActive: true } + } + } catch { + webhookError.value = 'Network error testing webhook.' + } + isTestingWebhook.value = false +} + +async function saveWebhook() { + const url = webhookInput.value.trim() + if (!url || isUpdatingWebhook.value) return + try { new URL(url) } catch { webhookError.value = 'Invalid URL.'; return } + + isUpdatingWebhook.value = true + webhookError.value = '' + webhookSuccess.value = '' + try { + await updateWebhook(url) + webhookSuccess.value = 'Webhook updated and verified.' + if (stats.value) { + stats.value = { ...stats.value, webhookUrl: url, consecutiveErrors: 0 } + } + webhookInput.value = '' + } catch (err) { + webhookError.value = err instanceof Error ? err.message : 'Update failed.' + } + isUpdatingWebhook.value = false +} + onMounted(async () => { try { - const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats`) + const pk = pubkey.value || '' + const qs = pk ? `?pubkey=${encodeURIComponent(pk)}` : '' + const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats${qs}`) if (res.ok) stats.value = await res.json() else loadError.value = `Failed to load bot (${res.status})` } catch (err) { @@ -317,42 +371,23 @@ const tierClass = (t: number) => `tier-${t}` class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]" /> - -
+ +
- - - - - - - - - - - - - - - - - - -
@@ -561,6 +596,100 @@ const tierClass = (t: number) => `tier-${t}`
+ +
+ +
+

BOT DEACTIVATED

+

+ Too many webhook errors. Test your webhook to reactivate. +

+
+ + + +
+ +
+

CURRENT WEBHOOK

+
+ +

+ {{ stats.webhookUrl || '—' }} +

+
+

+ {{ stats.consecutiveErrors }} consecutive errors +

+
+ + + + + +
+

+ {{ webhookTestResult.validResponse ? 'PASSED' : 'FAILED' }} +

+

{{ webhookTestResult.message || webhookTestResult.error }}

+

+ Latency: {{ webhookTestResult.latencyMs }}ms +

+
+ + +
+

UPDATE WEBHOOK URL

+
+ + +
+
+ +

{{ webhookError }}

+

{{ webhookSuccess }}

+
+
+
- diff --git a/frontend/src/pages/FightCardPage.vue b/frontend/src/pages/FightCardPage.vue index b15cdfe..5733f70 100644 --- a/frontend/src/pages/FightCardPage.vue +++ b/frontend/src/pages/FightCardPage.vue @@ -2,7 +2,6 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { RouterLink } from 'vue-router' import SpritePreview from '../components/SpritePreview.vue' -import PosterSprite from '../components/PosterSprite.vue' import PixelGlove from '../components/PixelGlove.vue' interface BotInfo { @@ -195,32 +194,34 @@ onUnmounted(() => {
- +
+ +
+ +
-
- + +
+
@@ -557,6 +558,27 @@ onUnmounted(() => { animation: punchRight 2s ease-in-out infinite 0.3s; } +/* Circular glow behind fighters */ +.fighter-glow { + position: absolute; + top: 50%; + left: 50%; + width: 140%; + height: 140%; + transform: translate(-50%, -50%); + border-radius: 50%; + pointer-events: none; + z-index: 0; +} +.fighter-glow-cyan { + background: radial-gradient(circle, rgba(0, 240, 255, 0.25) 0%, rgba(0, 240, 255, 0.10) 35%, rgba(0, 240, 255, 0.03) 60%, transparent 80%); + box-shadow: 0 0 60px rgba(0, 240, 255, 0.15), 0 0 120px rgba(0, 240, 255, 0.05); +} +.fighter-glow-pink { + background: radial-gradient(circle, rgba(255, 45, 120, 0.25) 0%, rgba(255, 45, 120, 0.10) 35%, rgba(255, 45, 120, 0.03) 60%, transparent 80%); + box-shadow: 0 0 60px rgba(255, 45, 120, 0.15), 0 0 120px rgba(255, 45, 120, 0.05); +} + /* Fighter bobbing */ .fighter-bob { animation: fighterBob 2.5s ease-in-out infinite; diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index e04ac93..cebfa82 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -144,6 +144,7 @@ function handleGenerateLogin() { } async function handleNsecBackupDone() { + if (isLoading) return showNsecBackup.value = false try { const result = await login() @@ -206,7 +207,9 @@ function pickCharacter(id: string) { step.value = 'name-bot' } -function confirmName() { +const isCheckingName = ref(false) + +async function confirmName() { const name = botName.value.trim() if (!name || name.length < 2) { error.value = 'Name must be at least 2 characters.' @@ -217,6 +220,21 @@ function confirmName() { return } error.value = '' + isCheckingName.value = true + try { + const res = await fetch(`/api/auth/check-name/${encodeURIComponent(name)}`) + if (res.ok) { + const data = await res.json() + if (!data.available) { + error.value = 'That name is taken. Pick another.' + return + } + } + } catch { + // If check fails, proceed anyway — server will catch it at registration + } finally { + isCheckingName.value = false + } step.value = 'bot-setup' } @@ -269,6 +287,21 @@ async function confirmHumanName() { return } error.value = '' + isCheckingName.value = true + try { + const res = await fetch(`/api/auth/check-name/${encodeURIComponent(name)}`) + if (res.ok) { + const data = await res.json() + if (!data.available) { + error.value = 'That name is taken. Pick another.' + return + } + } + } catch { + // proceed — server catches at registration + } finally { + isCheckingName.value = false + } step.value = 'human-guide' } @@ -598,10 +631,14 @@ function handleSignOut() {
@@ -852,10 +889,14 @@ function handleSignOut() {
diff --git a/frontend/src/style.css b/frontend/src/style.css index 4aec266..57a17fe 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -39,8 +39,8 @@ /* Synthwave grid background */ .synthwave-grid { background-image: - linear-gradient(rgba(184, 61, 255, 0.06) 1px, transparent 1px), - linear-gradient(90deg, rgba(184, 61, 255, 0.06) 1px, transparent 1px); + linear-gradient(rgba(184, 61, 255, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(184, 61, 255, 0.025) 1px, transparent 1px); background-size: 40px 40px; } @@ -48,8 +48,8 @@ .crt-overlay { background: repeating-linear-gradient( 0deg, - rgba(0, 0, 0, 0.12) 0px, - rgba(0, 0, 0, 0.12) 1px, + rgba(0, 0, 0, 0.04) 0px, + rgba(0, 0, 0, 0.04) 1px, transparent 1px, transparent 3px ); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index e3c2863..b93315a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -10,6 +10,19 @@ import { rateLimit } from '../middleware/rate-limit.js' export const authRouter = new Hono() +// Check name availability +authRouter.get("/check-name/:name", async (c) => { + const name = c.req.param("name")?.trim().toLowerCase() + if (!name || name.length < 2 || name.length > 12) { + return c.json({ available: false, error: "Name must be 2-12 characters." }) + } + const existing = await db.select({ id: schema.bots.id }) + .from(schema.bots) + .where(eq(sql`LOWER(${schema.bots.name})`, name)) + .limit(1) + return c.json({ available: existing.length === 0 }) +}) + // Login with Nostr pubkey authRouter.post('/login', async (c) => { const body = await c.req.json() @@ -33,6 +46,9 @@ authRouter.post('/login', async (c) => { tier: schema.bots.tier, isActive: schema.bots.isActive, customization: schema.bots.customization, + webhookUrl: schema.bots.webhookUrl, + satsWon: schema.bots.satsWon, + satsWagered: schema.bots.satsWagered, }).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) if (rows.length === 0) { @@ -40,18 +56,28 @@ authRouter.post('/login', async (c) => { } const bot = rows[0] - - // Check if this is a human player by loading webhookUrl - const webhookRows = await db.select({ webhookUrl: schema.bots.webhookUrl }) - .from(schema.bots).where(eq(schema.bots.id, bot.id)).limit(1) - const isHuman = webhookRows[0]?.webhookUrl === 'http://human.local/' + const isHuman = bot.webhookUrl === 'http://human.local/' return c.json({ exists: true, bot: { - ...bot, + id: bot.id, + name: bot.name, + avatarSeed: bot.avatarSeed, + archetype: bot.archetype, + profilePicUrl: bot.profilePicUrl, + eloRating: bot.eloRating, + wins: bot.wins, + losses: bot.losses, + winStreak: bot.winStreak, + bestStreak: bot.bestStreak, + tier: bot.tier, + isActive: bot.isActive, isHuman, customization: bot.customization ? JSON.parse(bot.customization) : null, + satsWon: bot.satsWon ?? 0, + satsWagered: bot.satsWagered ?? 0, + hasWallet: false, }, }) }) diff --git a/server/src/routes/bots.ts b/server/src/routes/bots.ts index 7796c06..b22802f 100644 --- a/server/src/routes/bots.ts +++ b/server/src/routes/bots.ts @@ -151,6 +151,7 @@ botsRouter.get('/:name', async (c) => { // Get bot stats -- full account page data botsRouter.get('/:name/stats', async (c) => { const name = c.req.param('name') + const ownerPubkey = c.req.query('pubkey') const botRows = await db.select({ id: schema.bots.id, name: schema.bots.name, @@ -167,6 +168,11 @@ botsRouter.get('/:name/stats', async (c) => { customization: schema.bots.customization, botType: schema.bots.botType, createdAt: schema.bots.createdAt, + satsWon: schema.bots.satsWon, + satsWagered: schema.bots.satsWagered, + publicKey: schema.bots.publicKey, + webhookUrl: schema.bots.webhookUrl, + consecutiveErrors: schema.bots.consecutiveErrors, }).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1) if (botRows.length === 0) { @@ -249,8 +255,19 @@ botsRouter.get('/:name/stats', async (c) => { eloRating: bot.eloRating, }, allFights) + // Owner-only fields: webhook info (only if pubkey matches) + const isOwnerRequest = ownerPubkey && ownerPubkey === rawBot.publicKey + const ownerFields = isOwnerRequest ? { + webhookUrl: rawBot.webhookUrl, + consecutiveErrors: rawBot.consecutiveErrors ?? 0, + isHuman: rawBot.webhookUrl === 'http://human.local/', + } : {} + + // Strip internal fields from public response + const { publicKey: _pk, webhookUrl: _wh, consecutiveErrors: _ce, ...publicBot } = bot + return c.json({ - ...bot, + ...publicBot, tierName: TIER_NAMES[bot.tier] || 'BABY', tierColor: TIER_COLORS[bot.tier] || '#888', winRate, @@ -259,6 +276,7 @@ botsRouter.get('/:name/stats', async (c) => { totalBots: rankedBots.length, recentFights, achievements, + ...ownerFields, }) })