@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 +178 -0
- package/dist/auth.d.ts +32 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +60 -0
- package/dist/challenges.d.ts +85 -0
- package/dist/challenges.d.ts.map +1 -0
- package/dist/challenges.js +248 -0
- package/dist/crypto.d.ts +21 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +54 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +308 -0
- package/dist/rate-limit.d.ts +25 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +94 -0
- package/package.json +52 -0
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":"AAAA;;;;;GAKG;AAMH,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,aAAa,GAAG,QAAQ,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IACtF,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzF,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAGF,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC/C,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;CACxC;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA4ED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,EAAE,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC;IACtE,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC,CA+BD;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EAAE,EACjB,EAAE,CAAC,EAAE,WAAW,GACf,OAAO,CAAC,eAAe,CAAC,CA4B1B;AASD;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,UAAU,GAAE,MAAM,GAAG,QAAQ,GAAG,MAAiB,EACjD,EAAE,CAAC,EAAE,WAAW,GACf,OAAO,CAAC;IACT,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC,CA4BD;AAED;;GAEG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,MAAM,EACd,EAAE,CAAC,EAAE,WAAW,GACf,OAAO,CAAC,eAAe,CAAC,CAyB1B;AAKD;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,EAAE,CAAC,EAAE,WAAW,GACf,OAAO,CAAC;IACT,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC,CAuCD;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAa5F;AAGD;;GAEG;AACH,wBAAsB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAE/E"}
|
|
@@ -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
|
+
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|