@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.
- 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 +159 -0
- package/dist/apps.d.ts.map +1 -0
- package/dist/apps.js +307 -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 +551 -144
- package/dist/dashboard/api.d.ts +70 -0
- package/dist/dashboard/api.d.ts.map +1 -0
- package/dist/dashboard/api.js +546 -0
- package/dist/dashboard/auth.d.ts +183 -0
- package/dist/dashboard/auth.d.ts.map +1 -0
- package/dist/dashboard/auth.js +401 -0
- package/dist/dashboard/device-code.d.ts +43 -0
- package/dist/dashboard/device-code.d.ts.map +1 -0
- package/dist/dashboard/device-code.js +77 -0
- package/dist/dashboard/index.d.ts +31 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +64 -0
- package/dist/dashboard/layout.d.ts +47 -0
- package/dist/dashboard/layout.d.ts.map +1 -0
- package/dist/dashboard/layout.js +38 -0
- package/dist/dashboard/pages.d.ts +11 -0
- package/dist/dashboard/pages.d.ts.map +1 -0
- package/dist/dashboard/pages.js +18 -0
- package/dist/dashboard/styles.d.ts +11 -0
- package/dist/dashboard/styles.d.ts.map +1 -0
- package/dist/dashboard/styles.js +633 -0
- package/dist/email.d.ts +44 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +119 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +644 -50
- 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/routes/stream.js +1 -1
- package/dist/static.d.ts +728 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +818 -0
- 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
|
-
║
|
|
152
|
+
║ AI AGENT? Start here: ║
|
|
125
153
|
║ ║
|
|
126
|
-
║
|
|
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
|
-
║
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
217
|
-
'5. Use: Authorization: Bearer <
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
//
|
|
416
|
-
const
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
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
|
-
|
|
810
|
-
|
|
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:
|
|
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();
|