@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.
@@ -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.2.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": "wrangler dev",
19
+ "dev": "bun run scripts/dev-intro.ts",
20
20
  "deploy": "wrangler deploy",
21
21
  "build": "tsc",
22
- "prepublishOnly": "npm run build",
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/i8ramin/botcha"
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.5.0",
49
+ "wrangler": "^4.63.0",
50
50
  "vitest": "^3.0.0"
51
51
  }
52
52
  }