@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/analytics.d.ts +60 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +130 -0
- package/dist/apps.d.ts +93 -0
- package/dist/apps.d.ts.map +1 -0
- package/dist/apps.js +152 -0
- package/dist/auth.d.ts +93 -6
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +251 -9
- package/dist/challenges.d.ts +31 -7
- package/dist/challenges.d.ts.map +1 -1
- package/dist/challenges.js +550 -144
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +412 -46
- package/dist/rate-limit.d.ts +11 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +13 -2
- package/dist/static.d.ts +517 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +635 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
217
|
-
'5. Use: Authorization: Bearer <
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
//
|
|
416
|
-
const
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
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
|
-
|
|
810
|
-
|
|
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:
|
|
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();
|