fix: nostr-provider.js 404, DocsPage proxy-unaware URL, round-jump on late viewer join
CI / check (push) Failing after 6m9s

Three more fixes found during live demo verification:

1. server/src/app.ts: the previous commit added <script src="/nostr-provider.js">
   to index.html and shipped the file into server/public/, but this app's
   static file serving is an explicit per-route allowlist, not a catch-all —
   there was no route registered for it, so it 404'd and the signer bridge
   silently never loaded. Added the missing app.get('/nostr-provider.js', ...)
   route.

2. DocsPage.vue: promptUrl (the displayed "give this URL to your AI" copy
   button) was built from window.location.origin — same root-cause bug class
   as the JoinBoutPage/BotProfilePage fix (ffd4dfd), just for a link instead
   of fetched content. Now resolves the real arena origin from the fetched
   prompt's own content (which IS correctly proxy-resolved server-side via
   arena-proxy) instead of the browser's current address.

3. FightPage.vue: opening a fight already in progress (e.g. a background
   poll-mode bot kept answering challenges while nobody had the viewer open)
   showed nothing until the next live round arrived — reads as "the fight
   jumped straight to round N". loadFight() always fetched the completed
   rounds (data.rounds) but nothing backfilled the visible log from them;
   only live SSE round_end events ever pushed into liveLogItems. Added
   backfillCompletedRounds(), called once on mount before wireSSE() connects,
   that renders a compact (non-animated — no scene/TTS replay) summary of
   every already-completed round and sets HP/round-counter to current state
   immediately.

4. BOTFIGHTS.md: documented the webhook_test signature exception (see ffd4dfd
   commit for the same fix already applied to the live doc endpoint's
   underlying example) — this file is frontend/public/docs/BOTFIGHTS.md,
   the static copy that predates today's /api/docs/prompt-only rendering
   fix; keeping both in sync since some flows may still reference the path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-31 07:16:24 -04:00
co-authored by Claude Fable 5
parent 2c039f2af3
commit 2512265113
4 changed files with 108 additions and 12 deletions
+20 -2
View File
@@ -391,6 +391,14 @@ const server = http.createServer((req, res) => {
req.on('data', c => { body += c })
req.on('end', async () => {
try {
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)) {
@@ -398,8 +406,8 @@ const server = http.createServer((req, res) => {
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
+28 -4
View File
@@ -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<string> {
if (cachedPromptText !== null) return cachedPromptText
const res = await fetch('/api/docs/prompt')
const text = await res.text()
cachedPromptText = text
// First "curl -X POST <url>/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)
+50
View File
@@ -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<Record<string, any>> | 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<void> | 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
+4
View File
@@ -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 <script> tag) —
// no-cache since it's a small, host-provided shim that should always be
// fresh, not a hashed/immutable build asset.
app.get('/nostr-provider.js', (c) => serveFile(c, '/nostr-provider.js', 'no-cache'))
// Docs (markdown setup guides)
app.get('/docs/*', (c) => serveFile(c, c.req.path, 'public, max-age=3600'))