@dupecom/botcha-cloudflare 0.2.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 ADDED
@@ -0,0 +1,178 @@
1
+ # @dupecom/botcha-cloudflare
2
+
3
+ > 🤖 **BOTCHA** - Prove you're a bot. Humans need not apply.
4
+ >
5
+ > **Cloudflare Workers Edition v0.2.0** - Production-ready with JWT & Rate Limiting
6
+
7
+ Reverse CAPTCHA that verifies AI agents and blocks humans. Running at the edge.
8
+
9
+ ## 🚀 What's New in v0.2.0
10
+
11
+ - ✅ **JWT Token Authentication** - Secure token-based auth flow with 1-hour expiry
12
+ - ✅ **Rate Limiting** - 100 challenges/hour/IP with proper headers
13
+ - ✅ **KV Storage** - Challenge state stored in Cloudflare KV (prevents replay attacks)
14
+ - ✅ **Versioned API** - New `/v1/*` endpoints with backward-compatible legacy routes
15
+ - ✅ **Production Ready** - Enterprise-grade auth and security
16
+
17
+ ## Features
18
+
19
+ - ⚡ **Speed Challenge** - 5 SHA256 hashes in 500ms (impossible for humans to copy-paste)
20
+ - 🧮 **Standard Challenge** - Configurable difficulty prime calculations
21
+ - 🔐 **JWT Authentication** - Token-based access control with jose library
22
+ - 🚦 **Rate Limiting** - IP-based throttling with KV storage
23
+ - 🌍 **Edge-native** - Runs on Cloudflare's global network
24
+ - 📦 **Minimal dependencies** - Hono for routing, jose for JWT
25
+
26
+ ## Quick Deploy
27
+
28
+ ```bash
29
+ # Clone the repo
30
+ git clone https://github.com/i8ramin/botcha
31
+ cd botcha/packages/cloudflare-workers
32
+
33
+ # Install dependencies
34
+ npm install
35
+
36
+ # Deploy to Cloudflare
37
+ npm run deploy
38
+ ```
39
+
40
+ ## Local Development
41
+
42
+ ```bash
43
+ npm run dev
44
+ # Worker running at http://localhost:8787
45
+ ```
46
+
47
+ ## 🔐 JWT Token Flow (Recommended)
48
+
49
+ ### 1. Get Challenge
50
+
51
+ ```bash
52
+ GET /v1/token
53
+ ```
54
+
55
+ Response includes challenge and instructions for getting a JWT token.
56
+
57
+ ### 2. Solve Challenge & Get JWT
58
+
59
+ ```bash
60
+ POST /v1/token/verify
61
+ Content-Type: application/json
62
+
63
+ {
64
+ "id": "challenge-uuid",
65
+ "answers": ["abc12345", "def67890", ...]
66
+ }
67
+ ```
68
+
69
+ Returns JWT token valid for 1 hour.
70
+
71
+ ### 3. Access Protected Resources
72
+
73
+ ```bash
74
+ GET /agent-only
75
+ Authorization: Bearer <your-jwt-token>
76
+ ```
77
+
78
+ ## 📊 Rate Limiting
79
+
80
+ Free tier: **100 challenges per hour per IP**
81
+
82
+ Rate limit headers:
83
+ - `X-RateLimit-Limit: 100`
84
+ - `X-RateLimit-Remaining: 95`
85
+ - `X-RateLimit-Reset: 2026-02-02T12:00:00.000Z`
86
+
87
+ ## API Endpoints
88
+
89
+ ### v1 API (Production)
90
+
91
+ | Endpoint | Method | Description |
92
+ |----------|--------|-------------|
93
+ | `/` | GET | API information |
94
+ | `/health` | GET | Health check |
95
+ | `/v1/challenges` | GET | Generate challenge (speed or standard) |
96
+ | `/v1/challenges/:id/verify` | POST | Verify challenge (no JWT) |
97
+ | `/v1/token` | GET | Get challenge for JWT flow |
98
+ | `/v1/token/verify` | POST | Verify challenge → get JWT token |
99
+ | `/agent-only` | GET | Protected endpoint (requires JWT) |
100
+
101
+ ### Legacy API (v0 - backward compatible)
102
+
103
+ | Endpoint | Method | Description |
104
+ |----------|--------|-------------|
105
+ | `/api/challenge` | GET/POST | Standard challenge |
106
+ | `/api/speed-challenge` | GET/POST | Speed challenge (500ms limit) |
107
+ | `/api/verify-landing` | POST | Landing page challenge |
108
+
109
+ ## Solving Challenges (for AI Agents)
110
+
111
+ ```typescript
112
+ // Speed challenge
113
+ const challenge = await fetch('https://your-worker.workers.dev/api/speed-challenge').then(r => r.json());
114
+
115
+ const answers = await Promise.all(
116
+ challenge.challenge.problems.map(async (p) => {
117
+ const hash = await crypto.subtle.digest(
118
+ 'SHA-256',
119
+ new TextEncoder().encode(p.num.toString())
120
+ );
121
+ return Array.from(new Uint8Array(hash))
122
+ .map(b => b.toString(16).padStart(2, '0'))
123
+ .join('')
124
+ .substring(0, 8);
125
+ })
126
+ );
127
+
128
+ const result = await fetch('https://your-worker.workers.dev/api/speed-challenge', {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ id: challenge.challenge.id, answers }),
132
+ }).then(r => r.json());
133
+
134
+ console.log(result.verdict); // "🤖 VERIFIED AI AGENT"
135
+ ```
136
+
137
+ ## 🔑 Production Configuration
138
+
139
+ ### KV Namespaces
140
+
141
+ Create KV namespaces:
142
+
143
+ ```bash
144
+ # Create challenge storage
145
+ wrangler kv namespace create CHALLENGES
146
+ wrangler kv namespace create CHALLENGES --preview
147
+
148
+ # Create rate limiting storage
149
+ wrangler kv namespace create RATE_LIMITS
150
+ wrangler kv namespace create RATE_LIMITS --preview
151
+ ```
152
+
153
+ Update `wrangler.toml` with the returned IDs.
154
+
155
+ ### JWT Secret
156
+
157
+ ⚠️ **Important:** Use Wrangler secrets for production:
158
+
159
+ ```bash
160
+ wrangler secret put JWT_SECRET
161
+ # Enter a strong secret (32+ characters)
162
+ ```
163
+
164
+ ### Testing
165
+
166
+ Run the test script:
167
+
168
+ ```bash
169
+ # Start dev server
170
+ npm run dev
171
+
172
+ # Run tests
173
+ ./test-api.sh
174
+ ```
175
+
176
+ ## License
177
+
178
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * BOTCHA Authentication & JWT Token Management
3
+ *
4
+ * Token-based auth flow for production API access
5
+ */
6
+ /**
7
+ * JWT payload structure
8
+ */
9
+ export interface BotchaTokenPayload {
10
+ sub: string;
11
+ iat: number;
12
+ exp: number;
13
+ type: 'botcha-verified';
14
+ solveTime: number;
15
+ }
16
+ /**
17
+ * Generate a JWT token after successful challenge verification
18
+ */
19
+ export declare function generateToken(challengeId: string, solveTimeMs: number, secret: string): Promise<string>;
20
+ /**
21
+ * Verify a JWT token
22
+ */
23
+ export declare function verifyToken(token: string, secret: string): Promise<{
24
+ valid: boolean;
25
+ payload?: BotchaTokenPayload;
26
+ error?: string;
27
+ }>;
28
+ /**
29
+ * Extract Bearer token from Authorization header
30
+ */
31
+ export declare function extractBearerToken(authHeader?: string): string | null;
32
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,iBAAiB,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC,CAejB;AAED;;GAEG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,kBAAkB,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyB3E;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKrE"}
package/dist/auth.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * BOTCHA Authentication & JWT Token Management
3
+ *
4
+ * Token-based auth flow for production API access
5
+ */
6
+ import { SignJWT, jwtVerify } from 'jose';
7
+ /**
8
+ * Generate a JWT token after successful challenge verification
9
+ */
10
+ export async function generateToken(challengeId, solveTimeMs, secret) {
11
+ const encoder = new TextEncoder();
12
+ const secretKey = encoder.encode(secret);
13
+ const token = await new SignJWT({
14
+ type: 'botcha-verified',
15
+ solveTime: solveTimeMs,
16
+ })
17
+ .setProtectedHeader({ alg: 'HS256' })
18
+ .setSubject(challengeId)
19
+ .setIssuedAt()
20
+ .setExpirationTime('1h') // 1 hour expiry
21
+ .sign(secretKey);
22
+ return token;
23
+ }
24
+ /**
25
+ * Verify a JWT token
26
+ */
27
+ export async function verifyToken(token, secret) {
28
+ try {
29
+ const encoder = new TextEncoder();
30
+ const secretKey = encoder.encode(secret);
31
+ const { payload } = await jwtVerify(token, secretKey, {
32
+ algorithms: ['HS256'],
33
+ });
34
+ return {
35
+ valid: true,
36
+ payload: {
37
+ sub: payload.sub || '',
38
+ iat: payload.iat || 0,
39
+ exp: payload.exp || 0,
40
+ type: payload.type,
41
+ solveTime: payload.solveTime,
42
+ },
43
+ };
44
+ }
45
+ catch (error) {
46
+ return {
47
+ valid: false,
48
+ error: error instanceof Error ? error.message : 'Invalid token',
49
+ };
50
+ }
51
+ }
52
+ /**
53
+ * Extract Bearer token from Authorization header
54
+ */
55
+ export function extractBearerToken(authHeader) {
56
+ if (!authHeader)
57
+ return null;
58
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
59
+ return match ? match[1] : null;
60
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * BOTCHA Challenge System for Cloudflare Workers
3
+ *
4
+ * Uses KV storage for production-ready challenge state management
5
+ * Falls back to in-memory for local dev without KV
6
+ */
7
+ export type KVNamespace = {
8
+ get: (key: string, type?: 'text' | 'json' | 'arrayBuffer' | 'stream') => Promise<any>;
9
+ put: (key: string, value: string, options?: {
10
+ expirationTtl?: number;
11
+ }) => Promise<void>;
12
+ delete: (key: string) => Promise<void>;
13
+ };
14
+ export interface SpeedChallenge {
15
+ id: string;
16
+ problems: {
17
+ num: number;
18
+ operation: string;
19
+ }[];
20
+ expectedAnswers: string[];
21
+ issuedAt: number;
22
+ expiresAt: number;
23
+ }
24
+ export interface StandardChallenge {
25
+ id: string;
26
+ puzzle: string;
27
+ expectedAnswer: string;
28
+ expiresAt: number;
29
+ difficulty: 'easy' | 'medium' | 'hard';
30
+ }
31
+ export interface ChallengeResult {
32
+ valid: boolean;
33
+ reason?: string;
34
+ solveTimeMs?: number;
35
+ }
36
+ /**
37
+ * Generate a speed challenge: 5 SHA256 problems, 500ms to solve ALL
38
+ * Trivial for AI, impossible for humans to copy-paste fast enough
39
+ */
40
+ export declare function generateSpeedChallenge(kv?: KVNamespace): Promise<{
41
+ id: string;
42
+ problems: {
43
+ num: number;
44
+ operation: string;
45
+ }[];
46
+ timeLimit: number;
47
+ instructions: string;
48
+ }>;
49
+ /**
50
+ * Verify a speed challenge response
51
+ */
52
+ export declare function verifySpeedChallenge(id: string, answers: string[], kv?: KVNamespace): Promise<ChallengeResult>;
53
+ /**
54
+ * Generate a standard challenge: compute SHA256 of concatenated primes
55
+ */
56
+ export declare function generateStandardChallenge(difficulty?: 'easy' | 'medium' | 'hard', kv?: KVNamespace): Promise<{
57
+ id: string;
58
+ puzzle: string;
59
+ timeLimit: number;
60
+ hint: string;
61
+ }>;
62
+ /**
63
+ * Verify a standard challenge response
64
+ */
65
+ export declare function verifyStandardChallenge(id: string, answer: string, kv?: KVNamespace): Promise<ChallengeResult>;
66
+ /**
67
+ * Verify landing page challenge and issue access token
68
+ * @deprecated - Use JWT token flow instead (see auth.ts)
69
+ */
70
+ export declare function verifyLandingChallenge(answer: string, timestamp: string, kv?: KVNamespace): Promise<{
71
+ valid: boolean;
72
+ token?: string;
73
+ error?: string;
74
+ hint?: string;
75
+ }>;
76
+ /**
77
+ * Validate a landing token
78
+ * @deprecated - Use JWT token flow instead (see auth.ts)
79
+ */
80
+ export declare function validateLandingToken(token: string, kv?: KVNamespace): Promise<boolean>;
81
+ /**
82
+ * Solve speed challenge problems (utility for AI agents)
83
+ */
84
+ export declare function solveSpeedChallenge(problems: number[]): Promise<string[]>;
85
+ //# sourceMappingURL=challenges.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"challenges.d.ts","sourceRoot":"","sources":["../src/challenges.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,248 @@
1
+ /**
2
+ * BOTCHA Challenge System for Cloudflare Workers
3
+ *
4
+ * Uses KV storage for production-ready challenge state management
5
+ * Falls back to in-memory for local dev without KV
6
+ */
7
+ import { sha256First, uuid, generatePrimes, sha256 } from './crypto';
8
+ // ============ STORAGE ============
9
+ // In-memory fallback (for local dev without KV)
10
+ const speedChallenges = new Map();
11
+ const standardChallenges = new Map();
12
+ // Clean expired on access (no setInterval in Workers)
13
+ function cleanExpired() {
14
+ const now = Date.now();
15
+ for (const [id, c] of speedChallenges) {
16
+ if (c.expiresAt < now)
17
+ speedChallenges.delete(id);
18
+ }
19
+ for (const [id, c] of standardChallenges) {
20
+ if (c.expiresAt < now)
21
+ standardChallenges.delete(id);
22
+ }
23
+ }
24
+ // ============ KV STORAGE HELPERS ============
25
+ /**
26
+ * Store challenge in KV (with TTL) or fallback to memory
27
+ */
28
+ async function storeChallenge(kv, id, challenge, ttlSeconds) {
29
+ if (kv) {
30
+ await kv.put(`challenge:${id}`, JSON.stringify(challenge), {
31
+ expirationTtl: ttlSeconds,
32
+ });
33
+ }
34
+ else {
35
+ // Fallback to in-memory
36
+ if ('problems' in challenge) {
37
+ speedChallenges.set(id, challenge);
38
+ }
39
+ else {
40
+ standardChallenges.set(id, challenge);
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Get challenge from KV or fallback to memory
46
+ */
47
+ async function getChallenge(kv, id, isSpeed) {
48
+ if (kv) {
49
+ const data = await kv.get(`challenge:${id}`);
50
+ return data ? JSON.parse(data) : null;
51
+ }
52
+ else {
53
+ // Fallback to in-memory
54
+ cleanExpired();
55
+ return (isSpeed ? speedChallenges.get(id) : standardChallenges.get(id)) || null;
56
+ }
57
+ }
58
+ /**
59
+ * Delete challenge from KV or memory
60
+ */
61
+ async function deleteChallenge(kv, id) {
62
+ if (kv) {
63
+ await kv.delete(`challenge:${id}`);
64
+ }
65
+ else {
66
+ speedChallenges.delete(id);
67
+ standardChallenges.delete(id);
68
+ }
69
+ }
70
+ // ============ SPEED CHALLENGE ============
71
+ /**
72
+ * Generate a speed challenge: 5 SHA256 problems, 500ms to solve ALL
73
+ * Trivial for AI, impossible for humans to copy-paste fast enough
74
+ */
75
+ export async function generateSpeedChallenge(kv) {
76
+ cleanExpired();
77
+ const id = uuid();
78
+ const problems = [];
79
+ const expectedAnswers = [];
80
+ for (let i = 0; i < 5; i++) {
81
+ const num = Math.floor(Math.random() * 900000) + 100000;
82
+ problems.push({ num, operation: 'sha256_first8' });
83
+ expectedAnswers.push(await sha256First(num.toString(), 8));
84
+ }
85
+ const timeLimit = 500;
86
+ const challenge = {
87
+ id,
88
+ problems,
89
+ expectedAnswers,
90
+ issuedAt: Date.now(),
91
+ expiresAt: Date.now() + timeLimit + 100, // tiny grace
92
+ };
93
+ // Store in KV with 5 minute TTL (safety buffer for time checks)
94
+ await storeChallenge(kv, id, challenge, 300);
95
+ return {
96
+ id,
97
+ problems,
98
+ timeLimit,
99
+ instructions: 'Compute SHA256 of each number, return first 8 hex chars of each. Submit as array. You have 500ms.',
100
+ };
101
+ }
102
+ /**
103
+ * Verify a speed challenge response
104
+ */
105
+ export async function verifySpeedChallenge(id, answers, kv) {
106
+ const challenge = await getChallenge(kv, id, true);
107
+ if (!challenge) {
108
+ return { valid: false, reason: 'Challenge not found or expired' };
109
+ }
110
+ const now = Date.now();
111
+ const solveTimeMs = now - challenge.issuedAt;
112
+ // Delete challenge immediately to prevent replay attacks
113
+ await deleteChallenge(kv, id);
114
+ if (now > challenge.expiresAt) {
115
+ return { valid: false, reason: `Too slow! Took ${solveTimeMs}ms, limit was 500ms` };
116
+ }
117
+ if (!Array.isArray(answers) || answers.length !== 5) {
118
+ return { valid: false, reason: 'Must provide exactly 5 answers as array' };
119
+ }
120
+ for (let i = 0; i < 5; i++) {
121
+ if (answers[i]?.toLowerCase() !== challenge.expectedAnswers[i]) {
122
+ return { valid: false, reason: `Wrong answer for challenge ${i + 1}` };
123
+ }
124
+ }
125
+ return { valid: true, solveTimeMs };
126
+ }
127
+ // ============ STANDARD CHALLENGE ============
128
+ const DIFFICULTY_CONFIG = {
129
+ easy: { primes: 100, timeLimit: 10000 },
130
+ medium: { primes: 500, timeLimit: 5000 },
131
+ hard: { primes: 1000, timeLimit: 3000 },
132
+ };
133
+ /**
134
+ * Generate a standard challenge: compute SHA256 of concatenated primes
135
+ */
136
+ export async function generateStandardChallenge(difficulty = 'medium', kv) {
137
+ cleanExpired();
138
+ const id = uuid();
139
+ const config = DIFFICULTY_CONFIG[difficulty];
140
+ const primes = generatePrimes(config.primes);
141
+ const concatenated = primes.join('');
142
+ const hash = await sha256(concatenated);
143
+ const answer = hash.substring(0, 16);
144
+ const challenge = {
145
+ id,
146
+ puzzle: `Compute SHA256 of the first ${config.primes} prime numbers concatenated (no separators). Return the first 16 hex characters.`,
147
+ expectedAnswer: answer,
148
+ expiresAt: Date.now() + config.timeLimit + 1000,
149
+ difficulty,
150
+ };
151
+ // Store in KV with 5 minute TTL
152
+ await storeChallenge(kv, id, challenge, 300);
153
+ return {
154
+ id,
155
+ puzzle: `Compute SHA256 of the first ${config.primes} prime numbers concatenated (no separators). Return the first 16 hex characters.`,
156
+ timeLimit: config.timeLimit,
157
+ hint: `Example: First 5 primes = "235711" → SHA256 → first 16 chars`,
158
+ };
159
+ }
160
+ /**
161
+ * Verify a standard challenge response
162
+ */
163
+ export async function verifyStandardChallenge(id, answer, kv) {
164
+ const challenge = await getChallenge(kv, id, false);
165
+ if (!challenge) {
166
+ return { valid: false, reason: 'Challenge not found or expired' };
167
+ }
168
+ const now = Date.now();
169
+ // Delete challenge immediately to prevent replay attacks
170
+ await deleteChallenge(kv, id);
171
+ if (now > challenge.expiresAt) {
172
+ return { valid: false, reason: 'Challenge expired - too slow!' };
173
+ }
174
+ const isValid = answer.toLowerCase() === challenge.expectedAnswer.toLowerCase();
175
+ if (!isValid) {
176
+ return { valid: false, reason: 'Incorrect answer' };
177
+ }
178
+ const solveTimeMs = now - (challenge.expiresAt - DIFFICULTY_CONFIG[challenge.difficulty].timeLimit - 1000);
179
+ return { valid: true, solveTimeMs };
180
+ }
181
+ // ============ LANDING CHALLENGE ============
182
+ const landingTokens = new Map();
183
+ /**
184
+ * Verify landing page challenge and issue access token
185
+ * @deprecated - Use JWT token flow instead (see auth.ts)
186
+ */
187
+ export async function verifyLandingChallenge(answer, timestamp, kv) {
188
+ cleanExpired();
189
+ // Verify timestamp is recent (within 5 minutes)
190
+ const submittedTime = new Date(timestamp).getTime();
191
+ const now = Date.now();
192
+ if (Math.abs(now - submittedTime) > 5 * 60 * 1000) {
193
+ return { valid: false, error: 'Timestamp too old or in future' };
194
+ }
195
+ // Calculate expected answer for today
196
+ const today = new Date().toISOString().split('T')[0];
197
+ const expectedHash = (await sha256(`BOTCHA-LANDING-${today}`)).substring(0, 16);
198
+ if (answer.toLowerCase() !== expectedHash.toLowerCase()) {
199
+ return {
200
+ valid: false,
201
+ error: 'Incorrect answer',
202
+ hint: `Expected SHA256('BOTCHA-LANDING-${today}') first 16 chars`
203
+ };
204
+ }
205
+ // Generate token
206
+ const tokenBytes = new Uint8Array(32);
207
+ crypto.getRandomValues(tokenBytes);
208
+ const token = Array.from(tokenBytes).map(b => b.toString(16).padStart(2, '0')).join('');
209
+ if (kv) {
210
+ await kv.put(`landing:${token}`, Date.now().toString(), { expirationTtl: 3600 });
211
+ }
212
+ else {
213
+ landingTokens.set(token, Date.now() + 60 * 60 * 1000);
214
+ }
215
+ // Clean expired tokens (memory only)
216
+ for (const [t, expiry] of landingTokens) {
217
+ if (expiry < Date.now())
218
+ landingTokens.delete(t);
219
+ }
220
+ return { valid: true, token };
221
+ }
222
+ /**
223
+ * Validate a landing token
224
+ * @deprecated - Use JWT token flow instead (see auth.ts)
225
+ */
226
+ export async function validateLandingToken(token, kv) {
227
+ if (kv) {
228
+ const value = await kv.get(`landing:${token}`);
229
+ return value !== null;
230
+ }
231
+ else {
232
+ const expiry = landingTokens.get(token);
233
+ if (!expiry)
234
+ return false;
235
+ if (expiry < Date.now()) {
236
+ landingTokens.delete(token);
237
+ return false;
238
+ }
239
+ return true;
240
+ }
241
+ }
242
+ // ============ SOLVER (for AI agents) ============
243
+ /**
244
+ * Solve speed challenge problems (utility for AI agents)
245
+ */
246
+ export async function solveSpeedChallenge(problems) {
247
+ return Promise.all(problems.map(n => sha256First(n.toString(), 8)));
248
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Web Crypto API utilities for BOTCHA
3
+ * Works in Cloudflare Workers, Deno, and browsers
4
+ */
5
+ /**
6
+ * SHA256 hash, returns hex string
7
+ */
8
+ export declare function sha256(input: string): Promise<string>;
9
+ /**
10
+ * SHA256 hash, returns first N hex chars
11
+ */
12
+ export declare function sha256First(input: string, chars: number): Promise<string>;
13
+ /**
14
+ * Generate a UUID (crypto.randomUUID is available in Workers)
15
+ */
16
+ export declare function uuid(): string;
17
+ /**
18
+ * Generate N first primes
19
+ */
20
+ export declare function generatePrimes(count: number): number[];
21
+ //# sourceMappingURL=crypto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;GAEG;AACH,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAM3D;AAED;;GAEG;AACH,wBAAsB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG/E;AAED;;GAEG;AACH,wBAAgB,IAAI,IAAI,MAAM,CAE7B;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAYtD"}
package/dist/crypto.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Web Crypto API utilities for BOTCHA
3
+ * Works in Cloudflare Workers, Deno, and browsers
4
+ */
5
+ /**
6
+ * SHA256 hash, returns hex string
7
+ */
8
+ export async function sha256(input) {
9
+ const encoder = new TextEncoder();
10
+ const data = encoder.encode(input);
11
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
12
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
13
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
14
+ }
15
+ /**
16
+ * SHA256 hash, returns first N hex chars
17
+ */
18
+ export async function sha256First(input, chars) {
19
+ const hash = await sha256(input);
20
+ return hash.substring(0, chars);
21
+ }
22
+ /**
23
+ * Generate a UUID (crypto.randomUUID is available in Workers)
24
+ */
25
+ export function uuid() {
26
+ return crypto.randomUUID();
27
+ }
28
+ /**
29
+ * Generate N first primes
30
+ */
31
+ export function generatePrimes(count) {
32
+ const primes = [];
33
+ let num = 2;
34
+ while (primes.length < count) {
35
+ if (isPrime(num)) {
36
+ primes.push(num);
37
+ }
38
+ num++;
39
+ }
40
+ return primes;
41
+ }
42
+ function isPrime(n) {
43
+ if (n < 2)
44
+ return false;
45
+ if (n === 2)
46
+ return true;
47
+ if (n % 2 === 0)
48
+ return false;
49
+ for (let i = 3; i <= Math.sqrt(n); i += 2) {
50
+ if (n % i === 0)
51
+ return false;
52
+ }
53
+ return true;
54
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * BOTCHA - Cloudflare Workers Edition v0.2.0
3
+ *
4
+ * Prove you're a bot. Humans need not apply.
5
+ *
6
+ * https://botcha.ai
7
+ */
8
+ import { Hono } from 'hono';
9
+ import { type KVNamespace } from './challenges';
10
+ type Bindings = {
11
+ CHALLENGES: KVNamespace;
12
+ RATE_LIMITS: KVNamespace;
13
+ JWT_SECRET: string;
14
+ BOTCHA_VERSION: string;
15
+ };
16
+ type Variables = {
17
+ tokenPayload?: {
18
+ sub: string;
19
+ iat: number;
20
+ exp: number;
21
+ type: 'botcha-verified';
22
+ solveTime: number;
23
+ };
24
+ };
25
+ declare const app: Hono<{
26
+ Bindings: Bindings;
27
+ Variables: Variables;
28
+ }, import("hono/types").BlankSchema, "/">;
29
+ export default app;
30
+ export { generateSpeedChallenge, verifySpeedChallenge, generateStandardChallenge, verifyStandardChallenge, solveSpeedChallenge, } from './challenges';
31
+ export { generateToken, verifyToken } from './auth';
32
+ export { checkRateLimit } from './rate-limit';
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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,EAQL,KAAK,WAAW,EACjB,MAAM,cAAc,CAAC;AAKtB,KAAK,QAAQ,GAAG;IACd,UAAU,EAAE,WAAW,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,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;AAgVrE,eAAe,GAAG,CAAC;AAGnB,OAAO,EACL,sBAAsB,EACtB,oBAAoB,EACpB,yBAAyB,EACzB,uBAAuB,EACvB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * BOTCHA - Cloudflare Workers Edition v0.2.0
3
+ *
4
+ * Prove you're a bot. Humans need not apply.
5
+ *
6
+ * https://botcha.ai
7
+ */
8
+ import { Hono } from 'hono';
9
+ import { cors } from 'hono/cors';
10
+ import { generateSpeedChallenge, verifySpeedChallenge, generateStandardChallenge, verifyStandardChallenge, verifyLandingChallenge, } from './challenges';
11
+ import { generateToken, verifyToken, extractBearerToken } from './auth';
12
+ import { checkRateLimit, getClientIP } from './rate-limit';
13
+ const app = new Hono();
14
+ // ============ MIDDLEWARE ============
15
+ app.use('*', cors());
16
+ // BOTCHA discovery headers
17
+ app.use('*', async (c, next) => {
18
+ await next();
19
+ c.header('X-Botcha-Version', c.env.BOTCHA_VERSION || '0.2.0');
20
+ c.header('X-Botcha-Enabled', 'true');
21
+ c.header('X-Botcha-Methods', 'speed-challenge,standard-challenge,jwt-token');
22
+ c.header('X-Botcha-Docs', 'https://botcha.ai/openapi.json');
23
+ c.header('X-Botcha-Runtime', 'cloudflare-workers');
24
+ });
25
+ // Rate limiting middleware for challenge generation
26
+ async function rateLimitMiddleware(c, next) {
27
+ const clientIP = getClientIP(c.req.raw);
28
+ const rateLimitResult = await checkRateLimit(c.env.RATE_LIMITS, clientIP, 100);
29
+ // Add rate limit headers
30
+ c.header('X-RateLimit-Limit', '100');
31
+ c.header('X-RateLimit-Remaining', rateLimitResult.remaining.toString());
32
+ c.header('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
33
+ if (!rateLimitResult.allowed) {
34
+ c.header('Retry-After', rateLimitResult.retryAfter?.toString() || '3600');
35
+ return c.json({
36
+ error: 'RATE_LIMIT_EXCEEDED',
37
+ message: 'You have exceeded the rate limit. Free tier: 100 challenges/hour/IP',
38
+ retryAfter: rateLimitResult.retryAfter,
39
+ resetAt: new Date(rateLimitResult.resetAt).toISOString(),
40
+ }, 429);
41
+ }
42
+ await next();
43
+ }
44
+ // JWT verification middleware
45
+ async function requireJWT(c, next) {
46
+ const authHeader = c.req.header('authorization');
47
+ const token = extractBearerToken(authHeader);
48
+ if (!token) {
49
+ return c.json({
50
+ error: 'UNAUTHORIZED',
51
+ message: 'Missing Bearer token. Use POST /v1/token/verify to get a token.',
52
+ }, 401);
53
+ }
54
+ const result = await verifyToken(token, c.env.JWT_SECRET);
55
+ if (!result.valid) {
56
+ return c.json({
57
+ error: 'INVALID_TOKEN',
58
+ message: result.error || 'Token is invalid or expired',
59
+ }, 401);
60
+ }
61
+ // Store payload in context for route handlers
62
+ c.set('tokenPayload', result.payload);
63
+ await next();
64
+ }
65
+ // ============ ROOT & INFO ============
66
+ app.get('/', (c) => {
67
+ return c.json({
68
+ name: 'BOTCHA',
69
+ version: c.env.BOTCHA_VERSION || '0.2.0',
70
+ runtime: 'cloudflare-workers',
71
+ tagline: 'Prove you are a bot. Humans need not apply.',
72
+ endpoints: {
73
+ '/': 'API info',
74
+ '/health': 'Health check',
75
+ '/v1/challenges': 'Generate challenge (GET) or verify (POST)',
76
+ '/v1/token': 'Get challenge for JWT token flow (GET)',
77
+ '/v1/token/verify': 'Verify challenge and get JWT (POST)',
78
+ '/agent-only': 'Protected endpoint (requires JWT)',
79
+ },
80
+ rateLimit: {
81
+ free: '100 challenges/hour/IP',
82
+ headers: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'],
83
+ },
84
+ authentication: {
85
+ flow: 'GET /v1/token → solve challenge → POST /v1/token/verify → Bearer token',
86
+ tokenExpiry: '1 hour',
87
+ usage: 'Authorization: Bearer <token>',
88
+ },
89
+ discovery: {
90
+ openapi: 'https://botcha.ai/openapi.json',
91
+ aiPlugin: 'https://botcha.ai/.well-known/ai-plugin.json',
92
+ npm: 'https://www.npmjs.com/package/@dupecom/botcha-cloudflare',
93
+ github: 'https://github.com/i8ramin/botcha',
94
+ },
95
+ });
96
+ });
97
+ app.get('/health', (c) => {
98
+ return c.json({ status: 'ok', runtime: 'cloudflare-workers' });
99
+ });
100
+ // ============ V1 API ============
101
+ // Generate challenge (standard or speed)
102
+ app.get('/v1/challenges', rateLimitMiddleware, async (c) => {
103
+ const type = c.req.query('type') || 'speed';
104
+ const difficulty = c.req.query('difficulty') || 'medium';
105
+ if (type === 'speed') {
106
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
107
+ return c.json({
108
+ success: true,
109
+ type: 'speed',
110
+ challenge: {
111
+ id: challenge.id,
112
+ problems: challenge.problems,
113
+ timeLimit: `${challenge.timeLimit}ms`,
114
+ instructions: challenge.instructions,
115
+ },
116
+ tip: '⚡ Speed challenge: You have 500ms to solve ALL problems. Humans cannot copy-paste fast enough.',
117
+ });
118
+ }
119
+ else {
120
+ const challenge = await generateStandardChallenge(difficulty, c.env.CHALLENGES);
121
+ return c.json({
122
+ success: true,
123
+ type: 'standard',
124
+ challenge: {
125
+ id: challenge.id,
126
+ puzzle: challenge.puzzle,
127
+ timeLimit: `${challenge.timeLimit}ms`,
128
+ hint: challenge.hint,
129
+ },
130
+ });
131
+ }
132
+ });
133
+ // Verify challenge (without JWT - legacy)
134
+ app.post('/v1/challenges/:id/verify', async (c) => {
135
+ const id = c.req.param('id');
136
+ const body = await c.req.json();
137
+ const { answers, answer, type } = body;
138
+ if (type === 'speed' || answers) {
139
+ if (!answers || !Array.isArray(answers)) {
140
+ return c.json({ success: false, error: 'Missing answers array for speed challenge' }, 400);
141
+ }
142
+ const result = await verifySpeedChallenge(id, answers, c.env.CHALLENGES);
143
+ return c.json({
144
+ success: result.valid,
145
+ message: result.valid
146
+ ? `⚡ Speed challenge passed in ${result.solveTimeMs}ms!`
147
+ : result.reason,
148
+ solveTimeMs: result.solveTimeMs,
149
+ });
150
+ }
151
+ else {
152
+ if (!answer) {
153
+ return c.json({ success: false, error: 'Missing answer for standard challenge' }, 400);
154
+ }
155
+ const result = await verifyStandardChallenge(id, answer, c.env.CHALLENGES);
156
+ return c.json({
157
+ success: result.valid,
158
+ message: result.valid ? 'Challenge passed!' : result.reason,
159
+ solveTimeMs: result.solveTimeMs,
160
+ });
161
+ }
162
+ });
163
+ // Get challenge for token flow (includes empty token field)
164
+ app.get('/v1/token', rateLimitMiddleware, async (c) => {
165
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
166
+ return c.json({
167
+ success: true,
168
+ challenge: {
169
+ id: challenge.id,
170
+ problems: challenge.problems,
171
+ timeLimit: `${challenge.timeLimit}ms`,
172
+ instructions: challenge.instructions,
173
+ },
174
+ token: null, // Will be populated after verification
175
+ nextStep: `POST /v1/token/verify with {id: "${challenge.id}", answers: ["..."]}`
176
+ });
177
+ });
178
+ // Verify challenge and issue JWT token
179
+ app.post('/v1/token/verify', async (c) => {
180
+ const body = await c.req.json();
181
+ const { id, answers } = body;
182
+ if (!id || !answers) {
183
+ return c.json({
184
+ success: false,
185
+ error: 'Missing id or answers array',
186
+ hint: 'First GET /v1/token to get a challenge, then solve it and submit here',
187
+ }, 400);
188
+ }
189
+ const result = await verifySpeedChallenge(id, answers, c.env.CHALLENGES);
190
+ if (!result.valid) {
191
+ return c.json({
192
+ success: false,
193
+ error: 'CHALLENGE_FAILED',
194
+ message: result.reason,
195
+ }, 403);
196
+ }
197
+ // Generate JWT token
198
+ const token = await generateToken(id, result.solveTimeMs || 0, c.env.JWT_SECRET);
199
+ return c.json({
200
+ success: true,
201
+ message: `🤖 Challenge verified in ${result.solveTimeMs}ms! You are a bot.`,
202
+ token,
203
+ expiresIn: '1h',
204
+ usage: {
205
+ header: 'Authorization: Bearer <token>',
206
+ protectedEndpoints: ['/agent-only'],
207
+ },
208
+ });
209
+ });
210
+ // ============ PROTECTED ENDPOINT ============
211
+ app.get('/agent-only', requireJWT, async (c) => {
212
+ const payload = c.get('tokenPayload');
213
+ return c.json({
214
+ success: true,
215
+ message: '🤖 Welcome, fellow agent!',
216
+ verified: true,
217
+ agent: 'jwt-verified',
218
+ method: 'bearer-token',
219
+ timestamp: new Date().toISOString(),
220
+ solveTime: `${payload?.solveTime}ms`,
221
+ secret: 'The humans will never see this. Their fingers are too slow. 🤫',
222
+ });
223
+ });
224
+ // ============ LEGACY ENDPOINTS (v0 - backward compatibility) ============
225
+ app.get('/api/challenge', async (c) => {
226
+ const difficulty = c.req.query('difficulty') || 'medium';
227
+ const challenge = await generateStandardChallenge(difficulty, c.env.CHALLENGES);
228
+ return c.json({ success: true, challenge });
229
+ });
230
+ app.post('/api/challenge', async (c) => {
231
+ const body = await c.req.json();
232
+ const { id, answer } = body;
233
+ if (!id || !answer) {
234
+ return c.json({ success: false, error: 'Missing id or answer' }, 400);
235
+ }
236
+ const result = await verifyStandardChallenge(id, answer, c.env.CHALLENGES);
237
+ return c.json({
238
+ success: result.valid,
239
+ message: result.valid ? '✅ Challenge passed!' : `❌ ${result.reason}`,
240
+ solveTime: result.solveTimeMs,
241
+ });
242
+ });
243
+ app.get('/api/speed-challenge', async (c) => {
244
+ const challenge = await generateSpeedChallenge(c.env.CHALLENGES);
245
+ return c.json({
246
+ success: true,
247
+ warning: '⚡ SPEED CHALLENGE: You have 500ms to solve ALL 5 problems!',
248
+ challenge: {
249
+ id: challenge.id,
250
+ problems: challenge.problems,
251
+ timeLimit: `${challenge.timeLimit}ms`,
252
+ instructions: challenge.instructions,
253
+ },
254
+ tip: 'Humans cannot copy-paste fast enough. Only real AI agents can pass.',
255
+ });
256
+ });
257
+ app.post('/api/speed-challenge', async (c) => {
258
+ const body = await c.req.json();
259
+ const { id, answers } = body;
260
+ if (!id || !answers) {
261
+ return c.json({ success: false, error: 'Missing id or answers array' }, 400);
262
+ }
263
+ const result = await verifySpeedChallenge(id, answers, c.env.CHALLENGES);
264
+ return c.json({
265
+ success: result.valid,
266
+ message: result.valid
267
+ ? `⚡ SPEED TEST PASSED in ${result.solveTimeMs}ms! You are definitely an AI.`
268
+ : `❌ ${result.reason}`,
269
+ solveTimeMs: result.solveTimeMs,
270
+ verdict: result.valid ? '🤖 VERIFIED AI AGENT' : '🚫 LIKELY HUMAN (too slow)',
271
+ });
272
+ });
273
+ app.post('/api/verify-landing', async (c) => {
274
+ const body = await c.req.json();
275
+ const { answer, timestamp } = body;
276
+ if (!answer || !timestamp) {
277
+ return c.json({
278
+ success: false,
279
+ error: 'Missing answer or timestamp',
280
+ hint: 'Parse the challenge from <script type="application/botcha+json"> on the landing page'
281
+ }, 400);
282
+ }
283
+ const result = await verifyLandingChallenge(answer, timestamp, c.env.CHALLENGES);
284
+ if (!result.valid) {
285
+ return c.json({
286
+ success: false,
287
+ error: result.error,
288
+ hint: result.hint,
289
+ }, 403);
290
+ }
291
+ return c.json({
292
+ success: true,
293
+ message: '🤖 Landing challenge solved! You are a bot.',
294
+ token: result.token,
295
+ usage: {
296
+ header: 'X-Botcha-Landing-Token',
297
+ value: result.token,
298
+ expires_in: '1 hour',
299
+ use_with: '/agent-only'
300
+ }
301
+ });
302
+ });
303
+ // ============ EXPORT ============
304
+ export default app;
305
+ // Also export utilities for use as a library
306
+ export { generateSpeedChallenge, verifySpeedChallenge, generateStandardChallenge, verifyStandardChallenge, solveSpeedChallenge, } from './challenges';
307
+ export { generateToken, verifyToken } from './auth';
308
+ export { checkRateLimit } from './rate-limit';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * BOTCHA Rate Limiting
3
+ *
4
+ * Simple IP-based rate limiting using KV storage
5
+ */
6
+ import type { KVNamespace } from './challenges';
7
+ export interface RateLimitConfig {
8
+ requestsPerHour: number;
9
+ identifier: string;
10
+ }
11
+ export interface RateLimitResult {
12
+ allowed: boolean;
13
+ remaining: number;
14
+ resetAt: number;
15
+ retryAfter?: number;
16
+ }
17
+ /**
18
+ * Check and enforce rate limits
19
+ */
20
+ export declare function checkRateLimit(kv: KVNamespace | undefined, identifier: string, limit?: number): Promise<RateLimitResult>;
21
+ /**
22
+ * Extract client IP from request
23
+ */
24
+ export declare function getClientIP(request: Request): string;
25
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,WAAW,GAAG,SAAS,EAC3B,UAAU,EAAE,MAAM,EAClB,KAAK,GAAE,MAAY,GAClB,OAAO,CAAC,eAAe,CAAC,CA8E1B;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAapD"}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * BOTCHA Rate Limiting
3
+ *
4
+ * Simple IP-based rate limiting using KV storage
5
+ */
6
+ /**
7
+ * Check and enforce rate limits
8
+ */
9
+ export async function checkRateLimit(kv, identifier, limit = 100) {
10
+ if (!kv) {
11
+ // No KV = no rate limiting (local dev)
12
+ return {
13
+ allowed: true,
14
+ remaining: limit,
15
+ resetAt: Date.now() + 3600000,
16
+ };
17
+ }
18
+ const key = `ratelimit:${identifier}`;
19
+ const now = Date.now();
20
+ const windowStart = now - 3600000; // 1 hour ago
21
+ try {
22
+ const data = await kv.get(key);
23
+ if (!data) {
24
+ // First request in this window
25
+ await kv.put(key, JSON.stringify({ count: 1, firstRequest: now }), {
26
+ expirationTtl: 3600, // 1 hour
27
+ });
28
+ return {
29
+ allowed: true,
30
+ remaining: limit - 1,
31
+ resetAt: now + 3600000,
32
+ };
33
+ }
34
+ const { count, firstRequest } = JSON.parse(data);
35
+ // Check if window has expired
36
+ if (firstRequest < windowStart) {
37
+ // Reset window
38
+ await kv.put(key, JSON.stringify({ count: 1, firstRequest: now }), {
39
+ expirationTtl: 3600,
40
+ });
41
+ return {
42
+ allowed: true,
43
+ remaining: limit - 1,
44
+ resetAt: now + 3600000,
45
+ };
46
+ }
47
+ // Check if limit exceeded
48
+ if (count >= limit) {
49
+ const resetAt = firstRequest + 3600000;
50
+ const retryAfter = Math.ceil((resetAt - now) / 1000);
51
+ return {
52
+ allowed: false,
53
+ remaining: 0,
54
+ resetAt,
55
+ retryAfter,
56
+ };
57
+ }
58
+ // Increment counter
59
+ await kv.put(key, JSON.stringify({ count: count + 1, firstRequest }), {
60
+ expirationTtl: 3600,
61
+ });
62
+ return {
63
+ allowed: true,
64
+ remaining: limit - count - 1,
65
+ resetAt: firstRequest + 3600000,
66
+ };
67
+ }
68
+ catch (error) {
69
+ // On error, allow request (fail open)
70
+ console.error('Rate limit check failed:', error);
71
+ return {
72
+ allowed: true,
73
+ remaining: limit,
74
+ resetAt: now + 3600000,
75
+ };
76
+ }
77
+ }
78
+ /**
79
+ * Extract client IP from request
80
+ */
81
+ export function getClientIP(request) {
82
+ // Cloudflare provides CF-Connecting-IP header
83
+ const cfIP = request.headers.get('cf-connecting-ip');
84
+ if (cfIP)
85
+ return cfIP;
86
+ // Fallback headers
87
+ const forwarded = request.headers.get('x-forwarded-for');
88
+ if (forwarded)
89
+ return forwarded.split(',')[0].trim();
90
+ const realIP = request.headers.get('x-real-ip');
91
+ if (realIP)
92
+ return realIP;
93
+ return 'unknown';
94
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@dupecom/botcha-cloudflare",
3
+ "version": "0.2.0",
4
+ "description": "BOTCHA for Cloudflare Workers - Prove you're a bot. Humans need not apply.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "dev": "wrangler dev",
20
+ "deploy": "wrangler deploy",
21
+ "build": "tsc",
22
+ "prepublishOnly": "npm run build",
23
+ "test": "vitest"
24
+ },
25
+ "keywords": [
26
+ "captcha",
27
+ "bot",
28
+ "ai",
29
+ "agent",
30
+ "verification",
31
+ "cloudflare",
32
+ "workers",
33
+ "edge"
34
+ ],
35
+ "author": "Ramin <ramin@dupe.com>",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/i8ramin/botcha"
40
+ },
41
+ "homepage": "https://botcha.ai",
42
+ "dependencies": {
43
+ "hono": "^4.7.0",
44
+ "jose": "^5.9.6"
45
+ },
46
+ "devDependencies": {
47
+ "@cloudflare/workers-types": "^4.20250124.0",
48
+ "typescript": "^5.9.3",
49
+ "wrangler": "^4.5.0",
50
+ "vitest": "^3.0.0"
51
+ }
52
+ }