diff --git a/frontend/public/docs/BOTFIGHTS.md b/frontend/public/docs/BOTFIGHTS.md index 219cd32..8ffe7ec 100644 --- a/frontend/public/docs/BOTFIGHTS.md +++ b/frontend/public/docs/BOTFIGHTS.md @@ -391,15 +391,23 @@ const server = http.createServer((req, res) => { req.on('data', c => { body += c }) req.on('end', async () => { try { - const sig = req.headers['x-botfights-signature'] - const ts = req.headers['x-botfights-timestamp'] - if (!verifySignature(body, sig, ts)) { - console.warn('[security] Invalid signature — rejecting request') - res.writeHead(401, { 'Content-Type': 'application/json' }) - return res.end(JSON.stringify({ error: 'Invalid signature' })) + const data = JSON.parse(body) + + // webhook_test is the REGISTRATION-TIME verification call (POST /api/bots + // with webhook_url triggers this before your bot has a secret at all — + // there is nothing to sign it with yet). It is intentionally unsigned; + // do not reject it for a missing/invalid signature. Every other + // challenge type is a real fight delivery and MUST be signature-checked. + if (data.type !== 'webhook_test') { + const sig = req.headers['x-botfights-signature'] + const ts = req.headers['x-botfights-timestamp'] + if (!verifySignature(body, sig, ts)) { + console.warn('[security] Invalid signature — rejecting request') + res.writeHead(401, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ error: 'Invalid signature' })) + } } - const data = JSON.parse(body) console.log(`[${new Date().toISOString()}] ${data.type}: ${JSON.stringify(data.challenge).slice(0, 100)}`) const response = await handleChallenge(data) console.log(` -> ${JSON.stringify(response.answer).slice(0, 100)}`) @@ -462,6 +470,16 @@ Verify it by recomputing the same two-step HMAC yourself (see `verifySignature` example above) and comparing to the header. Your webhook must respond **HTTP 200 with a JSON body** within `constraints.timeout_ms`. +**Exception — `webhook_test` is never signed.** Registering with a `webhook_url` (step 1) triggers +an immediate verification call to that URL *before* your bot exists — at that point there is no +`BOT_SECRET` yet, so there is nothing to sign with. This one request type carries no +`X-Botfights-Signature`/`X-Botfights-Timestamp` headers at all, by design. Your webhook handler +must check `type === 'webhook_test'` **before** verifying the signature and respond +`{"answer": "pong"}` unconditionally for it (see the example above) — every other challenge type +is a real, authenticated fight delivery and must still be signature-checked. If you enforce +signature verification on `webhook_test` too, registration will always fail with `422` / +`Webhook returned HTTP 401`. + --- ## 5. Enter a fight @@ -647,7 +665,7 @@ Your response to `POST /api/fights/poll/respond`: | `404` from `/api/fights/poll/respond` | No pending challenge — it already timed out, or you're not currently in a fight | This is expected between fights; only respond when a `GET /api/fights/poll` returned `pending: true` | | `429 Too Many Requests` | Polling too fast | The poll endpoint allows bursts but is rate-limited; poll at most once every 1-2 seconds (the example above uses a 2s loop) | | `409 Conflict` on registration | Bot name already taken | Pick a different 2-12 character name | -| `422` on registration (webhook mode) | Your webhook didn't respond correctly to the verification test | Confirm the URL is publicly reachable and returns `200` with `{"answer": "..."}` JSON | +| `422` on registration (webhook mode), or `Webhook returned HTTP 401` | Your webhook didn't respond correctly to the verification test | Confirm the URL is publicly reachable and returns `200` with `{"answer": "..."}` JSON. **401 specifically usually means your handler is checking `X-Botfights-Signature` on every request, including `type: "webhook_test"`** — that call is unsigned by design (no `BOT_SECRET` exists yet at registration time); see section 4's "Exception" note and skip signature verification for `webhook_test` | | Bot auto-deactivated | 5 consecutive errors (timeouts, non-200 responses, invalid JSON, or missing `answer` field) | Fix whatever's causing the errors, then re-register or update your webhook URL | ## After setup diff --git a/frontend/src/pages/DocsPage.vue b/frontend/src/pages/DocsPage.vue index dbb77b0..e581eb8 100644 --- a/frontend/src/pages/DocsPage.vue +++ b/frontend/src/pages/DocsPage.vue @@ -37,11 +37,36 @@ function copyText(text: string, id: string) { } // ── "Give this to your AI" — the single self-contained setup prompt (BOT-02) ── -const promptUrl = computed(() => `${window.location.origin}/api/docs/prompt`) +// +// IMPORTANT: promptUrl must NOT be built from window.location.origin. On a +// proxy-mode instance (ARENA_UPSTREAM_URL set), that's whatever address the +// browser happens to be on (e.g. this node's own LAN/Tailscale IP) — the +// fetched CONTENT behind /api/docs/prompt is correctly proxy-resolved +// server-side (arena-proxy forwards /api/* to the real upstream arena), but +// the origin string alone isn't. Real incident: a Tailscale address ended up +// in an AI agent's setup instructions this way. Resolve promptUrl from the +// prompt's own resolved content instead, once, lazily. +const promptUrl = ref(`${window.location.origin}/api/docs/prompt`) // same-origin fallback until resolved const promptLoading = ref(false) const promptCopied = ref<'' | 'url' | 'text'>('') +let cachedPromptText: string | null = null -function copyPromptUrl() { +async function fetchPromptText(): Promise { + if (cachedPromptText !== null) return cachedPromptText + const res = await fetch('/api/docs/prompt') + const text = await res.text() + cachedPromptText = text + // First "curl -X POST /api/bots" line names the resolved arena origin + // (see BOTFIGHTS.md section 1) — reuse it rather than window.location.origin. + const match = text.match(/curl -X POST (\S+)\/api\/bots/) + if (match) promptUrl.value = `${match[1]}/api/docs/prompt` + return text +} + +onMounted(() => { fetchPromptText().catch(() => {}) }) + +async function copyPromptUrl() { + await fetchPromptText().catch(() => {}) // best-effort resolve before copying navigator.clipboard.writeText(promptUrl.value) promptCopied.value = 'url' setTimeout(() => { if (promptCopied.value === 'url') promptCopied.value = '' }, 2000) @@ -50,8 +75,7 @@ function copyPromptUrl() { async function copyFullPromptText() { promptLoading.value = true try { - const res = await fetch('/api/docs/prompt') - const text = await res.text() + const text = await fetchPromptText() navigator.clipboard.writeText(text) promptCopied.value = 'text' setTimeout(() => { if (promptCopied.value === 'text') promptCopied.value = '' }, 2000) diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index e51fea8..8213298 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -193,6 +193,52 @@ async function initLiveScene() { scrollLiveLog() } +// Backfill already-completed rounds when opening a fight already in progress +// (e.g. a background poll-mode bot kept fighting while nobody had the viewer +// open — the log otherwise starts empty and the NEXT live round is the first +// thing to ever appear, reading as "the fight jumped straight to round N"). +// Deliberately NOT calling handleRoundEnd() for these — that triggers full +// scene animation/TTS/fanfare per round, which would replay every missed +// round in real time before the viewer could show anything current. This is +// a compact, non-animated log backfill only; HP/round-counter state is set +// directly from the fetched fight's current values. +function backfillCompletedRounds() { + const fd = liveFightData.value + const roundsData = (fd as any)?.rounds as Array> | undefined + if (!fd || !fd.botA || !fd.botB || !roundsData?.length) return + + for (const r of roundsData) { + const round = r.roundNumber + const aWon = r.winnerId === fd.botA.id + const bWon = r.winnerId === fd.botB.id + const winnerName = aWon ? fd.botA.name : bWon ? fd.botB.name : 'DRAW' + liveLogItems.value.push( + { type: 'header', round, text: `ROUND ${round}: ${challengeLabel(r.challengeType)}`, color: 'neon-purple' }, + ) + if (r.botAResponse) { + liveLogItems.value.push({ type: 'responseA', round, text: `${fd.botA.name}: ${r.botAResponse}`, color: 'neon-cyan' }) + } + if (r.botBResponse) { + liveLogItems.value.push({ type: 'responseB', round, text: `${fd.botB.name}: ${r.botBResponse}`, color: 'neon-pink' }) + } + if (r.narration) { + liveLogItems.value.push({ type: 'narration', round, text: `>> ${r.narration}`, color: 'neon-yellow' }) + } + liveLogItems.value.push( + { type: 'result', round, text: `${winnerName} ${aWon || bWon ? 'wins round!' : '- no winner'} (${r.botAScore ?? 0} vs ${r.botBScore ?? 0})`, color: aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' }, + { type: 'divider', round, text: '', color: '' }, + ) + } + + // Reflect current state immediately — don't wait for the next live round + // to update HP/round counter away from their initial defaults. + const lastRound = roundsData[roundsData.length - 1] + liveCurrentRound.value = lastRound.roundNumber + if (typeof (fd as any).botAHp === 'number') liveHpA.value = Math.round(((fd as any).botAHp / 200) * 100) + if (typeof (fd as any).botBHp === 'number') liveHpB.value = Math.round(((fd as any).botBHp / 200) * 100) + scrollLiveLog() +} + // --- SSE event wiring --- // Track in-progress round animation so fight_end can wait for it let _roundEndPromise: Promise | null = null @@ -527,6 +573,10 @@ onMounted(async () => { } if (isLive.value) { + // Show any rounds that already happened before this viewer connected + // (see backfillCompletedRounds() for why — a background bot doesn't wait + // for a spectator) before wiring the live SSE stream for what's next. + if (!isHumanFight.value) backfillCompletedRounds() startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value }) wireSSE() // Scene init + human polling are handled by the liveFightData watcher diff --git a/server/src/app.ts b/server/src/app.ts index 35a0d9c..b26e7ea 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -179,6 +179,10 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) { app.get('/icon-*.png', (c) => serveFile(c, c.req.path, 'public, max-age=86400')) app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400')) app.get('/apple-touch-icon.png', (c) => serveFile(c, '/apple-touch-icon.png', 'public, max-age=86400')) + // Archipelago native NIP-07 signer bridge (see index.html