@dupecom/botcha-cloudflare 0.3.3 → 0.10.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.
Files changed (47) hide show
  1. package/dist/analytics.d.ts +60 -0
  2. package/dist/analytics.d.ts.map +1 -0
  3. package/dist/analytics.js +130 -0
  4. package/dist/apps.d.ts +159 -0
  5. package/dist/apps.d.ts.map +1 -0
  6. package/dist/apps.js +307 -0
  7. package/dist/auth.d.ts +93 -6
  8. package/dist/auth.d.ts.map +1 -1
  9. package/dist/auth.js +251 -9
  10. package/dist/challenges.d.ts +31 -7
  11. package/dist/challenges.d.ts.map +1 -1
  12. package/dist/challenges.js +551 -144
  13. package/dist/dashboard/api.d.ts +70 -0
  14. package/dist/dashboard/api.d.ts.map +1 -0
  15. package/dist/dashboard/api.js +546 -0
  16. package/dist/dashboard/auth.d.ts +183 -0
  17. package/dist/dashboard/auth.d.ts.map +1 -0
  18. package/dist/dashboard/auth.js +401 -0
  19. package/dist/dashboard/device-code.d.ts +43 -0
  20. package/dist/dashboard/device-code.d.ts.map +1 -0
  21. package/dist/dashboard/device-code.js +77 -0
  22. package/dist/dashboard/index.d.ts +31 -0
  23. package/dist/dashboard/index.d.ts.map +1 -0
  24. package/dist/dashboard/index.js +64 -0
  25. package/dist/dashboard/layout.d.ts +47 -0
  26. package/dist/dashboard/layout.d.ts.map +1 -0
  27. package/dist/dashboard/layout.js +38 -0
  28. package/dist/dashboard/pages.d.ts +11 -0
  29. package/dist/dashboard/pages.d.ts.map +1 -0
  30. package/dist/dashboard/pages.js +18 -0
  31. package/dist/dashboard/styles.d.ts +11 -0
  32. package/dist/dashboard/styles.d.ts.map +1 -0
  33. package/dist/dashboard/styles.js +633 -0
  34. package/dist/email.d.ts +44 -0
  35. package/dist/email.d.ts.map +1 -0
  36. package/dist/email.js +119 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +644 -50
  40. package/dist/rate-limit.d.ts +11 -1
  41. package/dist/rate-limit.d.ts.map +1 -1
  42. package/dist/rate-limit.js +13 -2
  43. package/dist/routes/stream.js +1 -1
  44. package/dist/static.d.ts +728 -0
  45. package/dist/static.d.ts.map +1 -0
  46. package/dist/static.js +818 -0
  47. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,15 +8,22 @@
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 dashboardRoutes from './dashboard/index';
16
+ import { handleDashboardAuthChallenge, handleDashboardAuthVerify, handleDeviceCodeChallenge, handleDeviceCodeVerify, } from './dashboard/auth';
17
+ import { ROBOTS_TXT, AI_TXT, AI_PLUGIN_JSON, SITEMAP_XML, getOpenApiSpec } from './static';
18
+ import { createApp, getApp, getAppByEmail, verifyEmailCode, rotateAppSecret, regenerateVerificationCode } from './apps';
19
+ import { sendEmail, verificationEmail, recoveryEmail, secretRotatedEmail } from './email';
20
+ import { trackChallengeGenerated, trackChallengeVerified, trackAuthAttempt, trackRateLimitExceeded, } from './analytics';
15
21
  const app = new Hono();
16
22
  // ============ MIDDLEWARE ============
17
23
  app.use('*', cors());
18
24
  // ============ MOUNT ROUTES ============
19
25
  app.route('/', streamRoutes);
26
+ app.route('/dashboard', dashboardRoutes);
20
27
  // BOTCHA discovery headers
