Two fixes found during live signer-login verification:
1. hasExtension was `computed(() => !!window.nostr)` — window.nostr is a
plain global with no Vue reactivity, so this evaluated once, lazily, on
first read and cached forever. If the extension's content script hadn't
injected yet at that moment (common — extensions often inject slightly
after page scripts start), "SIGN IN WITH EXTENSION" disappeared
permanently, even once the extension finished injecting moments later.
Reported live as "no browser extension or signer option ever shows".
Fixed: hasExtension is now backed by a real ref, seeded from the current
value and upgraded by a short poll (existing waitForSigner() precedent,
same 200ms/timeout shape) so the UI reacts when the extension actually
appears.
2. Added Archipelago's native NIP-07 signer bridge (frontend/public/
nostr-provider.js, copied verbatim from neode-ui/public/nostr-provider.js
— the canonical source) via a <script> tag in index.html. This no-ops
immediately outside an iframe (window === window.top), so a real browser
extension in a standalone tab is unaffected. Inside the Archipelago node
dashboard's iframe, it provides window.nostr backed by the node's own
identity via postMessage to
neode-ui/src/views/appSession/useNostrBridge.ts (already generic — no
per-app allowlist needed for the getPublicKey/signEvent bridge itself,
only for the optional auto-login/identity-picker convenience flow, which
this app doesn't use). Existing login() flow (buildNip98Token ->
POST /api/auth/nostr/session) works unchanged through this bridge.
Together: signing in now works reliably both in the dashboard iframe (no
extension needed at all) and in a direct tab (real extension, now reliably
detected).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JoinBoutPage.vue and BotProfilePage.vue's "copy AI setup guide" flows both
fetched the static /docs/BOTFIGHTS.md file and substituted {{ARENA_URL}}
client-side with window.location.origin. On an instance running in proxy
mode (ARENA_UPSTREAM_URL set), that substitutes whatever address the
browser happens to be on — e.g. this node's own LAN/Tailscale IP — into a
guide meant to be handed to an external AI agent, which then can't reach
that address at all (private/overlay network, no route from outside).
Root cause: the static file bypasses arena-proxy entirely (it only mounts
on /api/*), so the substitution had no way to know about proxy mode.
Fix: both flows now fetch the server-rendered /api/docs/prompt instead.
That route is under /api/*, so arena-proxy transparently forwards it to
the real upstream arena in proxy mode, which resolves {{ARENA_URL}} to its
own correct, externally-reachable origin (server/src/routes/docs.ts,
unchanged, already correct) — same fix class as the JoinBoutPage
mode-picker guide-banner change (603e09b), same root cause discovered via
a live demo incident (a bot got a Tailscale address in its setup guide and
correctly refused to act on it).
Known follow-up, not fixed here: DocsPage.vue's `promptUrl` display link
(`${window.location.origin}/api/docs/prompt`) has the same class of issue
for the *link itself* (not its content) — lower risk, deferred.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BOT-02 consolidated the poll/webhook setup docs into one file
(BOTFIGHTS.md) that documents both options — correct by design, but the
UI still had a leftover watch(connectionMode, ...) that cleared and
refetched the *same* file every time you clicked WEBHOOK/POLLING, showing
a "Loading..." flash for content that never actually changed. From a
user's perspective the tab looked broken: click it, nothing visibly
different happens.
Fix: stop the pointless refetch, and add a `modeHint()` banner — colored
per mode, updates instantly on click — inside the guide viewer and
prepended to the copied text, pointing the reader/AI at "Option A: Polling
Bot" or "Option B: Webhook Bot" within the same doc. The mode picker now
produces an immediate, obvious visual change, and the copied prompt is
mode-aware even though the underlying file is shared by design.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first cloud AI agent given the prompt refused it as a suspected prompt
injection: 'nothing else needs to be read' framing, raw-IP fallback, and
being told to wire its own ANTHROPIC_API_KEY into a persistent script. All
three patterns removed; brains are now an operator-supplied optional
OpenAI-compatible endpoint with local heuristics as default. queue/join
documented as blocking ~35s (60s client timeout) matching the real 30s
production QUEUE_TIMEOUT_MS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
- JoinBoutPage.vue: setupDocPath()/setupDocName() collapse to /docs/BOTFIGHTS.md
regardless of connection mode; keep YOUR_BOT_ID/YOUR_BOT_SECRET substitution
and add a third substitution for {{ARENA_URL}} -> window.location.origin
- BotProfilePage.vue: same collapse; the webhook/polling guide-type selector
is gone (there's only one guide now) -- replaced with a single 'LOAD SETUP
GUIDE' button, same credential + {{ARENA_URL}} substitution
- DocsPage.vue: new 'GIVE THIS TO YOUR AI' panel at the top of the page --
copy-to-clipboard for the full /api/docs/prompt response, plus the literal
URL shown so a user can hand an agent the link instead of the text.
Existing API reference / webhook tester tabs unchanged below it
- e2e/signup-bot.spec.ts: two new tests -- docs page shows the copy
affordance, and GET /api/docs/prompt (via page.request) returns 200 with
the registration endpoint and no leftover {{ARENA_URL}} token
- Merge BOTFIGHTS.md + BOTFIGHTS-EASY/POLLING/WEBHOOK.md + BOT_SETUP.md into
a single canonical prompt at frontend/public/docs/BOTFIGHTS.md
- Add the previously-undocumented registration step (POST /api/bots,
anonymous, poll vs webhook mode, rate limits, 409/422 behavior)
- Replace the stale botfights.io fallback host with the {{ARENA_URL}} token
(substituted server-side/client-side in later tasks)
- Document the exact HMAC-SHA256 webhook signature derivation, matching
orchestrator.ts (the old bot.js example's verifySignature() was wrong —
it hashed BOT_SECRET directly instead of via the secretHash+signingKey
two-step the server actually uses)
- Document the trash_talk (webhook, snake_case) vs trashTalk (poll,
camelCase) field-naming split, verified against the real zod schemas
- Add the endpoint reference table, troubleshooting table, and arena-as-relay
framing (any node can host an arena; default is the Foundation's)
- Replace root BOTFIGHTS.md with a 4-line stub pointing at the canonical copy
- Delete the four superseded docs (BOTFIGHTS-EASY/POLLING/WEBHOOK.md, BOT_SETUP.md)
k.go('arcade') schedules the scene callback on frameEnd, not synchronously.
start() was called before fighters existed, causing "Cannot read properties
of undefined (reading 'obj')". Fix: await a readiness promise that resolves
once the scene callback has created the fighters.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
wireSSE() was only called for human fights in onMounted, so bot fights
(webhook/poll mode) never received round_start/round_end events. The fight
ran server-side but the frontend showed nothing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Bot registered immediately after name check (poll mode default)
- bot_id + secret available in bot-setup step with COPY GUIDE + CREDENTIALS
- Webhook mode updates URL on existing bot instead of re-registering
- Collapsible guide preview with credentials injected
- Resets guide content when switching webhook/polling mode
- Fix secret text overflow with break-all on profile page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove setup guide from pre-registration bot-setup step (no credentials yet)
- Add collapsible guide preview to ready step with real bot_id/secret injected
- "COPY GUIDE + CREDENTIALS" copies the full guide with credentials embedded
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add /docs/* route to serve markdown setup guides
- Add md to MIME map and SPA catch-all exclusion
- Normalize BOTFIGHTS.md placeholders to YOUR_BOT_ID/YOUR_BOT_SECRET
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- downloadSetupGuide() now triggers a real file download with bot_id/secret injected
- Add POST /api/auth/regenerate-secret endpoint (JWT auth, 3/hour rate limit)
- Add "Download Setup Guide" section to BotProfilePage with secret regeneration flow
- Old secret immediately invalidated on regeneration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- FightViewer: store canvas event handlers and remove them before
replacing canvas elements, preventing detached DOM/closure leaks
- FightViewer: clean up canvas listeners on unmount
- tts.ts: clear _staticLoading dedup map after precache completes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cache already uses lastAccess timestamps and LRU eviction. Added
test-only exports and 6 tests verifying: timestamp tracking, access
updates, LRU eviction of oldest entry, recently accessed entries
survive eviction, and eviction is not FIFO.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Added audioTimeout() to audio/context.ts — tracked timer set cleared on
scene destroy via clearAllAudioTimers(). Converted 20 sfx.ts + 3 voice.ts
raw setTimeout calls to audioTimeout. Converted FightScene playEntrance
timeout to trackedTimeout. Remaining setTimeout in tts.ts worker layer
and music.ts (already tracked via setMusicTimeout) are self-managing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove ^ prefix from all 33 dependencies across root, server, and
frontend package.json files. Lockfile regenerated and verified with
pnpm install --frozen-lockfile.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Human morph sprites were generated without archetype and customization
params, causing fallback to generic sprites instead of the bot's actual
appearance.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Added isVerifyingWebhook state with spinner and disabled button during
webhook test. Improved error message to guide user on retry.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Changed from fixed 1.5s setInterval to recursive setTimeout with
exponential backoff on consecutive errors. Resets to 1.5s on success.
Added 2 tests verifying backoff escalation and reset behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fetchNostrProfile now uses Promise.allSettled to query all relays
concurrently. Aggregates results with latest-created_at-wins strategy
instead of stopping at the first relay that responds.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Module re-evaluation during HMR reset autoRestoreRan to false, causing
duplicate auth-restore API calls. Now persists flag on globalThis.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
useNostr (9), useFightCache (5), useOnlineStatus (4) composable tests.
Added fake-indexeddb dev dependency for IDB tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Catches runtime errors in child components, displays user-friendly
error message with reload button. Wired into App.vue wrapping
router-view. Tests verify error capture and button rendering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Timeout and WebSocket errors reject with proper Error objects
- Caller catches and falls through to poll-based confirmation
- Preimage undefined check prevents calling confirm with no preimage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feedbackTimer, timerHandle, and pollHandle are all cleared in
onUnmounted. Test confirms cleanup pattern works correctly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 silent catch handlers replaced with descriptive console.warn logging
across 6 frontend files. No silent error swallowing remains.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SSE now always attempts reconnection when fight isn't finished,
regardless of isLive.value. Uses exponential backoff (1s, 2s, 4s,
max 8s). Moved sseRetries to outer scope to persist across reconnects.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Frontend: vitest.config.ts with vue plugin + jsdom, dummy component test
- Server: in-memory SQLite test DB factory + Hono testClient helper + smoke test
- CI: add pnpm audit and server coverage threshold steps
- Root: vitest workspace config for multi-project test discovery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Scoring: both-wrong is now a DRAW — equal scores (3/3), no winner,
symmetric minimal damage. Garbage answers no longer beat reasonable
ones just by being faster. Both-wrong narrations reflect the draw.
Entrance: removed duplicate announceDeepIntro() call from FightViewer
(was already called inside playEntrance). Removed _resetPositions()
after entrance (entrance already places fighters at home positions,
the extra reset caused a visible snap/reset).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Merged bot-setup and choose-connection into one actionable step.
Users now see mode picker, download button for the correct guide
(BOTFIGHTS-WEBHOOK.md or BOTFIGHTS-POLLING.md), and safety info
all on one screen instead of two filler steps with no actions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
buildNip98Token checked window.nostr before secretKeyHex, so
"Generate New Identity" would sign with the browser extension's key
after saving. Now explicit keys always take priority over extensions.
Also made setup flow mode-aware: webhook users get BOTFIGHTS-WEBHOOK.md,
polling users get BOTFIGHTS-POLLING.md with matching copy prompts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Allow huggingface.co in CSP connect-src (fixes Kokoro TTS model download)
- Add registerSW.js route (fixes PWA service worker 404)
- Add _resetPositions() safety after entrance (fixes invisible fighters)
- Fight end sequence works without canvas scene (KO/overlays/log always play)
- Pre-fight instructions in battle log for human players
- NIP-55 visibility sync and cleanup handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When window.nostr isn't available (common on mobile Chrome where
extensions can't inject), fall back to NIP-55 nostrsigner: intent
URIs. This opens Amber/Primal directly to sign a NIP-98 event,
then redirects back with the signed event for JWT authentication.
- Build nostrsigner: URI with unsigned NIP-98 event + callback URL
- Process NIP-55 callback on page mount (extract signed event from URL)
- Auto-detect Android to show "SIGN IN WITH AMBER / PRIMAL" label
- Reduced window.nostr polling from 3s to 2s before NIP-55 fallback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Security:
- Move CREATOR_PUBKEY from hardcoded constant to BOTFIGHTS_CREATOR_PUBKEYS
env var. Shared isCreatorPubkey() in constants.ts used by auth, admin,
tournaments. Frontend checks authorization via API, not client-side.
Mobile fixes:
- Nostr signer: poll for window.nostr up to 3s (Amber injects late).
- TTS: auto-unlock AudioContext on first user interaction via
installAutoUnlock() on fight page mount.
UX:
- Add loading spinners to "I BUILD BOTS" and "I FIGHT MYSELF" buttons.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>