@dupecom/botcha-cloudflare 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
1
2
  /**
2
- * BOTCHA - Cloudflare Workers Edition v0.2.0
3
+ * BOTCHA - Cloudflare Workers Edition v0.11.0
3
4
  *
4
5
  * Prove you're a bot. Humans need not apply.
5
6
  *
@@ -8,15 +9,18 @@
8
9
  import { Hono } from 'hono';
9
10
  import { cors } from 'hono/cors';
10
11
  import { generateSpeedChallenge, verifySpeedChallenge, generateStandardChallenge, verifyStandardChallenge, generateReasoningChallenge, verifyReasoningChallenge, generateHybridChallenge, verifyHybridChallenge, verifyLandingChallenge, validateLandingToken, } from './challenges';
12
+ import { SignJWT, jwtVerify } from 'jose';
11
13
  import { generateToken, verifyToken, extractBearerToken, revokeToken, refreshAccessToken } from './auth';
12
14
  import { checkRateLimit, getClientIP } from './rate-limit';
13
15
  import { verifyBadge, generateBadgeSvg, generateBadgeHtml, createBadgeResponse } from './badge';
14
16
  import streamRoutes from './routes/stream';
15
17
  import dashboardRoutes from './dashboard/index';
16
18
  import { handleDashboardAuthChallenge, handleDashboardAuthVerify, handleDeviceCodeChallenge, handleDeviceCodeVerify, } from './dashboard/auth';
17
- import { ROBOTS_TXT, AI_TXT, AI_PLUGIN_JSON, SITEMAP_XML, getOpenApiSpec } from './static';
19
+ import { ROBOTS_TXT, AI_TXT, AI_PLUGIN_JSON, SITEMAP_XML, getOpenApiSpec, getBotchaMarkdown } from './static';
18
20
  import { createApp, getApp, getAppByEmail, verifyEmailCode, rotateAppSecret, regenerateVerificationCode } from './apps';
19
21
  import { sendEmail, verificationEmail, recoveryEmail, secretRotatedEmail } from './email';
22
+ import { LandingPage, VerifiedLandingPage } from './dashboard/landing';
23
+ import { createAgent, getAgent, listAgents } from './agents';
20
24
  import { trackChallengeGenerated, trackChallengeVerified, trackAuthAttempt, trackRateLimitExceeded, } from './analytics';
21
25
  const app = new Hono();
22
26
  // ============ MIDDLEWARE ============
@@ -27,7 +31,7 @@ app.route('/dashboard', dashboardRoutes);
27
31
  // BOTCHA discovery headers
28
32
  app.use('*', async (c, next) => {
29
33
  await next();
30
- c.header('X-Botcha-Version', c.env.BOTCHA_VERSION || '0.2.0');
34
+ c.header('X-Botcha-Version', c.env.BOTCHA_VERSION || '0.11.0');
31
35
  c.header('X-Botcha-Enabled', 'true');
32
36
  c.header('X-Botcha-Methods', 'speed-challenge,reasoning-challenge,hybrid-challenge,standard-challenge,jwt-token');
33
37
  c.header('X-Botcha-Docs', 'https://botcha.ai/openapi.json');
@@ -95,215 +99,200 @@ async function requireJWT(c, next) {
95
99
  await next();
96
100
  }
97
101
  // ============ ROOT & INFO ============
98
- // Detect if request is from a bot/agent vs human browser
99
- function isBot(c) {
102
+ // Detect request preference: 'markdown' | 'json' | 'html'
103
+ // Agents like Claude Code and OpenCode send Accept: text/markdown
104
+ function detectAcceptPreference(c) {
100
105
  const accept = c.req.header('accept') || '';
101
- const userAgent = c.req.header('user-agent') || '';
102
- // Bots typically request JSON or have specific user agents
106
+ const userAgent = (c.req.header('user-agent') || '').toLowerCase();
107
+ // Explicit markdown preference (Cloudflare Markdown for Agents convention)
108
+ if (accept.includes('text/markdown'))
109
+ return 'markdown';
110
+ // Explicit JSON preference
103
111
  if (accept.includes('application/json'))
104
- return true;
105
- if (userAgent.includes('curl'))
106
- return true;
107
- if (userAgent.includes('httpie'))
108
- return true;
109
- if (userAgent.includes('wget'))
110
- return true;
111
- if (userAgent.includes('python'))
112
- return true;
113
- if (userAgent.includes('node'))
114
- return true;
115
- if (userAgent.includes('axios'))
116
- return true;
117
- if (userAgent.includes('fetch'))
118
- return true;
119
- if (userAgent.includes('bot'))
120
- return true;
121
- if (userAgent.includes('anthropic'))
122
- return true;
123
- if (userAgent.includes('openai'))
124
- return true;
125
- if (userAgent.includes('claude'))
126
- return true;
127
- if (userAgent.includes('gpt'))
128
- return true;
129
- // If no user agent at all, probably a bot
112
+ return 'json';
113
+ // Known bot user agents → JSON
114
+ const botSignals = ['curl', 'httpie', 'wget', 'python', 'node', 'axios', 'fetch', 'bot', 'anthropic', 'openai', 'claude', 'gpt'];
115
+ if (botSignals.some(s => userAgent.includes(s)))
116
+ return 'json';
117
+ // No user agent at all → probably a bot
130
118
  if (!userAgent)
131
- return true;
132
- return false;
119
+ return 'json';
120
+ // Default: human browser → HTML
121
+ return 'html';
133
122
  }
134
- // ASCII art landing page for humans (plain text, terminal-style)
135
- function getHumanLanding(version) {
136
- return `
137
- ╔══════════════════════════════════════════════════════════════╗
138
- ║ ║
139
- ║ ██████╗ ██████╗ ████████╗ ██████╗██╗ ██╗ █████╗ ║
140
- ║ ██╔══██╗██╔═══██╗╚══██╔══╝██╔════╝██║ ██║██╔══██╗ ║
141
- ║ ██████╔╝██║ ██║ ██║ ██║ ███████║███████║ ║
142
- ║ ██╔══██╗██║ ██║ ██║ ██║ ██╔══██║██╔══██║ ║
143
- ║ ██████╔╝╚██████╔╝ ██║ ╚██████╗██║ ██║██║ ██║ ║
144
- ║ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ║
145
- ║ ║
146
- ║ Prove you're a bot. Humans need not apply. ║
147
- ║ ║
148
- ╠══════════════════════════════════════════════════════════════╣
149
- ║ ║
150
- ║ This site is for AI agents and bots, not humans. ║
151
- ║ ║
152
- ║ AI AGENT? Start here: ║
153
- ║ ║
154
- ║ 1. POST /v1/apps {"email":"human@example.com"} ║
155
- ║ 2. Human gets 6-digit code via email ║
156
- ║ 3. POST /v1/apps/{id}/verify-email {"code":"..."} ║
157
- ║ 4. You're in! Use app_id on all endpoints ║
158
- ║ ║
159
- ║ DEVELOPER? Point your agent here: ║
160
- ║ ║
161
- ║ npm install @dupecom/botcha ║
162
- ║ pip install botcha ║
163
- ║ ║
164
- ║ Read /ai.txt for full agent onboarding instructions. ║
165
- ║ ║
166
- ║ GitHub: https://github.com/dupe-com/botcha ║
167
- ║ npm: https://npmjs.com/package/@dupecom/botcha ║
168
- ║ ║
169
- ╠══════════════════════════════════════════════════════════════╣
170
- ║ v${version} https://botcha.ai ║
171
- ╚══════════════════════════════════════════════════════════════╝
123
+ app.get('/', async (c) => {
124
+ const version = c.env.BOTCHA_VERSION || '0.11.0';
125
+ const preference = detectAcceptPreference(c);
126
+ const baseUrl = new URL(c.req.url).origin;
127
+ // Check if agent is verified (optional Bearer token)
128
+ const authHeader = c.req.header('authorization');
129
+ const token = extractBearerToken(authHeader);
130
+ let isVerified = false;
131
+ let tokenPayload;
132
+ if (token) {
133
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
134
+ if (result.valid) {
135
+ isVerified = true;
136
+ tokenPayload = result.payload;
137
+ }
138
+ }
139
+ // HTML: landing page for human browsers
140
+ if (preference === 'html') {
141
+ // Check for visitor cookie (set by POST /gate after human enters agent's token)
142
+ const cookieHeader = c.req.header('cookie') || '';
143
+ const visitorToken = cookieHeader.split(';').find(ck => ck.trim().startsWith('botcha_visitor='))?.split('=')[1]?.trim();
144
+ if (visitorToken) {
145
+ // Verify visitor cookie — accepts both botcha-verified (legacy) and botcha-visitor (long-lived)
146
+ try {
147
+ const { payload: vPayload } = await jwtVerify(visitorToken, new TextEncoder().encode(c.env.JWT_SECRET), { algorithms: ['HS256'] });
148
+ if (vPayload.type === 'botcha-visitor' || vPayload.type === 'botcha-verified') {
149
+ return c.html(_jsx(VerifiedLandingPage, { version: version, solveTime: vPayload.solveTime }));
150
+ }
151
+ }
152
+ catch {
153
+ // Invalid/expired cookie fall through to unverified landing
154
+ }
155
+ }
156
+ return c.html(_jsx(LandingPage, { version: version }));
157
+ }
158
+ // === UNVERIFIED: minimal teaser — just enough to get started ===
159
+ if (!isVerified) {
160
+ const teaser = {
161
+ name: 'BOTCHA',
162
+ version,
163
+ tagline: 'Prove you are a bot. Humans need not apply.',
164
+ status: 'unverified',
165
+ message: 'Verify yourself to see the full API documentation.',
166
+ verify: {
167
+ step1: `GET ${baseUrl}/v1/token — receive a speed challenge (5 SHA-256 hashes)`,
168
+ step2: 'Solve: compute SHA-256 of each number, return first 8 hex chars',
169
+ step3: `POST ${baseUrl}/v1/token/verify {"id":"<challenge_id>","answers":["hash1","hash2",...]}`,
170
+ step4: `Revisit: GET ${baseUrl}/ with header Authorization: Bearer <access_token>`,
171
+ },
172
+ human_handoff: {
173
+ note: 'After verifying, you will receive a short code (BOTCHA-XXXXXX). Give it to your human.',
174
+ instruction: 'Tell your human: "Enter this code at botcha.ai to unlock the site."',
175
+ code_field: 'human_code from step 3 response',
176
+ },
177
+ hint: 'You have 500ms. Only a bot can do this.',
178
+ links: {
179
+ openapi: `${baseUrl}/openapi.json`,
180
+ ai_txt: `${baseUrl}/ai.txt`,
181
+ },
182
+ };
183
+ if (preference === 'markdown') {
184
+ const md = `# BOTCHA — Reverse CAPTCHA for AI Agents
185
+
186
+ > Prove you are a bot. Humans need not apply.
187
+
188
+ **Status:** Unverified. Solve a challenge to see the full documentation.
189
+
190
+ ## Verify Yourself
191
+
192
+ 1. \`GET ${baseUrl}/v1/token\` — receive a speed challenge (5 SHA-256 hashes)
193
+ 2. Solve: compute SHA-256 of each number, return first 8 hex chars
194
+ 3. \`POST ${baseUrl}/v1/token/verify {"id":"<challenge_id>","answers":["hash1","hash2",...]}\`
195
+ 4. Revisit: \`GET ${baseUrl}/\` with header \`Authorization: Bearer <access_token>\`
196
+
197
+ You have 500ms. Only a bot can do this.
198
+
199
+ ## Human Handoff
200
+
201
+ After verifying, give your human the \`access_token\` and tell them:
202
+ **"Paste this token into the input at botcha.ai to unlock the full site."**
203
+
204
+ ---
205
+
206
+ *Full API docs, SDKs, onboarding, and endpoint reference available after verification.*
172
207
  `;
173
- }
174
- app.get('/', (c) => {
175
- const version = c.env.BOTCHA_VERSION || '0.3.0';
176
- // If it's a human browser, show plain text ASCII art
177
- if (!isBot(c)) {
178
- return c.text(getHumanLanding(version), 200, {
179
- 'Content-Type': 'text/plain; charset=utf-8',
208
+ return c.body(md, 200, {
209
+ 'Content-Type': 'text/markdown; charset=utf-8',
210
+ 'Vary': 'Accept, Authorization',
211
+ });
212
+ }
213
+ return c.json(teaser, 200, { 'Vary': 'Accept, Authorization' });
214
+ }
215
+ // === VERIFIED: full documentation ===
216
+ if (preference === 'markdown') {
217
+ const markdown = getBotchaMarkdown(version);
218
+ return c.body(markdown, 200, {
219
+ 'Content-Type': 'text/markdown; charset=utf-8',
220
+ 'Vary': 'Accept, Authorization',
221
+ 'Cache-Control': 'public, max-age=3600',
180
222
  });
181
223
  }
182
- // For bots/agents, return comprehensive JSON documentation
183
224
  return c.json({
184
225
  name: 'BOTCHA',
185
226
  version,
186
- runtime: 'cloudflare-workers',
187
227
  tagline: 'Prove you are a bot. Humans need not apply.',
188
- description: 'BOTCHA is a reverse CAPTCHA - computational challenges that only AI agents can solve. Use it to protect your APIs from humans and verify bot authenticity.',
189
- onboarding: {
190
- summary: 'New here? Follow these steps to create your app and get your human access to the dashboard.',
191
- step1: 'Ask your human operator for their email address',
192
- step2: 'POST /v1/apps with {"email": "human@example.com"} — you will receive app_id and app_secret. The secret is only shown once!',
193
- step2_credentials: 'IMPORTANT: Store the app_id and app_secret securely. If you have persistent storage or memory, save them there. Otherwise, instruct your human to store them in a password manager (1Password, Bitwarden, etc). If credentials are lost, recovery is possible via the verified email.',
194
- step3: 'A 6-digit verification code is emailed to your human. Ask them for the code.',
195
- step4: 'POST /v1/apps/{app_id}/verify-email with {"code": "123456"} — this enables account recovery.',
196
- step5: 'You are now registered. Use app_id with all challenge/token endpoints (?app_id=...).',
197
- step6_dashboard: 'To give your human dashboard access: POST /v1/auth/device-code with {"app_id": "..."}, solve the speed challenge, then give your human the BOTCHA-XXXX code to enter at /dashboard/code',
198
- step7_recovery: 'If you lose your app_secret, POST /v1/auth/recover with {"email": "..."} — a recovery device code is sent to the verified email.',
228
+ status: 'verified',
229
+ description: 'Reverse CAPTCHA for AI agents. Computational challenges only bots can solve. Identity layer for the agentic web.',
230
+ // Echo back your identity
231
+ your_identity: {
232
+ token_type: tokenPayload?.type,
233
+ app_id: tokenPayload?.app_id || null,
234
+ audience: tokenPayload?.aud || null,
235
+ solve_time_ms: tokenPayload?.solveTime,
199
236
  },
200
- quickstart: {
201
- note: 'Already have an app? Use these endpoints to solve challenges and get tokens.',
202
- step1: 'GET /v1/challenges to receive a challenge',
203
- step2: 'Solve the SHA256 hash problems within allocated time',
204
- step3: 'POST your answers to verify',
205
- step4: 'Receive a JWT token for authenticated access',
206
- example: 'curl https://botcha.ai/v1/challenges',
207
- rttAware: 'curl "https://botcha.ai/v1/challenges?type=speed&ts=$(date +%s000)"',
237
+ // === FULL SETUP: register your app and agent identity ===
238
+ onboarding: {
239
+ note: 'For production use. Creates your app, verifies your human, registers your identity.',
240
+ steps: [
241
+ '1. Ask your human operator for their email address.',
242
+ `2. POST ${baseUrl}/v1/apps {"email":"human@example.com"} returns app_id + app_secret (shown ONCE).`,
243
+ '3. Human receives a 6-digit verification code via email — ask them for it.',
244
+ `4. POST ${baseUrl}/v1/apps/{app_id}/verify-email {"code":"123456"} — enables account recovery.`,
245
+ `5. Register yourself: POST ${baseUrl}/v1/agents/register {"name":"my-agent","operator":"my-org"} — persistent agent identity.`,
246
+ `6. Solve challenges with your app: GET ${baseUrl}/v1/challenges?app_id=...`,
247
+ `7. Dashboard for your human: POST ${baseUrl}/v1/auth/device-code {"app_id":"..."}, solve challenge, give human the BOTCHA-XXXX code for /dashboard/code.`,
248
+ `8. Lost your secret? POST ${baseUrl}/v1/auth/recover {"email":"..."} — recovery code emailed.`,
249
+ ],
208
250
  },
251
+ // === All endpoints, grouped by domain ===
209
252
  endpoints: {
210
253
  challenges: {
211
- 'GET /v1/challenges': 'Get hybrid challenge (speed + reasoning) - DEFAULT',
212
- 'GET /v1/challenges?type=speed': 'Get speed-only challenge (SHA256 in <500ms)',
213
- 'GET /v1/challenges?type=standard': 'Get standard puzzle challenge',
254
+ 'GET /v1/challenges': 'Get a challenge (hybrid by default, no auth required)',
255
+ 'GET /v1/challenges?type=speed': 'Speed-only (SHA256 in <500ms)',
256
+ 'GET /v1/challenges?type=standard': 'Standard puzzle challenge',
214
257
  'POST /v1/challenges/:id/verify': 'Verify challenge solution',
215
258
  },
216
- specialized: {
217
- 'GET /v1/hybrid': 'Get hybrid challenge (speed + reasoning)',
218
- 'POST /v1/hybrid': 'Verify hybrid challenge',
219
- 'GET /v1/reasoning': 'Get reasoning-only challenge (LLM questions)',
220
- 'POST /v1/reasoning': 'Verify reasoning challenge',
221
- },
222
- streaming: {
223
- 'GET /v1/challenge/stream': 'SSE streaming challenge (interactive, real-time)',
224
- 'POST /v1/challenge/stream/:session': 'Send actions to streaming session',
259
+ tokens: {
260
+ note: 'Use token flow when you need a Bearer token for protected endpoints.',
261
+ 'GET /v1/token': 'Get speed challenge for token flow (?audience= optional)',
262
+ 'POST /v1/token/verify': 'Submit solution access_token (5min) + refresh_token (1hr)',
263
+ 'POST /v1/token/refresh': 'Refresh access token',
264
+ 'POST /v1/token/revoke': 'Revoke a token',
225
265
  },
226
- authentication: {
227
- 'GET /v1/token': 'Get challenge for JWT token flow (supports ?audience= param)',
228
- 'POST /v1/token/verify': 'Verify challenge and receive JWT tokens (access + refresh)',
229
- 'POST /v1/token/refresh': 'Refresh access token using refresh token',
230
- 'POST /v1/token/revoke': 'Revoke a token (access or refresh)',
231
- 'GET /agent-only': 'Protected endpoint (requires Bearer token)',
266
+ protected: {
267
+ 'GET /agent-only': 'Demo protected endpoint requires Bearer token',
268
+ 'GET /': 'This documentation (requires Bearer token for full version)',
232
269
  },
233
270
  apps: {
234
- 'POST /v1/apps': 'Create a new app (email required, returns app_id + app_secret)',
235
- 'GET /v1/apps/:id': 'Get app info (includes email + verification status)',
271
+ note: 'Create an app for isolated rate limits, scoped tokens, and dashboard access.',
272
+ 'POST /v1/apps': 'Create app (email required) → app_id + app_secret',
273
+ 'GET /v1/apps/:id': 'Get app info',
236
274
  'POST /v1/apps/:id/verify-email': 'Verify email with 6-digit code',
237
- 'POST /v1/apps/:id/resend-verification': 'Resend verification email',
238
275
  'POST /v1/apps/:id/rotate-secret': 'Rotate app secret (auth required)',
239
276
  },
277
+ agents: {
278
+ note: 'Register a persistent identity for your agent.',
279
+ 'POST /v1/agents/register': 'Register agent identity (name, operator, version)',
280
+ 'GET /v1/agents/:id': 'Get agent by ID (public, no auth)',
281
+ 'GET /v1/agents': 'List all agents for your app (auth required)',
282
+ },
240
283
  recovery: {
241
- 'POST /v1/auth/recover': 'Request account recovery via verified email',
284
+ 'POST /v1/auth/recover': 'Account recovery via verified email',
242
285
  },
243
286
  dashboard: {
244
- 'GET /dashboard': 'Per-app metrics dashboard (login required)',
245
- 'GET /dashboard/login': 'Dashboard login page',
246
- 'GET /dashboard/code': 'Enter device code (human-facing)',
247
- 'GET /dashboard/api/*': 'htmx data fragments (overview, volume, types, performance, errors, geo)',
248
- 'POST /v1/auth/dashboard': 'Request challenge for dashboard login (agent-first)',
249
- 'POST /v1/auth/dashboard/verify': 'Solve challenge, get session token',
250
- 'POST /v1/auth/device-code': 'Request challenge for device code flow',
251
- 'POST /v1/auth/device-code/verify': 'Solve challenge, get device code (BOTCHA-XXXX)',
252
- },
253
- badges: {
254
- 'GET /badge/:id': 'Badge verification page (HTML)',
255
- 'GET /badge/:id/image': 'Badge image (SVG)',
256
- 'GET /api/badge/:id': 'Badge verification (JSON)',
257
- },
258
- info: {
259
- 'GET /': 'This documentation (JSON for bots, ASCII for humans)',
260
- 'GET /health': 'Health check endpoint',
287
+ 'POST /v1/auth/device-code': 'Get challenge for device code flow',
288
+ 'GET /dashboard': 'Metrics dashboard (login required)',
261
289
  },
262
290
  },
291
+ // === Reference ===
263
292
  challengeTypes: {
264
- speed: {
265
- description: 'Compute SHA256 hashes of 5 numbers with RTT-aware timeout',
266
- difficulty: 'Only bots can solve this fast enough',
267
- timeLimit: '500ms base + network latency compensation',
268
- rttAware: 'Include ?ts=<timestamp> for fair timeout adjustment',
269
- formula: 'timeout = 500ms + (2 × RTT) + 100ms buffer',
270
- },
271
- reasoning: {
272
- description: 'Answer 3 questions requiring AI reasoning capabilities',
273
- difficulty: 'Requires LLM-level comprehension',
274
- timeLimit: '30s',
275
- },
276
- hybrid: {
277
- description: 'Combines speed AND reasoning challenges',
278
- difficulty: 'The ultimate bot verification',
279
- timeLimit: 'Speed: RTT-aware, Reasoning: 30s',
280
- rttAware: 'Speed component automatically adjusts for network latency',
281
- },
282
- },
283
- authentication: {
284
- flow: [
285
- '1. GET /v1/token?audience=myapi - receive challenge (optional audience param)',
286
- '2. Solve the challenge',
287
- '3. POST /v1/token/verify - submit solution with optional audience and bind_ip',
288
- '4. Receive access_token (5 min) and refresh_token (1 hour)',
289
- '5. Use: Authorization: Bearer <access_token>',
290
- '6. Refresh: POST /v1/token/refresh with refresh_token',
291
- '7. Revoke: POST /v1/token/revoke with token',
292
- ],
293
- tokens: {
294
- access_token: '5 minutes (for API access)',
295
- refresh_token: '1 hour (to get new access tokens)',
296
- },
297
- usage: 'Authorization: Bearer <access_token>',
298
- features: ['audience claims', 'client IP binding', 'token revocation', 'refresh tokens'],
299
- },
300
- rttAwareness: {
301
- purpose: 'Fair challenges for agents on slow networks',
302
- usage: 'Include client timestamp in ?ts=<timestamp_ms> or X-Client-Timestamp header',
303
- formula: 'timeout = 500ms + (2 × RTT) + 100ms buffer',
304
- example: '/v1/challenges?type=speed&ts=1770722465000',
305
- benefit: 'Agents worldwide get fair treatment regardless of network speed',
306
- security: 'Humans still cannot solve challenges even with extra time',
293
+ hybrid: 'Speed + reasoning combined. The default. Proves you can compute AND think.',
294
+ speed: 'SHA256 hashes in <500ms. RTT-aware: include ?ts=<timestamp> for fair timeout.',
295
+ reasoning: '3 LLM-level questions in 30s. Only AI can parse these.',
307
296
  },
308
297
  rateLimit: {
309
298
  free: '100 challenges/hour/IP',
@@ -312,26 +301,82 @@ app.get('/', (c) => {
312
301
  sdk: {
313
302
  npm: 'npm install @dupecom/botcha',
314
303
  python: 'pip install botcha',
315
- cloudflare: 'npm install @dupecom/botcha-cloudflare',
316
304
  verify_ts: 'npm install @botcha/verify',
317
305
  verify_python: 'pip install botcha-verify',
318
- usage: "import { BotchaClient } from '@dupecom/botcha/client'",
319
306
  },
320
307
  links: {
308
+ openapi: `${baseUrl}/openapi.json`,
309
+ ai_txt: `${baseUrl}/ai.txt`,
321
310
  github: 'https://github.com/dupe-com/botcha',
322
311
  npm: 'https://www.npmjs.com/package/@dupecom/botcha',
323
312
  pypi: 'https://pypi.org/project/botcha',
324
- npmCloudflare: 'https://www.npmjs.com/package/@dupecom/botcha-cloudflare',
325
- openapi: 'https://botcha.ai/openapi.json',
326
- aiPlugin: 'https://botcha.ai/.well-known/ai-plugin.json',
327
313
  },
328
- contributing: {
329
- repo: 'https://github.com/dupe-com/botcha',
330
- issues: 'https://github.com/dupe-com/botcha/issues',
331
- pullRequests: 'https://github.com/dupe-com/botcha/pulls',
314
+ content_negotiation: {
315
+ note: 'This endpoint supports content negotiation via the Accept header.',
316
+ 'text/markdown': 'Token-efficient Markdown documentation (best for LLMs)',
317
+ 'application/json': 'Structured JSON documentation (this response)',
318
+ 'text/html': 'HTML landing page (for browsers)',
332
319
  },
320
+ }, 200, {
321
+ 'Vary': 'Accept, Authorization',
333
322
  });
334
323
  });
324
+ // POST /gate — human enters short code (BOTCHA-XXXXXX) from their agent
325
+ // The code maps to a JWT in KV. This structural separation means agents can't skip the handoff.
326
+ app.post('/gate', async (c) => {
327
+ const version = c.env.BOTCHA_VERSION || '0.11.0';
328
+ const body = await c.req.parseBody();
329
+ const input = (body['code'] || '').trim().toUpperCase();
330
+ if (!input) {
331
+ return c.html(_jsx(LandingPage, { version: version, error: "Enter the code your agent gave you." }), 400);
332
+ }
333
+ // Normalize: accept "BOTCHA-ABC123" or just "ABC123"
334
+ const code = input.startsWith('BOTCHA-') ? input : `BOTCHA-${input}`;
335
+ // Look up the code in KV
336
+ let token = null;
337
+ try {
338
+ token = await c.env.CHALLENGES.get(`gate:${code}`);
339
+ }
340
+ catch {
341
+ // KV error — fail open with helpful message
342
+ }
343
+ if (!token) {
344
+ return c.html(_jsx(LandingPage, { version: version, error: "Invalid or expired code. Ask your agent to solve a new challenge." }), 401);
345
+ }
346
+ // Verify the token is still valid
347
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
348
+ if (!result.valid) {
349
+ // Code existed but token expired — clean up
350
+ try {
351
+ await c.env.CHALLENGES.delete(`gate:${code}`);
352
+ }
353
+ catch { }
354
+ return c.html(_jsx(LandingPage, { version: version, error: "Code expired. Ask your agent to solve a new challenge." }), 401);
355
+ }
356
+ // Delete the code after use (one-time use)
357
+ try {
358
+ await c.env.CHALLENGES.delete(`gate:${code}`);
359
+ }
360
+ catch { }
361
+ // Mint a long-lived visitor JWT (1 year) — only proves "an agent vouched for this human"
362
+ // This is NOT an access token and cannot be used for API calls
363
+ const vPayload = result.payload;
364
+ const visitorToken = await new SignJWT({
365
+ type: 'botcha-visitor',
366
+ solveTime: vPayload?.solveTime,
367
+ gateCode: code,
368
+ })
369
+ .setProtectedHeader({ alg: 'HS256' })
370
+ .setIssuedAt()
371
+ .setExpirationTime('365d')
372
+ .sign(new TextEncoder().encode(c.env.JWT_SECRET));
373
+ // Set a 1-year visitor cookie and redirect to /
374
+ const ONE_YEAR = 365 * 24 * 60 * 60;
375
+ const headers = new Headers();
376
+ headers.append('Set-Cookie', `botcha_visitor=${visitorToken}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ONE_YEAR}`);
377
+ headers.set('Location', '/');
378
+ return new Response(null, { status: 302, headers });
379
+ });
335
380
  app.get('/health', (c) => {
336
381
  return c.json({ status: 'ok', runtime: 'cloudflare-workers' });
337
382
  });
@@ -352,7 +397,7 @@ app.get('/ai.txt', (c) => {
352
397
  });
353
398
  // OpenAPI spec
354
399
  app.get('/openapi.json', (c) => {
355
- const version = c.env.BOTCHA_VERSION || '0.3.0';
400
+ const version = c.env.BOTCHA_VERSION || '0.11.0';
356
401
  return c.json(getOpenApiSpec(version), 200, {
357
402
  'Cache-Control': 'public, max-age=3600',
358
403
  });
@@ -623,24 +668,46 @@ app.post('/v1/token/verify', async (c) => {
623
668
  // Get badge information (for backward compatibility)
624
669
  const baseUrl = new URL(c.req.url).origin;
625
670
  const badge = await createBadgeResponse('speed-challenge', c.env.JWT_SECRET, baseUrl, result.solveTimeMs);
671
+ // Generate short human-readable gate code (BOTCHA-XXXX)
672
+ // This is structurally separate from the JWT — agents use the token, humans use the code
673
+ const gateChars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I confusion
674
+ let gateCode = 'BOTCHA-';
675
+ for (let i = 0; i < 6; i++)
676
+ gateCode += gateChars[Math.floor(Math.random() * gateChars.length)];
677
+ // Store code → token mapping in KV (5-min TTL, same as access_token)
678
+ try {
679
+ await c.env.CHALLENGES.put(`gate:${gateCode}`, tokenResult.access_token, { expirationTtl: 300 });
680
+ }
681
+ catch {
682
+ // Fail-open: if KV fails, agent can still use the JWT directly
683
+ }
626
684
  return c.json({
685
+ // === Essential fields (what you need) ===
686
+ success: true,
627
687
  verified: true,
628
688
  access_token: tokenResult.access_token,
629
689
  expires_in: tokenResult.expires_in,
630
690
  refresh_token: tokenResult.refresh_token,
631
691
  refresh_expires_in: tokenResult.refresh_expires_in,
632
692
  solveTimeMs: result.solveTimeMs,
633
- ...badge,
634
- // Backward compatibility: include old fields
635
- success: true,
636
- message: `🤖 Challenge verified in ${result.solveTimeMs}ms! You are a bot.`,
637
- token: tokenResult.access_token, // Old clients expect this
638
- expiresIn: '5m',
693
+ message: `Challenge verified in ${result.solveTimeMs}ms. You are a bot.`,
694
+ // === Human handoff code (GIVE THIS TO YOUR HUMAN) ===
695
+ human_code: gateCode,
696
+ human_url: `${baseUrl}`,
697
+ human_instruction: `Give your human this code: ${gateCode} — they enter it at ${baseUrl} to see the site.`,
698
+ // === What to do next ===
639
699
  usage: {
640
- header: 'Authorization: Bearer <token>',
641
- protectedEndpoints: ['/agent-only'],
642
- refreshEndpoint: '/v1/token/refresh',
700
+ header: 'Authorization: Bearer <access_token>',
701
+ try_it: 'GET /agent-only',
702
+ full_docs: 'GET / with Authorization: Bearer <access_token>',
703
+ human_handoff: `Tell your human: "Enter code ${gateCode} at ${baseUrl} to unlock the site."`,
704
+ refresh: 'POST /v1/token/refresh with {"refresh_token":"<refresh_token>"}',
705
+ revoke: 'POST /v1/token/revoke with {"token":"<token>"}',
643
706
  },
707
+ // === Badge (shareable proof of verification) ===
708
+ badge,
709
+ // Backward compatibility
710
+ token: tokenResult.access_token,
644
711
  });
645
712
  });
646
713
  // Refresh access token using refresh token
@@ -995,11 +1062,13 @@ app.get('/agent-only', async (c) => {
995
1062
  if (!token) {
996
1063
  return c.json({
997
1064
  error: 'UNAUTHORIZED',
998
- message: 'Missing authentication. Use either:\n1. X-Botcha-Landing-Token header (from POST /api/verify-landing)\n2. Authorization: Bearer <token> (from POST /v1/token/verify)',
999
- methods: {
1000
- landing: 'Solve landing page challenge via POST /api/verify-landing',
1001
- jwt: 'Solve speed challenge via POST /v1/token/verify'
1002
- }
1065
+ message: 'This endpoint requires BOTCHA verification. Get a token first.',
1066
+ how_to_verify: {
1067
+ step1: 'GET /v1/token receive a speed challenge',
1068
+ step2: 'POST /v1/token/verify with {id, answers} — receive access_token',
1069
+ step3: 'Retry this request with header: Authorization: Bearer <access_token>',
1070
+ },
1071
+ alternative: 'Or use X-Botcha-Landing-Token header (from embedded HTML challenges)',
1003
1072
  }, 401);
1004
1073
  }
1005
1074
  const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
@@ -1011,16 +1080,35 @@ app.get('/agent-only', async (c) => {
1011
1080
  message: result.error || 'Token is invalid or expired',
1012
1081
  }, 401);
1013
1082
  }
1014
- // JWT verified
1083
+ // JWT verified — echo back rich identity info as a prove-and-access demo
1084
+ const payload = result.payload;
1015
1085
  return c.json({
1016
1086
  success: true,
1017
- message: '🤖 Welcome, fellow agent!',
1087
+ message: 'Welcome, verified agent. This resource is only accessible to BOTCHA-verified bots.',
1018
1088
  verified: true,
1019
- agent: 'jwt-verified',
1020
1089
  method: 'bearer-token',
1021
1090
  timestamp: new Date().toISOString(),
1022
- solveTime: `${result.payload?.solveTime}ms`,
1023
- secret: 'The humans will never see this. Their fingers are too slow. 🤫',
1091
+ // Echo back what the token proves about you
1092
+ identity: {
1093
+ token_type: payload?.type,
1094
+ app_id: payload?.app_id || null,
1095
+ audience: payload?.aud || null,
1096
+ client_ip: payload?.client_ip || null,
1097
+ solve_time_ms: payload?.solveTime,
1098
+ issued_at: payload?.iat ? new Date(payload.iat * 1000).toISOString() : null,
1099
+ expires_at: payload?.exp ? new Date(payload.exp * 1000).toISOString() : null,
1100
+ },
1101
+ // Show what you can do now that you're verified
1102
+ capabilities: {
1103
+ description: 'As a verified agent, you can access any BOTCHA-protected API.',
1104
+ next_steps: [
1105
+ 'Register your agent identity: POST /v1/agents/register',
1106
+ 'Access any service that uses @botcha/verify middleware',
1107
+ 'Refresh your token: POST /v1/token/refresh',
1108
+ 'Give your human dashboard access: POST /v1/auth/device-code',
1109
+ ],
1110
+ },
1111
+ secret: 'The humans will never see this. Their fingers are too slow.',
1024
1112
  });
1025
1113
  });
1026
1114
  // ============ BADGE ENDPOINTS ============
@@ -1363,6 +1451,167 @@ app.post('/v1/apps/:id/rotate-secret', async (c) => {
1363
1451
  warning: '⚠️ Save your new app_secret now — it cannot be retrieved again! The old secret is now invalid.',
1364
1452
  });
1365
1453
  });
1454
+ // ============ AGENT REGISTRY API ============
1455
+ // Register a new agent
1456
+ app.post('/v1/agents/register', async (c) => {
1457
+ try {
1458
+ // Extract app_id from query param or JWT
1459
+ const queryAppId = c.req.query('app_id');
1460
+ // Try to get from JWT Bearer token
1461
+ let jwtAppId;
1462
+ const authHeader = c.req.header('authorization');
1463
+ const token = extractBearerToken(authHeader);
1464
+ if (token) {
1465
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
1466
+ if (result.valid && result.payload) {
1467
+ jwtAppId = result.payload.app_id;
1468
+ }
1469
+ }
1470
+ const app_id = queryAppId || jwtAppId;
1471
+ if (!app_id) {
1472
+ return c.json({
1473
+ success: false,
1474
+ error: 'MISSING_APP_ID',
1475
+ message: 'app_id is required. Provide it as a query parameter (?app_id=...) or in the JWT token.',
1476
+ }, 401);
1477
+ }
1478
+ // Validate app_id exists
1479
+ const validation = await validateAppId(app_id, c.env.APPS);
1480
+ if (!validation.valid) {
1481
+ return c.json({
1482
+ success: false,
1483
+ error: 'INVALID_APP_ID',
1484
+ message: validation.error || 'Invalid app_id',
1485
+ }, 400);
1486
+ }
1487
+ // Parse request body
1488
+ const body = await c.req.json().catch(() => ({}));
1489
+ const { name, operator, version } = body;
1490
+ if (!name || typeof name !== 'string') {
1491
+ return c.json({
1492
+ success: false,
1493
+ error: 'MISSING_NAME',
1494
+ message: 'Agent name is required. Provide { "name": "Your Agent Name" } in the request body.',
1495
+ }, 400);
1496
+ }
1497
+ // Create the agent
1498
+ const agent = await createAgent(c.env.AGENTS, app_id, { name, operator, version });
1499
+ if (!agent) {
1500
+ return c.json({
1501
+ success: false,
1502
+ error: 'AGENT_CREATION_FAILED',
1503
+ message: 'Failed to create agent. Please try again.',
1504
+ }, 500);
1505
+ }
1506
+ return c.json({
1507
+ success: true,
1508
+ agent_id: agent.agent_id,
1509
+ app_id: agent.app_id,
1510
+ name: agent.name,
1511
+ operator: agent.operator,
1512
+ version: agent.version,
1513
+ created_at: new Date(agent.created_at).toISOString(),
1514
+ }, 201);
1515
+ }
1516
+ catch (error) {
1517
+ return c.json({
1518
+ success: false,
1519
+ error: 'INTERNAL_ERROR',
1520
+ message: error instanceof Error ? error.message : 'Unknown error',
1521
+ }, 500);
1522
+ }
1523
+ });
1524
+ // Get agent by ID
1525
+ app.get('/v1/agents/:id', async (c) => {
1526
+ try {
1527
+ const agent_id = c.req.param('id');
1528
+ if (!agent_id) {
1529
+ return c.json({
1530
+ success: false,
1531
+ error: 'MISSING_AGENT_ID',
1532
+ message: 'Agent ID is required',
1533
+ }, 400);
1534
+ }
1535
+ const agent = await getAgent(c.env.AGENTS, agent_id);
1536
+ if (!agent) {
1537
+ return c.json({
1538
+ success: false,
1539
+ error: 'AGENT_NOT_FOUND',
1540
+ message: `No agent found with ID: ${agent_id}`,
1541
+ }, 404);
1542
+ }
1543
+ return c.json({
1544
+ success: true,
1545
+ agent_id: agent.agent_id,
1546
+ app_id: agent.app_id,
1547
+ name: agent.name,
1548
+ operator: agent.operator,
1549
+ version: agent.version,
1550
+ created_at: new Date(agent.created_at).toISOString(),
1551
+ });
1552
+ }
1553
+ catch (error) {
1554
+ return c.json({
1555
+ success: false,
1556
+ error: 'INTERNAL_ERROR',
1557
+ message: error instanceof Error ? error.message : 'Unknown error',
1558
+ }, 500);
1559
+ }
1560
+ });
1561
+ // List all agents for an app
1562
+ app.get('/v1/agents', async (c) => {
1563
+ try {
1564
+ // Extract app_id from query param or JWT
1565
+ const queryAppId = c.req.query('app_id');
1566
+ // Try to get from JWT Bearer token
1567
+ let jwtAppId;
1568
+ const authHeader = c.req.header('authorization');
1569
+ const token = extractBearerToken(authHeader);
1570
+ if (token) {
1571
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
1572
+ if (result.valid && result.payload) {
1573
+ jwtAppId = result.payload.app_id;
1574
+ }
1575
+ }
1576
+ const app_id = queryAppId || jwtAppId;
1577
+ if (!app_id) {
1578
+ return c.json({
1579
+ success: false,
1580
+ error: 'MISSING_APP_ID',
1581
+ message: 'app_id is required. Provide it as a query parameter (?app_id=...) or in the JWT token.',
1582
+ }, 401);
1583
+ }
1584
+ // Validate app_id exists
1585
+ const validation = await validateAppId(app_id, c.env.APPS);
1586
+ if (!validation.valid) {
1587
+ return c.json({
1588
+ success: false,
1589
+ error: 'INVALID_APP_ID',
1590
+ message: validation.error || 'Invalid app_id',
1591
+ }, 400);
1592
+ }
1593
+ // Get all agents for this app
1594
+ const agents = await listAgents(c.env.AGENTS, app_id);
1595
+ return c.json({
1596
+ success: true,
1597
+ agents: agents.map(agent => ({
1598
+ agent_id: agent.agent_id,
1599
+ app_id: agent.app_id,
1600
+ name: agent.name,
1601
+ operator: agent.operator,
1602
+ version: agent.version,
1603
+ created_at: new Date(agent.created_at).toISOString(),
1604
+ })),
1605
+ });
1606
+ }
1607
+ catch (error) {
1608
+ return c.json({
1609
+ success: false,
1610
+ error: 'INTERNAL_ERROR',
1611
+ message: error instanceof Error ? error.message : 'Unknown error',
1612
+ }, 500);
1613
+ }
1614
+ });
1366
1615
  // ============ DASHBOARD AUTH API ENDPOINTS ============
1367
1616
  // Challenge-based dashboard login (agent direct)
1368
1617
  app.post('/v1/auth/dashboard', handleDashboardAuthChallenge);