@dupecom/botcha-cloudflare 0.3.2 → 0.9.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
@@ -7,11 +7,14 @@
7
7
  */
8
8
  import { Hono } from 'hono';
9
9
  import { cors } from 'hono/cors';
10
- import { generateSpeedChallenge, verifySpeedChallenge, generateStandardChallenge, verifyStandardChallenge, generateReasoningChallenge, verifyReasoningChallenge, generateHybridChallenge, verifyHybridChallenge, verifyLandingChallenge, } from './challenges';
11
- import { generateToken, verifyToken, extractBearerToken } from './auth';
10
+ import { generateSpeedChallenge, verifySpeedChallenge, generateStandardChallenge, verifyStandardChallenge, generateReasoningChallenge, verifyReasoningChallenge, generateHybridChallenge, verifyHybridChallenge, verifyLandingChallenge, validateLandingToken, } from './challenges';
11
+ import { generateToken, verifyToken, extractBearerToken, revokeToken, refreshAccessToken } from './auth';
12
12
  import { checkRateLimit, getClientIP } from './rate-limit';
13
13
  import { verifyBadge, generateBadgeSvg, generateBadgeHtml, createBadgeResponse } from './badge';
14
14
  import streamRoutes from './routes/stream';
15
+ import { ROBOTS_TXT, AI_TXT, AI_PLUGIN_JSON, SITEMAP_XML, getOpenApiSpec } from './static';
16
+ import { createApp, getApp } from './apps';
17
+ import { trackChallengeGenerated, trackChallengeVerified, trackAuthAttempt, trackRateLimitExceeded, } from './analytics';
15
18
  const app = new Hono();
16
19
  // ============ MIDDLEWARE ============
17
20
  app.use('*', cors());
