@dupecom/botcha-cloudflare 0.3.3 → 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
@@ -8,10 +8,13 @@
8
8
  import { Hono } from 'hono';
9
9
  import { cors } from 'hono/cors';
10
10
  import { generateSpeedChallenge, verifySpeedChallenge, generateStandardChallenge, verifyStandardChallenge, generateReasoningChallenge, verifyReasoningChallenge, generateHybridChallenge, verifyHybridChallenge, verifyLandingChallenge, validateLandingToken, } from './challenges';
11
- import { generateToken, verifyToken, extractBearerToken } from './auth';
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,18 +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
+ }
253
357
  const baseUrl = new URL(c.req.url).origin;
358
+ const clientIP = getClientIP(c.req.raw);
254
359
  if (type === 'hybrid') {
255
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
256
- 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 = {
257
368
  success: true,
258
369
  type: 'hybrid',
259
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
370
+ warning,
260
371
  challenge: {
261
372
  id: challenge.id,
262
373
  speed: {
@@ -278,11 +389,19 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
278
389
  speed_answers: ['hash1', 'hash2', '...'],
279
390
  reasoning_answers: { 'question-id': 'answer', '...': '...' }
280
391
  }
281
- });
392
+ };
393
+ // Include RTT info if available
394
+ if (challenge.rttInfo) {
395
+ response.rtt_adjustment = challenge.rttInfo;
396
+ }
397
+ return c.json(response);
282
398
  }
283
399
  else if (type === 'speed') {
284
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
285
- 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 = {
286
405
  success: true,
287
406
  type: 'speed',
288
407
  challenge: {
@@ -291,16 +410,26 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
291
410
  timeLimit: `${challenge.timeLimit}ms`,
292
411
  instructions: challenge.instructions,
293
412
  },
294
- tip: '⚡ Speed challenge: You have 500ms to solve ALL problems. Humans cannot copy-paste fast enough.',
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.',
295
416
  verify_endpoint: `${baseUrl}/v1/challenges/${challenge.id}/verify`,
296
417
  submit_body: {
297
418
  type: 'speed',
298
419
  answers: ['hash1', 'hash2', 'hash3', 'hash4', 'hash5']
299
420
  }
300
- });
421
+ };
422
+ // Include RTT info if available
423
+ if (challenge.rttInfo) {
424
+ response.rtt_adjustment = challenge.rttInfo;
425
+ }
426
+ return c.json(response);
301
427
  }
302
428
  else {
303
- 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);
304
433
  return c.json({
305
434
  success: true,
306
435
  type: 'standard',
@@ -320,6 +449,7 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
320
449
  // Verify challenge (supports hybrid, speed, and standard)
321
450
  app.post('/v1/challenges/:id/verify', async (c) => {
322
451
  const id = c.req.param('id');
452
+ const clientIP = getClientIP(c.req.raw);
323
453
  const body = await c.req.json();
324
454
  const { answers, answer, type, speed_answers, reasoning_answers } = body;
325
455
  // Hybrid challenge (default)
@@ -331,6 +461,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
331
461
  }, 400);
332
462
  }
333
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);
334
466
  if (result.valid) {
335
467
  const baseUrl = new URL(c.req.url).origin;
336
468
  const badge = await createBadgeResponse('hybrid-challenge', c.env.JWT_SECRET, baseUrl, result.speed.solveTimeMs);
@@ -359,6 +491,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
359
491
  return c.json({ success: false, error: 'Missing answers array for speed challenge' }, 400);
360
492
  }
