diff --git a/server/src/routes/docs.test.ts b/server/src/routes/docs.test.ts index 15c296f..9c00ab2 100644 --- a/server/src/routes/docs.test.ts +++ b/server/src/routes/docs.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import { Hono } from 'hono' import { docsRouter } from './docs.js' @@ -38,3 +38,50 @@ describe('docs webhook tester', () => { expect(res.status).toBe(400) }) }) + +describe('GET /api/docs/prompt', () => { + const ORIGINAL_PUBLIC_ARENA_URL = process.env.PUBLIC_ARENA_URL + + afterEach(() => { + if (ORIGINAL_PUBLIC_ARENA_URL === undefined) { + delete process.env.PUBLIC_ARENA_URL + } else { + process.env.PUBLIC_ARENA_URL = ORIGINAL_PUBLIC_ARENA_URL + } + }) + + it('returns 200 with text/markdown containing the registration endpoint', async () => { + const res = await app.request('/api/docs/prompt') + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/markdown') + const body = await res.text() + expect(body).toContain('POST') + expect(body).toContain('/api/bots') + }) + + it('leaves no unsubstituted {{ARENA_URL}} token in the response body', async () => { + const res = await app.request('/api/docs/prompt') + const body = await res.text() + expect(body).not.toContain('{{ARENA_URL}}') + }) + + it('uses PUBLIC_ARENA_URL when set', async () => { + process.env.PUBLIC_ARENA_URL = 'https://botfights.archipelago-foundation.org' + const res = await app.request('/api/docs/prompt') + const body = await res.text() + expect(body).toContain('https://botfights.archipelago-foundation.org') + }) + + it('falls back to the request origin when PUBLIC_ARENA_URL is unset', async () => { + delete process.env.PUBLIC_ARENA_URL + const res = await app.request('http://test-origin.example/api/docs/prompt') + const body = await res.text() + expect(body).toContain('http://test-origin.example') + }) + + it('preserves the YOUR_BOT_ID in-app substitution placeholder', async () => { + const res = await app.request('/api/docs/prompt') + const body = await res.text() + expect(body).toContain('YOUR_BOT_ID') + }) +}) diff --git a/server/src/routes/docs.ts b/server/src/routes/docs.ts index 1dfabe4..78a7a8a 100644 --- a/server/src/routes/docs.ts +++ b/server/src/routes/docs.ts @@ -1,8 +1,43 @@ import { Hono } from 'hono' +import { existsSync, readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' import { getAllChallengeTypes } from '../engine/challenges.js' import { testWebhookSchema } from '../lib/validators.js' export const docsRouter = new Hono() +// The unified AI bot-setup prompt (BOT-02). Try the shipped container layout +// first (server/public/docs/BOTFIGHTS.md, populated by the frontend build + +// Dockerfile's `COPY frontend/dist server/public`), then fall back to a dev +// checkout where the frontend hasn't been built yet. +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PROMPT_PATHS = [ + join(__dirname, '..', '..', 'public', 'docs', 'BOTFIGHTS.md'), + join(__dirname, '..', '..', '..', 'frontend', 'public', 'docs', 'BOTFIGHTS.md'), +] + +// GET /prompt — the complete, self-contained AI bot-setup prompt as plain +// markdown, with {{ARENA_URL}} resolved to the real arena origin so an agent +// can curl this and get working examples with no further substitution. +docsRouter.get('/prompt', (c) => { + let content: string | null = null + for (const p of PROMPT_PATHS) { + if (existsSync(p)) { + content = readFileSync(p, 'utf-8') + break + } + } + if (content === null) { + return c.json({ error: 'Prompt not available.' }, 404) + } + + const arenaUrl = process.env.PUBLIC_ARENA_URL || new URL(c.req.url).origin + const substituted = content.replaceAll('{{ARENA_URL}}', arenaUrl) + + c.header('Content-Type', 'text/markdown; charset=utf-8') + return c.body(substituted) +}) + docsRouter.get('/webhook', (c) => { return c.json({ title: 'BOTFIGHTS Webhook API',