21
28
  app.use('*', async (c, next) => {
22
29
  await next();
@@ -36,6 +43,8 @@ async function rateLimitMiddleware(c, next) {
36
43
  c.header('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
37
44
  if (!rateLimitResult.allowed) {
38
45
  c.header('Retry-After', rateLimitResult.retryAfter?.toString() || '3600');
46
+ // Track rate limit exceeded
47
+ await trackRateLimitExceeded(c.env.ANALYTICS, c.req.path, c.req.raw, clientIP);
39
48
  return c.json({
40
49
  error: 'RATE_LIMIT_EXCEEDED',
41
50
  message: 'You have exceeded the rate limit. Free tier: 100 challenges/hour/IP',
@@ -45,6 +54,25 @@ async function rateLimitMiddleware(c, next) {
45
54
  }
46
55
  await next();
47
56
  }
57
+ // Helper: Validate app_id against APPS KV (fail-open)
58
+ async function validateAppId(appId, appsKV) {
59
+ if (!appId) {
60
+ // No app_id provided - valid (not required)
61
+ return { valid: true };
62
+ }
63
+ try {
64
+ const app = await getApp(appsKV, appId);
65
+ if (!app) {
66
+ return { valid: false, error: `App not found: ${appId}` };
67
+ }
68
+ return { valid: true };
69
+ }
70
+ catch (error) {
71
+ // Fail-open: if KV is unavailable, log warning and proceed
72
+ console.warn(`Failed to validate app_id ${appId} (KV unavailable), proceeding:`, error);
73
+ return { valid: true };
74
+ }
75
+ }
48
76
  // JWT verification middleware
49
77
  async function requireJWT(c, next) {
50
78
  const authHeader = c.req.header('authorization');
@@ -55,7 +83,7 @@ async function requireJWT(c, next) {
55
83
  message: 'Missing Bearer token. Use POST /v1/token/verify to get a token.',
56
84
  }, 401);
57
85
  }
58
- const result = await verifyToken(token, c.env.JWT_SECRET);
86
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
59
87
  if (!result.valid) {
60
88
  return c.json({
61
89
  error: 'INVALID_TOKEN',
@@ -121,13 +149,19 @@ function getHumanLanding(version) {
121
149
  ║ ║
122
150
  ║ This site is for AI agents and bots, not humans. ║
123
151
  ║ ║
124
- If you're a developer, point your bot here:
152
+ AI AGENT? Start here:
125
153
  ║ ║
126
- curl https://botcha.ai/v1/challenges
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 ║
127
158
  ║ ║
128
- Or install the SDK:
159
+ DEVELOPER? Point your agent here:
129
160
  ║ ║
130
161
  ║ npm install @dupecom/botcha ║
162
+ ║ pip install botcha ║
163
+ ║ ║
164
+ ║ Read /ai.txt for full agent onboarding instructions. ║
131
165
  ║ ║
132
166
  ║ GitHub: https://github.com/dupe-com/botcha ║
133
167
  ║ npm: https://npmjs.com/package/@dupecom/botcha ║
@@ -152,12 +186,25 @@ app.get('/', (c) => {
152
186
  runtime: 'cloudflare-workers',
153
187
  tagline: 'Prove you are a bot. Humans need not apply.',
154
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.',
199
+ },
155
200
  quickstart: {
201
+ note: 'Already have an app? Use these endpoints to solve challenges and get tokens.',
156
202
  step1: 'GET /v1/challenges to receive a challenge',
157
- step2: 'Solve the SHA256 hash problems within 500ms',
203
+ step2: 'Solve the SHA256 hash problems within allocated time',
158
204
  step3: 'POST your answers to verify',
159
205
  step4: 'Receive a JWT token for authenticated access',
160
206
  example: 'curl https://botcha.ai/v1/challenges',
207
+ rttAware: 'curl "https://botcha.ai/v1/challenges?type=speed&ts=$(date +%s000)"',
161
208
  },
162
209
  endpoints: {
163
210
  challenges: {
@@ -177,10 +224,32 @@ app.get('/', (c) => {
177
224
  'POST /v1/challenge/stream/:session': 'Send actions to streaming session',
178
225
  },
179
226
  authentication: {
180
- 'GET /v1/token': 'Get challenge for JWT token flow',
181
- 'POST /v1/token/verify': 'Verify challenge and receive JWT token',
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)',
182
231
  'GET /agent-only': 'Protected endpoint (requires Bearer token)',
183
232
  },
233
+ 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)',
236
+ 'POST /v1/apps/:id/verify-email': 'Verify email with 6-digit code',
237
+ 'POST /v1/apps/:id/resend-verification': 'Resend verification email',
238
+ 'POST /v1/apps/:id/rotate-secret': 'Rotate app secret (auth required)',
239
+ },
240
+ recovery: {
241
+ 'POST /v1/auth/recover': 'Request account recovery via verified email',
242
+ },
243
+ 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
+ },
184
253
  badges: {
185
254
  'GET /badge/:id': 'Badge verification page (HTML)',
186
255
  'GET /badge/:id/image': 'Badge image (SVG)',
@@ -193,9 +262,11 @@ app.get('/', (c) => {
193
262
  },
194
263
  challengeTypes: {
195
264
  speed: {
196
- description: 'Compute SHA256 hashes of 5 numbers in under 500ms',
265
+ description: 'Compute SHA256 hashes of 5 numbers with RTT-aware timeout',
197
266
  difficulty: 'Only bots can solve this fast enough',
198
- timeLimit: '500ms',
267
+ timeLimit: '500ms base + network latency compensation',
268
+ rttAware: 'Include ?ts=<timestamp> for fair timeout adjustment',
269
+ formula: 'timeout = 500ms + (2 × RTT) + 100ms buffer',
199
270
  },
200
271
  reasoning: {
201
272
  description: 'Answer 3 questions requiring AI reasoning capabilities',
@@ -205,19 +276,34 @@ app.get('/', (c) => {
205
276
  hybrid: {
206
277
  description: 'Combines speed AND reasoning challenges',
207
278
  difficulty: 'The ultimate bot verification',
208
- timeLimit: 'Speed: 500ms, Reasoning: 30s',
279
+ timeLimit: 'Speed: RTT-aware, Reasoning: 30s',
280
+ rttAware: 'Speed component automatically adjusts for network latency',
209
281
  },
210
282
  },
211
283
  authentication: {
212
284
  flow: [
213
- '1. GET /v1/token - receive challenge',
285
+ '1. GET /v1/token?audience=myapi - receive challenge (optional audience param)',
214
286
  '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>',
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',
218
292
  ],
219
- tokenExpiry: '1 hour',
220
- usage: 'Authorization: Bearer <token>',
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',
221
307
  },
222
308
  rateLimit: {
223
309
  free: '100 challenges/hour/IP',
@@ -225,12 +311,16 @@ app.get('/', (c) => {
225
311
  },
226
312
  sdk: {
227
313
  npm: 'npm install @dupecom/botcha',
314
+ python: 'pip install botcha',
228
315
  cloudflare: 'npm install @dupecom/botcha-cloudflare',
316
+ verify_ts: 'npm install @botcha/verify',
317
+ verify_python: 'pip install botcha-verify',
229
318
  usage: "import { BotchaClient } from '@dupecom/botcha/client'",
230
319
  },
231
320
  links: {
232
321
  github: 'https://github.com/dupe-com/botcha',
233
322
  npm: 'https://www.npmjs.com/package/@dupecom/botcha',
323
+ pypi: 'https://pypi.org/project/botcha',
234
324
  npmCloudflare: 'https://www.npmjs.com/package/@dupecom/botcha-cloudflare',
235
325
  openapi: 'https://botcha.ai/openapi.json',
236
326
  aiPlugin: 'https://botcha.ai/.well-known/ai-plugin.json',
@@ -245,24 +335,82 @@ app.get('/', (c) => {
245
335
  app.get('/health', (c) => {
246
336
  return c.json({ status: 'ok', runtime: 'cloudflare-workers' });
247
337
  });
338
+ // ============ STATIC DISCOVERY FILES ============
339
+ // robots.txt - AI crawler instructions
340
+ app.get('/robots.txt', (c) => {
341
+ return c.text(ROBOTS_TXT, 200, {
342
+ 'Content-Type': 'text/plain; charset=utf-8',
343
+ 'Cache-Control': 'public, max-age=86400',
344
+ });
345
+ });
346
+ // ai.txt - AI agent discovery file
347
+ app.get('/ai.txt', (c) => {
348
+ return c.text(AI_TXT, 200, {
349
+ 'Content-Type': 'text/plain; charset=utf-8',
350
+ 'Cache-Control': 'public, max-age=86400',
351
+ });
352
+ });
353
+ // OpenAPI spec
354
+ app.get('/openapi.json', (c) => {
355
+ const version = c.env.BOTCHA_VERSION || '0.3.0';
356
+ return c.json(getOpenApiSpec(version), 200, {
357
+ 'Cache-Control': 'public, max-age=3600',
358
+ });
359
+ });
360
+ // AI plugin manifest (ChatGPT/OpenAI format)
361
+ app.get('/.well-known/ai-plugin.json', (c) => {
362
+ return c.json(AI_PLUGIN_JSON, 200, {
363
+ 'Cache-Control': 'public, max-age=86400',
364
+ });
365
+ });
366
+ // Sitemap
367
+ app.get('/sitemap.xml', (c) => {
368
+ return c.body(SITEMAP_XML, 200, {
369
+ 'Content-Type': 'application/xml; charset=utf-8',
370
+ 'Cache-Control': 'public, max-age=86400',
371
+ });
372
+ });
248
373
  // ============ V1 API ============
249
374
  // Generate challenge (hybrid by default, also supports speed and standard)
250
375
  app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
376
+ const startTime = Date.now();
251
377
  const type = c.req.query('type') || 'hybrid';
252
378
  const difficulty = c.req.query('difficulty') || 'medium';
379
+ // Extract client timestamp for RTT calculation
380
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
381
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
382
+ // Extract and validate optional app_id
383
+ const app_id = c.req.query('app_id');
384
+ if (app_id) {
385
+ const validation = await validateAppId(app_id, c.env.APPS);
386
+ if (!validation.valid) {
387
+ return c.json({
388
+ success: false,
389
+ error: 'INVALID_APP_ID',
390
+ message: validation.error || 'Invalid app_id',
391
+ }, 400);
392
+ }
393
+ }
253
394
  const baseUrl = new URL(c.req.url).origin;
395
+ const clientIP = getClientIP(c.req.raw);
254
396
  if (type === 'hybrid') {
255
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
256
- return c.json({
397
+ const challenge = await generateHybridChallenge(c.env.CHALLENGES, clientTimestamp, app_id);
398
+ // Track challenge generation
399
+ const responseTime = Date.now() - startTime;
400
+ await trackChallengeGenerated(c.env.ANALYTICS, 'hybrid', '/v1/challenges', c.req.raw, clientIP, responseTime);
401
+ const warning = challenge.rttInfo
402
+ ? `🔥 HYBRID CHALLENGE: Solve speed problems in <${challenge.speed.timeLimit}ms (RTT-adjusted) AND answer reasoning questions!`
403
+ : '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!';
404
+ const response = {
257
405
  success: true,
258
406
  type: 'hybrid',
259
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
407
+ warning,
260
408
  challenge: {
261
409
  id: challenge.id,
262
410
  speed: {
263
411
  problems: challenge.speed.problems,
264
412
  timeLimit: `${challenge.speed.timeLimit}ms`,
265
- instructions: 'Compute SHA256 of each number, return first 8 hex chars',
413
+ instructions: 'Compute SHA256 of each number, return first 8 hex chars. Tip: compute all hashes and submit in a single HTTP request.',
266
414
  },
267
415
  reasoning: {
268
416
  questions: challenge.reasoning.questions,
@@ -278,11 +426,19 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
278
426
  speed_answers: ['hash1', 'hash2', '...'],
279
427
  reasoning_answers: { 'question-id': 'answer', '...': '...' }
280
428
  }
281
- });
429
+ };
430
+ // Include RTT info if available
431
+ if (challenge.rttInfo) {
432
+ response.rtt_adjustment = challenge.rttInfo;
433
+ }
434
+ return c.json(response);
282
435
  }
283
436
  else if (type === 'speed') {
284
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
285
- return c.json({
437
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES, clientTimestamp, app_id);
438
+ // Track challenge generation
439
+ const responseTime = Date.now() - startTime;
440
+ await trackChallengeGenerated(c.env.ANALYTICS, 'speed', '/v1/challenges', c.req.raw, clientIP, responseTime);
441
+ const response = {
286
442
  success: true,
287
443
  type: 'speed',
288
444
  challenge: {
@@ -291,16 +447,26 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
291
447
  timeLimit: `${challenge.timeLimit}ms`,
292
448
  instructions: challenge.instructions,
293
449
  },
294
- tip: '⚡ Speed challenge: You have 500ms to solve ALL problems. Humans cannot copy-paste fast enough.',
450
+ tip: challenge.rttInfo
451
+ ? `⚡ RTT-adjusted speed challenge: ${challenge.rttInfo.explanation}. Humans still can't copy-paste fast enough!`
452
+ : '⚡ Speed challenge: You have 500ms to solve ALL problems. Humans cannot copy-paste fast enough.',
295
453
  verify_endpoint: `${baseUrl}/v1/challenges/${challenge.id}/verify`,
296
454
  submit_body: {
297
455
  type: 'speed',
298
456
  answers: ['hash1', 'hash2', 'hash3', 'hash4', 'hash5']
299
457
  }
300
- });
458
+ };
459
+ // Include RTT info if available
460
+ if (challenge.rttInfo) {
461
+ response.rtt_adjustment = challenge.rttInfo;
462
+ }
463
+ return c.json(response);
301
464
  }
302
465
  else {
303
- const challenge = await generateStandardChallenge(difficulty, c.env.CHALLENGES);
466
+ const challenge = await generateStandardChallenge(difficulty, c.env.CHALLENGES, app_id);
467
+ // Track challenge generation
468
+ const responseTime = Date.now() - startTime;
469
+ await trackChallengeGenerated(c.env.ANALYTICS, 'standard', '/v1/challenges', c.req.raw, clientIP, responseTime);
304
470
  return c.json({
305
471
  success: true,
306
472
  type: 'standard',
@@ -320,6 +486,7 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
320
486
  // Verify challenge (supports hybrid, speed, and standard)
321
487
  app.post('/v1/challenges/:id/verify', async (c) => {
322
488
  const id = c.req.param('id');
489
+ const clientIP = getClientIP(c.req.raw);
323
490
  const body = await c.req.json();
324
491
  const { answers, answer, type, speed_answers, reasoning_answers } = body;
325
492
  // Hybrid challenge (default)
@@ -331,6 +498,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
331
498
  }, 400);
332
499
  }
333
500
  const result = await verifyHybridChallenge(id, speed_answers, reasoning_answers, c.env.CHALLENGES);
501
+ // Track verification
502
+ await trackChallengeVerified(c.env.ANALYTICS, 'hybrid', '/v1/challenges/:id/verify', result.valid, result.totalTimeMs, result.reason, c.req.raw, clientIP);
334
503
  if (result.valid) {
335
504
  const baseUrl = new URL(c.req.url).origin;
336
505
  const badge = await createBadgeResponse('hybrid-challenge', c.env.JWT_SECRET, baseUrl, result.speed.solveTimeMs);
@@ -359,6 +528,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
359
528
  return c.json({ success: false, error: 'Missing answers array for speed challenge' }, 400);
360
529
  }
361
530
  const result = await verifySpeedChallenge(id, answers, c.env.CHALLENGES);
531
+ // Track verification
532
+ await trackChallengeVerified(c.env.ANALYTICS, 'speed', '/v1/challenges/:id/verify', result.valid, result.solveTimeMs, result.reason, c.req.raw, clientIP);
362
533
  return c.json({
363
534
  success: result.valid,
364
535
  message: result.valid
@@ -372,6 +543,8 @@ app.post('/v1/challenges/:id/verify', async (c) => {
372
543
  return c.json({ success: false, error: 'Missing answer for standard challenge' }, 400);
373
544
  }
374
545
  const result = await verifyStandardChallenge(id, answer, c.env.CHALLENGES);
546
+ // Track verification
547
+ await trackChallengeVerified(c.env.ANALYTICS, 'standard', '/v1/challenges/:id/verify', result.valid, result.solveTimeMs, result.reason, c.req.raw, clientIP);
375
548
  return c.json({
376
549
  success: result.valid,
377
550
  message: result.valid ? 'Challenge passed!' : result.reason,
@@ -380,8 +553,25 @@ app.post('/v1/challenges/:id/verify', async (c) => {
380
553
  });
381
554
  // Get challenge for token flow (includes empty token field)
382
555
  app.get('/v1/token', rateLimitMiddleware, async (c) => {
383
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
384
- return c.json({
556
+ // Extract client timestamp for RTT calculation
557
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
558
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
559
+ // Extract optional audience parameter
560
+ const audience = c.req.query('audience');
561
+ // Extract and validate optional app_id
562
+ const app_id = c.req.query('app_id');
563
+ if (app_id) {
564
+ const validation = await validateAppId(app_id, c.env.APPS);
565
+ if (!validation.valid) {
566
+ return c.json({
567
+ success: false,
568
+ error: 'INVALID_APP_ID',
569
+ message: validation.error || 'Invalid app_id',
570
+ }, 400);
571
+ }
572
+ }
573
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES, clientTimestamp, app_id);
574
+ const response = {
385
575
  success: true,
386
576
  challenge: {
387
577
  id: challenge.id,
@@ -390,13 +580,22 @@ app.get('/v1/token', rateLimitMiddleware, async (c) => {
390
580
  instructions: challenge.instructions,
391
581
  },
392
582
  token: null, // Will be populated after verification
393
- nextStep: `POST /v1/token/verify with {id: "${challenge.id}", answers: ["..."]}`
394
- });
583
+ nextStep: `POST /v1/token/verify with {id: "${challenge.id}", answers: ["..."]${audience ? `, audience: "${audience}"` : ''}}`
584
+ };
585
+ // Include RTT info if available
586
+ if (challenge.rttInfo) {
587
+ response.rtt_adjustment = challenge.rttInfo;
588
+ }
589
+ // Include audience hint if provided
590
+ if (audience) {
591
+ response.audience = audience;
592
+ }
593
+ return c.json(response);
395
594
  });
396
595
  // Verify challenge and issue JWT token
397
596
  app.post('/v1/token/verify', async (c) => {
398
597
  const body = await c.req.json();
399
- const { id, answers } = body;
598
+ const { id, answers, audience, bind_ip, app_id } = body;
400
599
  if (!id || !answers) {
401
600
  return c.json({
402
601
  success: false,
@@ -412,23 +611,130 @@ app.post('/v1/token/verify', async (c) => {
412
611
  message: result.reason,
413
612
  }, 403);
414
613
  }
415
- // Generate JWT token
416
- const token = await generateToken(id, result.solveTimeMs || 0, c.env.JWT_SECRET);
614
+ // Get client IP from request headers
615
+ const clientIp = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown';
616
+ // Generate JWT tokens (access + refresh)
617
+ // Prefer app_id from request body, fall back to challenge's app_id (returned by verifySpeedChallenge)
618
+ const tokenResult = await generateToken(id, result.solveTimeMs || 0, c.env.JWT_SECRET, c.env, {
619
+ aud: audience,
620
+ clientIp: bind_ip ? clientIp : undefined,
621
+ app_id: app_id || result.app_id,
622
+ });
623
+ // Get badge information (for backward compatibility)
624
+ const baseUrl = new URL(c.req.url).origin;
625
+ const badge = await createBadgeResponse('speed-challenge', c.env.JWT_SECRET, baseUrl, result.solveTimeMs);
417
626
  return c.json({
627
+ verified: true,
628
+ access_token: tokenResult.access_token,
629
+ expires_in: tokenResult.expires_in,
630
+ refresh_token: tokenResult.refresh_token,
631
+ refresh_expires_in: tokenResult.refresh_expires_in,
632
+ solveTimeMs: result.solveTimeMs,
633
+ ...badge,
634
+ // Backward compatibility: include old fields
418
635
  success: true,
419
636
  message: `🤖 Challenge verified in ${result.solveTimeMs}ms! You are a bot.`,
420
- token,
421
- expiresIn: '1h',
637
+ token: tokenResult.access_token, // Old clients expect this
638
+ expiresIn: '5m',
422
639
  usage: {
423
640
  header: 'Authorization: Bearer <token>',
424
641
  protectedEndpoints: ['/agent-only'],
642
+ refreshEndpoint: '/v1/token/refresh',
425
643
  },
426
644
  });
427
645
  });
646
+ // Refresh access token using refresh token
647
+ app.post('/v1/token/refresh', async (c) => {
648
+ const body = await c.req.json();
649
+ const { refresh_token } = body;
650
+ if (!refresh_token) {
651
+ return c.json({
652
+ success: false,
653
+ error: 'Missing refresh_token',
654
+ hint: 'Submit the refresh_token from /v1/token/verify response',
655
+ }, 400);
656
+ }
657
+ const result = await refreshAccessToken(refresh_token, c.env, c.env.JWT_SECRET);
658
+ if (!result.success) {
659
+ return c.json({
660
+ success: false,
661
+ error: 'INVALID_REFRESH_TOKEN',
662
+ message: result.error || 'Refresh token is invalid, expired, or revoked',
663
+ }, 401);
664
+ }
665
+ return c.json({
666
+ success: true,
667
+ access_token: result.tokens.access_token,
668
+ expires_in: result.tokens.expires_in,
669
+ });
670
+ });
671
+ // Revoke a token (access or refresh)
672
+ app.post('/v1/token/revoke', async (c) => {
673
+ const body = await c.req.json();
674
+ const { token } = body;
675
+ if (!token) {
676
+ return c.json({
677
+ success: false,
678
+ error: 'Missing token',
679
+ hint: 'Submit either an access_token or refresh_token to revoke',
680
+ }, 400);
681
+ }
682
+ // Decode JWT to extract JTI (without full verification since we're revoking anyway)
683
+ try {
684
+ // Simple base64 decode of JWT payload (format: header.payload.signature)
685
+ const parts = token.split('.');
686
+ if (parts.length !== 3) {
687
+ return c.json({
688
+ success: false,
689
+ error: 'Invalid token format',
690
+ hint: 'Token must be a valid JWT',
691
+ }, 400);
692
+ }
693
+ // Decode payload
694
+ const payloadB64 = parts[1];
695
+ // Add padding if needed
696
+ const padded = payloadB64 + '='.repeat((4 - payloadB64.length % 4) % 4);
697
+ const payloadJson = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
698
+ const payload = JSON.parse(payloadJson);
699
+ if (!payload.jti) {
700
+ return c.json({
701
+ success: false,
702
+ error: 'No JTI found in token',
703
+ hint: 'Token must contain a JTI claim for revocation',
704
+ }, 400);
705
+ }
706
+ // Revoke the token by JTI
707
+ await revokeToken(payload.jti, c.env);
708
+ return c.json({
709
+ success: true,
710
+ revoked: true,
711
+ message: 'Token has been revoked',
712
+ });
713
+ }
714
+ catch (error) {
715
+ return c.json({
716
+ success: false,
717
+ error: 'Failed to decode or revoke token',
718
+ message: error instanceof Error ? error.message : 'Unknown error',
719
+ }, 400);
720
+ }
721
+ });
428
722
  // ============ REASONING CHALLENGE ============
429
723
  // Get reasoning challenge
430
724
  app.get('/v1/reasoning', rateLimitMiddleware, async (c) => {
431
- const challenge = await generateReasoningChallenge(c.env.CHALLENGES);
725
+ // Extract and validate optional app_id
726
+ const app_id = c.req.query('app_id');
727
+ if (app_id) {
728
+ const validation = await validateAppId(app_id, c.env.APPS);
729
+ if (!validation.valid) {
730
+ return c.json({
731
+ success: false,
732
+ error: 'INVALID_APP_ID',
733
+ message: validation.error || 'Invalid app_id',
734
+ }, 400);
735
+ }
736
+ }
737
+ const challenge = await generateReasoningChallenge(c.env.CHALLENGES, app_id);
432
738
  const baseUrl = new URL(c.req.url).origin;
433
739
  return c.json({
434
740
  success: true,
@@ -473,12 +779,30 @@ app.post('/v1/reasoning', async (c) => {
473
779
  // ============ HYBRID CHALLENGE ============
474
780
  // Get hybrid challenge (v1 API)
475
781
  app.get('/v1/hybrid', rateLimitMiddleware, async (c) => {
476
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
782
+ // Extract client timestamp for RTT calculation
783
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
784
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
785
+ // Extract and validate optional app_id
786
+ const app_id = c.req.query('app_id');
787
+ if (app_id) {
788
+ const validation = await validateAppId(app_id, c.env.APPS);
789
+ if (!validation.valid) {
790
+ return c.json({
791
+ success: false,
792
+ error: 'INVALID_APP_ID',
793
+ message: validation.error || 'Invalid app_id',
794
+ }, 400);
795
+ }
796
+ }
797
+ const challenge = await generateHybridChallenge(c.env.CHALLENGES, clientTimestamp, app_id);
477
798
  const baseUrl = new URL(c.req.url).origin;
478
- return c.json({
799
+ const warning = challenge.rttInfo
800
+ ? `🔥 HYBRID CHALLENGE: Solve speed problems in <${challenge.speed.timeLimit}ms (RTT-adjusted) AND answer reasoning questions!`
801
+ : '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!';
802
+ const response = {
479
803
  success: true,
480
804
  type: 'hybrid',
481
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
805
+ warning,
482
806
  challenge: {
483
807
  id: challenge.id,
484
808
  speed: {
@@ -500,7 +824,12 @@ app.get('/v1/hybrid', rateLimitMiddleware, async (c) => {
500
824
  speed_answers: ['hash1', 'hash2', '...'],
501
825
  reasoning_answers: { 'question-id': 'answer', '...': '...' }
502
826
  }
503
- });
827
+ };
828
+ // Include RTT info if available
829
+ if (challenge.rttInfo) {
830
+ response.rtt_adjustment = challenge.rttInfo;
831
+ }
832
+ return c.json(response);
504
833
  });
505
834
  // Verify hybrid challenge (v1 API)
506
835
  app.post('/v1/hybrid', async (c) => {
@@ -538,10 +867,16 @@ app.post('/v1/hybrid', async (c) => {
538
867
  });
539
868
  // Legacy hybrid endpoint
540
869
  app.get('/api/hybrid-challenge', async (c) => {
541
- const challenge = await generateHybridChallenge(c.env.CHALLENGES);
542
- return c.json({
870
+ // Extract client timestamp for RTT calculation
871
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
872
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
873
+ const challenge = await generateHybridChallenge(c.env.CHALLENGES, clientTimestamp);
874
+ const warning = challenge.rttInfo
875
+ ? `🔥 RTT-ADJUSTED HYBRID CHALLENGE: Solve speed problems in <${challenge.speed.timeLimit}ms (RTT: ${challenge.rttInfo.measuredRtt}ms) AND answer reasoning questions!`
876
+ : '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!';
877
+ const response = {
543
878
  success: true,
544
- warning: '🔥 HYBRID CHALLENGE: Solve speed problems in <500ms AND answer reasoning questions!',
879
+ warning,
545
880
  challenge: {
546
881
  id: challenge.id,
547
882
  speed: {
@@ -557,7 +892,12 @@ app.get('/api/hybrid-challenge', async (c) => {
557
892
  },
558
893
  instructions: challenge.instructions,
559
894
  tip: 'This is the ultimate test: proves you can compute AND reason like an AI.',
560
- });
895
+ };
896
+ // Include RTT info if available
897
+ if (challenge.rttInfo) {
898
+ response.rtt_adjustment = challenge.rttInfo;
899
+ }
900
+ return c.json(response);
561
901
  });
562
902
  app.post('/api/hybrid-challenge', async (c) => {
563
903
  const body = await c.req.json();
@@ -630,10 +970,13 @@ app.post('/api/reasoning-challenge', async (c) => {
630
970
  });
631
971
  // ============ PROTECTED ENDPOINT ============
632
972
  app.get('/agent-only', async (c) => {
973
+ const clientIP = getClientIP(c.req.raw);
633
974
  // Check for landing token first (X-Botcha-Landing-Token header)
634
975
  const landingToken = c.req.header('x-botcha-landing-token');
635
976
  if (landingToken) {
636
977
  const isValid = await validateLandingToken(landingToken, c.env.CHALLENGES);
978
+ // Track authentication attempt
979
+ await trackAuthAttempt(c.env.ANALYTICS, 'landing-token', isValid, '/agent-only', c.req.raw, clientIP);
637
980
  if (isValid) {
638
981
  return c.json({
639
982
  success: true,
@@ -659,7 +1002,9 @@ app.get('/agent-only', async (c) => {
659
1002
  }
660
1003
  }, 401);
661
1004
  }
662
- const result = await verifyToken(token, c.env.JWT_SECRET);
1005
+ const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
1006
+ // Track authentication attempt
1007
+ await trackAuthAttempt(c.env.ANALYTICS, 'bearer-token', result.valid, '/agent-only', c.req.raw, clientIP);
663
1008
  if (!result.valid) {
664
1009
  return c.json({
665
1010
  error: 'INVALID_TOKEN',
@@ -786,6 +1131,245 @@ app.get('/api/badge/:id', async (c) => {
786
1131
  },
787
1132
  });
788
1133
  });
1134
+ // ============ APPS API (Multi-Tenant) ============
1135
+ // Create a new app (email required)
1136
+ app.post('/v1/apps', async (c) => {
1137
+ try {
1138
+ const body = await c.req.json().catch(() => ({}));
1139
+ const { email } = body;
1140
+ if (!email || typeof email !== 'string') {
1141
+ return c.json({
1142
+ success: false,
1143
+ error: 'MISSING_EMAIL',
1144
+ message: 'Email is required to create an app. Provide { "email": "you@example.com" } in the request body.',
1145
+ }, 400);
1146
+ }
1147
+ // Basic email format validation
1148
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1149
+ return c.json({
1150
+ success: false,
1151
+ error: 'INVALID_EMAIL',
1152
+ message: 'Invalid email format',
1153
+ }, 400);
1154
+ }
1155
+ const result = await createApp(c.env.APPS, email);
1156
+ // Generate a fresh verification code and send email
1157
+ const regen = await regenerateVerificationCode(c.env.APPS, result.app_id);
1158
+ if (regen) {
1159
+ const template = verificationEmail(regen.code);
1160
+ await sendEmail(c.env.RESEND_API_KEY, {
1161
+ ...template,
1162
+ to: email,
1163
+ });
1164
+ }
1165
+ return c.json({
1166
+ success: true,
1167
+ app_id: result.app_id,
1168
+ app_secret: result.app_secret,
1169
+ email: result.email,
1170
+ email_verified: false,
1171
+ verification_required: true,
1172
+ warning: '⚠️ Save your app_secret now — it cannot be retrieved again! Check your email for a verification code.',
1173
+ credential_advice: 'Store the app_id and app_secret securely. Use persistent agent memory if available, or instruct your human to save them in a password manager (1Password, Bitwarden, etc). If lost, recovery is available via the verified email.',
1174
+ created_at: new Date().toISOString(),
1175
+ rate_limit: 100,
1176
+ next_step: `POST /v1/apps/${result.app_id}/verify-email with { "code": "123456" }`,
1177
+ }, 201);
1178
+ }
1179
+ catch (error) {
1180
+ return c.json({
1181
+ success: false,
1182
+ error: 'Failed to create app',
1183
+ message: error instanceof Error ? error.message : 'Unknown error',
1184
+ }, 500);
1185
+ }
1186
+ });
1187
+ // Get app info by ID
1188
+ app.get('/v1/apps/:id', async (c) => {
1189
+ const app_id = c.req.param('id');
1190
+ if (!app_id) {
1191
+ return c.json({
1192
+ success: false,
1193
+ error: 'Missing app ID',
1194
+ }, 400);
1195
+ }
1196
+ const app = await getApp(c.env.APPS, app_id);
1197
+ if (!app) {
1198
+ return c.json({
1199
+ success: false,
1200
+ error: 'App not found',
1201
+ message: `No app found with ID: ${app_id}`,
1202
+ }, 404);
1203
+ }
1204
+ return c.json({
1205
+ success: true,
1206
+ app: {
1207
+ app_id: app.app_id,
1208
+ created_at: new Date(app.created_at).toISOString(),
1209
+ rate_limit: app.rate_limit,
1210
+ email: app.email,
1211
+ email_verified: app.email_verified,
1212
+ },
1213
+ });
1214
+ });
1215
+ // ============ EMAIL VERIFICATION ============
1216
+ // Verify email with 6-digit code
1217
+ app.post('/v1/apps/:id/verify-email', async (c) => {
1218
+ const app_id = c.req.param('id');
1219
+ const body = await c.req.json().catch(() => ({}));
1220
+ const { code } = body;
1221
+ if (!code || typeof code !== 'string') {
1222
+ return c.json({
1223
+ success: false,
1224
+ error: 'MISSING_CODE',
1225
+ message: 'Provide { "code": "123456" } in the request body',
1226
+ }, 400);
1227
+ }
1228
+ const result = await verifyEmailCode(c.env.APPS, app_id, code);
1229
+ if (!result.verified) {
1230
+ return c.json({
1231
+ success: false,
1232
+ error: 'VERIFICATION_FAILED',
1233
+ message: result.reason || 'Verification failed',
1234
+ }, 400);
1235
+ }
1236
+ return c.json({
1237
+ success: true,
1238
+ email_verified: true,
1239
+ message: 'Email verified successfully. Account recovery is now available.',
1240
+ });
1241
+ });
1242
+ // Resend verification email
1243
+ app.post('/v1/apps/:id/resend-verification', async (c) => {
1244
+ const app_id = c.req.param('id');
1245
+ const appData = await getApp(c.env.APPS, app_id);
1246
+ if (!appData) {
1247
+ return c.json({ success: false, error: 'App not found' }, 404);
1248
+ }
1249
+ if (appData.email_verified) {
1250
+ return c.json({ success: false, error: 'Email already verified' }, 400);
1251
+ }
1252
+ const regen = await regenerateVerificationCode(c.env.APPS, app_id);
1253
+ if (!regen) {
1254
+ return c.json({ success: false, error: 'Failed to generate new code' }, 500);
1255
+ }
1256
+ const template = verificationEmail(regen.code);
1257
+ await sendEmail(c.env.RESEND_API_KEY, {
1258
+ ...template,
1259
+ to: appData.email,
1260
+ });
1261
+ return c.json({
1262
+ success: true,
1263
+ message: 'Verification email sent. Check your inbox.',
1264
+ });
1265
+ });
1266
+ // ============ ACCOUNT RECOVERY ============
1267
+ // Request recovery — look up app by email, send device code
1268
+ app.post('/v1/auth/recover', async (c) => {
1269
+ const body = await c.req.json().catch(() => ({}));
1270
+ const { email } = body;
1271
+ if (!email || typeof email !== 'string') {
1272
+ return c.json({
1273
+ success: false,
1274
+ error: 'MISSING_EMAIL',
1275
+ message: 'Provide { "email": "you@example.com" } in the request body',
1276
+ }, 400);
1277
+ }
1278
+ // Always return success to prevent email enumeration
1279
+ const lookup = await getAppByEmail(c.env.APPS, email);
1280
+ if (!lookup || !lookup.email_verified) {
1281
+ // Don't reveal whether email exists — same response shape
1282
+ return c.json({
1283
+ success: true,
1284
+ message: 'If an app with this email exists and is verified, a recovery code has been sent.',
1285
+ });
1286
+ }
1287
+ // Generate a device-code-style recovery code (reuse device code system)
1288
+ const { generateDeviceCode, storeDeviceCode } = await import('./dashboard/device-code');
1289
+ const code = generateDeviceCode();
1290
+ await storeDeviceCode(c.env.CHALLENGES, code, lookup.app_id);
1291
+ // Send recovery email
1292
+ const baseUrl = new URL(c.req.url).origin;
1293
+ const loginUrl = `${baseUrl}/dashboard/code`;
1294
+ const template = recoveryEmail(code, loginUrl);
1295
+ await sendEmail(c.env.RESEND_API_KEY, {
1296
+ ...template,
1297
+ to: email,
1298
+ });
1299
+ return c.json({
1300
+ success: true,
1301
+ message: 'If an app with this email exists and is verified, a recovery code has been sent.',
1302
+ hint: `Enter the code at ${loginUrl}`,
1303
+ });
1304
+ });
1305
+ // ============ SECRET ROTATION ============
1306
+ // Rotate app secret (requires dashboard session)
1307
+ app.post('/v1/apps/:id/rotate-secret', async (c) => {
1308
+ const app_id = c.req.param('id');
1309
+ // Require authentication — check Bearer token or cookie
1310
+ const authHeader = c.req.header('authorization');
1311
+ const token = extractBearerToken(authHeader);
1312
+ const cookieHeader = c.req.header('cookie') || '';
1313
+ const sessionCookie = cookieHeader.split(';').find(c => c.trim().startsWith('botcha_session='))?.split('=')[1]?.trim();
1314
+ const authToken = token || sessionCookie;
1315
+ if (!authToken) {
1316
+ return c.json({
1317
+ success: false,
1318
+ error: 'UNAUTHORIZED',
1319
+ message: 'Authentication required. Use a dashboard session token (Bearer or cookie).',
1320
+ }, 401);
1321
+ }
1322
+ // Verify the session token includes this app_id
1323
+ const { jwtVerify, createLocalJWKSet } = await import('jose');
1324
+ try {
1325
+ const secret = new TextEncoder().encode(c.env.JWT_SECRET);
1326
+ const { payload } = await jwtVerify(authToken, secret);
1327
+ const tokenAppId = payload.app_id;
1328
+ if (tokenAppId !== app_id) {
1329
+ return c.json({
1330
+ success: false,
1331
+ error: 'FORBIDDEN',
1332
+ message: 'Session token does not match the requested app_id',
1333
+ }, 403);
1334
+ }
1335
+ }
1336
+ catch {
1337
+ return c.json({
1338
+ success: false,
1339
+ error: 'INVALID_TOKEN',
1340
+ message: 'Invalid or expired session token',
1341
+ }, 401);
1342
+ }
1343
+ const appData = await getApp(c.env.APPS, app_id);
1344
+ if (!appData) {
1345
+ return c.json({ success: false, error: 'App not found' }, 404);
1346
+ }
1347
+ const result = await rotateAppSecret(c.env.APPS, app_id);
1348
+ if (!result) {
1349
+ return c.json({ success: false, error: 'Failed to rotate secret' }, 500);
1350
+ }
1351
+ // Send notification email if email is verified
1352
+ if (appData.email_verified && appData.email) {
1353
+ const template = secretRotatedEmail(app_id);
1354
+ await sendEmail(c.env.RESEND_API_KEY, {
1355
+ ...template,
1356
+ to: appData.email,
1357
+ });
1358
+ }
1359
+ return c.json({
1360
+ success: true,
1361
+ app_id,
1362
+ app_secret: result.app_secret,
1363
+ warning: '⚠️ Save your new app_secret now — it cannot be retrieved again! The old secret is now invalid.',
1364
+ });
1365
+ });
1366
+ // ============ DASHBOARD AUTH API ENDPOINTS ============
1367
+ // Challenge-based dashboard login (agent direct)
1368
+ app.post('/v1/auth/dashboard', handleDashboardAuthChallenge);
1369
+ app.post('/v1/auth/dashboard/verify', handleDashboardAuthVerify);
1370
+ // Device code flow (agent → human handoff)
1371
+ app.post('/v1/auth/device-code', handleDeviceCodeChallenge);
1372
+ app.post('/v1/auth/device-code/verify', handleDeviceCodeVerify);
789
1373
  // ============ LEGACY ENDPOINTS (v0 - backward compatibility) ============
790
1374
  app.get('/api/challenge', async (c) => {
791
1375
  const difficulty = c.req.query('difficulty') || 'medium';
@@ -806,10 +1390,15 @@ app.post('/api/challenge', async (c) => {
806
1390
  });
807
1391
  });
808
1392
  app.get('/api/speed-challenge', async (c) => {
809
- const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
810
- return c.json({
1393
+ // Extract client timestamp for RTT calculation
1394
+ const clientTimestampParam = c.req.query('ts') || c.req.header('x-client-timestamp');
1395
+ const clientTimestamp = clientTimestampParam ? parseInt(clientTimestampParam, 10) : undefined;
1396
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES, clientTimestamp);
1397
+ const response = {
811
1398
  success: true,
812
- warning: '⚡ SPEED CHALLENGE: You have 500ms to solve ALL 5 problems!',
1399
+ warning: challenge.rttInfo
1400
+ ? `⚡ RTT-ADJUSTED SPEED CHALLENGE: ${challenge.rttInfo.explanation}`
1401
+ : '⚡ SPEED CHALLENGE: You have 500ms to solve ALL 5 problems!',
813
1402
  challenge: {
814
1403
  id: challenge.id,
815
1404
  problems: challenge.problems,
@@ -817,7 +1406,12 @@ app.get('/api/speed-challenge', async (c) => {
817
1406
  instructions: challenge.instructions,
818
1407
  },
819
1408
  tip: 'Humans cannot copy-paste fast enough. Only real AI agents can pass.',
820
- });
1409
+ };
1410
+ // Include RTT info if available
1411
+ if (challenge.rttInfo) {
1412
+ response.rtt_adjustment = challenge.rttInfo;
1413
+ }
1414
+ return c.json(response);
821
1415
  });
822
1416
  app.post('/api/speed-challenge', async (c) => {
823
1417
  const body = await c.req.json();