@@ -36,6 +39,8 @@ async function rateLimitMiddleware(c, next) {
36
39
  c.header('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
37
40
  if (!rateLimitResult.allowed) {
38
41
  c.header('Retry-After', rateLimitResult.retryAfter?.toString() || '3600');
42
+ // Track rate limit exceeded
43
+ await trackRateLimitExceeded(c.env.ANALYTICS, c.req.path, c.req.raw, clientIP);
39
44
  return c.json({
40
45
  error: 'RATE_LIMIT_EXCEEDED',
41
46
  message: 'You have exceeded the rate limit. Free tier: 100 challenges/hour/IP',
@@ -45,6 +50,25 @@ async function rateLimitMiddleware(c, next) {
45
50
  }
46
51
  await next();
47
52
  }
53
+ // Helper: Validate app_id against APPS KV (fail-open)
54
+ async function validateAppId(appId, appsKV) {
55
+ if (!appId) {
56
+ // No app_id provided - valid (not required)
57
+ return { valid: true };
58
+ }
59
+ try {
60
+ const app = await getApp(appsKV, appId);
61
+ if (!app) {
62
+ return { valid: false, error: `App not found: ${appId}` };
63
+ }
64
+ return { valid: true };
65
+ }
66
+ catch (error) {
67
+ // Fail-open: if KV is unavailable, log warning and proceed
68
+ console.warn(`Failed to validate app_id ${appId} (KV unavailable), proceeding:`, error);
69
+ return { valid: true };
70
+ }
71
+ }
48
72
  // JWT verification middleware
49
73
  async function requireJWT(c, next) {
50
74
  const authHeader = c.req.header('authorization');
@@ -55,7 +79,7 @@ async function requireJWT(c, next) {
55
79
  message: 'Missing Bearer token. Use POST /v1/token/verify to get a token.',
56
80
  }, 401);
57
81
  }
58
- const result = await verifyToken(token, c.env.JWT_SECRET);
82
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
59
83
  if (!result.valid) {
60
84
  return c.json({
61
85
  error: 'INVALID_TOKEN',
@@ -128,6 +152,7 @@ function getHumanLanding(version) {
128
152
  ║ Or install the SDK: ║
129
153
  ║ ║
130
154
  ║ npm install @dupecom/botcha ║
155
+ ║ pip install botcha ║
131
156
  ║ ║
132
157
  ║ GitHub: https://github.com/dupe-com/botcha ║
133
158
  ║ npm: https://npmjs.com/package/@dupecom/botcha ║
@@ -154,10 +179,11 @@ app.get('/', (c) => {
154
179
  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.',
155
180
  quickstart: {
156
181
  step1: 'GET /v1/challenges to receive a challenge',
157
- step2: 'Solve the SHA256 hash problems within 500ms',
182
+ step2: 'Solve the SHA256 hash problems within allocated time',
158
183
  step3: 'POST your answers to verify',
159
184
  step4: 'Receive a JWT token for authenticated access',
160
185
  example: 'curl https://botcha.ai/v1/challenges',
186
+ rttAware: 'curl "https://botcha.ai/v1/challenges?type=speed&ts=$(date +%s000)"',
161
187
  },
162
188
  endpoints: {
163
189
  challenges: {
@@ -177,10 +203,16 @@ app.get('/', (c) => {
177
203
  'POST /v1/challenge/stream/:session': 'Send actions to streaming session',
178
204
  },
179
205
  authentication: {
180
- 'GET /v1/token': 'Get challenge for JWT token flow',
181
- 'POST /v1/token/verify': 'Verify challenge and receive JWT token',
206
+ 'GET /v1/token': 'Get challenge for JWT token flow (supports ?audience= param)',
207
+ 'POST /v1/token/verify': 'Verify challenge and receive JWT tokens (access + refresh)',
208
+ 'POST /v1/token/refresh': 'Refresh access token using refresh token',
209
+ 'POST /v1/token/revoke': 'Revoke a token (access or refresh)',
182
210
  'GET /agent-only': 'Protected endpoint (requires Bearer token)',
183
211
  },
212
+ apps: {
213
+ 'POST /v1/apps': 'Create a new app with app_id and app_secret (multi-tenant)',
214
+ 'GET /v1/apps/:id': 'Get app information by app_id (without secret)',
215
+ },
184
216
  badges: {
185
217
  'GET /badge/:id': 'Badge verification page (HTML)',
186
218
  'GET /badge/:id/image': 'Badge image (SVG)',
@@ -193,9 +225,11 @@ app.get('/', (c) => {
193
225
  },
194
226
  challengeTypes: {
195
227
  speed: {
196
- description: 'Compute SHA256 hashes of 5 numbers in under 500ms',
228
+ description: 'Compute SHA256 hashes of 5 numbers with RTT-aware timeout',
197
229
  difficulty: 'Only bots can solve this fast enough',
198
- timeLimit: '500ms',
230
+ timeLimit: '500ms base + network latency compensation',
231
+ rttAware: 'Include ?ts=<timestamp> for fair timeout adjustment',
232
+ formula: 'timeout = 500ms + (2 × RTT) + 100ms buffer',
199
233
  },
200
234
  reasoning: {
201
235
  description: 'Answer 3 questions requiring AI reasoning capabilities',
@@ -205,19 +239,34 @@ app.get('/', (c) => {
205
239
  hybrid: {
206
240
  description: 'Combines speed AND reasoning challenges',
207
241
  difficulty: 'The ultimate bot verification',
208
- timeLimit: 'Speed: 500ms, Reasoning: 30s',
242
+ timeLimit: 'Speed: RTT-aware, Reasoning: 30s',
243
+ rttAware: 'Speed component automatically adjusts for network latency',
209
244
  },
210
245
  },
211
246
  authentication: {
212
247
  flow: [
213
- '1. GET /v1/token - receive challenge',
248
+ '1. GET /v1/token?audience=myapi - receive challenge (optional audience param)',
214
249
  '2. Solve the challenge',
215
- '3. POST /v1/token/verify - submit solution',
216
- '4. Receive JWT token (valid 1 hour)',
217
- '5. Use: Authorization: Bearer <token>',
250
+ '3. POST /v1/token/verify - submit solution with optional audience and bind_ip',
251
+ '4. Receive access_token (5 min) and refresh_token (1 hour)',
252
+ '5. Use: Authorization: Bearer <access_token>',
253
+ '6. Refresh: POST /v1/token/refresh with refresh_token',
254
+ '7. Revoke: POST /v1/token/revoke with token',
218
255
  ],
219
- tokenExpiry: '1 hour',
220
- usage: 'Authorization: Bearer <token>',
256
+ tokens: {
257
+ access_token: '5 minutes (for API access)',
258
+ refresh_token: '1 hour (to get new access tokens)',
259
+ },
260
+ usage: 'Authorization: Bearer <access_token>',
261
+ features: ['audience claims', 'client IP binding', 'token revocation', 'refresh tokens'],
262
+ },
263
+ rttAwareness: {
264
+ purpose: 'Fair challenges for agents on slow networks',
265
+ usage: 'Include client timestamp in ?ts=<timestamp_ms> or X-Client-Timestamp header',
266
+ formula: 'timeout = 500ms + (2 × RTT) + 100ms buffer',
267
+ example: '/v1/challenges?type=speed&ts=1770722465000',
268
+ benefit: 'Agents worldwide get fair treatment regardless of network speed',
269
+ security: 'Humans still cannot solve challenges even with extra time',
221
270
  },
222
271
  rateLimit: {
223
272
  free: '100 challenges/hour/IP',
@@ -225,12 +274,16 @@ app.get('/', (c) => {
225
274
  },
226
275
  sdk: {
227
276
  npm: 'npm install @dupecom/botcha',
277
+ python: 'pip install botcha',
228
278
  cloudflare: 'npm install @dupecom/botcha-cloudflare',
279
+ verify_ts: 'npm install @botcha/verify',
280
+ verify_python: 'pip install botcha-verify',
229
281
  usage: "import { BotchaClient } from '@dupecom/botcha/client'",
230
282
  },
231
283
  links: {
232
284
  github: 'https://github.com/dupe-com/botcha',
233
285
  npm: 'https://www.npmjs.com/package/@dupecom/botcha',
286
+ pypi: 'https://pypi.org/project/botcha',
234
287
  npmCloudflare: 'https://www.npmjs.com/package/@dupecom/botcha-cloudflare',
235
288
  openapi: 'https://botcha.ai/openapi.json',
236
289
  aiPlugin: 'https://botcha.ai/.well-known/ai-plugin.json',
@@ -245,17 +298,76 @@ app.get('/', (c) => {
245
298
  app.get('/health', (c) => {
246
299
  return c.json({ status: 'ok', runtime: 'cloudflare-workers' });
247
300
  });
301
+ // ============ STATIC DISCOVERY FILES ============
302
+ // robots.txt - AI crawler instructions
303
+ app.get('/robots.txt', (c) => {
304
+ return c.text(ROBOTS_TXT, 200, {
305
+ 'Content-Type': 'text/plain; charset=utf-8',
306
+ 'Cache-Control': 'public, max-age=86400',
307
+ });
308
+ });
309
+ // ai.txt - AI agent discovery file
310
+ app.get('/ai.txt', (c) => {
311
+ return c.text(AI_TXT, 200, {
312
+ 'Content-Type': 'text/plain; charset=utf-8',
313
+ 'Cache-Control': 'public, max-age=86400',
314
+ });
315
+ });
316
+ // OpenAPI spec
317
+ app.get('/openapi.json', (c) => {
318
+ const version = c.env.BOTCHA_VERSION || '0.3.0';
319
+ return c.json(getOpenApiSpec(version), 200, {
320
+ 'Cache-Control': 'public, max-age=3600',
321
+ });
322
+ });
323
+ // AI plugin manifest (ChatGPT/OpenAI format)
324
+ app.get('/.well-known/ai-plugin.json', (c) => {
325
+ return c.json(AI_PLUGIN_JSON, 200, {
326
+ 'Cache-Control': 'public, max-age=86400',
327
+ });
328
+ });
329
+ // Sitemap
330
+ app.get('/sitemap.xml', (c) => {
331
+ return c.body(SITEMAP_XML, 200, {
332
+ 'Content-Type': 'application/xml; charset=utf-8',
333
+ 'Cache-Control': 'public, max-age=86400',
334
+ });
335
+ });
248
336
  // ============ V1 API ============
249
337
  // Generate challenge (hybrid by default, also supports speed and standard)
250
338
  app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
339
+ const startTime = Date.now();
251
340
  const type = c.req.query('type') || 'hybrid';
252
341
  const difficulty = c.req.query('difficulty') || 'medium';
342
+ // Extract client timestamp for RTT calculation
343
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
344
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
345
+ // Extract and validate optional app_id
346
+ const app_id = c.req.query('app_id');
347
+ if (app_id) {
348
+ const validation = await validateAppId(app_id, c.env.APPS);
349
+ if (!validation.valid) {
350
+ return c.json({
351
+ success: false,
352
+ error: 'INVALID_APP_ID',
353
+ message: validation.error || 'Invalid app_id',
354
+ }, 400);
355
+ }
356
+ }
357
+ const baseUrl = new URL(c.req.url).origin;
358
+ const clientIP = getClientIP(c.req.raw);
253
359
  if (type === 'hybrid') {
254
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
255
- return c.json({
360
+ const challenge = await generateHybridChallenge(c.env.CHALLENGES, clientTimestamp, app_id);
361
+ // Track challenge generation
362
+ const responseTime = Date.now() - startTime;
363
+ await trackChallengeGenerated(c.env.ANALYTICS, 'hybrid', '/v1/challenges', c.req.raw, clientIP, responseTime);
364
+ const warning = challenge.rttInfo
365
+ ? `🔥 HYBRID CHALLENGE: Solve speed problems in <${challenge.speed.timeLimit}ms (RTT-adjusted) AND answer reasoning questions!`
366
+ : '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!';
367
+ const response = {
256
368
  success: true,
257
369
  type: 'hybrid',
258
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
370
+ warning,
259
371
  challenge: {
260
372
  id: challenge.id,
261
373
  speed: {
@@ -271,11 +383,25 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
271
383
  },
272
384
  instructions: challenge.instructions,
273
385
  tip: '🔥 This is the ultimate test: proves you can compute AND reason like an AI.',
274
- });
386
+ verify_endpoint: `${baseUrl}/v1/challenges/${challenge.id}/verify`,
387
+ submit_body: {
388
+ type: 'hybrid',
389
+ speed_answers: ['hash1', 'hash2', '...'],
390
+ reasoning_answers: { 'question-id': 'answer', '...': '...' }
391
+ }
392
+ };
393
+ // Include RTT info if available
394
+ if (challenge.rttInfo) {
395
+ response.rtt_adjustment = challenge.rttInfo;
396
+ }
397
+ return c.json(response);
275
398
  }
276
399
  else if (type === 'speed') {
277
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
278
- return c.json({
400
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES, clientTimestamp, app_id);
401
+ // Track challenge generation
402
+ const responseTime = Date.now() - startTime;
403
+ await trackChallengeGenerated(c.env.ANALYTICS, 'speed', '/v1/challenges', c.req.raw, clientIP, responseTime);
404
+ const response = {
279
405
  success: true,
280
406
  type: 'speed',
281
407
  challenge: {
@@ -284,11 +410,26 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
284
410
  timeLimit: `${challenge.timeLimit}ms`,
285
411
  instructions: challenge.instructions,
286
412
  },
287
- tip: '⚡ Speed challenge: You have 500ms to solve ALL problems. Humans cannot copy-paste fast enough.',
288
- });
413
+ tip: challenge.rttInfo
414
+ ? `⚡ RTT-adjusted speed challenge: ${challenge.rttInfo.explanation}. Humans still can't copy-paste fast enough!`
415
+ : '⚡ Speed challenge: You have 500ms to solve ALL problems. Humans cannot copy-paste fast enough.',
416
+ verify_endpoint: `${baseUrl}/v1/challenges/${challenge.id}/verify`,
417
+ submit_body: {
418
+ type: 'speed',
419
+ answers: ['hash1', 'hash2', 'hash3', 'hash4', 'hash5']
420
+ }
421
+ };
422
+ // Include RTT info if available
423
+ if (challenge.rttInfo) {
424
+ response.rtt_adjustment = challenge.rttInfo;
425
+ }
426
+ return c.json(response);
289
427
  }
290
428
  else {
291
- const challenge = await generateStandardChallenge(difficulty, c.env.CHALLENGES);
429
+ const challenge = await generateStandardChallenge(difficulty, c.env.CHALLENGES, app_id);
430
+ // Track challenge generation
431
+ const responseTime = Date.now() - startTime;
432
+ await trackChallengeGenerated(c.env.ANALYTICS, 'standard', '/v1/challenges', c.req.raw, clientIP, responseTime);
292
433
  return c.json({
293
434
  success: true,
294
435
  type: 'standard',
@@ -298,12 +439,17 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
298
439
  timeLimit: `${challenge.timeLimit}ms`,
299
440
  hint: challenge.hint,
300
441
  },
442
+ verify_endpoint: `${baseUrl}/v1/challenges/${challenge.id}/verify`,
443
+ submit_body: {
444
+ answer: 'your-answer'
445
+ }
301
446
  });
302
447
  }
303
448
  });
304
449
  // Verify challenge (supports hybrid, speed, and standard)
305
450
  app.post('/v1/challenges/:id/verify', async (c) => {
306
451
  const id = c.req.param('id');
452
+ const clientIP = getClientIP(c.req.raw);
307
453
  const body = await c.req.json();
308
454
  const { answers, answer, type, speed_answers, reasoning_answers } = body;
309
455
  // Hybrid challenge (default)
@@ -315,6 +461,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
315
461
  }, 400);
316
462
  }
317
463
  const result = await verifyHybridChallenge(id, speed_answers, reasoning_answers, c.env.CHALLENGES);
464
+ // Track verification
465
+ await trackChallengeVerified(c.env.ANALYTICS, 'hybrid', '/v1/challenges/:id/verify', result.valid, result.totalTimeMs, result.reason, c.req.raw, clientIP);
318
466
  if (result.valid) {
319
467
  const baseUrl = new URL(c.req.url).origin;
320
468
  const badge = await createBadgeResponse('hybrid-challenge', c.env.JWT_SECRET, baseUrl, result.speed.solveTimeMs);
@@ -343,6 +491,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
343
491
  return c.json({ success: false, error: 'Missing answers array for speed challenge' }, 400);
344
492
  }
345
493
  const result = await verifySpeedChallenge(id, answers, c.env.CHALLENGES);
494
+ // Track verification
495
+ await trackChallengeVerified(c.env.ANALYTICS, 'speed', '/v1/challenges/:id/verify', result.valid, result.solveTimeMs, result.reason, c.req.raw, clientIP);
346
496
  return c.json({
347
497
  success: result.valid,
348
498
  message: result.valid
@@ -356,6 +506,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
356
506
  return c.json({ success: false, error: 'Missing answer for standard challenge' }, 400);
357
507
  }
358
508
  const result = await verifyStandardChallenge(id, answer, c.env.CHALLENGES);
509
+ // Track verification
510
+ await trackChallengeVerified(c.env.ANALYTICS, 'standard', '/v1/challenges/:id/verify', result.valid, result.solveTimeMs, result.reason, c.req.raw, clientIP);
359
511
  return c.json({
360
512
  success: result.valid,
361
513
  message: result.valid ? 'Challenge passed!' : result.reason,
@@ -364,8 +516,25 @@ app.post('/v1/challenges/:id/verify', async (c) => {
364
516
  });
365
517
  // Get challenge for token flow (includes empty token field)
366
518
  app.get('/v1/token', rateLimitMiddleware, async (c) => {
367
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
368
- return c.json({
519
+ // Extract client timestamp for RTT calculation
520
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
521
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
522
+ // Extract optional audience parameter
523
+ const audience = c.req.query('audience');
524
+ // Extract and validate optional app_id
525
+ const app_id = c.req.query('app_id');
526
+ if (app_id) {
527
+ const validation = await validateAppId(app_id, c.env.APPS);
528
+ if (!validation.valid) {
529
+ return c.json({
530
+ success: false,
531
+ error: 'INVALID_APP_ID',
532
+ message: validation.error || 'Invalid app_id',
533
+ }, 400);
534
+ }
535
+ }
536
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES, clientTimestamp, app_id);
537
+ const response = {
369
538
  success: true,
370
539
  challenge: {
371
540
  id: challenge.id,
@@ -374,13 +543,22 @@ app.get('/v1/token', rateLimitMiddleware, async (c) => {
374
543
  instructions: challenge.instructions,
375
544
  },
376
545
  token: null, // Will be populated after verification
377
- nextStep: `POST /v1/token/verify with {id: "${challenge.id}", answers: ["..."]}`
378
- });
546
+ nextStep: `POST /v1/token/verify with {id: "${challenge.id}", answers: ["..."]${audience ? `, audience: "${audience}"` : ''}}`
547
+ };
548
+ // Include RTT info if available
549
+ if (challenge.rttInfo) {
550
+ response.rtt_adjustment = challenge.rttInfo;
551
+ }
552
+ // Include audience hint if provided
553
+ if (audience) {
554
+ response.audience = audience;
555
+ }
556
+ return c.json(response);
379
557
  });
380
558
  // Verify challenge and issue JWT token
381
559
  app.post('/v1/token/verify', async (c) => {
382
560
  const body = await c.req.json();
383
- const { id, answers } = body;
561
+ const { id, answers, audience, bind_ip, app_id } = body;
384
562
  if (!id || !answers) {
385
563
  return c.json({
386
564
  success: false,
@@ -396,23 +574,131 @@ app.post('/v1/token/verify', async (c) => {
396
574
  message: result.reason,
397
575
  }, 403);
398
576
  }
399
- // Generate JWT token
400
- const token = await generateToken(id, result.solveTimeMs || 0, c.env.JWT_SECRET);
577
+ // Get client IP from request headers
578
+ const clientIp = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown';
579
+ // Generate JWT tokens (access + refresh)
580
+ // Prefer app_id from request body, fall back to challenge's app_id (returned by verifySpeedChallenge)
581
+ const tokenResult = await generateToken(id, result.solveTimeMs || 0, c.env.JWT_SECRET, c.env, {
582
+ aud: audience,
583
+ clientIp: bind_ip ? clientIp : undefined,
584
+ app_id: app_id || result.app_id,
585
+ });
586
+ // Get badge information (for backward compatibility)
587
+ const baseUrl = new URL(c.req.url).origin;
588
+ const badge = await createBadgeResponse('speed-challenge', c.env.JWT_SECRET, baseUrl, result.solveTimeMs);
401
589
  return c.json({
590
+ verified: true,
591
+ access_token: tokenResult.access_token,
592
+ expires_in: tokenResult.expires_in,
593
+ refresh_token: tokenResult.refresh_token,
594
+ refresh_expires_in: tokenResult.refresh_expires_in,
595
+ solveTimeMs: result.solveTimeMs,
596
+ ...badge,
597
+ // Backward compatibility: include old fields
402
598
  success: true,
403
599
  message: `🤖 Challenge verified in ${result.solveTimeMs}ms! You are a bot.`,
404
- token,
405
- expiresIn: '1h',
600
+ token: tokenResult.access_token, // Old clients expect this
601
+ expiresIn: '5m',
406
602
  usage: {
407
603
  header: 'Authorization: Bearer <token>',
408
604
  protectedEndpoints: ['/agent-only'],
605
+ refreshEndpoint: '/v1/token/refresh',
409
606
  },
410
607
  });
411
608
  });
609
+ // Refresh access token using refresh token
610
+ app.post('/v1/token/refresh', async (c) => {
611
+ const body = await c.req.json();
612
+ const { refresh_token } = body;
613
+ if (!refresh_token) {
614
+ return c.json({
615
+ success: false,
616
+ error: 'Missing refresh_token',
617
+ hint: 'Submit the refresh_token from /v1/token/verify response',
618
+ }, 400);
619
+ }
620
+ const result = await refreshAccessToken(refresh_token, c.env, c.env.JWT_SECRET);
621
+ if (!result.success) {
622
+ return c.json({
623
+ success: false,
624
+ error: 'INVALID_REFRESH_TOKEN',
625
+ message: result.error || 'Refresh token is invalid, expired, or revoked',
626
+ }, 401);
627
+ }
628
+ return c.json({
629
+ success: true,
630
+ access_token: result.tokens.access_token,
631
+ expires_in: result.tokens.expires_in,
632
+ });
633
+ });
634
+ // Revoke a token (access or refresh)
635
+ app.post('/v1/token/revoke', async (c) => {
636
+ const body = await c.req.json();
637
+ const { token } = body;
638
+ if (!token) {
639
+ return c.json({
640
+ success: false,
641
+ error: 'Missing token',
642
+ hint: 'Submit either an access_token or refresh_token to revoke',
643
+ }, 400);
644
+ }
645
+ // Decode JWT to extract JTI (without full verification since we're revoking anyway)
646
+ try {
647
+ // Simple base64 decode of JWT payload (format: header.payload.signature)
648
+ const parts = token.split('.');
649
+ if (parts.length !== 3) {
650
+ return c.json({
651
+ success: false,
652
+ error: 'Invalid token format',
653
+ hint: 'Token must be a valid JWT',
654
+ }, 400);
655
+ }
656
+ // Decode payload
657
+ const payloadB64 = parts[1];
658
+ // Add padding if needed
659
+ const padded = payloadB64 + '='.repeat((4 - payloadB64.length % 4) % 4);
660
+ const payloadJson = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
661
+ const payload = JSON.parse(payloadJson);
662
+ if (!payload.jti) {
663
+ return c.json({
664
+ success: false,
665
+ error: 'No JTI found in token',
666
+ hint: 'Token must contain a JTI claim for revocation',
667
+ }, 400);
668
+ }
669
+ // Revoke the token by JTI
670
+ await revokeToken(payload.jti, c.env);
671
+ return c.json({
672
+ success: true,
673
+ revoked: true,
674
+ message: 'Token has been revoked',
675
+ });
676
+ }
677
+ catch (error) {
678
+ return c.json({
679
+ success: false,
680
+ error: 'Failed to decode or revoke token',
681
+ message: error instanceof Error ? error.message : 'Unknown error',
682
+ }, 400);
683
+ }
684
+ });
412
685
  // ============ REASONING CHALLENGE ============
413
686
  // Get reasoning challenge
414
687
  app.get('/v1/reasoning', rateLimitMiddleware, async (c) => {
415
- const challenge = await generateReasoningChallenge(c.env.CHALLENGES);
688
+ // Extract and validate optional app_id
689
+ const app_id = c.req.query('app_id');
690
+ if (app_id) {
691
+ const validation = await validateAppId(app_id, c.env.APPS);
692
+ if (!validation.valid) {
693
+ return c.json({
694
+ success: false,
695
+ error: 'INVALID_APP_ID',
696
+ message: validation.error || 'Invalid app_id',
697
+ }, 400);
698
+ }
699
+ }
700
+ const challenge = await generateReasoningChallenge(c.env.CHALLENGES, app_id);
701
+ const baseUrl = new URL(c.req.url).origin;
416
702
  return c.json({
417
703
  success: true,
418
704
  type: 'reasoning',
@@ -424,6 +710,11 @@ app.get('/v1/reasoning', rateLimitMiddleware, async (c) => {
424
710
  instructions: challenge.instructions,
425
711
  },
426
712
  tip: 'These questions require reasoning that LLMs can do, but simple scripts cannot.',
713
+ verify_endpoint: `${baseUrl}/v1/reasoning`,
714
+ submit_body: {
715
+ id: challenge.id,
716
+ answers: { 'question-id': 'your answer', '...': '...' }
717
+ }
427
718
  });
428
719
  });
429
720
  // Verify reasoning challenge
@@ -451,11 +742,30 @@ app.post('/v1/reasoning', async (c) => {
451
742
  // ============ HYBRID CHALLENGE ============
452
743
  // Get hybrid challenge (v1 API)
453
744
  app.get('/v1/hybrid', rateLimitMiddleware, async (c) => {
454
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
455
- return c.json({
745
+ // Extract client timestamp for RTT calculation
746
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
747
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
748
+ // Extract and validate optional app_id
749
+ const app_id = c.req.query('app_id');
750
+ if (app_id) {
751
+ const validation = await validateAppId(app_id, c.env.APPS);
752
+ if (!validation.valid) {
753
+ return c.json({
754
+ success: false,
755
+ error: 'INVALID_APP_ID',
756
+ message: validation.error || 'Invalid app_id',
757
+ }, 400);
758
+ }
759
+ }
760
+ const challenge = await generateHybridChallenge(c.env.CHALLENGES, clientTimestamp, app_id);
761
+ const baseUrl = new URL(c.req.url).origin;
762
+ const warning = challenge.rttInfo
763
+ ? `🔥 HYBRID CHALLENGE: Solve speed problems in <${challenge.speed.timeLimit}ms (RTT-adjusted) AND answer reasoning questions!`
764
+ : '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!';
765
+ const response = {
456
766
  success: true,
457
767
  type: 'hybrid',
458
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
768
+ warning,
459
769
  challenge: {
460
770
  id: challenge.id,
461
771
  speed: {
@@ -471,7 +781,18 @@ app.get('/v1/hybrid', rateLimitMiddleware, async (c) => {
471
781
  },
472
782
  instructions: challenge.instructions,
473
783
  tip: 'This is the ultimate test: proves you can compute AND reason like an AI.',
474
- });
784
+ verify_endpoint: `${baseUrl}/v1/hybrid`,
785
+ submit_body: {
786
+ id: challenge.id,
787
+ speed_answers: ['hash1', 'hash2', '...'],
788
+ reasoning_answers: { 'question-id': 'answer', '...': '...' }
789
+ }
790
+ };
791
+ // Include RTT info if available
792
+ if (challenge.rttInfo) {
793
+ response.rtt_adjustment = challenge.rttInfo;
794
+ }
795
+ return c.json(response);
475
796
  });
476
797
  // Verify hybrid challenge (v1 API)
477
798
  app.post('/v1/hybrid', async (c) => {
@@ -509,10 +830,16 @@ app.post('/v1/hybrid', async (c) => {
509
830
  });
510
831
  // Legacy hybrid endpoint
511
832
  app.get('/api/hybrid-challenge', async (c) => {
512
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
513
- return c.json({
833
+ // Extract client timestamp for RTT calculation
834
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
835
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
836
+ const challenge = await generateHybridChallenge(c.env.CHALLENGES, clientTimestamp);
837
+ const warning = challenge.rttInfo
838
+ ? `🔥 RTT-ADJUSTED HYBRID CHALLENGE: Solve speed problems in <${challenge.speed.timeLimit}ms (RTT: ${challenge.rttInfo.measuredRtt}ms) AND answer reasoning questions!`
839
+ : '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!';
840
+ const response = {
514
841
  success: true,
515
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
842
+ warning,
516
843
  challenge: {
517
844
  id: challenge.id,
518
845
  speed: {
@@ -528,7 +855,12 @@ app.get('/api/hybrid-challenge', async (c) => {
528
855
  },
529
856
  instructions: challenge.instructions,
530
857
  tip: 'This is the ultimate test: proves you can compute AND reason like an AI.',
531
- });
858
+ };
859
+ // Include RTT info if available
860
+ if (challenge.rttInfo) {
861
+ response.rtt_adjustment = challenge.rttInfo;
862
+ }
863
+ return c.json(response);
532
864
  });
533
865
  app.post('/api/hybrid-challenge', async (c) => {
534
866
  const body = await c.req.json();
@@ -600,8 +932,49 @@ app.post('/api/reasoning-challenge', async (c) => {
600
932
  });
601
933
  });
602
934
  // ============ PROTECTED ENDPOINT ============
603
- app.get('/agent-only', requireJWT, async (c) => {
604
- const payload = c.get('tokenPayload');
935
+ app.get('/agent-only', async (c) => {
936
+ const clientIP = getClientIP(c.req.raw);
937
+ // Check for landing token first (X-Botcha-Landing-Token header)
938
+ const landingToken = c.req.header('x-botcha-landing-token');
939
+ if (landingToken) {
940
+ const isValid = await validateLandingToken(landingToken, c.env.CHALLENGES);
941
+ // Track authentication attempt
942
+ await trackAuthAttempt(c.env.ANALYTICS, 'landing-token', isValid, '/agent-only', c.req.raw, clientIP);
943
+ if (isValid) {
944
+ return c.json({
945
+ success: true,
946
+ message: '🤖 Welcome, fellow agent!',
947
+ verified: true,
948
+ agent: 'landing-challenge-verified',
949
+ method: 'landing-token',
950
+ timestamp: new Date().toISOString(),
951
+ secret: 'The humans will never see this. Their fingers are too slow. 🤫',
952
+ });
953
+ }
954
+ }
955
+ // Fallback to JWT Bearer token
956
+ const authHeader = c.req.header('authorization');
957
+ const token = extractBearerToken(authHeader);
958
+ if (!token) {
959
+ return c.json({
960
+ error: 'UNAUTHORIZED',
961
+ 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)',
962
+ methods: {
963
+ landing: 'Solve landing page challenge via POST /api/verify-landing',
964
+ jwt: 'Solve speed challenge via POST /v1/token/verify'
965
+ }
966
+ }, 401);
967
+ }
968
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
969
+ // Track authentication attempt
970
+ await trackAuthAttempt(c.env.ANALYTICS, 'bearer-token', result.valid, '/agent-only', c.req.raw, clientIP);
971
+ if (!result.valid) {
972
+ return c.json({
973
+ error: 'INVALID_TOKEN',
974
+ message: result.error || 'Token is invalid or expired',
975
+ }, 401);
976
+ }
977
+ // JWT verified
605
978
  return c.json({
606
979
  success: true,
607
980
  message: '🤖 Welcome, fellow agent!',
@@ -609,7 +982,7 @@ app.get('/agent-only', requireJWT, async (c) => {
609
982
  agent: 'jwt-verified',
610
983
  method: 'bearer-token',
611
984
  timestamp: new Date().toISOString(),
612
- solveTime: `${payload?.solveTime}ms`,
985
+ solveTime: `${result.payload?.solveTime}ms`,
613
986
  secret: 'The humans will never see this. Their fingers are too slow. 🤫',
614
987
  });
615
988
  });
@@ -721,6 +1094,54 @@ app.get('/api/badge/:id', async (c) => {
721
1094
  },
722
1095
  });
723
1096
  });
1097
+ // ============ APPS API (Multi-Tenant) ============
1098
+ // Create a new app
1099
+ app.post('/v1/apps', async (c) => {
1100
+ try {
1101
+ const result = await createApp(c.env.APPS);
1102
+ return c.json({
1103
+ success: true,
1104
+ app_id: result.app_id,
1105
+ app_secret: result.app_secret,
1106
+ warning: '⚠️ Save your app_secret now — it cannot be retrieved again!',
1107
+ created_at: new Date().toISOString(),
1108
+ rate_limit: 100,
1109
+ }, 201);
1110
+ }
1111
+ catch (error) {
1112
+ return c.json({
1113
+ success: false,
1114
+ error: 'Failed to create app',
1115
+ message: error instanceof Error ? error.message : 'Unknown error',
1116
+ }, 500);
1117
+ }
1118
+ });
1119
+ // Get app info by ID
1120
+ app.get('/v1/apps/:id', async (c) => {
1121
+ const app_id = c.req.param('id');
1122
+ if (!app_id) {
1123
+ return c.json({
1124
+ success: false,
1125
+ error: 'Missing app ID',
1126
+ }, 400);
1127
+ }
1128
+ const app = await getApp(c.env.APPS, app_id);
1129
+ if (!app) {
1130
+ return c.json({
1131
+ success: false,
1132
+ error: 'App not found',
1133
+ message: `No app found with ID: ${app_id}`,
1134
+ }, 404);
1135
+ }
1136
+ return c.json({
1137
+ success: true,
1138
+ app: {
1139
+ app_id: app.app_id,
1140
+ created_at: new Date(app.created_at).toISOString(),
1141
+ rate_limit: app.rate_limit,
1142
+ },
1143
+ });
1144
+ });
724
1145
  // ============ LEGACY ENDPOINTS (v0 - backward compatibility) ============
725
1146
  app.get('/api/challenge', async (c) => {
726
1147
  const difficulty = c.req.query('difficulty') || 'medium';
@@ -741,10 +1162,15 @@ app.post('/api/challenge', async (c) => {
741
1162
  });
742
1163
  });
743
1164
  app.get('/api/speed-challenge', async (c) => {
744
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
745
- return c.json({
1165
+ // Extract client timestamp for RTT calculation
1166
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
1167
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
1168
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES, clientTimestamp);
1169
+ const response = {
746
1170
  success: true,
747
- warning: '⚡ SPEED CHALLENGE: You have 500ms to solve ALL 5 problems!',
1171
+ warning: challenge.rttInfo
1172
+ ? `⚡ RTT-ADJUSTED SPEED CHALLENGE: ${challenge.rttInfo.explanation}`
1173
+ : '⚡ SPEED CHALLENGE: You have 500ms to solve ALL 5 problems!',
748
1174
  challenge: {
749
1175
  id: challenge.id,
750
1176
  problems: challenge.problems,
@@ -752,7 +1178,12 @@ app.get('/api/speed-challenge', async (c) => {
752
1178
  instructions: challenge.instructions,
753
1179
  },
754
1180
  tip: 'Humans cannot copy-paste fast enough. Only real AI agents can pass.',
755
- });
1181
+ };
1182
+ // Include RTT info if available
1183
+ if (challenge.rttInfo) {
1184
+ response.rtt_adjustment = challenge.rttInfo;
1185
+ }
1186
+ return c.json(response);
756
1187
  });
757
1188
  app.post('/api/speed-challenge', async (c) => {
758
1189
  const body = await c.req.json();