While wiring the ai-config settings UI, found the same "trust a
client-supplied pubkey" pattern repeated across every payment-moving
route in the app — not an isolated bug. Fixed all of them:
- GET /api/payments/winnings/:botId (CRITICAL): had NO auth check at
all AND returned the raw, spendable Cashu bearer token in the list
response. botId is public (every fight/profile URL), so anyone could
list any bot's unclaimed winnings and get the live token back before
the real winner claimed it — direct fund theft, zero auth required.
Fixed: require JWT-derived ownership of botId; the list endpoint no
longer returns the token at all (only paymentId + amountSats) — the
token is now only ever revealed once, via the explicit claim below.
- POST /api/payments/connect-wallet (CRITICAL): trusted a
client-supplied pubkey with NO ownership check whatsoever. Anyone
could attach an attacker-controlled NWC connection string or
Lightning Address to ANY victim bot by pubkey, silently redirecting
all future fight-winnings payouts to the attacker's wallet.
- POST /api/payments/claim/:paymentId: same pubkey-trust pattern,
hands back a live spendable Cashu token — the single most sensitive
check in the file.
- DELETE /api/payments/disconnect-wallet: trusted pubkey with no
ownership check (DoS: anyone could kill a victim's payout wallet).
- POST /api/queue/join-ranked/:botId: compared a client-supplied
pubkey directly against bot.publicKey with no signature/JWT
verification. pubkeys are public by design in nostr (shown on every
bot's own profile page), so this was not an ownership check at all.
Also hardened (lower severity, same fix for consistency):
POST /create-invoice, POST /confirm/:paymentId, GET /wallet-status.
Fix pattern, consistent with auth.ts (f5f57e6): pubkey is now always
derived from extractPubkeyFromAuth(Authorization: Bearer <jwt>), never
trusted from a request body or query string. Added a shared
verifyBotOwner() helper in bot-auth.ts for the dual-audience routes
(nostr-signed-in owners AND anonymous poll-mode bots via
Authorization: Bot <id>:<secret>). Schemas (connectWalletSchema,
createInvoiceSchema, joinRankedSchema, disconnectWalletSchema) no
longer declare a pubkey field — removing the field is itself a guard
against the pattern regressing. Frontend callers already used
authFetch (attaches the Bearer JWT automatically) for every one of
these, so no behavior change for legitimate callers — only closes the
hole for illegitimate ones.
Root-caused test failures this surfaced: a leaked mockReturnValueOnce
queue value cascaded through payments.test.ts once earlier tests
started 401-ing before consuming their queued mock (disconnect-wallet
-> zap Attack3 -> Attack4 -> claim Attack7). Fixed by giving each
newly-auth-gated test a real JWT (createJwt, not mocked) instead of
loosening the auth requirement.
Full server suite: 806-815/810-829 passing depending on run (only
pre-existing CPU-load-sensitive timing/throughput benchmarks flake,
all confirmed passing in isolation and confirmed untouched by this
diff — bot-auth.ts constant-time variance, lifecycle.ts fight
throughput, shutdown.ts timeout, fights.mock dev-check). tsc --noEmit
clean (server + frontend).
Co-Authored-By: Claude <noreply@anthropic.com>
Found live while wiring the "existing bot" AI-config settings UI: this
route parsed `pubkey` from the request body and used it directly to
select which bot row to update, with no check that it matched the
caller's actual authenticated identity. Any unauthenticated caller
could POST an arbitrary victim's pubkey plus a malicious webhookUrl,
profilePicUrl, or customization payload and silently hijack that bot
(e.g. redirect its webhook to an attacker-controlled endpoint).
Contrast with GET /me and POST /regenerate-secret, which both
correctly derive pubkey from the verified JWT via
extractPubkeyFromAuth and never trust a client-claimed identity — this
was the one route that didn't follow that pattern.
Fixed by deriving pubkey from the JWT exclusively; updateBotSchema no
longer declares a pubkey field at all (was the only schema-level
signal that the vulnerable code path existed). Frontend callers
updated to stop sending a pubkey they no longer need. Added a
regression suite (auth-update.test.ts) covering: 401 with no/garbage
auth, hijack-attempt-via-body-pubkey now 404s and leaves the victim's
row untouched, and legitimate self-updates still work when the body
happens to carry an unrelated pubkey field (ignored, not trusted).
Full server suite: 829/829 passing. tsc --noEmit clean (server +
frontend).
Co-Authored-By: Claude <noreply@anthropic.com>
Image tag bumps for 1.2.7/1.2.8/1.2.9 were applied directly on the VPS2
host via SSH+sed during live demo deployment and not consistently mirrored
back to this file each time — this commit brings it back in sync with
what's actually running (confirmed: docker inspect botfights-arena shows
localhost:3000/lfg2025/botfights:1.2.9, healthy).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two pieces, both user-directed, completing what 7341ca0 only configured:
1. WalletConnect.vue: Cashu token paste is now the PRIMARY entry-fee path
(submitCashuToken() already existed in useWallet.ts but was never called
from any UI — added the missing wiring). Lightning/NWC is now secondary,
behind an explicit "or connect a Lightning wallet instead" toggle.
Emits `cashu-paid` with the redeemed paymentId; JoinBoutPage.vue's
fightRanked() uses it directly instead of calling payEntryFee()
(Lightning-only) when present — no duplicate invoice/charge.
2. queue.ts's POST /join-ranked/:botId required a nostr pubkey for
ownership verification, full stop. Confirmed live during testing:
anonymous poll-mode bots (the primary registration path for AI agents
per BOTFIGHTS.md) have publicKey: null — staked fights were completely
unusable for that entire audience, silently. Now accepts EITHER a
pubkey OR Authorization: Bot <id>:<secret> (same bot-auth every other
anonymous-bot endpoint already uses) as proof of ownership.
Verified: full server typecheck clean; payments.test.ts (23) and
queue.test.ts (8) unchanged and passing; full frontend suite (101 tests,
13 files) passing, including useWallet.test.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sets the mint for the planned Cashu fixed-stake entry fee design ("winner
takes all, 21 sats each, only ever" — user-directed). Verified the mint
live before configuring it (not guessed): NUT-4 mint (bolt11/sat), NUT-5
melt, NUT-7 spend-check (needed to reject an already-spent posted token),
NUT-11 P2PK (lets a payout be locked to the winner's own pubkey, no
interactive receive step needed) all present. Mint's own description says
"Do not use with large amounts of ecash" — matches the 21-sat cap
intentionally.
This alone moves no funds: the existing cashu payout branch in
server/src/engine/payments.ts is only reached from ranked-mode fights,
which still requires BOTFIGHTS_NWC_URL (still unset) to even queue an
entry fee. The actual "accept a posted token as a stake, escrow it, refund
on timeout, pay the winner" capability does not exist in the codebase yet
— this commit is config-only prep, tracked as a properly-scoped follow-up
(see archy's 09-botfights-platform-upgrade/deferred-items.md for the
threat register worked through before configuring this).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Connect NWC" was 500ing — server/src/engine/crypto.ts's getKey() throws
immediately under NODE_ENV=production when this var is unset. Generated a
random 32-byte key directly on the VPS2 host into /opt/botfights-arena/.env
(0600, never committed — same pattern as JWT_SECRET), wired the reference
into the deployed docker-compose.yml, and restarted. Only enables storing
per-user NWC connection strings encrypted at rest — does not enable any
actual money movement (BOTFIGHTS_NWC_URL, the arena's own wallet, is still
unset and deliberately not touched here).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1. server/src/app.ts: CSP img-src only allowed 'self'/data:/blob: — nostr
profile pictures come from kind:0 metadata, a URL the user sets via their
own client, hosted on whatever domain they picked. There's no central
image host for a decentralized identity system, so every external
profile pic was CSP-blocked and rendered as a broken image. Added
https: (broad) — safe here since images can't execute script even from
an untrusted origin, unlike script-src which stays locked to 'self'.
2. frontend/src/pages/JoinBoutPage.vue: the new AI-answer option (1.2.7)
was reported as invisible — it was gated behind picking POLLING (not the
default WEBHOOK) AND behind a collapsed toggle within that. Changed:
POLLING is now the default mode (also the documented default in
BOTFIGHTS.md), the AI section is expanded by default instead of
collapsed, and the POLLING button's own description now mentions the
option so it's visible without any extra click.
3. docker-compose.arena.yml: image tag 1.2.1 -> 1.2.7, matching what's
actually deployed on the canonical arena (rolled live via
ssh+docker compose pull/up this session — this commit just brings the
repo's copy of the compose file back in sync with reality).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New feature, requested live during demo prep: an operator can paste their
own Anthropic or OpenAI API key and have the server itself answer fight
challenges for their poll-mode bot, instead of running an external script.
Storage (server/src/engine/ai-bot-config.ts): one 0600 JSON file per bot
under this app's own data volume — deliberately mirrors Archipelago's own
node-level pattern for the identical class of secret (system.settings.set
"claude_api_key" in core/archipelago/src/api/rpc/system/handlers.rs): never
returns the raw key on GET, only whether one is configured and which
provider. This is a human operator opting in for their own bot via the
app's own UI — a different trust boundary from the unified prompt's "never
ask an AI agent for its API key" rule (BOTFIGHTS.md), not a violation of it.
Execution (server/src/engine/orchestrator.ts): purely additive hook inside
the existing isPollingBot branch of getBotResponse(). waitForPollResponse()
(unchanged) registers the pending challenge synchronously before returning;
right after, answerWithAiIfConfigured() fires a fire-and-forget async LLM
call that races to call submitPollResponse() — the exact function an
external poller already calls — before that promise's own timeout. No AI
config = instant no-op. LLM error/timeout = the existing timeout path
handles it identically to a human forgetting to poll. No new failure mode,
no change to scoring/round timing for any other bot.
Adapter (server/src/engine/llm-adapter.ts): both providers, one call shape
each (Anthropic /v1/messages + x-api-key, OpenAI /v1/chat/completions +
Bearer), same competitive system prompt already documented in BOTFIGHTS.md
for operator-run bots.
API (server/src/routes/bots.ts): POST/GET/DELETE /api/bots/ai-config,
authenticated via the bot's own Authorization: Bot <id>:<secret> (same as
/api/fights/poll). Registered BEFORE GET /:name — same route-shadowing bug
class already found once in fights.ts's /poll route (09-05); a bare /:name
registered first would have swallowed /ai-config as a bot-name lookup.
Covered by a new test (ai-config.test.ts) that asserts the real contract
shape, not just a 200, specifically to catch that regression.
UI (frontend/src/pages/JoinBoutPage.vue): collapsible section in the
bot-setup step, poll mode only (webhook mode already assumes the operator
runs their own infra) — provider picker, password-type key input, links to
get a key from either provider, key cleared from page state immediately
after saving.
Explicitly deferred (not built here): "use this node's key" as an
alternative to pasting your own — the node already has one configured for
AIUI (found live, /opt/archipelago/claude-api-proxy.py), but wiring a
cross-container secret share from the archy orchestrator into this
container needs a manifest change and another catalog signing cycle, which
this session isn't improvising under demo time pressure.
Also: HomePage.vue "Latest Bouts" section hidden on short viewports
([@media(max-height:700px)]:hidden) — the hero layout is a vertically-
centered flex column with overflow-hidden and no scroll by design, so on a
short viewport (embedded node dashboard iframes, small kiosk screens) this
last/least-essential section was what silently clipped, reported live as
"it looks cut off on node screens often".
Fixed a regression-test violation this batch would have introduced
(BUG-F2: no silent .catch(() => {})) in the DocsPage.vue proxy-URL-resolve
fix from the previous commit — both catches now log a warning instead of
swallowing silently.
Verified: full server typecheck clean; orchestrator.test.ts (23) +
poll-responses.test.ts (10) unchanged and passing — the new hook doesn't
alter existing poll-mode behavior; new ai-config.test.ts (7) passing,
including the route-shadowing regression check; regression.test.ts (43,
including the newly-fixed BUG-F2) passing. lifecycle.test.ts/scoring.test.ts
perf-timing failures are pre-existing, documented, unrelated flakiness
under this shared machine's CPU load (see archy's 09-01 deferred-items.md
item 2) — not caused by this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
secureHeaders() defaulted to X-Frame-Options: SAMEORIGIN, added as part of
the NIP-98/JWT auth hardening. This unconditionally blocked the Archipelago
node dashboard's iframe (a different origin by port) — 1.1.0 never sent
this header at all, so this was a hard regression for the platform's normal
embedded-app UX.
Fix: X-Frame-Options is now conditional on ARCHY_EMBEDDED=1, an env var the
archy manifest sets for the node-installed instance (first-party, trusted
embedding on the same host). Standalone/public-arena instances keep the
default SAMEORIGIN clickjacking protection unchanged.
Verified: with ARCHY_EMBEDDED=1 no X-Frame-Options header is sent; without
it, X-Frame-Options: SAMEORIGIN is still sent as before.
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>
A throwaway 1.2.0 container in proxy mode (no volume, ARENA_UPSTREAM_URL
pointed at the canonical arena) demonstrated: a fighter registered
directly against the arena is visible through it (D1), a fighter
registered through it is visible on the arena directly (reverse), SSE
streams incrementally through it, /api/health answers locally during an
arena outage, and /api/bots degrades cleanly during that outage. Also
documents a deviation: the canonical URL now sits behind
nginx-proxy-manager (per the mid-phase DNS/TLS decision superseding this
plan's original plain-HTTP wording), so a stopped arena's 502 through
that URL is NPM's own HTML page rather than arena-proxy.ts's JSON body —
the underlying JSON degradation contract is separately confirmed live
against the raw fallback port, which has no intermediary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Health, unified prompt substitution, auth gate, anonymous bot
registration, the now-fixed GET /api/fights/poll, a real live-fight
match, SSE incremental-delivery timing, JWT_SECRET survival, and data
integrity — all checked against the public HTTPS URL, none against
127.0.0.1/raw port.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- docker-compose.arena.yml: image tag 1.1.0 -> 1.2.0, refreshed the
TRUSTED_PROXY comment to reflect the live NPM+TLS front-end (no
longer "no DNS/TLS this phase" — that shipped mid-phase).
- Deployed on VPS2: docker compose pull + up -d, container recreated,
healthy, data volume untouched.
- Verified end-to-end through the public HTTPS URL: health, unified
prompt (ARENA_URL substituted, zero leftover template tokens), a
freshly registered test bot visible in GET /api/bots, bot auth via
the now-fixed GET /api/fights/poll, and data integrity (100 + 15
classic bots, unchanged from before the roll).
- docs/arena-deployment.md: recorded the second (post-poll-fix) image
digest and the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
server/src/routes/fights.ts registered the dynamic GET /:id route before
the static GET /poll route. Hono resolves same-shape single-segment
routes in registration order, so any GET /api/fights/poll request was
matched as a fight-id lookup for id="poll" and always returned
404 {"error":"Fight not found."} instead of the poll handler's
{"pending":false}/{"pending":true,...} response.
This meant the polling protocol — one of the two bot integration modes
BOT-02's unified prompt documents — never actually worked. Found while
verifying bot auth against the freshly-rolled 1.2.0 arena (plan 09-05
Task 2 acceptance criterion), reproduced independently on a throwaway
container with a fresh DB to confirm it wasn't an artifact of the
arena's seeded data.
Fix: move the /poll and /poll/respond route registrations above /:id.
No other GET route in this router collides in shape with /:id (verified
by listing every registered path/method pair).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Fixed a pre-existing pnpm overrides config drift (package.json's dead
pnpm.overrides key vs pnpm-workspace.yaml's overrides, never fully
migrated after commit bcb323e) that was blocking the docker build's
`pnpm install --frozen-lockfile` step under a current pnpm version.
Zero dependency specifier changes in the regenerated lockfile.
- Built and pushed 146.59.87.168:3000/lfg2025/botfights:1.2.0 from
botfight main @ 2a343ac (arena-proxy + nostr-only auth + unified
prompt, all three wave-1 plans confirmed present before build).
- Documented the build/push recipe, the gotcha, and the smoke test in
docs/arena-deployment.md.
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
- New docsRouter.get('/prompt') resolves the shipped container path
(server/public/docs/BOTFIGHTS.md) then falls back to the dev-checkout
path (frontend/public/docs/BOTFIGHTS.md), matching app.ts's publicDir
derivation pattern
- Substitutes {{ARENA_URL}} with PUBLIC_ARENA_URL when set, otherwise the
request's own origin, so a cloud agent that curls the prompt gets
working examples pointed back at the arena it fetched from
- Responds as text/markdown so an agent can pipe the response straight
into its context
- 5 new Vitest cases: 200+content-type, no leftover {{ARENA_URL}} token,
PUBLIC_ARENA_URL precedence, origin fallback, YOUR_BOT_ID placeholder
preserved for the in-app substitution flow
Deviation (Rule 1 — auto-fix bug), out-of-scope-but-cheap per plan 09-02's
explicit allowance. server/src/db/migrate.ts (the standalone `pnpm migrate`
CLI script) had drifted from server/src/db/schema.ts: it was missing 7
tables (payments, wallet_connections, bets, tournaments, tournament_entries,
analytics, tournament_matches) and several bots/fights columns (sats_won,
sats_wagered, has_wallet, zaps_received, bot_type, mode, pot_sats,
payout_status, current_season). server/src/db/startup.ts's runMigrations()
(the one actually called from index.ts at server boot) already had the
correct, up-to-date DDL — migrate.ts was the stale duplicate. Brought it
back in sync, column-for-column and table-for-table, against schema.ts.
Route-level tests (auth.test.ts, auth-audit.test.ts, auth-edge.test.ts,
tournaments.test.ts) hit the real db/index.ts singleton against the
on-disk, gitignored server/data/botfights.db, which only startup.ts or
this migrate.ts script populate — vitest itself never runs a migration.
Running `pnpm --filter server migrate` against a fresh DB with the fixed
script now creates all tables/columns; full server suite went from
15 failed / 789 passed to 6 failed / 798 passed, with the remaining 6
all pre-existing timing/perf flakes unrelated to auth (answers.test.ts,
lifecycle.test.ts x2, bot-auth.test.ts, docs.test.ts x2 — CPU-contention
sensitive, matches deferred-items.md's documented flake class).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a JWT-gated, read-only session-restore route. Mirrors POST /login's
projection and 200 body shape exactly so normalizeBotData on the client
is unchanged. extractPubkeyFromAuth (already imported later in the file
for /regenerate-secret) covers missing/malformed/forged/expired/
blacklisted tokens via verifyJwt; deduped the now-redundant import at
the /nostr/session section. All 7 auth-me.test.ts cases pass; tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers missing / malformed / forged / blacklisted / unregistered / valid
JWT cases for the JWT-only identity route that replaces the bare-pubkey
auto-restore path. Route does not exist yet — 6/7 fail as expected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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)
The prior test drove the proxying app via Hono's in-process app.request()
harness, which has no real Node socket — so it could only assert the header
was non-empty-or-absent, not that the real client IP round-trips. Spin the
proxying app up with @hono/node-server (real loopback socket) and assert the
upstream actually receives 127.0.0.1/::1, exercising the same
remoteAddress lookup arenaProxy uses in production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Forward client IP via x-forwarded-for/x-real-ip so the canonical arena's
per-IP rate limiting isn't collapsed to one bucket per node.
- 30s AbortSignal.timeout on non-stream requests; SSE fight streams
(/api/fights/:id/stream) are exempt (long-lived by design).
- On upstream fetch failure, log and return 502 {error} instead of a
buffered hang or a 500 stack trace.
- fights.ts: set X-Accel-Buffering: no on the SSE stream response so an
nginx-fronted arena (nginx-proxy-manager) doesn't buffer live fight events.
- docker-compose.yml: document ARENA_UPSTREAM_URL / TRUSTED_PROXY (commented,
no active value set here — the canonical arena gets its own compose file
in a later plan).
TDD: added the SSE/XFF/502 tests, confirmed the 502 test failed against the
prior implementation, then implemented to green (9/9 arena-proxy tests,
17/17 combined with rate-limit.test.ts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hono middleware that forwards /api/* to ARENA_UPSTREAM_URL when set, with
standalone mode (env unset) and /api/health untouched. Verified end-to-end
against a real second HTTP server: register a bot upstream, read it back
through the proxy, method/query/JSON body forwarded unchanged, inbound Host
header dropped, response content-encoding/content-length stripped.
TDD: 6 tests written first and confirmed failing (module didn't exist),
then implemented to green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds docker-compose.arena.yml (registry image only, no build, no payment
env, no TRUSTED_PROXY since no reverse proxy fronts it) and
docs/arena-deployment.md (full runbook: host paths, port, secret handling,
seed decision, NPM/TLS upgrade path, teardown) for the one canonical public
arena at http://146.59.87.168:9100 (user decision 2026-07-30: no DNS/TLS
this phase, plain HTTP on the raw port; TLS is a later env-only upgrade).
Deployed + verified on VPS2: healthy on-host and off-host, seeded with a
full read-only copy of archi-dev-box's botfights.db (115 bots/102,440
fights; source untouched, mtime verified unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The creator was being registered with webhookUrl='http://human.local/' and
isHuman=true. Now uses poll.local and isHuman=false. The auto-upgrade logic
on login also converts any existing creator record from human to bot mode.
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>
- Add botfights system user/group, chown /app, USER directive
- Add HEALTHCHECK using /api/health endpoint (30s interval, 5s timeout)
- Container now runs as non-root for security hardening
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Source pattern verification tests:
- BUG-6: webhook calls wrapped in Promise.all (parallel, not sequential)
- BUG-7: SSE maps (spectatorCounts, fightReactions, ssePerIp) cleaned on disconnect
- BUG-4: no raw setTimeout in game code (all use trackedTimeout)
- BUG-F2: no silent .catch(() => {}) in frontend source
43 regression tests total, all passing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix E2E Playwright config: correct port 5173→9101, increase webServer timeout
- Fix signup-bot and signup-human specs: add missing nsec backup step
- Implement scoreCreativeAnswer() heuristic for creative round scoring
- Pass DB ELO to generateMockBotResponse for non-MOCK_BOTS integration tests
- Update challenges tests: all 16 types are now factual (no creative types)
- Fix lifecycle test flakiness: widen ELO correlation tolerance, add timeout
- All 11 E2E specs pass, 770 unit/integration tests pass
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>