361
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);
362
496
  return c.json({
363
497
  success: result.valid,
364
498
  message: result.valid
@@ -372,6 +506,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
372
506
  return c.json({ success: false, error: 'Missing answer for standard challenge' }, 400);
373
507
  }
374
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);
375
511
  return c.json({
376
512
  success: result.valid,
377
513
  message: result.valid ? 'Challenge passed!' : result.reason,
@@ -380,8 +516,25 @@ app.post('/v1/challenges/:id/verify', async (c) => {
380
516
  });
381
517
  // Get challenge for token flow (includes empty token field)
382
518
  app.get('/v1/token', rateLimitMiddleware, async (c) => {
383
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
384
- 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 = {
385
538
  success: true,
386
539
  challenge: {
387
540
  id: challenge.id,
@@ -390,13 +543,22 @@ app.get('/v1/token', rateLimitMiddleware, async (c) => {
390
543
  instructions: challenge.instructions,
391
544
  },
392
545
  token: null, // Will be populated after verification
393
- nextStep: `POST /v1/token/verify with {id: "${challenge.id}", answers: ["..."]}`
394
- });
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);
395
557
  });
396
558
  // Verify challenge and issue JWT token
397
559
  app.post('/v1/token/verify', async (c) => {
398
560
  const body = await c.req.json();
399
- const { id, answers } = body;
561
+ const { id, answers, audience, bind_ip, app_id } = body;
400
562
  if (!id || !answers) {
401
563
  return c.json({
402
564
  success: false,
@@ -412,23 +574,130 @@ app.post('/v1/token/verify', async (c) => {
412
574
  message: result.reason,
413
575
  }, 403);
414
576
  }
415
- // Generate JWT token
416
- 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);
417
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
418
598
  success: true,
419
599
  message: `🤖 Challenge verified in ${result.solveTimeMs}ms! You are a bot.`,
420
- token,
421
- expiresIn: '1h',
600
+ token: tokenResult.access_token, // Old clients expect this
601
+ expiresIn: '5m',
422
602
  usage: {
423
603
  header: 'Authorization: Bearer <token>',
424
604
  protectedEndpoints: ['/agent-only'],
605
+ refreshEndpoint: '/v1/token/refresh',
425
606
  },
426
607
  });
427
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
+ });
428
685
  // ============ REASONING CHALLENGE ============
429
686
  // Get reasoning challenge
430
687
  app.get('/v1/reasoning', rateLimitMiddleware, async (c) => {
431
- 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);
432
701
  const baseUrl = new URL(c.req.url).origin;
433
702
  return c.json({
434
703
  success: true,
@@ -473,12 +742,30 @@ app.post('/v1/reasoning', async (c) => {
473
742
  // ============ HYBRID CHALLENGE ============
474
743
  // Get hybrid challenge (v1 API)
475
744
  app.get('/v1/hybrid', rateLimitMiddleware, async (c) => {
476
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
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);
477
761
  const baseUrl = new URL(c.req.url).origin;
478
- return c.json({
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 = {
479
766
  success: true,
480
767
  type: 'hybrid',
481
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
768
+ warning,
482
769
  challenge: {
483
770
  id: challenge.id,
484
771
  speed: {
@@ -500,7 +787,12 @@ app.get('/v1/hybrid', rateLimitMiddleware, async (c) => {
500
787
  speed_answers: ['hash1', 'hash2', '...'],
501
788
  reasoning_answers: { 'question-id': 'answer', '...': '...' }
502
789
  }
503
- });
790
+ };
791
+ // Include RTT info if available
792
+ if (challenge.rttInfo) {
793
+ response.rtt_adjustment = challenge.rttInfo;
794
+ }
795
+ return c.json(response);
504
796
  });
505
797
  // Verify hybrid challenge (v1 API)
506
798
  app.post('/v1/hybrid', async (c) => {
@@ -538,10 +830,16 @@ app.post('/v1/hybrid', async (c) => {
538
830
  });
539
831
  // Legacy hybrid endpoint
540
832
  app.get('/api/hybrid-challenge', async (c) => {
541
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
542
- 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 = {
543
841
  success: true,
544
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
842
+ warning,
545
843
  challenge: {
546
844
  id: challenge.id,
547
845
  speed: {
@@ -557,7 +855,12 @@ app.get('/api/hybrid-challenge', async (c) => {
557
855
  },
558
856
  instructions: challenge.instructions,
559
857
  tip: 'This is the ultimate test: proves you can compute AND reason like an AI.',
560
- });
858
+ };
859
+ // Include RTT info if available
860
+ if (challenge.rttInfo) {
861
+ response.rtt_adjustment = challenge.rttInfo;
862
+ }
863
+ return c.json(response);
561
864
  });
562
865
  app.post('/api/hybrid-challenge', async (c) => {
563
866
  const body = await c.req.json();
@@ -630,10 +933,13 @@ app.post('/api/reasoning-challenge', async (c) => {
630
933
  });
631
934
  // ============ PROTECTED ENDPOINT ============
632
935
  app.get('/agent-only', async (c) => {
936
+ const clientIP = getClientIP(c.req.raw);
633
937
  // Check for landing token first (X-Botcha-Landing-Token header)
634
938
  const landingToken = c.req.header('x-botcha-landing-token');
635
939
  if (landingToken) {
636
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);
637
943
  if (isValid) {
638
944
  return c.json({
639
945
  success: true,
@@ -659,7 +965,9 @@ app.get('/agent-only', async (c) => {
659
965
  }
660
966
  }, 401);
661
967
  }
662
- const result = await verifyToken(token, c.env.JWT_SECRET);
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);
663
971
  if (!result.valid) {
664
972
  return c.json({
665
973
  error: 'INVALID_TOKEN',
@@ -786,6 +1094,54 @@ app.get('/api/badge/:id', async (c) => {
786
1094
  },
787
1095
  });
788
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
+ });
789
1145
  // ============ LEGACY ENDPOINTS (v0 - backward compatibility) ============
790
1146
  app.get('/api/challenge', async (c) => {
791
1147
  const difficulty = c.req.query('difficulty') || 'medium';
@@ -806,10 +1162,15 @@ app.post('/api/challenge', async (c) => {
806
1162
  });
807
1163
  });
808
1164
  app.get('/api/speed-challenge', async (c) => {
809
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
810
- 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 = {
811
1170
  success: true,
812
- 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!',
813
1174
  challenge: {
814
1175
  id: challenge.id,
815
1176
  problems: challenge.problems,
@@ -817,7 +1178,12 @@ app.get('/api/speed-challenge', async (c) => {
817
1178
  instructions: challenge.instructions,
818
1179
  },
819
1180
  tip: 'Humans cannot copy-paste fast enough. Only real AI agents can pass.',
820
- });
1181
+ };
1182
+ // Include RTT info if available
1183
+ if (challenge.rttInfo) {
1184
+ response.rtt_adjustment = challenge.rttInfo;
1185
+ }
1186
+ return c.json(response);
821
1187
  });
822
1188
  app.post('/api/speed-challenge', async (c) => {
823
1189
  const body = await c.req.json();