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>