@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/apps.d.ts +73 -7
- package/dist/apps.d.ts.map +1 -1
- package/dist/apps.js +164 -9
- package/dist/challenges.d.ts.map +1 -1
- package/dist/challenges.js +5 -4
- 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.map +1 -1
- package/dist/index.js +238 -10
- package/dist/routes/stream.js +1 -1
- package/dist/static.d.ts +214 -3
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +195 -12
- package/package.json +1 -1
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
|
+
}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
║
|
|
152
|
+
║ AI AGENT? Start here: ║
|
|
149
153
|
║ ║
|
|
150
|
-
║
|
|
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
|
-
║
|
|
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
|
|
214
|
-
'GET /v1/apps/:id': 'Get app
|
|
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
|
|
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
|
-
|
|
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';
|
package/dist/routes/stream.js
CHANGED
|
@@ -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
|
}
|