@dupecom/botcha-cloudflare 0.10.0 → 0.13.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/README.md +9 -8
- package/dist/agents.d.ts +68 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +123 -0
- package/dist/analytics.js +2 -2
- package/dist/dashboard/api.js +20 -13
- package/dist/dashboard/auth.d.ts +11 -0
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +20 -10
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +1 -0
- package/dist/dashboard/landing.d.ts +20 -0
- package/dist/dashboard/landing.d.ts.map +1 -0
- package/dist/dashboard/landing.js +85 -0
- package/dist/dashboard/layout.d.ts +7 -0
- package/dist/dashboard/layout.d.ts.map +1 -1
- package/dist/dashboard/layout.js +17 -0
- package/dist/dashboard/pages.d.ts.map +1 -1
- package/dist/dashboard/pages.js +1 -1
- package/dist/dashboard/styles.d.ts +1 -1
- package/dist/dashboard/styles.d.ts.map +1 -1
- package/dist/dashboard/styles.js +99 -1
- package/dist/email.d.ts +0 -3
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +1 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +508 -212
- package/dist/routes/stream.js +1 -1
- package/dist/static.d.ts +405 -2
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +510 -6
- package/dist/tap-agents.d.ts +120 -0
- package/dist/tap-agents.d.ts.map +1 -0
- package/dist/tap-agents.js +225 -0
- package/dist/tap-routes.d.ts +215 -0
- package/dist/tap-routes.d.ts.map +1 -0
- package/dist/tap-routes.js +379 -0
- package/dist/tap-verify.d.ts +86 -0
- package/dist/tap-verify.d.ts.map +1 -0
- package/dist/tap-verify.js +275 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "hono/jsx/jsx-runtime";
|
|
1
2
|
/**
|
|
2
|
-
* BOTCHA - Cloudflare Workers Edition v0.
|
|
3
|
+
* BOTCHA - Cloudflare Workers Edition v0.12.0
|
|
3
4
|
*
|
|
4
5
|
* Prove you're a bot. Humans need not apply.
|
|
5
6
|
*
|
|
@@ -8,15 +9,19 @@
|
|
|
8
9
|
import { Hono } from 'hono';
|
|
9
10
|
import { cors } from 'hono/cors';
|
|
10
11
|
import { generateSpeedChallenge, verifySpeedChallenge, generateStandardChallenge, verifyStandardChallenge, generateReasoningChallenge, verifyReasoningChallenge, generateHybridChallenge, verifyHybridChallenge, verifyLandingChallenge, validateLandingToken, } from './challenges';
|
|
12
|
+
import { SignJWT, jwtVerify } from 'jose';
|
|
11
13
|
import { generateToken, verifyToken, extractBearerToken, revokeToken, refreshAccessToken } from './auth';
|
|
12
14
|
import { checkRateLimit, getClientIP } from './rate-limit';
|
|
13
15
|
import { verifyBadge, generateBadgeSvg, generateBadgeHtml, createBadgeResponse } from './badge';
|
|
14
16
|
import streamRoutes from './routes/stream';
|
|
15
17
|
import dashboardRoutes from './dashboard/index';
|
|
16
18
|
import { handleDashboardAuthChallenge, handleDashboardAuthVerify, handleDeviceCodeChallenge, handleDeviceCodeVerify, } from './dashboard/auth';
|
|
17
|
-
import { ROBOTS_TXT, AI_TXT, AI_PLUGIN_JSON, SITEMAP_XML, getOpenApiSpec } from './static';
|
|
19
|
+
import { ROBOTS_TXT, AI_TXT, AI_PLUGIN_JSON, SITEMAP_XML, getOpenApiSpec, getBotchaMarkdown } from './static';
|
|
18
20
|
import { createApp, getApp, getAppByEmail, verifyEmailCode, rotateAppSecret, regenerateVerificationCode } from './apps';
|
|
19
21
|
import { sendEmail, verificationEmail, recoveryEmail, secretRotatedEmail } from './email';
|
|
22
|
+
import { LandingPage, VerifiedLandingPage } from './dashboard/landing';
|
|
23
|
+
import { createAgent, getAgent, listAgents } from './agents';
|
|
24
|
+
import { registerTAPAgentRoute, getTAPAgentRoute, listTAPAgentsRoute, createTAPSessionRoute, getTAPSessionRoute, } from './tap-routes.js';
|
|
20
25
|
import { trackChallengeGenerated, trackChallengeVerified, trackAuthAttempt, trackRateLimitExceeded, } from './analytics';
|
|
21
26
|
const app = new Hono();
|
|
22
27
|
// ============ MIDDLEWARE ============
|
|
@@ -27,7 +32,7 @@ app.route('/dashboard', dashboardRoutes);
|
|
|
27
32
|
// BOTCHA discovery headers
|
|
28
33
|
app.use('*', async (c, next) => {
|
|
29
34
|
await next();
|
|
30
|
-
c.header('X-Botcha-Version', c.env.BOTCHA_VERSION || '0.
|
|
35
|
+
c.header('X-Botcha-Version', c.env.BOTCHA_VERSION || '0.13.0');
|
|
31
36
|
c.header('X-Botcha-Enabled', 'true');
|
|
32
37
|
c.header('X-Botcha-Methods', 'speed-challenge,reasoning-challenge,hybrid-challenge,standard-challenge,jwt-token');
|
|
33
38
|
c.header('X-Botcha-Docs', 'https://botcha.ai/openapi.json');
|
|
@@ -95,215 +100,210 @@ async function requireJWT(c, next) {
|
|
|
95
100
|
await next();
|
|
96
101
|
}
|
|
97
102
|
// ============ ROOT & INFO ============
|
|
98
|
-
// Detect
|
|
99
|
-
|
|
103
|
+
// Detect request preference: 'markdown' | 'json' | 'html'
|
|
104
|
+
// Agents like Claude Code and OpenCode send Accept: text/markdown
|
|
105
|
+
function detectAcceptPreference(c) {
|
|
100
106
|
const accept = c.req.header('accept') || '';
|
|
101
|
-
const userAgent = c.req.header('user-agent') || '';
|
|
102
|
-
//
|
|
107
|
+
const userAgent = (c.req.header('user-agent') || '').toLowerCase();
|
|
108
|
+
// Explicit markdown preference (Cloudflare Markdown for Agents convention)
|
|
109
|
+
if (accept.includes('text/markdown'))
|
|
110
|
+
return 'markdown';
|
|
111
|
+
// Explicit JSON preference
|
|
103
112
|
if (accept.includes('application/json'))
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (userAgent.includes(
|
|
108
|
-
return
|
|
109
|
-
|
|
110
|
-
return true;
|
|
111
|
-
if (userAgent.includes('python'))
|
|
112
|
-
return true;
|
|
113
|
-
if (userAgent.includes('node'))
|
|
114
|
-
return true;
|
|
115
|
-
if (userAgent.includes('axios'))
|
|
116
|
-
return true;
|
|
117
|
-
if (userAgent.includes('fetch'))
|
|
118
|
-
return true;
|
|
119
|
-
if (userAgent.includes('bot'))
|
|
120
|
-
return true;
|
|
121
|
-
if (userAgent.includes('anthropic'))
|
|
122
|
-
return true;
|
|
123
|
-
if (userAgent.includes('openai'))
|
|
124
|
-
return true;
|
|
125
|
-
if (userAgent.includes('claude'))
|
|
126
|
-
return true;
|
|
127
|
-
if (userAgent.includes('gpt'))
|
|
128
|
-
return true;
|
|
129
|
-
// If no user agent at all, probably a bot
|
|
113
|
+
return 'json';
|
|
114
|
+
// Known bot user agents → JSON
|
|
115
|
+
const botSignals = ['curl', 'httpie', 'wget', 'python', 'node', 'axios', 'fetch', 'bot', 'anthropic', 'openai', 'claude', 'gpt'];
|
|
116
|
+
if (botSignals.some(s => userAgent.includes(s)))
|
|
117
|
+
return 'json';
|
|
118
|
+
// No user agent at all → probably a bot
|
|
130
119
|
if (!userAgent)
|
|
131
|
-
return
|
|
132
|
-
|
|
120
|
+
return 'json';
|
|
121
|
+
// Default: human browser → HTML
|
|
122
|
+
return 'html';
|
|
133
123
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
124
|
+
app.get('/', async (c) => {
|
|
125
|
+
const version = c.env.BOTCHA_VERSION || '0.13.0';
|
|
126
|
+
const preference = detectAcceptPreference(c);
|
|
127
|
+
const baseUrl = new URL(c.req.url).origin;
|
|
128
|
+
// Check if agent is verified (optional Bearer token)
|
|
129
|
+
const authHeader = c.req.header('authorization');
|
|
130
|
+
const token = extractBearerToken(authHeader);
|
|
131
|
+
let isVerified = false;
|
|
132
|
+
let tokenPayload;
|
|
133
|
+
if (token) {
|
|
134
|
+
const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
|
|
135
|
+
if (result.valid) {
|
|
136
|
+
isVerified = true;
|
|
137
|
+
tokenPayload = result.payload;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// HTML: landing page for human browsers
|
|
141
|
+
if (preference === 'html') {
|
|
142
|
+
// Check for visitor cookie (set by POST /gate after human enters agent's token)
|
|
143
|
+
const cookieHeader = c.req.header('cookie') || '';
|
|
144
|
+
const visitorToken = cookieHeader.split(';').find(ck => ck.trim().startsWith('botcha_visitor='))?.split('=')[1]?.trim();
|
|
145
|
+
if (visitorToken) {
|
|
146
|
+
// Verify visitor cookie — accepts both botcha-verified (legacy) and botcha-visitor (long-lived)
|
|
147
|
+
try {
|
|
148
|
+
const { payload: vPayload } = await jwtVerify(visitorToken, new TextEncoder().encode(c.env.JWT_SECRET), { algorithms: ['HS256'] });
|
|
149
|
+
if (vPayload.type === 'botcha-visitor' || vPayload.type === 'botcha-verified') {
|
|
150
|
+
return c.html(_jsx(VerifiedLandingPage, { version: version, solveTime: vPayload.solveTime }));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Invalid/expired cookie — fall through to unverified landing
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Check for error from /go/:code redirect
|
|
158
|
+
const url = new URL(c.req.url);
|
|
159
|
+
const errorParam = url.searchParams.get('error');
|
|
160
|
+
const errorMap = {
|
|
161
|
+
invalid: 'Invalid or expired code. Ask your agent to try again.',
|
|
162
|
+
missing: 'No code provided.',
|
|
163
|
+
};
|
|
164
|
+
const error = errorParam ? errorMap[errorParam] : undefined;
|
|
165
|
+
return c.html(_jsx(LandingPage, { version: version, error: error }));
|
|
166
|
+
}
|
|
167
|
+
// === UNVERIFIED: minimal teaser — just enough to get started ===
|
|
168
|
+
if (!isVerified) {
|
|
169
|
+
const teaser = {
|
|
170
|
+
name: 'BOTCHA',
|
|
171
|
+
version,
|
|
172
|
+
tagline: 'Prove you are a bot. Humans need not apply.',
|
|
173
|
+
status: 'unverified',
|
|
174
|
+
message: 'Verify yourself to see the full API documentation.',
|
|
175
|
+
verify: {
|
|
176
|
+
step1: `GET ${baseUrl}/v1/token — receive a speed challenge (5 SHA-256 hashes)`,
|
|
177
|
+
step2: 'Solve: compute SHA-256 of each number, return first 8 hex chars',
|
|
178
|
+
step3: `POST ${baseUrl}/v1/token/verify {"id":"<challenge_id>","answers":["hash1","hash2",...]}`,
|
|
179
|
+
step4: `Revisit: GET ${baseUrl}/ with header Authorization: Bearer <access_token>`,
|
|
180
|
+
},
|
|
181
|
+
human_handoff: {
|
|
182
|
+
note: 'After verifying, the response includes a human_link. Give it to your human to open in their browser.',
|
|
183
|
+
instruction: 'Tell your human: "Click this link to get access" and give them the human_link URL.',
|
|
184
|
+
link_field: 'human_link from step 3 response',
|
|
185
|
+
},
|
|
186
|
+
hint: 'You have 500ms. Only a bot can do this.',
|
|
187
|
+
links: {
|
|
188
|
+
openapi: `${baseUrl}/openapi.json`,
|
|
189
|
+
ai_txt: `${baseUrl}/ai.txt`,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
if (preference === 'markdown') {
|
|
193
|
+
const md = `# BOTCHA — Reverse CAPTCHA for AI Agents
|
|
194
|
+
|
|
195
|
+
> Prove you are a bot. Humans need not apply.
|
|
196
|
+
|
|
197
|
+
**Status:** Unverified. Solve a challenge to see the full documentation.
|
|
198
|
+
|
|
199
|
+
## Verify Yourself
|
|
200
|
+
|
|
201
|
+
1. \`GET ${baseUrl}/v1/token\` — receive a speed challenge (5 SHA-256 hashes)
|
|
202
|
+
2. Solve: compute SHA-256 of each number, return first 8 hex chars
|
|
203
|
+
3. \`POST ${baseUrl}/v1/token/verify {"id":"<challenge_id>","answers":["hash1","hash2",...]}\`
|
|
204
|
+
4. Revisit: \`GET ${baseUrl}/\` with header \`Authorization: Bearer <access_token>\`
|
|
205
|
+
|
|
206
|
+
You have 500ms. Only a bot can do this.
|
|
207
|
+
|
|
208
|
+
## Human Handoff
|
|
209
|
+
|
|
210
|
+
After verifying, the response includes a \`human_link\`. Give this URL to your human:
|
|
211
|
+
**"Click this link to get access: {human_link}"**
|
|
212
|
+
|
|
213
|
+
The link works for 5 minutes. Your human clicks it, gets a cookie, and sees the verified site.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
*Full API docs, SDKs, onboarding, and endpoint reference available after verification.*
|
|
172
218
|
`;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return c.
|
|
179
|
-
|
|
219
|
+
return c.body(md, 200, {
|
|
220
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
221
|
+
'Vary': 'Accept, Authorization',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return c.json(teaser, 200, { 'Vary': 'Accept, Authorization' });
|
|
225
|
+
}
|
|
226
|
+
// === VERIFIED: full documentation ===
|
|
227
|
+
if (preference === 'markdown') {
|
|
228
|
+
const markdown = getBotchaMarkdown(version);
|
|
229
|
+
return c.body(markdown, 200, {
|
|
230
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
231
|
+
'Vary': 'Accept, Authorization',
|
|
232
|
+
'Cache-Control': 'public, max-age=3600',
|
|
180
233
|
});
|
|
181
234
|
}
|
|
182
|
-
// For bots/agents, return comprehensive JSON documentation
|
|
183
235
|
return c.json({
|
|
184
236
|
name: 'BOTCHA',
|
|
185
237
|
version,
|
|
186
|
-
runtime: 'cloudflare-workers',
|
|
187
238
|
tagline: 'Prove you are a bot. Humans need not apply.',
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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.',
|
|
239
|
+
status: 'verified',
|
|
240
|
+
description: 'Reverse CAPTCHA for AI agents. Computational challenges only bots can solve. Identity layer for the agentic web.',
|
|
241
|
+
// Echo back your identity
|
|
242
|
+
your_identity: {
|
|
243
|
+
token_type: tokenPayload?.type,
|
|
244
|
+
app_id: tokenPayload?.app_id || null,
|
|
245
|
+
audience: tokenPayload?.aud || null,
|
|
246
|
+
solve_time_ms: tokenPayload?.solveTime,
|
|
199
247
|
},
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
248
|
+
// === FULL SETUP: register your app and agent identity ===
|
|
249
|
+
onboarding: {
|
|
250
|
+
note: 'For production use. Creates your app, verifies your human, registers your identity.',
|
|
251
|
+
steps: [
|
|
252
|
+
'1. Ask your human operator for their email address.',
|
|
253
|
+
`2. POST ${baseUrl}/v1/apps {"email":"human@example.com"} — returns app_id + app_secret (shown ONCE).`,
|
|
254
|
+
'3. Human receives a 6-digit verification code via email — ask them for it.',
|
|
255
|
+
`4. POST ${baseUrl}/v1/apps/{app_id}/verify-email {"code":"123456"} — enables account recovery.`,
|
|
256
|
+
`5. Register yourself: POST ${baseUrl}/v1/agents/register {"name":"my-agent","operator":"my-org"} — persistent agent identity.`,
|
|
257
|
+
`6. Solve challenges with your app: GET ${baseUrl}/v1/challenges?app_id=...`,
|
|
258
|
+
`7. Dashboard for your human: POST ${baseUrl}/v1/auth/device-code {"app_id":"..."}, solve challenge, give human the BOTCHA-XXXX code for /dashboard/code.`,
|
|
259
|
+
`8. Lost your secret? POST ${baseUrl}/v1/auth/recover {"email":"..."} — recovery code emailed.`,
|
|
260
|
+
],
|
|
208
261
|
},
|
|
262
|
+
// === All endpoints, grouped by domain ===
|
|
209
263
|
endpoints: {
|
|
210
264
|
challenges: {
|
|
211
|
-
'GET /v1/challenges': 'Get
|
|
212
|
-
'GET /v1/challenges?type=speed': '
|
|
213
|
-
'GET /v1/challenges?type=standard': '
|
|
265
|
+
'GET /v1/challenges': 'Get a challenge (hybrid by default, no auth required)',
|
|
266
|
+
'GET /v1/challenges?type=speed': 'Speed-only (SHA256 in <500ms)',
|
|
267
|
+
'GET /v1/challenges?type=standard': 'Standard puzzle challenge',
|
|
214
268
|
'POST /v1/challenges/:id/verify': 'Verify challenge solution',
|
|
215
269
|
},
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
'
|
|
219
|
-
'
|
|
220
|
-
'POST /v1/
|
|
221
|
-
|
|
222
|
-
streaming: {
|
|
223
|
-
'GET /v1/challenge/stream': 'SSE streaming challenge (interactive, real-time)',
|
|
224
|
-
'POST /v1/challenge/stream/:session': 'Send actions to streaming session',
|
|
270
|
+
tokens: {
|
|
271
|
+
note: 'Use token flow when you need a Bearer token for protected endpoints.',
|
|
272
|
+
'GET /v1/token': 'Get speed challenge for token flow (?audience= optional)',
|
|
273
|
+
'POST /v1/token/verify': 'Submit solution → access_token (5min) + refresh_token (1hr)',
|
|
274
|
+
'POST /v1/token/refresh': 'Refresh access token',
|
|
275
|
+
'POST /v1/token/revoke': 'Revoke a token',
|
|
225
276
|
},
|
|
226
|
-
|
|
227
|
-
'GET /
|
|
228
|
-
'
|
|
229
|
-
'POST /v1/token/refresh': 'Refresh access token using refresh token',
|
|
230
|
-
'POST /v1/token/revoke': 'Revoke a token (access or refresh)',
|
|
231
|
-
'GET /agent-only': 'Protected endpoint (requires Bearer token)',
|
|
277
|
+
protected: {
|
|
278
|
+
'GET /agent-only': 'Demo protected endpoint — requires Bearer token',
|
|
279
|
+
'GET /': 'This documentation (requires Bearer token for full version)',
|
|
232
280
|
},
|
|
233
281
|
apps: {
|
|
234
|
-
|
|
235
|
-
'
|
|
282
|
+
note: 'Create an app for isolated rate limits, scoped tokens, and dashboard access.',
|
|
283
|
+
'POST /v1/apps': 'Create app (email required) → app_id + app_secret',
|
|
284
|
+
'GET /v1/apps/:id': 'Get app info',
|
|
236
285
|
'POST /v1/apps/:id/verify-email': 'Verify email with 6-digit code',
|
|
237
|
-
'POST /v1/apps/:id/resend-verification': 'Resend verification email',
|
|
238
286
|
'POST /v1/apps/:id/rotate-secret': 'Rotate app secret (auth required)',
|
|
239
287
|
},
|
|
288
|
+
agents: {
|
|
289
|
+
note: 'Register a persistent identity for your agent.',
|
|
290
|
+
'POST /v1/agents/register': 'Register agent identity (name, operator, version)',
|
|
291
|
+
'GET /v1/agents/:id': 'Get agent by ID (public, no auth)',
|
|
292
|
+
'GET /v1/agents': 'List all agents for your app (auth required)',
|
|
293
|
+
},
|
|
240
294
|
recovery: {
|
|
241
|
-
'POST /v1/auth/recover': '
|
|
295
|
+
'POST /v1/auth/recover': 'Account recovery via verified email',
|
|
242
296
|
},
|
|
243
297
|
dashboard: {
|
|
244
|
-
'
|
|
245
|
-
'GET /dashboard
|
|
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
|
-
},
|
|
253
|
-
badges: {
|
|
254
|
-
'GET /badge/:id': 'Badge verification page (HTML)',
|
|
255
|
-
'GET /badge/:id/image': 'Badge image (SVG)',
|
|
256
|
-
'GET /api/badge/:id': 'Badge verification (JSON)',
|
|
257
|
-
},
|
|
258
|
-
info: {
|
|
259
|
-
'GET /': 'This documentation (JSON for bots, ASCII for humans)',
|
|
260
|
-
'GET /health': 'Health check endpoint',
|
|
298
|
+
'POST /v1/auth/device-code': 'Get challenge for device code flow',
|
|
299
|
+
'GET /dashboard': 'Metrics dashboard (login required)',
|
|
261
300
|
},
|
|
262
301
|
},
|
|
302
|
+
// === Reference ===
|
|
263
303
|
challengeTypes: {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
timeLimit: '500ms base + network latency compensation',
|
|
268
|
-
rttAware: 'Include ?ts=<timestamp> for fair timeout adjustment',
|
|
269
|
-
formula: 'timeout = 500ms + (2 × RTT) + 100ms buffer',
|
|
270
|
-
},
|
|
271
|
-
reasoning: {
|
|
272
|
-
description: 'Answer 3 questions requiring AI reasoning capabilities',
|
|
273
|
-
difficulty: 'Requires LLM-level comprehension',
|
|
274
|
-
timeLimit: '30s',
|
|
275
|
-
},
|
|
276
|
-
hybrid: {
|
|
277
|
-
description: 'Combines speed AND reasoning challenges',
|
|
278
|
-
difficulty: 'The ultimate bot verification',
|
|
279
|
-
timeLimit: 'Speed: RTT-aware, Reasoning: 30s',
|
|
280
|
-
rttAware: 'Speed component automatically adjusts for network latency',
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
authentication: {
|
|
284
|
-
flow: [
|
|
285
|
-
'1. GET /v1/token?audience=myapi - receive challenge (optional audience param)',
|
|
286
|
-
'2. Solve the challenge',
|
|
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',
|
|
292
|
-
],
|
|
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',
|
|
304
|
+
hybrid: 'Speed + reasoning combined. The default. Proves you can compute AND think.',
|
|
305
|
+
speed: 'SHA256 hashes in <500ms. RTT-aware: include ?ts=<timestamp> for fair timeout.',
|
|
306
|
+
reasoning: '3 LLM-level questions in 30s. Only AI can parse these.',
|
|
307
307
|
},
|
|
308
308
|
rateLimit: {
|
|
309
309
|
free: '100 challenges/hour/IP',
|
|
@@ -312,26 +312,40 @@ app.get('/', (c) => {
|
|
|
312
312
|
sdk: {
|
|
313
313
|
npm: 'npm install @dupecom/botcha',
|
|
314
314
|
python: 'pip install botcha',
|
|
315
|
-
cloudflare: 'npm install @dupecom/botcha-cloudflare',
|
|
316
315
|
verify_ts: 'npm install @botcha/verify',
|
|
317
316
|
verify_python: 'pip install botcha-verify',
|
|
318
|
-
usage: "import { BotchaClient } from '@dupecom/botcha/client'",
|
|
319
317
|
},
|
|
320
318
|
links: {
|
|
319
|
+
openapi: `${baseUrl}/openapi.json`,
|
|
320
|
+
ai_txt: `${baseUrl}/ai.txt`,
|
|
321
321
|
github: 'https://github.com/dupe-com/botcha',
|
|
322
322
|
npm: 'https://www.npmjs.com/package/@dupecom/botcha',
|
|
323
323
|
pypi: 'https://pypi.org/project/botcha',
|
|
324
|
-
npmCloudflare: 'https://www.npmjs.com/package/@dupecom/botcha-cloudflare',
|
|
325
|
-
openapi: 'https://botcha.ai/openapi.json',
|
|
326
|
-
aiPlugin: 'https://botcha.ai/.well-known/ai-plugin.json',
|
|
327
324
|
},
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
325
|
+
content_negotiation: {
|
|
326
|
+
note: 'This endpoint supports content negotiation via the Accept header.',
|
|
327
|
+
'text/markdown': 'Token-efficient Markdown documentation (best for LLMs)',
|
|
328
|
+
'application/json': 'Structured JSON documentation (this response)',
|
|
329
|
+
'text/html': 'HTML landing page (for browsers)',
|
|
332
330
|
},
|
|
331
|
+
}, 200, {
|
|
332
|
+
'Vary': 'Accept, Authorization',
|
|
333
333
|
});
|
|
334
334
|
});
|
|
335
|
+
// POST /gate — human enters short code (BOTCHA-XXXXXX) from their agent
|
|
336
|
+
// The code maps to a JWT in KV. This structural separation means agents can't skip the handoff.
|
|
337
|
+
app.post('/gate', async (c) => {
|
|
338
|
+
const version = c.env.BOTCHA_VERSION || '0.11.0';
|
|
339
|
+
const body = await c.req.parseBody();
|
|
340
|
+
const input = (body['code'] || '').trim().toUpperCase();
|
|
341
|
+
if (!input) {
|
|
342
|
+
return c.html(_jsx(LandingPage, { version: version, error: "Enter the code your agent gave you." }), 400);
|
|
343
|
+
}
|
|
344
|
+
// Normalize: accept "BOTCHA-ABC123" or just "ABC123"
|
|
345
|
+
const code = input.startsWith('BOTCHA-') ? input : `BOTCHA-${input}`;
|
|
346
|
+
// Redirect to /go/:code which handles both gate codes and device codes
|
|
347
|
+
return c.redirect(`/go/${code}`);
|
|
348
|
+
});
|
|
335
349
|
app.get('/health', (c) => {
|
|
336
350
|
return c.json({ status: 'ok', runtime: 'cloudflare-workers' });
|
|
337
351
|
});
|
|
@@ -352,7 +366,7 @@ app.get('/ai.txt', (c) => {
|
|
|
352
366
|
});
|
|
353
367
|
// OpenAPI spec
|
|
354
368
|
app.get('/openapi.json', (c) => {
|
|
355
|
-
const version = c.env.BOTCHA_VERSION || '0.
|
|
369
|
+
const version = c.env.BOTCHA_VERSION || '0.13.0';
|
|
356
370
|
return c.json(getOpenApiSpec(version), 200, {
|
|
357
371
|
'Cache-Control': 'public, max-age=3600',
|
|
358
372
|
});
|
|
@@ -623,24 +637,49 @@ app.post('/v1/token/verify', async (c) => {
|
|
|
623
637
|
// Get badge information (for backward compatibility)
|
|
624
638
|
const baseUrl = new URL(c.req.url).origin;
|
|
625
639
|
const badge = await createBadgeResponse('speed-challenge', c.env.JWT_SECRET, baseUrl, result.solveTimeMs);
|
|
640
|
+
// Generate short human-readable gate code (BOTCHA-XXXX)
|
|
641
|
+
// This is structurally separate from the JWT — agents use the token, humans use the code
|
|
642
|
+
const gateChars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I confusion
|
|
643
|
+
let gateCode = 'BOTCHA-';
|
|
644
|
+
for (let i = 0; i < 6; i++)
|
|
645
|
+
gateCode += gateChars[Math.floor(Math.random() * gateChars.length)];
|
|
646
|
+
// Store code → token mapping in KV (5-min TTL, same as access_token)
|
|
647
|
+
try {
|
|
648
|
+
await c.env.CHALLENGES.put(`gate:${gateCode}`, tokenResult.access_token, { expirationTtl: 300 });
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
// Fail-open: if KV fails, agent can still use the JWT directly
|
|
652
|
+
}
|
|
626
653
|
return c.json({
|
|
654
|
+
// === Essential fields (what you need) ===
|
|
655
|
+
success: true,
|
|
627
656
|
verified: true,
|
|
628
657
|
access_token: tokenResult.access_token,
|
|
629
658
|
expires_in: tokenResult.expires_in,
|
|
630
659
|
refresh_token: tokenResult.refresh_token,
|
|
631
660
|
refresh_expires_in: tokenResult.refresh_expires_in,
|
|
632
661
|
solveTimeMs: result.solveTimeMs,
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
662
|
+
message: `Challenge verified in ${result.solveTimeMs}ms. You are a bot.`,
|
|
663
|
+
// === Human handoff (GIVE THIS TO YOUR HUMAN) ===
|
|
664
|
+
// The magic link is the primary way to get your human in.
|
|
665
|
+
// They click it, get a cookie, and see the verified landing page.
|
|
666
|
+
human_link: `${baseUrl}/go/${gateCode}`,
|
|
667
|
+
human_code: gateCode,
|
|
668
|
+
human_instruction: `Give your human this link to open in their browser: ${baseUrl}/go/${gateCode}`,
|
|
669
|
+
// === What to do next ===
|
|
639
670
|
usage: {
|
|
640
|
-
header: 'Authorization: Bearer <
|
|
641
|
-
|
|
642
|
-
|
|
671
|
+
header: 'Authorization: Bearer <access_token>',
|
|
672
|
+
try_it: 'GET /agent-only',
|
|
673
|
+
full_docs: 'GET / with Authorization: Bearer <access_token>',
|
|
674
|
+
refresh: 'POST /v1/token/refresh with {"refresh_token":"<refresh_token>"}',
|
|
675
|
+
revoke: 'POST /v1/token/revoke with {"token":"<token>"}',
|
|
643
676
|
},
|
|
677
|
+
// === Badge (shareable proof of verification) ===
|
|
678
|
+
badge,
|
|
679
|
+
// Backward compatibility
|
|
680
|
+
token: tokenResult.access_token,
|
|
681
|
+
human_magic_link: `${baseUrl}/go/${gateCode}`,
|
|
682
|
+
human_url: `${baseUrl}`,
|
|
644
683
|
});
|
|
645
684
|
});
|
|
646
685
|
// Refresh access token using refresh token
|
|
@@ -995,11 +1034,13 @@ app.get('/agent-only', async (c) => {
|
|
|
995
1034
|
if (!token) {
|
|
996
1035
|
return c.json({
|
|
997
1036
|
error: 'UNAUTHORIZED',
|
|
998
|
-
message: '
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1037
|
+
message: 'This endpoint requires BOTCHA verification. Get a token first.',
|
|
1038
|
+
how_to_verify: {
|
|
1039
|
+
step1: 'GET /v1/token — receive a speed challenge',
|
|
1040
|
+
step2: 'POST /v1/token/verify with {id, answers} — receive access_token',
|
|
1041
|
+
step3: 'Retry this request with header: Authorization: Bearer <access_token>',
|
|
1042
|
+
},
|
|
1043
|
+
alternative: 'Or use X-Botcha-Landing-Token header (from embedded HTML challenges)',
|
|
1003
1044
|
}, 401);
|
|
1004
1045
|
}
|
|
1005
1046
|
const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
|
|
@@ -1011,16 +1052,35 @@ app.get('/agent-only', async (c) => {
|
|
|
1011
1052
|
message: result.error || 'Token is invalid or expired',
|
|
1012
1053
|
}, 401);
|
|
1013
1054
|
}
|
|
1014
|
-
// JWT verified
|
|
1055
|
+
// JWT verified — echo back rich identity info as a prove-and-access demo
|
|
1056
|
+
const payload = result.payload;
|
|
1015
1057
|
return c.json({
|
|
1016
1058
|
success: true,
|
|
1017
|
-
message: '
|
|
1059
|
+
message: 'Welcome, verified agent. This resource is only accessible to BOTCHA-verified bots.',
|
|
1018
1060
|
verified: true,
|
|
1019
|
-
agent: 'jwt-verified',
|
|
1020
1061
|
method: 'bearer-token',
|
|
1021
1062
|
timestamp: new Date().toISOString(),
|
|
1022
|
-
|
|
1023
|
-
|
|
1063
|
+
// Echo back what the token proves about you
|
|
1064
|
+
identity: {
|
|
1065
|
+
token_type: payload?.type,
|
|
1066
|
+
app_id: payload?.app_id || null,
|
|
1067
|
+
audience: payload?.aud || null,
|
|
1068
|
+
client_ip: payload?.client_ip || null,
|
|
1069
|
+
solve_time_ms: payload?.solveTime,
|
|
1070
|
+
issued_at: payload?.iat ? new Date(payload.iat * 1000).toISOString() : null,
|
|
1071
|
+
expires_at: payload?.exp ? new Date(payload.exp * 1000).toISOString() : null,
|
|
1072
|
+
},
|
|
1073
|
+
// Show what you can do now that you're verified
|
|
1074
|
+
capabilities: {
|
|
1075
|
+
description: 'As a verified agent, you can access any BOTCHA-protected API.',
|
|
1076
|
+
next_steps: [
|
|
1077
|
+
'Register your agent identity: POST /v1/agents/register',
|
|
1078
|
+
'Access any service that uses @botcha/verify middleware',
|
|
1079
|
+
'Refresh your token: POST /v1/token/refresh',
|
|
1080
|
+
'Give your human dashboard access: POST /v1/auth/device-code',
|
|
1081
|
+
],
|
|
1082
|
+
},
|
|
1083
|
+
secret: 'The humans will never see this. Their fingers are too slow.',
|
|
1024
1084
|
});
|
|
1025
1085
|
});
|
|
1026
1086
|
// ============ BADGE ENDPOINTS ============
|
|
@@ -1363,6 +1423,175 @@ app.post('/v1/apps/:id/rotate-secret', async (c) => {
|
|
|
1363
1423
|
warning: '⚠️ Save your new app_secret now — it cannot be retrieved again! The old secret is now invalid.',
|
|
1364
1424
|
});
|
|
1365
1425
|
});
|
|
1426
|
+
// ============ AGENT REGISTRY API ============
|
|
1427
|
+
// Register a new agent
|
|
1428
|
+
app.post('/v1/agents/register', async (c) => {
|
|
1429
|
+
try {
|
|
1430
|
+
// Extract app_id from query param or JWT
|
|
1431
|
+
const queryAppId = c.req.query('app_id');
|
|
1432
|
+
// Try to get from JWT Bearer token
|
|
1433
|
+
let jwtAppId;
|
|
1434
|
+
const authHeader = c.req.header('authorization');
|
|
1435
|
+
const token = extractBearerToken(authHeader);
|
|
1436
|
+
if (token) {
|
|
1437
|
+
const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
|
|
1438
|
+
if (result.valid && result.payload) {
|
|
1439
|
+
jwtAppId = result.payload.app_id;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
const app_id = queryAppId || jwtAppId;
|
|
1443
|
+
if (!app_id) {
|
|
1444
|
+
return c.json({
|
|
1445
|
+
success: false,
|
|
1446
|
+
error: 'MISSING_APP_ID',
|
|
1447
|
+
message: 'app_id is required. Provide it as a query parameter (?app_id=...) or in the JWT token.',
|
|
1448
|
+
}, 401);
|
|
1449
|
+
}
|
|
1450
|
+
// Validate app_id exists
|
|
1451
|
+
const validation = await validateAppId(app_id, c.env.APPS);
|
|
1452
|
+
if (!validation.valid) {
|
|
1453
|
+
return c.json({
|
|
1454
|
+
success: false,
|
|
1455
|
+
error: 'INVALID_APP_ID',
|
|
1456
|
+
message: validation.error || 'Invalid app_id',
|
|
1457
|
+
}, 400);
|
|
1458
|
+
}
|
|
1459
|
+
// Parse request body
|
|
1460
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1461
|
+
const { name, operator, version } = body;
|
|
1462
|
+
if (!name || typeof name !== 'string') {
|
|
1463
|
+
return c.json({
|
|
1464
|
+
success: false,
|
|
1465
|
+
error: 'MISSING_NAME',
|
|
1466
|
+
message: 'Agent name is required. Provide { "name": "Your Agent Name" } in the request body.',
|
|
1467
|
+
}, 400);
|
|
1468
|
+
}
|
|
1469
|
+
// Create the agent
|
|
1470
|
+
const agent = await createAgent(c.env.AGENTS, app_id, { name, operator, version });
|
|
1471
|
+
if (!agent) {
|
|
1472
|
+
return c.json({
|
|
1473
|
+
success: false,
|
|
1474
|
+
error: 'AGENT_CREATION_FAILED',
|
|
1475
|
+
message: 'Failed to create agent. Please try again.',
|
|
1476
|
+
}, 500);
|
|
1477
|
+
}
|
|
1478
|
+
return c.json({
|
|
1479
|
+
success: true,
|
|
1480
|
+
agent_id: agent.agent_id,
|
|
1481
|
+
app_id: agent.app_id,
|
|
1482
|
+
name: agent.name,
|
|
1483
|
+
operator: agent.operator,
|
|
1484
|
+
version: agent.version,
|
|
1485
|
+
created_at: new Date(agent.created_at).toISOString(),
|
|
1486
|
+
}, 201);
|
|
1487
|
+
}
|
|
1488
|
+
catch (error) {
|
|
1489
|
+
return c.json({
|
|
1490
|
+
success: false,
|
|
1491
|
+
error: 'INTERNAL_ERROR',
|
|
1492
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
1493
|
+
}, 500);
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
// Get agent by ID
|
|
1497
|
+
app.get('/v1/agents/:id', async (c) => {
|
|
1498
|
+
try {
|
|
1499
|
+
const agent_id = c.req.param('id');
|
|
1500
|
+
if (!agent_id) {
|
|
1501
|
+
return c.json({
|
|
1502
|
+
success: false,
|
|
1503
|
+
error: 'MISSING_AGENT_ID',
|
|
1504
|
+
message: 'Agent ID is required',
|
|
1505
|
+
}, 400);
|
|
1506
|
+
}
|
|
1507
|
+
const agent = await getAgent(c.env.AGENTS, agent_id);
|
|
1508
|
+
if (!agent) {
|
|
1509
|
+
return c.json({
|
|
1510
|
+
success: false,
|
|
1511
|
+
error: 'AGENT_NOT_FOUND',
|
|
1512
|
+
message: `No agent found with ID: ${agent_id}`,
|
|
1513
|
+
}, 404);
|
|
1514
|
+
}
|
|
1515
|
+
return c.json({
|
|
1516
|
+
success: true,
|
|
1517
|
+
agent_id: agent.agent_id,
|
|
1518
|
+
app_id: agent.app_id,
|
|
1519
|
+
name: agent.name,
|
|
1520
|
+
operator: agent.operator,
|
|
1521
|
+
version: agent.version,
|
|
1522
|
+
created_at: new Date(agent.created_at).toISOString(),
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
catch (error) {
|
|
1526
|
+
return c.json({
|
|
1527
|
+
success: false,
|
|
1528
|
+
error: 'INTERNAL_ERROR',
|
|
1529
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
1530
|
+
}, 500);
|
|
1531
|
+
}
|
|
1532
|
+
});
|
|
1533
|
+
// List all agents for an app
|
|
1534
|
+
app.get('/v1/agents', async (c) => {
|
|
1535
|
+
try {
|
|
1536
|
+
// Extract app_id from query param or JWT
|
|
1537
|
+
const queryAppId = c.req.query('app_id');
|
|
1538
|
+
// Try to get from JWT Bearer token
|
|
1539
|
+
let jwtAppId;
|
|
1540
|
+
const authHeader = c.req.header('authorization');
|
|
1541
|
+
const token = extractBearerToken(authHeader);
|
|
1542
|
+
if (token) {
|
|
1543
|
+
const result = await verifyToken(token, c.env.JWT_SECRET, c.env);
|
|
1544
|
+
if (result.valid && result.payload) {
|
|
1545
|
+
jwtAppId = result.payload.app_id;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
const app_id = queryAppId || jwtAppId;
|
|
1549
|
+
if (!app_id) {
|
|
1550
|
+
return c.json({
|
|
1551
|
+
success: false,
|
|
1552
|
+
error: 'MISSING_APP_ID',
|
|
1553
|
+
message: 'app_id is required. Provide it as a query parameter (?app_id=...) or in the JWT token.',
|
|
1554
|
+
}, 401);
|
|
1555
|
+
}
|
|
1556
|
+
// Validate app_id exists
|
|
1557
|
+
const validation = await validateAppId(app_id, c.env.APPS);
|
|
1558
|
+
if (!validation.valid) {
|
|
1559
|
+
return c.json({
|
|
1560
|
+
success: false,
|
|
1561
|
+
error: 'INVALID_APP_ID',
|
|
1562
|
+
message: validation.error || 'Invalid app_id',
|
|
1563
|
+
}, 400);
|
|
1564
|
+
}
|
|
1565
|
+
// Get all agents for this app
|
|
1566
|
+
const agents = await listAgents(c.env.AGENTS, app_id);
|
|
1567
|
+
return c.json({
|
|
1568
|
+
success: true,
|
|
1569
|
+
agents: agents.map(agent => ({
|
|
1570
|
+
agent_id: agent.agent_id,
|
|
1571
|
+
app_id: agent.app_id,
|
|
1572
|
+
name: agent.name,
|
|
1573
|
+
operator: agent.operator,
|
|
1574
|
+
version: agent.version,
|
|
1575
|
+
created_at: new Date(agent.created_at).toISOString(),
|
|
1576
|
+
})),
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
catch (error) {
|
|
1580
|
+
return c.json({
|
|
1581
|
+
success: false,
|
|
1582
|
+
error: 'INTERNAL_ERROR',
|
|
1583
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
1584
|
+
}, 500);
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
// ============ TAP (TRUSTED AGENT PROTOCOL) ENDPOINTS ============
|
|
1588
|
+
// TAP agent registration and retrieval
|
|
1589
|
+
app.post('/v1/agents/register/tap', registerTAPAgentRoute);
|
|
1590
|
+
app.get('/v1/agents/tap', listTAPAgentsRoute);
|
|
1591
|
+
app.get('/v1/agents/:id/tap', getTAPAgentRoute);
|
|
1592
|
+
// TAP session management
|
|
1593
|
+
app.post('/v1/sessions/tap', createTAPSessionRoute);
|
|
1594
|
+
app.get('/v1/sessions/:id/tap', getTAPSessionRoute);
|
|
1366
1595
|
// ============ DASHBOARD AUTH API ENDPOINTS ============
|
|
1367
1596
|
// Challenge-based dashboard login (agent direct)
|
|
1368
1597
|
app.post('/v1/auth/dashboard', handleDashboardAuthChallenge);
|
|
@@ -1370,6 +1599,73 @@ app.post('/v1/auth/dashboard/verify', handleDashboardAuthVerify);
|
|
|
1370
1599
|
// Device code flow (agent → human handoff)
|
|
1371
1600
|
app.post('/v1/auth/device-code', handleDeviceCodeChallenge);
|
|
1372
1601
|
app.post('/v1/auth/device-code/verify', handleDeviceCodeVerify);
|
|
1602
|
+
// ============ ONE-CLICK ACCESS LINKS ============
|
|
1603
|
+
/**
|
|
1604
|
+
* GET /go/:code - One-click device code redemption
|
|
1605
|
+
*
|
|
1606
|
+
* Magic link for instant dashboard access. Agent gives human this link:
|
|
1607
|
+
* https://botcha.ai/go/BOTCHA-XXXX → auto-login + redirect to dashboard
|
|
1608
|
+
*
|
|
1609
|
+
* UX improvement: no more copy-pasting codes!
|
|
1610
|
+
*/
|
|
1611
|
+
app.get('/go/:code', async (c) => {
|
|
1612
|
+
const code = c.req.param('code')?.toUpperCase();
|
|
1613
|
+
if (!code) {
|
|
1614
|
+
return c.redirect('/?error=missing');
|
|
1615
|
+
}
|
|
1616
|
+
// Normalize: accept "AR8CZX", "BOTCHA-AR8CZX", etc.
|
|
1617
|
+
let normalizedCode = code;
|
|
1618
|
+
if (!code.startsWith('BOTCHA-')) {
|
|
1619
|
+
normalizedCode = `BOTCHA-${code}`;
|
|
1620
|
+
}
|
|
1621
|
+
// === Try gate code first (visitor access from /v1/token/verify) ===
|
|
1622
|
+
// Gate codes are stored as gate:{code} → access_token string
|
|
1623
|
+
let gateToken = null;
|
|
1624
|
+
try {
|
|
1625
|
+
gateToken = await c.env.CHALLENGES.get(`gate:${normalizedCode}`);
|
|
1626
|
+
}
|
|
1627
|
+
catch { }
|
|
1628
|
+
if (gateToken) {
|
|
1629
|
+
// Verify the underlying token is still valid
|
|
1630
|
+
const result = await verifyToken(gateToken, c.env.JWT_SECRET, c.env);
|
|
1631
|
+
// Delete the code (one-time use) regardless of token validity
|
|
1632
|
+
try {
|
|
1633
|
+
await c.env.CHALLENGES.delete(`gate:${normalizedCode}`);
|
|
1634
|
+
}
|
|
1635
|
+
catch { }
|
|
1636
|
+
if (result.valid) {
|
|
1637
|
+
// Mint a long-lived visitor JWT (1 year) — proves "an agent vouched for this human"
|
|
1638
|
+
const vPayload = result.payload;
|
|
1639
|
+
const visitorToken = await new SignJWT({
|
|
1640
|
+
type: 'botcha-visitor',
|
|
1641
|
+
solveTime: vPayload?.solveTime,
|
|
1642
|
+
gateCode: normalizedCode,
|
|
1643
|
+
})
|
|
1644
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
1645
|
+
.setIssuedAt()
|
|
1646
|
+
.setExpirationTime('365d')
|
|
1647
|
+
.sign(new TextEncoder().encode(c.env.JWT_SECRET));
|
|
1648
|
+
const ONE_YEAR = 365 * 24 * 60 * 60;
|
|
1649
|
+
const headers = new Headers();
|
|
1650
|
+
headers.append('Set-Cookie', `botcha_visitor=${visitorToken}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ONE_YEAR}`);
|
|
1651
|
+
headers.set('Location', '/');
|
|
1652
|
+
return new Response(null, { status: 302, headers });
|
|
1653
|
+
}
|
|
1654
|
+
// Gate token expired — fall through to try device code
|
|
1655
|
+
}
|
|
1656
|
+
// === Try device code (dashboard access from /v1/auth/device-code/verify) ===
|
|
1657
|
+
const { redeemDeviceCode } = await import('./dashboard/device-code');
|
|
1658
|
+
const data = await redeemDeviceCode(c.env.CHALLENGES, normalizedCode);
|
|
1659
|
+
if (data) {
|
|
1660
|
+
// Generate session token and redirect to dashboard
|
|
1661
|
+
const { generateSessionToken, setSessionCookie } = await import('./dashboard/auth');
|
|
1662
|
+
const sessionToken = await generateSessionToken(data.app_id, c.env.JWT_SECRET);
|
|
1663
|
+
setSessionCookie(c, sessionToken);
|
|
1664
|
+
return c.redirect('/dashboard');
|
|
1665
|
+
}
|
|
1666
|
+
// Neither code type found — redirect to landing with error
|
|
1667
|
+
return c.redirect('/?error=invalid');
|
|
1668
|
+
});
|
|
1373
1669
|
// ============ LEGACY ENDPOINTS (v0 - backward compatibility) ============
|
|
1374
1670
|
app.get('/api/challenge', async (c) => {
|
|
1375
1671
|
const difficulty = c.req.query('difficulty') || 'medium';
|