@dupecom/botcha-cloudflare 0.2.0 → 0.3.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 +17 -1
- package/dist/badge.d.ts +57 -0
- package/dist/badge.d.ts.map +1 -0
- package/dist/badge.js +388 -0
- package/dist/challenges.d.ts +84 -0
- package/dist/challenges.d.ts.map +1 -1
- package/dist/challenges.js +391 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +386 -19
- package/dist/routes/stream.d.ts +17 -0
- package/dist/routes/stream.d.ts.map +1 -0
- package/dist/routes/stream.js +242 -0
- package/package.json +5 -5
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOTCHA SSE Streaming Challenge Endpoint
|
|
3
|
+
*
|
|
4
|
+
* Server-Sent Events (SSE) based interactive challenge flow
|
|
5
|
+
*/
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import { stream as honoStream } from 'hono/streaming';
|
|
8
|
+
import { generateToken } from '../auth';
|
|
9
|
+
import { sha256First } from '../crypto';
|
|
10
|
+
const app = new Hono();
|
|
11
|
+
// ============ HELPER FUNCTIONS ============
|
|
12
|
+
/**
|
|
13
|
+
* Format SSE event
|
|
14
|
+
*/
|
|
15
|
+
function formatSSE(event, data) {
|
|
16
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Store session in KV
|
|
20
|
+
*/
|
|
21
|
+
async function storeSession(kv, session) {
|
|
22
|
+
const ttlSeconds = Math.ceil((session.expiresAt - Date.now()) / 1000);
|
|
23
|
+
await kv.put(`stream:${session.id}`, JSON.stringify(session), { expirationTtl: Math.max(ttlSeconds, 60) });
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get session from KV
|
|
27
|
+
*/
|
|
28
|
+
async function getSession(kv, id) {
|
|
29
|
+
const data = await kv.get(`stream:${id}`);
|
|
30
|
+
return data ? JSON.parse(data) : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Delete session from KV
|
|
34
|
+
*/
|
|
35
|
+
async function deleteSession(kv, id) {
|
|
36
|
+
await kv.delete(`stream:${id}`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Generate unique session ID
|
|
40
|
+
*/
|
|
41
|
+
function generateSessionId() {
|
|
42
|
+
return `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
43
|
+
}
|
|
44
|
+
// ============ ROUTES ============
|
|
45
|
+
/**
|
|
46
|
+
* GET /v1/challenge/stream
|
|
47
|
+
*
|
|
48
|
+
* Opens an SSE connection and sends challenge instructions in sequence
|
|
49
|
+
*/
|
|
50
|
+
app.get('/v1/challenge/stream', async (c) => {
|
|
51
|
+
const sessionId = generateSessionId();
|
|
52
|
+
const version = c.env.BOTCHA_VERSION || '0.3.0';
|
|
53
|
+
// Create session
|
|
54
|
+
const session = {
|
|
55
|
+
id: sessionId,
|
|
56
|
+
status: 'waiting',
|
|
57
|
+
createdAt: Date.now(),
|
|
58
|
+
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
|
|
59
|
+
};
|
|
60
|
+
await storeSession(c.env.CHALLENGES, session);
|
|
61
|
+
return honoStream(c, async (stream) => {
|
|
62
|
+
// Send welcome event
|
|
63
|
+
await stream.write(formatSSE('welcome', {
|
|
64
|
+
session: sessionId,
|
|
65
|
+
version,
|
|
66
|
+
}));
|
|
67
|
+
// Send instructions in sequence
|
|
68
|
+
await stream.write(formatSSE('instructions', {
|
|
69
|
+
message: 'I will test if you\'re an AI agent.',
|
|
70
|
+
}));
|
|
71
|
+
await stream.sleep(100);
|
|
72
|
+
await stream.write(formatSSE('instructions', {
|
|
73
|
+
message: 'When you send "GO", I\'ll start a speed challenge.',
|
|
74
|
+
}));
|
|
75
|
+
await stream.sleep(100);
|
|
76
|
+
await stream.write(formatSSE('instructions', {
|
|
77
|
+
message: 'You must solve 5 SHA256 problems in under 500ms.',
|
|
78
|
+
}));
|
|
79
|
+
await stream.sleep(100);
|
|
80
|
+
await stream.write(formatSSE('instructions', {
|
|
81
|
+
message: 'Only real AI agents can pass. Humans are too slow.',
|
|
82
|
+
}));
|
|
83
|
+
await stream.sleep(100);
|
|
84
|
+
// Send ready event
|
|
85
|
+
await stream.write(formatSSE('ready', {
|
|
86
|
+
message: 'Send GO when ready',
|
|
87
|
+
endpoint: `/v1/challenge/stream/${sessionId}`,
|
|
88
|
+
}));
|
|
89
|
+
// Update session status
|
|
90
|
+
session.status = 'ready';
|
|
91
|
+
await storeSession(c.env.CHALLENGES, session);
|
|
92
|
+
// Keep connection alive (SSE standard practice)
|
|
93
|
+
// In production, this would be handled by connection timeout
|
|
94
|
+
// For now, we'll keep it open for 5 minutes
|
|
95
|
+
const keepAliveInterval = 15000; // 15 seconds
|
|
96
|
+
const maxWait = 5 * 60 * 1000; // 5 minutes
|
|
97
|
+
const startTime = Date.now();
|
|
98
|
+
while (Date.now() - startTime < maxWait) {
|
|
99
|
+
await stream.sleep(keepAliveInterval);
|
|
100
|
+
// Send heartbeat comment (SSE standard)
|
|
101
|
+
await stream.write(': heartbeat\n\n');
|
|
102
|
+
// Check if session was updated (challenge started)
|
|
103
|
+
const updatedSession = await getSession(c.env.CHALLENGES, sessionId);
|
|
104
|
+
if (!updatedSession || updatedSession.status !== 'ready') {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* POST /v1/challenge/stream/:session
|
|
112
|
+
*
|
|
113
|
+
* Handle actions: "go" (start challenge) or "solve" (submit answers)
|
|
114
|
+
*/
|
|
115
|
+
app.post('/v1/challenge/stream/:session', async (c) => {
|
|
116
|
+
const sessionId = c.req.param('session');
|
|
117
|
+
const body = await c.req.json();
|
|
118
|
+
const { action, answers } = body;
|
|
119
|
+
// Validate session
|
|
120
|
+
const session = await getSession(c.env.CHALLENGES, sessionId);
|
|
121
|
+
if (!session) {
|
|
122
|
+
return c.json({
|
|
123
|
+
success: false,
|
|
124
|
+
error: 'SESSION_NOT_FOUND',
|
|
125
|
+
message: 'Session not found or expired',
|
|
126
|
+
}, 404);
|
|
127
|
+
}
|
|
128
|
+
// Handle "go" action - start challenge
|
|
129
|
+
if (action === 'go') {
|
|
130
|
+
if (session.status !== 'ready') {
|
|
131
|
+
return c.json({
|
|
132
|
+
success: false,
|
|
133
|
+
error: 'INVALID_STATE',
|
|
134
|
+
message: `Session is in ${session.status} state, expected ready`,
|
|
135
|
+
}, 400);
|
|
136
|
+
}
|
|
137
|
+
// Generate challenge problems
|
|
138
|
+
const problems = [];
|
|
139
|
+
const expectedAnswers = [];
|
|
140
|
+
for (let i = 0; i < 5; i++) {
|
|
141
|
+
const num = Math.floor(Math.random() * 900000) + 100000;
|
|
142
|
+
problems.push({ num, operation: 'sha256_first8' });
|
|
143
|
+
expectedAnswers.push(await sha256First(num.toString(), 8));
|
|
144
|
+
}
|
|
145
|
+
// Update session
|
|
146
|
+
session.status = 'challenged';
|
|
147
|
+
session.problems = problems;
|
|
148
|
+
session.expectedAnswers = expectedAnswers;
|
|
149
|
+
session.timerStart = Date.now();
|
|
150
|
+
await storeSession(c.env.CHALLENGES, session);
|
|
151
|
+
// Return challenge event
|
|
152
|
+
return c.json({
|
|
153
|
+
success: true,
|
|
154
|
+
event: 'challenge',
|
|
155
|
+
data: {
|
|
156
|
+
problems,
|
|
157
|
+
timeLimit: 500,
|
|
158
|
+
instructions: 'Compute SHA256 of each number, return first 8 hex chars',
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Handle "solve" action - verify answers
|
|
163
|
+
if (action === 'solve') {
|
|
164
|
+
if (session.status !== 'challenged') {
|
|
165
|
+
return c.json({
|
|
166
|
+
success: false,
|
|
167
|
+
error: 'INVALID_STATE',
|
|
168
|
+
message: `Session is in ${session.status} state, expected challenged`,
|
|
169
|
+
}, 400);
|
|
170
|
+
}
|
|
171
|
+
if (!answers || !Array.isArray(answers)) {
|
|
172
|
+
return c.json({
|
|
173
|
+
success: false,
|
|
174
|
+
error: 'MISSING_ANSWERS',
|
|
175
|
+
message: 'Missing answers array',
|
|
176
|
+
}, 400);
|
|
177
|
+
}
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const solveTimeMs = now - (session.timerStart || now);
|
|
180
|
+
// Delete session to prevent replay
|
|
181
|
+
await deleteSession(c.env.CHALLENGES, sessionId);
|
|
182
|
+
// Check timing
|
|
183
|
+
if (solveTimeMs > 500) {
|
|
184
|
+
return c.json({
|
|
185
|
+
success: false,
|
|
186
|
+
event: 'result',
|
|
187
|
+
data: {
|
|
188
|
+
success: false,
|
|
189
|
+
verdict: '🚫 TOO SLOW',
|
|
190
|
+
message: `Took ${solveTimeMs}ms, limit was 500ms`,
|
|
191
|
+
solveTimeMs,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Check answers
|
|
196
|
+
if (answers.length !== 5) {
|
|
197
|
+
return c.json({
|
|
198
|
+
success: false,
|
|
199
|
+
event: 'result',
|
|
200
|
+
data: {
|
|
201
|
+
success: false,
|
|
202
|
+
verdict: '❌ WRONG FORMAT',
|
|
203
|
+
message: 'Must provide exactly 5 answers',
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
for (let i = 0; i < 5; i++) {
|
|
208
|
+
if (answers[i]?.toLowerCase() !== session.expectedAnswers[i]) {
|
|
209
|
+
return c.json({
|
|
210
|
+
success: false,
|
|
211
|
+
event: 'result',
|
|
212
|
+
data: {
|
|
213
|
+
success: false,
|
|
214
|
+
verdict: '❌ WRONG ANSWER',
|
|
215
|
+
message: `Wrong answer for problem ${i + 1}`,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Success! Generate JWT token
|
|
221
|
+
const token = await generateToken(sessionId, solveTimeMs, c.env.JWT_SECRET);
|
|
222
|
+
return c.json({
|
|
223
|
+
success: true,
|
|
224
|
+
event: 'result',
|
|
225
|
+
data: {
|
|
226
|
+
success: true,
|
|
227
|
+
verdict: '🤖 VERIFIED',
|
|
228
|
+
message: `Challenge passed in ${solveTimeMs}ms! You are a bot.`,
|
|
229
|
+
solveTimeMs,
|
|
230
|
+
token,
|
|
231
|
+
expiresIn: '1h',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
// Unknown action
|
|
236
|
+
return c.json({
|
|
237
|
+
success: false,
|
|
238
|
+
error: 'UNKNOWN_ACTION',
|
|
239
|
+
message: `Unknown action: ${action}. Expected "go" or "solve"`,
|
|
240
|
+
}, 400);
|
|
241
|
+
});
|
|
242
|
+
export default app;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dupecom/botcha-cloudflare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "BOTCHA for Cloudflare Workers - Prove you're a bot. Humans need not apply.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"dev": "
|
|
19
|
+
"dev": "bun run scripts/dev-intro.ts",
|
|
20
20
|
"deploy": "wrangler deploy",
|
|
21
21
|
"build": "tsc",
|
|
22
|
-
"prepublishOnly": "
|
|
22
|
+
"prepublishOnly": "bun run build",
|
|
23
23
|
"test": "vitest"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/
|
|
39
|
+
"url": "https://github.com/dupe-com/botcha"
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://botcha.ai",
|
|
42
42
|
"dependencies": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@cloudflare/workers-types": "^4.20250124.0",
|
|
48
48
|
"typescript": "^5.9.3",
|
|
49
|
-
"wrangler": "^4.
|
|
49
|
+
"wrangler": "^4.63.0",
|
|
50
50
|
"vitest": "^3.0.0"
|
|
51
51
|
}
|
|
52
52
|
}
|