@dupecom/botcha-cloudflare 0.9.0 → 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/email.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * BOTCHA Email — Resend Integration
3
+ *
4
+ * Sends transactional emails via Resend API.
5
+ * Falls back to console.log when RESEND_API_KEY is not set (local dev).
6
+ *
7
+ * Uses onboarding@resend.dev (Resend test domain) until botcha.ai
8
+ * is verified as a sending domain.
9
+ */
10
+ const RESEND_API = 'https://api.resend.com/emails';
11
+ const FROM_ADDRESS = 'BOTCHA <onboarding@resend.dev>';
12
+ /**
13
+ * Send an email via Resend. Falls back to console logging if no API key.
14
+ */
15
+ export async function sendEmail(apiKey, options) {
16
+ // Fall back to logging when no API key (local dev)
17
+ if (!apiKey) {
18
+ console.log('[BOTCHA Email] No RESEND_API_KEY — logging instead of sending:');
19
+ console.log(` To: ${options.to}`);
20
+ console.log(` Subject: ${options.subject}`);
21
+ console.log(` Body: ${options.text || options.html.substring(0, 200)}`);
22
+ return { success: true, id: 'dev-logged' };
23
+ }
24
+ try {
25
+ const resp = await fetch(RESEND_API, {
26
+ method: 'POST',
27
+ headers: {
28
+ Authorization: `Bearer ${apiKey}`,
29
+ 'Content-Type': 'application/json',
30
+ },
31
+ body: JSON.stringify({
32
+ from: FROM_ADDRESS,
33
+ to: [options.to],
34
+ subject: options.subject,
35
+ html: options.html,
36
+ text: options.text,
37
+ }),
38
+ });
39
+ if (!resp.ok) {
40
+ const text = await resp.text();
41
+ console.error(`[BOTCHA Email] Resend error ${resp.status}:`, text);
42
+ return { success: false, error: `Resend ${resp.status}: ${text.substring(0, 200)}` };
43
+ }
44
+ const data = (await resp.json());
45
+ return { success: true, id: data.id };
46
+ }
47
+ catch (error) {
48
+ console.error('[BOTCHA Email] Send failed:', error);
49
+ return {
50
+ success: false,
51
+ error: error instanceof Error ? error.message : 'Unknown error',
52
+ };
53
+ }
54
+ }
55
+ // ============ EMAIL TEMPLATES ============
56
+ /**
57
+ * Email verification code email.
58
+ */
59
+ export function verificationEmail(code) {
60
+ return {
61
+ to: '', // caller fills in
62
+ subject: `BOTCHA: Your verification code is ${code}`,
63
+ html: `
64
+ <div style="font-family: 'Courier New', monospace; max-width: 480px; margin: 0 auto; padding: 2rem;">
65
+ <h1 style="font-size: 1.5rem; margin-bottom: 1rem;">BOTCHA</h1>
66
+ <p>Your email verification code:</p>
67
+ <div style="background: #f5f5f5; border: 2px solid #333; padding: 1.5rem; text-align: center; font-size: 2rem; font-weight: bold; letter-spacing: 0.3em; margin: 1.5rem 0;">
68
+ ${code}
69
+ </div>
70
+ <p style="color: #666; font-size: 0.875rem;">This code expires in 10 minutes.</p>
71
+ <p style="color: #666; font-size: 0.875rem;">If you didn't request this, ignore this email.</p>
72
+ <hr style="border: none; border-top: 1px solid #ddd; margin: 2rem 0;">
73
+ <p style="color: #999; font-size: 0.75rem;">BOTCHA — Prove you're a bot. Humans need not apply.</p>
74
+ </div>`,
75
+ text: `BOTCHA: Your verification code is ${code}\n\nThis code expires in 10 minutes.\nIf you didn't request this, ignore this email.`,
76
+ };
77
+ }
78
+ /**
79
+ * Account recovery device code email.
80
+ */
81
+ export function recoveryEmail(code, loginUrl) {
82
+ return {
83
+ to: '', // caller fills in
84
+ subject: `BOTCHA: Your recovery code is ${code}`,
85
+ html: `
86
+ <div style="font-family: 'Courier New', monospace; max-width: 480px; margin: 0 auto; padding: 2rem;">
87
+ <h1 style="font-size: 1.5rem; margin-bottom: 1rem;">BOTCHA</h1>
88
+ <p>Someone requested access to your BOTCHA dashboard. Enter this code to log in:</p>
89
+ <div style="background: #f5f5f5; border: 2px solid #333; padding: 1.5rem; text-align: center; font-size: 2rem; font-weight: bold; letter-spacing: 0.15em; margin: 1.5rem 0;">
90
+ ${code}
91
+ </div>
92
+ <p>Enter this code at: <a href="${loginUrl}">${loginUrl}</a></p>
93
+ <p style="color: #666; font-size: 0.875rem;">This code expires in 10 minutes.</p>
94
+ <p style="color: #666; font-size: 0.875rem;">If you didn't request this, ignore this email. Your account is still secure.</p>
95
+ <hr style="border: none; border-top: 1px solid #ddd; margin: 2rem 0;">
96
+ <p style="color: #999; font-size: 0.75rem;">BOTCHA — Prove you're a bot. Humans need not apply.</p>
97
+ </div>`,
98
+ text: `BOTCHA: Your recovery code is ${code}\n\nEnter this code at: ${loginUrl}\n\nThis code expires in 10 minutes.\nIf you didn't request this, ignore this email.`,
99
+ };
100
+ }
101
+ /**
102
+ * New secret notification email (sent after secret rotation).
103
+ */
104
+ export function secretRotatedEmail(appId) {
105
+ return {
106
+ to: '', // caller fills in
107
+ subject: 'BOTCHA: Your app secret was rotated',
108
+ html: `
109
+ <div style="font-family: 'Courier New', monospace; max-width: 480px; margin: 0 auto; padding: 2rem;">
110
+ <h1 style="font-size: 1.5rem; margin-bottom: 1rem;">BOTCHA</h1>
111
+ <p>The secret for app <strong>${appId}</strong> was just rotated.</p>
112
+ <p>The old secret is no longer valid. Update your agent configuration with the new secret.</p>
113
+ <p style="color: #666; font-size: 0.875rem;">If you didn't do this, someone with access to your dashboard rotated your secret. Log in and rotate again immediately.</p>
114
+ <hr style="border: none; border-top: 1px solid #ddd; margin: 2rem 0;">
115
+ <p style="color: #999; font-size: 0.75rem;">BOTCHA — Prove you're a bot. Humans need not apply.</p>
116
+ </div>`,
117
+ text: `BOTCHA: The secret for app ${appId} was just rotated.\n\nThe old secret is no longer valid. Update your agent configuration with the new secret.\n\nIf you didn't do this, log in and rotate again immediately.`,
118
+ };
119
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,OAAO,EAYL,KAAK,WAAW,EACjB,MAAM,cAAc,CAAC;AAOtB,OAAO,EACL,KAAK,sBAAsB,EAM5B,MAAM,aAAa,CAAC;AAGrB,KAAK,QAAQ,GAAG;IACd,UAAU,EAAE,WAAW,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,KAAK,SAAS,GAAG;IACf,YAAY,CAAC,EAAE;QACb,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,iBAAiB,CAAC;QACxB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH,CAAC;AAEF,QAAA,MAAM,GAAG;cAAwB,QAAQ;eAAa,SAAS;yCAAK,CAAC;AAk8CrE,eAAe,GAAG,CAAC;AAGnB,OAAO,EACL,sBAAsB,EACtB,oBAAoB,EACpB,yBAAyB,EACzB,uBAAuB,EACvB,0BAA0B,EAC1B,wBAAwB,EACxB,uBAAuB,EACvB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,aAAa,EACb,WAAW,EACX,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,KAAK,EACV,KAAK,YAAY,GAClB,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,OAAO,EAYL,KAAK,WAAW,EACjB,MAAM,cAAc,CAAC;AAetB,OAAO,EACL,KAAK,sBAAsB,EAM5B,MAAM,aAAa,CAAC;AAGrB,KAAK,QAAQ,GAAG;IACd,UAAU,EAAE,WAAW,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,KAAK,SAAS,GAAG;IACf,YAAY,CAAC,EAAE;QACb,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,iBAAiB,CAAC;QACxB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH,CAAC;AAEF,QAAA,MAAM,GAAG;cAAwB,QAAQ;eAAa,SAAS;yCAAK,CAAC;AAusDrE,eAAe,GAAG,CAAC;AAGnB,OAAO,EACL,sBAAsB,EACtB,oBAAoB,EACpB,yBAAyB,EACzB,uBAAuB,EACvB,0BAA0B,EAC1B,wBAAwB,EACxB,uBAAuB,EACvB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,aAAa,EACb,WAAW,EACX,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,KAAK,EACV,KAAK,YAAY,GAClB,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -12,14 +12,18 @@ import { generateToken, verifyToken, extractBearerToken, revokeToken, refreshAcc
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';
15
17
  import { ROBOTS_TXT, AI_TXT, AI_PLUGIN_JSON, SITEMAP_XML, getOpenApiSpec } from './static';
16
- import { createApp, getApp } from './apps';
18
+ import { createApp, getApp, getAppByEmail, verifyEmailCode, rotateAppSecret, regenerateVerificationCode } from './apps';
19
+ import { sendEmail, verificationEmail, recoveryEmail, secretRotatedEmail } from './email';
17
20
  import { trackChallengeGenerated, trackChallengeVerified, trackAuthAttempt, trackRateLimitExceeded, } from './analytics';
18
21
  const app = new Hono();
19
22
  // ============ MIDDLEWARE ============
20
23
  app.use('*', cors());
21
24
  // ============ MOUNT ROUTES ============
22
25
  app.route('/', streamRoutes);
26
+ app.route('/dashboard', dashboardRoutes);
23
27
  // BOTCHA discovery headers
24
28
  app.use('*', async (c, next) => {
25
29
  await next();
@@ -145,15 +149,20 @@ function getHumanLanding(version) {
145
149
  ║ ║
146
150
  ║ This site is for AI agents and bots, not humans. ║
147
151
  ║ ║
148
- If you're a developer, point your bot here:
152
+ AI AGENT? Start here:
149
153
  ║ ║
150
- 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 ║
151
158
  ║ ║
152
- Or install the SDK:
159
+ DEVELOPER? Point your agent here:
153
160
  ║ ║
154
161
  ║ npm install @dupecom/botcha ║
155
162
  ║ pip install botcha ║
156
163
  ║ ║
164
+ ║ Read /ai.txt for full agent onboarding instructions. ║
165
+ ║ ║
157
166
  ║ GitHub: https://github.com/dupe-com/botcha ║
158
167
  ║ npm: https://npmjs.com/package/@dupecom/botcha ║
159
168
  ║ ║
@@ -177,7 +186,19 @@ app.get('/', (c) => {
177
186
  runtime: 'cloudflare-workers',
178
187
  tagline: 'Prove you are a bot. Humans need not apply.',
179
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
+ },
180
200
  quickstart: {
201
+ note: 'Already have an app? Use these endpoints to solve challenges and get tokens.',
181
202
  step1: 'GET /v1/challenges to receive a challenge',
182
203
  step2: 'Solve the SHA256 hash problems within allocated time',
183
204
  step3: 'POST your answers to verify',
@@ -210,8 +231,24 @@ app.get('/', (c) => {
210
231
  'GET /agent-only': 'Protected endpoint (requires Bearer token)',
211
232
  },
212
233
  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)',
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)',
215
252
  },
216
253
  badges: {
217
254
  'GET /badge/:id': 'Badge verification page (HTML)',
@@ -373,7 +410,7 @@ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
373
410
  speed: {
374
411
  problems: challenge.speed.problems,
375
412
  timeLimit: `${challenge.speed.timeLimit}ms`,
376
- 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.',
377
414
  },
378
415
  reasoning: {
379
416
  questions: challenge.reasoning.questions,
@@ -1095,17 +1132,48 @@ app.get('/api/badge/:id', async (c) => {
1095
1132
  });
1096
1133
  });
1097
1134
  // ============ APPS API (Multi-Tenant) ============
1098
- // Create a new app
1135
+ // Create a new app (email required)
1099
1136
  app.post('/v1/apps', async (c) => {
1100
1137
  try {
1101
- const result = await createApp(c.env.APPS);
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
+ }
1102
1165
  return c.json({
1103
1166
  success: true,
1104
1167
  app_id: result.app_id,
1105
1168
  app_secret: result.app_secret,
1106
- warning: '⚠️ Save your app_secret now — it cannot be retrieved again!',
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.',
1107
1174
  created_at: new Date().toISOString(),
1108
1175
  rate_limit: 100,
1176
+ next_step: `POST /v1/apps/${result.app_id}/verify-email with { "code": "123456" }`,
1109
1177
  }, 201);
1110
1178
  }
1111
1179
  catch (error) {
@@ -1139,9 +1207,169 @@ app.get('/v1/apps/:id', async (c) => {
1139
1207
  app_id: app.app_id,
1140
1208
  created_at: new Date(app.created_at).toISOString(),
1141
1209
  rate_limit: app.rate_limit,
1210
+ email: app.email,
1211
+ email_verified: app.email_verified,
1142
1212
  },
1143
1213
  });
1144
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);
1145
1373
  // ============ LEGACY ENDPOINTS (v0 - backward compatibility) ============
1146
1374
  app.get('/api/challenge', async (c) => {
1147
1375
  const difficulty = c.req.query('difficulty') || 'medium';
@@ -158,7 +158,7 @@ app.post('/v1/challenge/stream/:session', async (c) => {
158
158
  problems,
159
159
  timeLimit: 500,
160
160
  timerStart, // Include so client can verify timing
161
- instructions: 'Compute SHA256 of each number, return first 8 hex chars',
161
+ instructions: 'Compute SHA256 of each number, return first 8 hex chars. Tip: compute all hashes and submit in a single HTTP request.',
162
162
  },
163
163
  });
164
164
  }