Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudflare-turnstile",
|
||||||
|
"description": "Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Jeremy Dawes",
|
||||||
|
"email": "jeremy@jezweb.net"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# cloudflare-turnstile
|
||||||
|
|
||||||
|
Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.
|
||||||
432
SKILL.md
Normal file
432
SKILL.md
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
---
|
||||||
|
name: cloudflare-turnstile
|
||||||
|
description: |
|
||||||
|
Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cloudflare Turnstile
|
||||||
|
|
||||||
|
**Status**: Production Ready ✅
|
||||||
|
**Last Updated**: 2025-11-24
|
||||||
|
**Dependencies**: None (optional: @marsidev/react-turnstile for React)
|
||||||
|
**Latest Versions**: @marsidev/react-turnstile@1.3.1, turnstile-types@1.2.3
|
||||||
|
|
||||||
|
**Recent Updates (2025)**:
|
||||||
|
- **March 2025**: Upgraded Turnstile Analytics with TopN statistics (7 dimensions: hostnames, browsers, countries, user agents, ASNs, OS, source IPs), anomaly detection, enhanced bot behavior monitoring
|
||||||
|
- **2025**: WCAG 2.1 AA compliance, Free plan (20 widgets, 7-day analytics), Enterprise features (unlimited widgets, ephemeral IDs, any hostname support, 30-day analytics, offlabel branding)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (5 Minutes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create widget: https://dash.cloudflare.com/?to=/:account/turnstile
|
||||||
|
# Copy sitekey (public) and secret key (private)
|
||||||
|
|
||||||
|
# 2. Add widget to frontend
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
<form>
|
||||||
|
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
# 3. Validate token server-side (Cloudflare Workers)
|
||||||
|
const formData = await request.formData()
|
||||||
|
const token = formData.get('cf-turnstile-response')
|
||||||
|
|
||||||
|
const verifyFormData = new FormData()
|
||||||
|
verifyFormData.append('secret', env.TURNSTILE_SECRET_KEY)
|
||||||
|
verifyFormData.append('response', token)
|
||||||
|
verifyFormData.append('remoteip', request.headers.get('CF-Connecting-IP'))
|
||||||
|
|
||||||
|
const result = await fetch(
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
{ method: 'POST', body: verifyFormData }
|
||||||
|
)
|
||||||
|
|
||||||
|
const outcome = await result.json()
|
||||||
|
if (!outcome.success) return new Response('Invalid', { status: 401 })
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL:**
|
||||||
|
- Token expires in 5 minutes, single-use only
|
||||||
|
- ALWAYS validate server-side (Siteverify API required)
|
||||||
|
- Never proxy/cache api.js (must load from Cloudflare CDN)
|
||||||
|
- Use different widgets for dev/staging/production
|
||||||
|
|
||||||
|
## Rendering Modes
|
||||||
|
|
||||||
|
**Implicit** (auto-render on page load):
|
||||||
|
```html
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-callback="onSuccess"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Explicit** (programmatic control for SPAs):
|
||||||
|
```typescript
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||||
|
const widgetId = turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY' })
|
||||||
|
turnstile.reset(widgetId) // Reset widget
|
||||||
|
turnstile.getResponse(widgetId) // Get token
|
||||||
|
```
|
||||||
|
|
||||||
|
**React** (using @marsidev/react-turnstile):
|
||||||
|
```tsx
|
||||||
|
import { Turnstile } from '@marsidev/react-turnstile'
|
||||||
|
<Turnstile siteKey={TURNSTILE_SITE_KEY} onSuccess={setToken} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
### Always Do
|
||||||
|
|
||||||
|
✅ **Call Siteverify API** - Server-side validation is mandatory
|
||||||
|
✅ **Use HTTPS** - Never validate over HTTP
|
||||||
|
✅ **Protect secret keys** - Never expose in frontend code
|
||||||
|
✅ **Handle token expiration** - Tokens expire after 5 minutes
|
||||||
|
✅ **Implement error callbacks** - Handle failures gracefully
|
||||||
|
✅ **Use dummy keys for testing** - Test sitekey: `1x00000000000000000000AA`
|
||||||
|
✅ **Set reasonable timeouts** - Don't wait indefinitely for validation
|
||||||
|
✅ **Validate action/hostname** - Check additional fields when specified
|
||||||
|
✅ **Rotate keys periodically** - Use dashboard or API to rotate secrets
|
||||||
|
✅ **Monitor analytics** - Track solve rates and failures
|
||||||
|
|
||||||
|
### Never Do
|
||||||
|
|
||||||
|
❌ **Skip server validation** - Client-side only = security vulnerability
|
||||||
|
❌ **Proxy api.js script** - Must load from Cloudflare CDN
|
||||||
|
❌ **Reuse tokens** - Each token is single-use only
|
||||||
|
❌ **Use GET requests** - Siteverify only accepts POST
|
||||||
|
❌ **Expose secret key** - Keep secrets in backend environment only
|
||||||
|
❌ **Trust client-side validation** - Tokens can be forged
|
||||||
|
❌ **Cache api.js** - Future updates will break your integration
|
||||||
|
❌ **Use production keys in tests** - Use dummy keys instead
|
||||||
|
❌ **Ignore error callbacks** - Always handle failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues Prevention
|
||||||
|
|
||||||
|
This skill prevents **12** documented issues:
|
||||||
|
|
||||||
|
### Issue #1: Missing Server-Side Validation
|
||||||
|
**Error**: Zero token validation in Turnstile Analytics dashboard
|
||||||
|
**Source**: https://developers.cloudflare.com/turnstile/get-started/
|
||||||
|
**Why It Happens**: Developers only implement client-side widget, skip Siteverify call
|
||||||
|
**Prevention**: All templates include mandatory server-side validation with Siteverify API
|
||||||
|
|
||||||
|
### Issue #2: Token Expiration (5 Minutes)
|
||||||
|
**Error**: `success: false` for valid tokens submitted after delay
|
||||||
|
**Source**: https://developers.cloudflare.com/turnstile/get-started/server-side-validation
|
||||||
|
**Why It Happens**: Tokens expire 300 seconds after generation
|
||||||
|
**Prevention**: Templates document TTL and implement token refresh on expiration
|
||||||
|
|
||||||
|
### Issue #3: Secret Key Exposed in Frontend
|
||||||
|
**Error**: Security bypass - attackers can validate their own tokens
|
||||||
|
**Source**: https://developers.cloudflare.com/turnstile/get-started/server-side-validation
|
||||||
|
**Why It Happens**: Secret key hardcoded in JavaScript or visible in source
|
||||||
|
**Prevention**: All templates show backend-only validation with environment variables
|
||||||
|
|
||||||
|
### Issue #4: GET Request to Siteverify
|
||||||
|
**Error**: API returns 405 Method Not Allowed
|
||||||
|
**Source**: https://developers.cloudflare.com/turnstile/migration/recaptcha
|
||||||
|
**Why It Happens**: reCAPTCHA supports GET, Turnstile requires POST
|
||||||
|
**Prevention**: Templates use POST with FormData or JSON body
|
||||||
|
|
||||||
|
### Issue #5: Content Security Policy Blocking
|
||||||
|
**Error**: Error 200500 - "Loading error: The iframe could not be loaded"
|
||||||
|
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes
|
||||||
|
**Why It Happens**: CSP blocks challenges.cloudflare.com iframe
|
||||||
|
**Prevention**: Skill includes CSP configuration reference and check-csp.sh script
|
||||||
|
|
||||||
|
### Issue #6: Widget Crash (Error 300030)
|
||||||
|
**Error**: Generic client execution error for legitimate users
|
||||||
|
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||||
|
**Why It Happens**: Unknown - appears to be Cloudflare-side issue (2025)
|
||||||
|
**Prevention**: Templates implement error callbacks, retry logic, and fallback handling
|
||||||
|
|
||||||
|
### Issue #7: Configuration Error (Error 600010)
|
||||||
|
**Error**: Widget fails with "configuration error"
|
||||||
|
**Source**: https://community.cloudflare.com/t/repeated-cloudflare-turnstile-error-600010/644578
|
||||||
|
**Why It Happens**: Missing or deleted hostname in widget configuration
|
||||||
|
**Prevention**: Templates document hostname allowlist requirement and verification steps
|
||||||
|
|
||||||
|
### Issue #8: Safari 18 / macOS 15 "Hide IP" Issue
|
||||||
|
**Error**: Error 300010 when Safari's "Hide IP address" is enabled
|
||||||
|
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||||
|
**Why It Happens**: Privacy settings interfere with challenge signals
|
||||||
|
**Prevention**: Error handling reference documents Safari workaround (disable Hide IP)
|
||||||
|
|
||||||
|
### Issue #9: Brave Browser Confetti Animation Failure
|
||||||
|
**Error**: Verification fails during success animation
|
||||||
|
**Source**: https://github.com/brave/brave-browser/issues/45608 (April 2025)
|
||||||
|
**Why It Happens**: Brave shields block animation scripts
|
||||||
|
**Prevention**: Templates handle success before animation completes
|
||||||
|
|
||||||
|
### Issue #10: Next.js + Jest Incompatibility
|
||||||
|
**Error**: @marsidev/react-turnstile breaks Jest tests
|
||||||
|
**Source**: https://github.com/marsidev/react-turnstile/issues/112 (Oct 2025)
|
||||||
|
**Why It Happens**: Module resolution issues with Jest
|
||||||
|
**Prevention**: Testing guide includes Jest mocking patterns and dummy sitekey usage
|
||||||
|
|
||||||
|
### Issue #11: localhost Not in Allowlist
|
||||||
|
**Error**: Error 110200 - "Unknown domain: Domain not allowed"
|
||||||
|
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes
|
||||||
|
**Why It Happens**: Production widget used in development without localhost in allowlist
|
||||||
|
**Prevention**: Templates use dummy test keys for dev, document localhost allowlist requirement
|
||||||
|
|
||||||
|
### Issue #12: Token Reuse Attempt
|
||||||
|
**Error**: `success: false` with "token already spent" error
|
||||||
|
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/testing
|
||||||
|
**Why It Happens**: Each token can only be validated once
|
||||||
|
**Prevention**: Templates document single-use constraint and token refresh patterns
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**wrangler.jsonc:**
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
|
||||||
|
"secrets": ["TURNSTILE_SECRET_KEY"] // Run: wrangler secret put TURNSTILE_SECRET_KEY
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required CSP:**
|
||||||
|
```html
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
script-src 'self' https://challenges.cloudflare.com;
|
||||||
|
frame-src 'self' https://challenges.cloudflare.com;
|
||||||
|
">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Hono + Cloudflare Workers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
|
type Bindings = {
|
||||||
|
TURNSTILE_SECRET_KEY: string
|
||||||
|
TURNSTILE_SITE_KEY: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Bindings }>()
|
||||||
|
|
||||||
|
app.post('/api/login', async (c) => {
|
||||||
|
const body = await c.req.formData()
|
||||||
|
const token = body.get('cf-turnstile-response')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return c.text('Missing Turnstile token', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
const verifyFormData = new FormData()
|
||||||
|
verifyFormData.append('secret', c.env.TURNSTILE_SECRET_KEY)
|
||||||
|
verifyFormData.append('response', token.toString())
|
||||||
|
verifyFormData.append('remoteip', c.req.header('CF-Connecting-IP') || '')
|
||||||
|
|
||||||
|
const verifyResult = await fetch(
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: verifyFormData,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const outcome = await verifyResult.json<{ success: boolean }>()
|
||||||
|
|
||||||
|
if (!outcome.success) {
|
||||||
|
return c.text('Invalid Turnstile token', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process login
|
||||||
|
return c.json({ message: 'Login successful' })
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use**: API routes in Cloudflare Workers with Hono framework
|
||||||
|
|
||||||
|
### Pattern 2: React + Next.js App Router
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Turnstile } from '@marsidev/react-turnstile'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function ContactForm() {
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError('Please complete the challenge')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
formData.append('cf-turnstile-response', token)
|
||||||
|
|
||||||
|
const response = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError('Submission failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input name="email" type="email" required />
|
||||||
|
<textarea name="message" required />
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||||
|
onSuccess={setToken}
|
||||||
|
onError={() => setError('Challenge failed')}
|
||||||
|
onExpire={() => setToken(undefined)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={!token}>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use**: Client-side forms in Next.js with React hooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Keys
|
||||||
|
|
||||||
|
**Dummy Sitekeys (client):**
|
||||||
|
- Always pass: `1x00000000000000000000AA`
|
||||||
|
- Always block: `2x00000000000000000000AB`
|
||||||
|
- Force interactive: `3x00000000000000000000FF`
|
||||||
|
|
||||||
|
**Dummy Secret Keys (server):**
|
||||||
|
- Always pass: `1x0000000000000000000000000000000AA`
|
||||||
|
- Always fail: `2x0000000000000000000000000000000AA`
|
||||||
|
- Token already spent: `3x0000000000000000000000000000000AA`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundled Resources
|
||||||
|
|
||||||
|
**Scripts:** `check-csp.sh` - Verify CSP allows Turnstile
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
- `widget-configs.md` - All configuration options
|
||||||
|
- `error-codes.md` - Error code troubleshooting (100*/200*/300*/400*/600*)
|
||||||
|
- `testing-guide.md` - Testing strategies, dummy keys
|
||||||
|
- `react-integration.md` - React/Next.js patterns
|
||||||
|
|
||||||
|
**Templates:** Complete examples for Hono, React, implicit/explicit rendering, validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
**Pre-Clearance (SPAs):** Issue cookie that persists across page navigations
|
||||||
|
```typescript
|
||||||
|
turnstile.render('#container', {
|
||||||
|
sitekey: SITE_KEY,
|
||||||
|
callback: async (token) => {
|
||||||
|
await fetch('/api/pre-clearance', { method: 'POST', body: JSON.stringify({ token }) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom Actions & Data:** Track challenge types, pass custom data (max 255 chars)
|
||||||
|
```typescript
|
||||||
|
turnstile.render('#container', {
|
||||||
|
action: 'login', // Track in analytics
|
||||||
|
cdata: JSON.stringify({ userId: '123' }), // Custom payload
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling:** Use `retry: 'auto'` and `error-callback` for resilience
|
||||||
|
```typescript
|
||||||
|
turnstile.render('#container', {
|
||||||
|
retry: 'auto',
|
||||||
|
'retry-interval': 8000, // ms between retries
|
||||||
|
'error-callback': (error) => { /* handle or show fallback */ }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Required:** None (loads from CDN)
|
||||||
|
**React:** @marsidev/react-turnstile@1.3.1 (Cloudflare-recommended), turnstile-types@1.2.3
|
||||||
|
**Other:** vue-turnstile, ngx-turnstile, svelte-turnstile, @nuxtjs/turnstile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Official Documentation
|
||||||
|
|
||||||
|
- https://developers.cloudflare.com/turnstile/
|
||||||
|
- Use `mcp__cloudflare-docs__search_cloudflare_documentation` tool
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Problem: Error 110200 - "Unknown domain"
|
||||||
|
**Solution**: Add your domain (including localhost for dev) to widget's allowed domains in Cloudflare Dashboard. For local dev, use dummy test sitekey `1x00000000000000000000AA` instead.
|
||||||
|
|
||||||
|
### Problem: Error 300030 - Widget crashes for legitimate users
|
||||||
|
**Solution**: Implement error callback with retry logic. This is a known Cloudflare-side issue (2025). Fallback to alternative verification if retries fail.
|
||||||
|
|
||||||
|
### Problem: Tokens always return `success: false`
|
||||||
|
**Solution**:
|
||||||
|
1. Check token hasn't expired (5 min TTL)
|
||||||
|
2. Verify secret key is correct
|
||||||
|
3. Ensure token hasn't been validated before (single-use)
|
||||||
|
4. Check hostname matches widget configuration
|
||||||
|
|
||||||
|
### Problem: CSP blocking iframe (Error 200500)
|
||||||
|
**Solution**: Add CSP directives:
|
||||||
|
```html
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
frame-src https://challenges.cloudflare.com;
|
||||||
|
script-src https://challenges.cloudflare.com;
|
||||||
|
">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: Safari 18 "Hide IP" causing Error 300010
|
||||||
|
**Solution**: Document in error message that users should disable Safari's "Hide IP address" setting (Safari → Settings → Privacy → Hide IP address → Off)
|
||||||
|
|
||||||
|
### Problem: Next.js + Jest tests failing with @marsidev/react-turnstile
|
||||||
|
**Solution**: Mock the Turnstile component in Jest setup:
|
||||||
|
```typescript
|
||||||
|
// jest.setup.ts
|
||||||
|
jest.mock('@marsidev/react-turnstile', () => ({
|
||||||
|
Turnstile: () => <div data-testid="turnstile-mock" />,
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Errors Prevented**: 12 documented issues (Safari 18 Hide IP, Brave confetti, Next.js Jest, CSP blocking, token reuse, expiration, hostname allowlist, widget crash 300030, config error 600010, missing validation, GET request, secret exposure)
|
||||||
14
assets/example-template.txt
Normal file
14
assets/example-template.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[TODO: Example Template File]
|
||||||
|
|
||||||
|
[TODO: This directory contains files that will be used in the OUTPUT that Claude produces.]
|
||||||
|
|
||||||
|
[TODO: Examples:]
|
||||||
|
- Templates (.html, .tsx, .md)
|
||||||
|
- Images (.png, .svg)
|
||||||
|
- Fonts (.ttf, .woff)
|
||||||
|
- Boilerplate code
|
||||||
|
- Configuration file templates
|
||||||
|
|
||||||
|
[TODO: Delete this file and add your actual assets]
|
||||||
|
|
||||||
|
These files are NOT loaded into context. They are copied or used directly in the final output.
|
||||||
105
plugin.lock.json
Normal file
105
plugin.lock.json
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:jezweb/claude-skills:skills/cloudflare-turnstile",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "395f7a3fbf640673b3e03ed800673c320bac4327",
|
||||||
|
"treeHash": "0d0340805a6c2ea84ae4fa5a4f1a52b687917fbaeef4731605eec19a822cdc3a",
|
||||||
|
"generatedAt": "2025-11-28T10:18:58.793082Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "cloudflare-turnstile",
|
||||||
|
"description": "Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "4be7157860d8e65e17bb70554607aeb8c8bc1b8172764d97df7edc3514f83724"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"sha256": "9644cf2b04733ca4ef638b11f1dcaff6e94db6a69f7fad9dcf6f996b4f25df84"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "references/example-reference.md",
|
||||||
|
"sha256": "77c788d727d05d6479a61d6652b132e43882ffc67c145bb46ba880567d83f7f8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "references/widget-configs.md",
|
||||||
|
"sha256": "43d089076218ff08cc27716f09eaaed361bb7b5d9a99bff1467dceaf9271c016"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "references/react-integration.md",
|
||||||
|
"sha256": "d2e21fdf4e85f5e269bcfba4c0b365b5273794d136af3ff030155a00551684fb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "references/error-codes.md",
|
||||||
|
"sha256": "fdf8c6a1ca3bf6b5eb3b9922939f2f6523bc3485e19fc5e58f60339971213365"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "references/testing-guide.md",
|
||||||
|
"sha256": "3a0a1ec5258c712d8885d595f726514e94cf94ffab50745ff50be73aa404c4ce"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/check-csp.sh",
|
||||||
|
"sha256": "7560516e85807369b9136653e909bf6c80369a4f136cd38964a9eda3f225639e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/example-script.sh",
|
||||||
|
"sha256": "83d2b09d044811608e17cbd8e66d993b1e9998c7bd3379a42ab81fbdba973e0e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "c4d49a11df5616f13b7245275209495dc580d4d1fa09209001608b7f7534e128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/turnstile-test-config.ts",
|
||||||
|
"sha256": "529ec05cba26f8c1c2076700977d0678bf7ffcc95be885d530e9548aac2012c9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/turnstile-widget-implicit.html",
|
||||||
|
"sha256": "b59a17c9565df0b5d576f794b4afb144403763d9e4e025fe5e85082c7792113d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/turnstile-react-component.tsx",
|
||||||
|
"sha256": "0ec5ab1d61fa1bb3bee9dd8c2ee35a47e1445865edabb18010689cd576c7e13c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/turnstile-hono-route.ts",
|
||||||
|
"sha256": "0482db70722bf6f006dbd82e6dacef8c8a0f46250ff89cb2e6c803f9ab6c907f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/turnstile-widget-explicit.ts",
|
||||||
|
"sha256": "acda3dcf63ff3b053517d74204a2074bb979e889da5a6c1c8b07af6337b9228c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/turnstile-server-validation.ts",
|
||||||
|
"sha256": "17f9b65d7e7b6c7f47e916f84f39c7330c9962dde24eb76a0c656131bfe8d5cc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/wrangler-turnstile-config.jsonc",
|
||||||
|
"sha256": "bf20c5cd1721dfaf37187101d3deaa25f291349c9b59eb409247ee618c199d3b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/example-template.txt",
|
||||||
|
"sha256": "3f725c80d70847fd8272bf1400515ba753f12f98f3b294d09e50b54b4c1b024a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "0d0340805a6c2ea84ae4fa5a4f1a52b687917fbaeef4731605eec19a822cdc3a"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
559
references/error-codes.md
Normal file
559
references/error-codes.md
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
# Turnstile Error Codes Reference
|
||||||
|
|
||||||
|
**Complete error code reference with troubleshooting**
|
||||||
|
|
||||||
|
**Official Docs**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Code Families
|
||||||
|
|
||||||
|
Error codes use the format `XXXYYY` where:
|
||||||
|
- `XXX` = Error family (indicates general category)
|
||||||
|
- `YYY` = Specific error (internal use, often marked `***` in docs)
|
||||||
|
|
||||||
|
**Note**: When `***` appears, the last 3 digits can be ignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 100*** - Initialization Problems
|
||||||
|
|
||||||
|
**Error**: Problem initializing Turnstile before challenge could start
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**: Usually caused by:
|
||||||
|
- Old instance of solved challenge still present
|
||||||
|
- Page state corruption
|
||||||
|
- Cache issues
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Reload the page
|
||||||
|
2. Clear browser cache
|
||||||
|
3. Reset Turnstile widget programmatically
|
||||||
|
4. On continuous failures → likely automated device
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 102***, 103***, 104***, 106*** - Invalid Parameters
|
||||||
|
|
||||||
|
**Error**: Visitor sent invalid parameter as part of challenge
|
||||||
|
|
||||||
|
**Retry**: Yes
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Malformed request data
|
||||||
|
- Corrupted challenge parameters
|
||||||
|
- Browser/extension interference
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Retry the challenge automatically
|
||||||
|
2. On continuous failures → likely bot
|
||||||
|
3. Implement `error-callback` with retry logic
|
||||||
|
4. Verify visitor authenticity by other means
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 105*** - API Compatibility
|
||||||
|
|
||||||
|
**Error**: Turnstile invoked in deprecated or invalid way
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Using outdated API methods
|
||||||
|
- Invalid widget configuration
|
||||||
|
- Script version mismatch
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check official Turnstile documentation
|
||||||
|
2. Refresh page to get latest script version
|
||||||
|
3. Review widget initialization code
|
||||||
|
4. Ensure `api.js` loads from Cloudflare CDN
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 110100, 110110 - Invalid Sitekey
|
||||||
|
|
||||||
|
**Error**: Turnstile invoked with invalid or inactive sitekey
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Sitekey doesn't exist
|
||||||
|
- Sitekey was deleted
|
||||||
|
- Typo in sitekey
|
||||||
|
- Using wrong sitekey for environment
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Verify sitekey in Cloudflare Dashboard
|
||||||
|
2. Check sitekey is still active
|
||||||
|
3. Ensure no typos in configuration
|
||||||
|
4. Use correct sitekey for environment (dev/prod)
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// ❌ Wrong
|
||||||
|
const SITE_KEY = '1x00000000000000000000AA' // Test key in production
|
||||||
|
|
||||||
|
// ✅ Correct
|
||||||
|
const SITE_KEY = process.env.TURNSTILE_SITE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 110200 - Unknown Domain
|
||||||
|
|
||||||
|
**Error**: Domain not allowed for this widget
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Current hostname not in widget's allowed domains list
|
||||||
|
- Using production widget on localhost
|
||||||
|
- Subdomain not added to allowlist
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Add domain to allowed list in Cloudflare Dashboard
|
||||||
|
2. For localhost: add `localhost` or use test sitekey `1x00000000000000000000AA`
|
||||||
|
3. Check subdomain matches exactly (www.example.com ≠ example.com)
|
||||||
|
|
||||||
|
**Example Allowed Domains**:
|
||||||
|
```
|
||||||
|
example.com
|
||||||
|
www.example.com
|
||||||
|
localhost # For development
|
||||||
|
127.0.0.1 # For development
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 110420 - Invalid Action
|
||||||
|
|
||||||
|
**Error**: Unsupported or incorrectly formatted action submitted
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Action contains invalid characters
|
||||||
|
- Action exceeds 32 character limit
|
||||||
|
- Non-alphanumeric characters (except `-` and `_`)
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Use only `a-z`, `A-Z`, `0-9`, `-`, `_`
|
||||||
|
2. Keep action ≤ 32 characters
|
||||||
|
3. Example valid actions: `login`, `signup`, `contact-form`
|
||||||
|
|
||||||
|
**Reference**: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 110430 - Invalid cData
|
||||||
|
|
||||||
|
**Error**: Custom data (cData) format invalid
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- cData contains invalid characters
|
||||||
|
- cData exceeds 255 character limit
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Keep cData ≤ 255 characters
|
||||||
|
2. Use JSON.stringify() for objects
|
||||||
|
3. Validate data before passing to Turnstile
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// ❌ Wrong - too long
|
||||||
|
const cdata = JSON.stringify({ /* 300+ chars */ })
|
||||||
|
|
||||||
|
// ✅ Correct
|
||||||
|
const cdata = JSON.stringify({ userId: '123', sessionId: 'abc' })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 110500 - Unsupported Browser
|
||||||
|
|
||||||
|
**Error**: Visitor using unsupported browser
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Internet Explorer (not supported)
|
||||||
|
- Very outdated browser version
|
||||||
|
- Browser without required APIs
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Encourage visitor to upgrade browser
|
||||||
|
2. Provide alternative verification method
|
||||||
|
3. Display browser upgrade message
|
||||||
|
|
||||||
|
**Supported Browsers**: https://developers.cloudflare.com/cloudflare-challenges/reference/supported-browsers/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 110510 - Inconsistent User-Agent
|
||||||
|
|
||||||
|
**Error**: Visitor provided inconsistent user-agent during challenge
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Browser extensions spoofing user-agent
|
||||||
|
- Privacy tools modifying headers
|
||||||
|
- Browser settings
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Ask visitor to disable user-agent spoofing extensions
|
||||||
|
2. Disable privacy tools temporarily
|
||||||
|
3. Try different browser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11060* - Challenge Timed Out
|
||||||
|
|
||||||
|
**Error**: Visitor took too long to solve challenge
|
||||||
|
|
||||||
|
**Retry**: Yes
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Slow network connection
|
||||||
|
- System clock set incorrectly
|
||||||
|
- Visitor distracted/inactive
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Retry the challenge
|
||||||
|
2. Check system clock is correct
|
||||||
|
3. Improve network connection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11062* - Interactive Challenge Timeout
|
||||||
|
|
||||||
|
**Error**: Visitor didn't interact with checkbox (visible mode only)
|
||||||
|
|
||||||
|
**Retry**: Yes
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Challenge became outdated while waiting for interaction
|
||||||
|
- User abandoned form
|
||||||
|
- Long delays between rendering and submission
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Reset widget programmatically
|
||||||
|
2. Re-initialize widget
|
||||||
|
3. Prompt user to interact
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
'timeout-callback': () => {
|
||||||
|
turnstile.reset(widgetId)
|
||||||
|
alert('Please complete the verification')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 120*** - Internal Cloudflare Errors
|
||||||
|
|
||||||
|
**Error**: Internal debugging errors (Cloudflare employees only)
|
||||||
|
|
||||||
|
**Retry**: N/A
|
||||||
|
|
||||||
|
**Solution**: Only encountered by Cloudflare Support during debugging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 200010 - Invalid Caching
|
||||||
|
|
||||||
|
**Error**: Some portion of Turnstile was accidentally cached
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Browser cached Turnstile resources incorrectly
|
||||||
|
- CDN/proxy caching `api.js` script
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Clear browser cache
|
||||||
|
2. Hard refresh (Ctrl+Shift+R or Cmd+Shift+R)
|
||||||
|
3. Ensure `api.js` is not proxied or cached
|
||||||
|
|
||||||
|
**CRITICAL**: Never proxy or cache `https://challenges.cloudflare.com/turnstile/v0/api.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 200100 - Time Problem
|
||||||
|
|
||||||
|
**Error**: Visitor's system clock is incorrect
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- System time is wrong
|
||||||
|
- Timezone misconfigured
|
||||||
|
- Date/time not synchronized
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Set system clock to correct time
|
||||||
|
2. Enable automatic time synchronization
|
||||||
|
3. Check timezone settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 200500 - Loading Error
|
||||||
|
|
||||||
|
**Error**: iframe under challenges.cloudflare.com could not be loaded
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- **Content Security Policy (CSP) blocking iframe**
|
||||||
|
- Browser security settings blocking 3rd-party iframes
|
||||||
|
- Network firewall blocking challenges.cloudflare.com
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. **Add CSP directives**:
|
||||||
|
```html
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
script-src 'self' https://challenges.cloudflare.com;
|
||||||
|
frame-src 'self' https://challenges.cloudflare.com;
|
||||||
|
connect-src 'self' https://challenges.cloudflare.com;
|
||||||
|
">
|
||||||
|
```
|
||||||
|
2. Reduce browser security preferences
|
||||||
|
3. Check firewall/network settings
|
||||||
|
|
||||||
|
**Most Common Cause**: CSP blocking. See check-csp.sh script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 300*** - Generic Client Execution Error
|
||||||
|
|
||||||
|
**Error**: Unspecified error occurred while visitor solved challenge
|
||||||
|
|
||||||
|
**Retry**: Yes
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Browser extension interference
|
||||||
|
- JavaScript errors on page
|
||||||
|
- Memory issues
|
||||||
|
- Network interruption
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Retry automatically
|
||||||
|
2. On continuous failures → potentially automated visitor
|
||||||
|
3. Disable browser extensions
|
||||||
|
4. Try incognito/private mode
|
||||||
|
|
||||||
|
**Known Issue (2025)**: Safari 18 + macOS 15 with "Hide IP" enabled causes Error 300010.
|
||||||
|
|
||||||
|
**Safari Fix**: Settings → Privacy → Hide IP address → Off
|
||||||
|
|
||||||
|
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 300030 - Widget Crash
|
||||||
|
|
||||||
|
**Error**: Widget crashed for legitimate users
|
||||||
|
|
||||||
|
**Retry**: Yes
|
||||||
|
|
||||||
|
**Cause**: Unknown - Cloudflare-side issue (2025)
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Implement robust error handling
|
||||||
|
2. Retry with exponential backoff
|
||||||
|
3. Provide fallback verification method
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
let retryCount = 0
|
||||||
|
const maxRetries = 3
|
||||||
|
|
||||||
|
turnstile.render('#container', {
|
||||||
|
sitekey: SITE_KEY,
|
||||||
|
'error-callback': (error) => {
|
||||||
|
if (error.includes('300030') && retryCount < maxRetries) {
|
||||||
|
retryCount++
|
||||||
|
setTimeout(() => {
|
||||||
|
turnstile.reset(widgetId)
|
||||||
|
}, 2000 * retryCount) // Exponential backoff
|
||||||
|
} else {
|
||||||
|
showFallbackVerification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 400020 - Invalid Sitekey (Server)
|
||||||
|
|
||||||
|
**Error**: Sitekey is invalid or does not exist
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**: Same as 110100/110110 but caught server-side
|
||||||
|
|
||||||
|
**Solution**: Verify sitekey exists and is active
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 400030 - Invalid Size
|
||||||
|
|
||||||
|
**Error**: Provided size option is not valid
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**: Using invalid `size` parameter
|
||||||
|
|
||||||
|
**Valid Options**: `normal`, `compact`, `flexible`
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ❌ Wrong
|
||||||
|
{ size: 'large' }
|
||||||
|
|
||||||
|
// ✅ Correct
|
||||||
|
{ size: 'compact' }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 400040 - Invalid Theme
|
||||||
|
|
||||||
|
**Error**: Provided theme is not valid
|
||||||
|
|
||||||
|
**Retry**: No
|
||||||
|
|
||||||
|
**Cause**: Using invalid `theme` parameter
|
||||||
|
|
||||||
|
**Valid Options**: `light`, `dark`, `auto`
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ❌ Wrong
|
||||||
|
{ theme: 'custom' }
|
||||||
|
|
||||||
|
// ✅ Correct
|
||||||
|
{ theme: 'dark' }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 401 - Unauthorized (Expected)
|
||||||
|
|
||||||
|
**Error**: 401 error in browser console during challenge
|
||||||
|
|
||||||
|
**Retry**: N/A
|
||||||
|
|
||||||
|
**Cause**: Turnstile requesting Private Access Token (not supported by all devices/browsers)
|
||||||
|
|
||||||
|
**Solution**: **Ignore this error** - it's expected behavior
|
||||||
|
|
||||||
|
**Note**: If widget is successfully resolving and generating tokens, no action required.
|
||||||
|
|
||||||
|
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 600*** - Challenge Execution Failure
|
||||||
|
|
||||||
|
**Error**: Visitor failed to solve Turnstile challenge
|
||||||
|
|
||||||
|
**Retry**: Yes
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- Suspected bot behavior
|
||||||
|
- Challenge signals indicate automation
|
||||||
|
- Failing test sitekey (intentional)
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Retry automatically
|
||||||
|
2. On continuous failures → likely bot
|
||||||
|
3. Verify visitor by other means
|
||||||
|
4. Consider alternative verification
|
||||||
|
|
||||||
|
**Testing**: Test sitekey `2x00000000000000000000AB` always fails with this error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 600010 - Configuration Error
|
||||||
|
|
||||||
|
**Error**: Widget configuration error
|
||||||
|
|
||||||
|
**Retry**: Depends
|
||||||
|
|
||||||
|
**Cause**:
|
||||||
|
- **Missing hostname in allowlist** (most common)
|
||||||
|
- Hostname was deleted from configuration
|
||||||
|
- Widget misconfigured
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check Cloudflare Dashboard → Turnstile → Widget Settings
|
||||||
|
2. Verify hostname in allowed domains
|
||||||
|
3. Re-add hostname if missing
|
||||||
|
|
||||||
|
**Known Issue**: Hostnames sometimes disappear from dashboard configuration
|
||||||
|
|
||||||
|
**Source**: https://community.cloudflare.com/t/repeated-cloudflare-turnstile-error-600010/644578
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser-Specific Issues
|
||||||
|
|
||||||
|
### Brave Browser - Confetti Animation Failure (2025)
|
||||||
|
|
||||||
|
**Error**: Verification fails during success animation
|
||||||
|
|
||||||
|
**Cause**: Brave shields block animation scripts
|
||||||
|
|
||||||
|
**Solution**: Handle success callback before animation completes
|
||||||
|
|
||||||
|
**Source**: https://github.com/brave/brave-browser/issues/45608
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Checklist
|
||||||
|
|
||||||
|
When encountering errors:
|
||||||
|
|
||||||
|
1. **Check Error Code Family**
|
||||||
|
- 100*: Initialization → Reload page
|
||||||
|
- 110*: Configuration → Check sitekey, domain allowlist
|
||||||
|
- 200*: Client issues → Check cache, CSP, system clock
|
||||||
|
- 300*: Execution → Retry, check browser compatibility
|
||||||
|
- 400*: Invalid input → Fix configuration
|
||||||
|
- 600*: Challenge failure → Check for bot-like behavior
|
||||||
|
|
||||||
|
2. **Common Fixes**
|
||||||
|
- Clear browser cache
|
||||||
|
- Disable browser extensions
|
||||||
|
- Try incognito/private mode
|
||||||
|
- Check CSP headers
|
||||||
|
- Verify system clock
|
||||||
|
- Use test sitekey for development
|
||||||
|
|
||||||
|
3. **Network/Firewall**
|
||||||
|
- Ensure `challenges.cloudflare.com` is accessible
|
||||||
|
- Check for VPN/proxy interference
|
||||||
|
- Verify no firewall blocking
|
||||||
|
|
||||||
|
4. **Code Review**
|
||||||
|
- Server-side validation implemented?
|
||||||
|
- Token expiration handled?
|
||||||
|
- Error callbacks configured?
|
||||||
|
- Using latest `api.js` from CDN?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-10-22
|
||||||
|
**Most Common Errors**: 110200 (domain), 200500 (CSP), 300030 (crash), 600010 (config)
|
||||||
26
references/example-reference.md
Normal file
26
references/example-reference.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# [TODO: Reference Document Name]
|
||||||
|
|
||||||
|
[TODO: This file contains reference documentation that Claude can load when needed.]
|
||||||
|
|
||||||
|
[TODO: Delete this file if you don't have reference documentation to provide.]
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
[TODO: Explain what information this document contains]
|
||||||
|
|
||||||
|
## When Claude Should Use This
|
||||||
|
|
||||||
|
[TODO: Describe specific scenarios where Claude should load this reference]
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
[TODO: Add your reference content here - schemas, guides, specifications, etc.]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This file is NOT loaded into context by default. Claude will only load it when:
|
||||||
|
- It determines the information is needed
|
||||||
|
- You explicitly ask Claude to reference it
|
||||||
|
- The SKILL.md instructions direct Claude to read it
|
||||||
|
|
||||||
|
Keep this file under 10k words for best performance.
|
||||||
550
references/react-integration.md
Normal file
550
references/react-integration.md
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
# React Integration Guide
|
||||||
|
|
||||||
|
**Best practices for integrating Turnstile with React, Next.js, and modern React patterns**
|
||||||
|
|
||||||
|
**Recommended Package**: @marsidev/react-turnstile (Cloudflare-verified)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @marsidev/react-turnstile
|
||||||
|
# or
|
||||||
|
pnpm add @marsidev/react-turnstile
|
||||||
|
# or
|
||||||
|
yarn add @marsidev/react-turnstile
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Version**: 1.3.1 (September 2025)
|
||||||
|
**React Compatibility**: React 18+, Next.js 13+, 14+, 15+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Turnstile } from '@marsidev/react-turnstile'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function ContactForm() {
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<input name="email" type="email" required />
|
||||||
|
<textarea name="message" required />
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||||
|
onSuccess={setToken}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button disabled={!token}>Submit</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Props Reference
|
||||||
|
|
||||||
|
### Required Props
|
||||||
|
|
||||||
|
#### `siteKey`
|
||||||
|
**Type**: `string`
|
||||||
|
**Description**: Your Turnstile sitekey
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Turnstile siteKey="YOUR_SITE_KEY" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Props
|
||||||
|
|
||||||
|
#### `onSuccess`
|
||||||
|
**Type**: `(token: string) => void`
|
||||||
|
**Description**: Called when challenge succeeds
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Turnstile onSuccess={(token) => console.log(token)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `onError`
|
||||||
|
**Type**: `(error: string) => void`
|
||||||
|
**Description**: Called when challenge fails
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Turnstile onError={(error) => console.error(error)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `onExpire`
|
||||||
|
**Type**: `() => void`
|
||||||
|
**Description**: Called when token expires (5 min)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Turnstile onExpire={() => setToken(undefined)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `onAbort`
|
||||||
|
**Type**: `() => void`
|
||||||
|
**Description**: Called when challenge is aborted
|
||||||
|
|
||||||
|
#### `options`
|
||||||
|
**Type**: `TurnstileOptions`
|
||||||
|
**Description**: Widget configuration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Turnstile
|
||||||
|
siteKey="..."
|
||||||
|
options={{
|
||||||
|
theme: 'dark',
|
||||||
|
size: 'compact',
|
||||||
|
action: 'login',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using Refs
|
||||||
|
|
||||||
|
Access widget instance for manual control:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||||
|
|
||||||
|
export function AdvancedForm() {
|
||||||
|
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
turnstileRef.current?.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove() {
|
||||||
|
turnstileRef.current?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Turnstile ref={turnstileRef} siteKey="..." />
|
||||||
|
<button onClick={handleReset}>Reset</button>
|
||||||
|
<button onClick={handleRemove}>Remove</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next.js App Router
|
||||||
|
|
||||||
|
### Client Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/contact/page.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Turnstile } from '@marsidev/react-turnstile'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
|
||||||
|
const response = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.get('email'),
|
||||||
|
message: formData.get('message'),
|
||||||
|
'cf-turnstile-response': token,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Success!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input name="email" type="email" required />
|
||||||
|
<textarea name="message" required />
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||||
|
onSuccess={setToken}
|
||||||
|
onExpire={() => setToken(undefined)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button disabled={!token}>Submit</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Action (Next.js 14+)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { validateTurnstile } from '@/lib/turnstile'
|
||||||
|
|
||||||
|
export async function submitContact(formData: FormData) {
|
||||||
|
const token = formData.get('cf-turnstile-response')?.toString()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return { error: 'Missing verification' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await validateTurnstile(token, process.env.TURNSTILE_SECRET_KEY!)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { error: 'Verification failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process form
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next.js Pages Router
|
||||||
|
|
||||||
|
### Page Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// pages/contact.tsx
|
||||||
|
import { Turnstile } from '@marsidev/react-turnstile'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<Turnstile
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||||
|
onSuccess={setToken}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Route
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/api/contact.ts
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import { validateTurnstile } from '@/lib/turnstile'
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = req.body['cf-turnstile-response']
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(400).json({ error: 'Missing token' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
token,
|
||||||
|
process.env.TURNSTILE_SECRET_KEY!,
|
||||||
|
{
|
||||||
|
remoteip: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(401).json({ error: 'Invalid token' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process form
|
||||||
|
res.status(200).json({ success: true })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Hook Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// hooks/useTurnstile.ts
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||||
|
|
||||||
|
export function useTurnstile(siteKey: string) {
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
turnstileRef.current?.reset()
|
||||||
|
setToken(undefined)
|
||||||
|
setIsReady(false)
|
||||||
|
setError(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TurnstileWidget = () => (
|
||||||
|
<Turnstile
|
||||||
|
ref={turnstileRef}
|
||||||
|
siteKey={siteKey}
|
||||||
|
onSuccess={(token) => {
|
||||||
|
setToken(token)
|
||||||
|
setIsReady(true)
|
||||||
|
setError(undefined)
|
||||||
|
}}
|
||||||
|
onError={(err) => {
|
||||||
|
setError(err)
|
||||||
|
setIsReady(false)
|
||||||
|
}}
|
||||||
|
onExpire={() => {
|
||||||
|
setToken(undefined)
|
||||||
|
setIsReady(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
isReady,
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
TurnstileWidget,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
export function MyForm() {
|
||||||
|
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile(
|
||||||
|
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<TurnstileWidget />
|
||||||
|
{error && <div>Error: {error}</div>}
|
||||||
|
<button disabled={!isReady}>Submit</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Jest Testing
|
||||||
|
|
||||||
|
### Mock Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// jest.setup.ts
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
jest.mock('@marsidev/react-turnstile', () => ({
|
||||||
|
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||||
|
}, [onSuccess])
|
||||||
|
|
||||||
|
return <div data-testid="turnstile-mock" />
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Test
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ContactForm.test.tsx
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ContactForm } from './ContactForm'
|
||||||
|
|
||||||
|
test('submits form with Turnstile', async () => {
|
||||||
|
render(<ContactForm />)
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||||
|
|
||||||
|
// Turnstile auto-solves (mocked)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(submitButton)
|
||||||
|
|
||||||
|
expect(await screen.findByText('Success')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment-Aware Sitekey
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/turnstile.ts
|
||||||
|
export function useTurnstileSiteKey() {
|
||||||
|
// Test/Development: Use dummy key
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
return '1x00000000000000000000AA'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production: Use real key
|
||||||
|
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
import { useTurnstileSiteKey } from '@/lib/turnstile'
|
||||||
|
|
||||||
|
export function MyForm() {
|
||||||
|
const siteKey = useTurnstileSiteKey()
|
||||||
|
|
||||||
|
return <Turnstile siteKey={siteKey} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues & Workarounds
|
||||||
|
|
||||||
|
### Issue #112: Next.js + Jest Compatibility (Oct 2025)
|
||||||
|
|
||||||
|
**Problem**: @marsidev/react-turnstile breaks Jest tests
|
||||||
|
|
||||||
|
**Source**: https://github.com/marsidev/react-turnstile/issues/112
|
||||||
|
|
||||||
|
**Workaround**: Mock the component (see Jest Testing section above)
|
||||||
|
|
||||||
|
### Issue #113: Blocked Script Execution (Oct 2025)
|
||||||
|
|
||||||
|
**Problem**: Script execution blocked in some environments
|
||||||
|
|
||||||
|
**Source**: https://github.com/marsidev/react-turnstile/issues/113
|
||||||
|
|
||||||
|
**Workaround**:
|
||||||
|
1. Check CSP headers allow `challenges.cloudflare.com`
|
||||||
|
2. Ensure `api.js` loads from CDN (not proxied)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
TurnstileInstance,
|
||||||
|
TurnstileProps,
|
||||||
|
TurnstileOptions,
|
||||||
|
} from '@marsidev/react-turnstile'
|
||||||
|
|
||||||
|
// Widget instance methods
|
||||||
|
interface TurnstileInstance {
|
||||||
|
reset(): void
|
||||||
|
remove(): void
|
||||||
|
execute(): void
|
||||||
|
getResponse(): string | undefined
|
||||||
|
isExpired(): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component props
|
||||||
|
interface TurnstileProps {
|
||||||
|
siteKey: string
|
||||||
|
onSuccess?: (token: string) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
onExpire?: () => void
|
||||||
|
onAbort?: () => void
|
||||||
|
options?: TurnstileOptions
|
||||||
|
scriptOptions?: {
|
||||||
|
nonce?: string
|
||||||
|
defer?: boolean
|
||||||
|
async?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget options
|
||||||
|
interface TurnstileOptions {
|
||||||
|
theme?: 'light' | 'dark' | 'auto'
|
||||||
|
size?: 'normal' | 'compact' | 'flexible'
|
||||||
|
action?: string
|
||||||
|
cdata?: string
|
||||||
|
execution?: 'render' | 'execute'
|
||||||
|
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||||
|
retry?: 'auto' | 'never'
|
||||||
|
'retry-interval'?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Lazy Loading
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
|
||||||
|
const Turnstile = lazy(() =>
|
||||||
|
import('@marsidev/react-turnstile').then(mod => ({ default: mod.Turnstile }))
|
||||||
|
)
|
||||||
|
|
||||||
|
export function LazyTurnstileForm() {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<Suspense fallback={<div>Loading verification...</div>}>
|
||||||
|
<Turnstile siteKey="..." />
|
||||||
|
</Suspense>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Rendering
|
||||||
|
|
||||||
|
Only render Turnstile when needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function ConditionalForm() {
|
||||||
|
const [showTurnstile, setShowTurnstile] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<input onChange={() => setShowTurnstile(true)} />
|
||||||
|
|
||||||
|
{showTurnstile && <Turnstile siteKey="..." />}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
✅ **Use environment variables** for sitekeys
|
||||||
|
✅ **Mock in tests** using Jest setup file
|
||||||
|
✅ **Handle expiration** with `onExpire` callback
|
||||||
|
✅ **Disable submit until ready** based on token state
|
||||||
|
✅ **Reset after submission** for multi-use forms
|
||||||
|
✅ **Use TypeScript** for type safety
|
||||||
|
✅ **Lazy load** if not immediately needed
|
||||||
|
|
||||||
|
❌ **Don't hardcode sitekeys** in components
|
||||||
|
❌ **Don't skip error handling** (`onError`)
|
||||||
|
❌ **Don't forget server validation** (critical!)
|
||||||
|
❌ **Don't use production keys in tests**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-10-22
|
||||||
|
**Package Version**: @marsidev/react-turnstile@1.3.1
|
||||||
|
**Cloudflare Status**: ✅ Officially Recommended
|
||||||
430
references/testing-guide.md
Normal file
430
references/testing-guide.md
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# Turnstile Testing Guide
|
||||||
|
|
||||||
|
**Complete testing strategies for E2E, unit, and integration tests**
|
||||||
|
|
||||||
|
**Official Docs**: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: Dummy Credentials
|
||||||
|
|
||||||
|
### Sitekeys (Client-Side)
|
||||||
|
```typescript
|
||||||
|
const TEST_SITEKEYS = {
|
||||||
|
ALWAYS_PASS: '1x00000000000000000000AA', // Visible, always passes
|
||||||
|
ALWAYS_BLOCK: '2x00000000000000000000AB', // Visible, always blocks
|
||||||
|
ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB', // Invisible, always passes
|
||||||
|
ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',// Invisible, always blocks
|
||||||
|
FORCE_INTERACTIVE: '3x00000000000000000000FF', // Visible, forces checkbox
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secret Keys (Server-Side)
|
||||||
|
```typescript
|
||||||
|
const TEST_SECRET_KEYS = {
|
||||||
|
ALWAYS_PASS: '1x0000000000000000000000000000000AA', // success: true
|
||||||
|
ALWAYS_FAIL: '2x0000000000000000000000000000000AA', // success: false
|
||||||
|
TOKEN_SPENT: '3x0000000000000000000000000000000AA', // "already spent" error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dummy Token
|
||||||
|
```typescript
|
||||||
|
const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL**:
|
||||||
|
- Dummy sitekeys generate `XXXX.DUMMY.TOKEN.XXXX`
|
||||||
|
- Dummy secret keys ONLY accept dummy token
|
||||||
|
- Production secret keys REJECT dummy token
|
||||||
|
- Real tokens FAIL with dummy secret keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Detection Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Request Headers
|
||||||
|
```typescript
|
||||||
|
function isTestEnvironment(request: Request): boolean {
|
||||||
|
return request.headers.get('x-test-environment') === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in Cloudflare Worker
|
||||||
|
if (isTestEnvironment(request)) {
|
||||||
|
secretKey = TEST_SECRET_KEYS.ALWAYS_PASS
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: IP Address
|
||||||
|
```typescript
|
||||||
|
function isTestEnvironment(request: Request): boolean {
|
||||||
|
const ip = request.headers.get('CF-Connecting-IP') || ''
|
||||||
|
const testIPs = ['127.0.0.1', '::1', 'localhost']
|
||||||
|
return testIPs.includes(ip)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Query Parameter
|
||||||
|
```typescript
|
||||||
|
function isTestEnvironment(request: Request): boolean {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
return url.searchParams.get('test') === 'true'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Environment Variable
|
||||||
|
```typescript
|
||||||
|
const sitekey = process.env.NODE_ENV === 'test'
|
||||||
|
? TEST_SITEKEYS.ALWAYS_PASS
|
||||||
|
: process.env.TURNSTILE_SITE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Playwright Testing
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```typescript
|
||||||
|
// playwright.config.ts
|
||||||
|
export default defineConfig({
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
'x-test-environment': 'true', // Auto-use test credentials
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Test
|
||||||
|
```typescript
|
||||||
|
// tests/contact-form.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('submits contact form with Turnstile', async ({ page }) => {
|
||||||
|
await page.goto('/contact')
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await page.fill('input[name="email"]', 'test@example.com')
|
||||||
|
await page.fill('textarea[name="message"]', 'Test message')
|
||||||
|
|
||||||
|
// Turnstile auto-solves with dummy token in test mode
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
// Verify success
|
||||||
|
await expect(page.locator('.success-message')).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced: Multiple Scenarios
|
||||||
|
```typescript
|
||||||
|
test('handles Turnstile failure gracefully', async ({ page, context }) => {
|
||||||
|
// Override to use "always fail" sitekey
|
||||||
|
await context.route('**/api.js', route => {
|
||||||
|
const FAIL_SITEKEY = '2x00000000000000000000AB'
|
||||||
|
// Inject failing sitekey
|
||||||
|
route.continue()
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/contact')
|
||||||
|
await page.fill('input[name="email"]', 'test@example.com')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
await expect(page.locator('.error-message')).toContainText('verification failed')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cypress Testing
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```typescript
|
||||||
|
// cypress.config.ts
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: {
|
||||||
|
baseUrl: 'http://localhost:5173',
|
||||||
|
setupNodeEvents(on, config) {
|
||||||
|
// Set test header on all requests
|
||||||
|
on('before:browser:launch', (browser, launchOptions) => {
|
||||||
|
launchOptions.args.push('--disable-web-security')
|
||||||
|
return launchOptions
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Example
|
||||||
|
```typescript
|
||||||
|
// cypress/e2e/turnstile.cy.ts
|
||||||
|
describe('Turnstile Form', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.intercept('**/*', (req) => {
|
||||||
|
req.headers['x-test-environment'] = 'true'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits form successfully', () => {
|
||||||
|
cy.visit('/contact')
|
||||||
|
|
||||||
|
cy.get('input[name="email"]').type('test@example.com')
|
||||||
|
cy.get('textarea[name="message"]').type('Test message')
|
||||||
|
|
||||||
|
// Turnstile auto-solves in test mode
|
||||||
|
cy.get('button[type="submit"]').click()
|
||||||
|
|
||||||
|
cy.contains('Success').should('be.visible')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Jest / Vitest (React)
|
||||||
|
|
||||||
|
### Mock @marsidev/react-turnstile
|
||||||
|
```typescript
|
||||||
|
// jest.setup.ts or vitest.setup.ts
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
jest.mock('@marsidev/react-turnstile', () => ({
|
||||||
|
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||||
|
// Auto-solve with dummy token
|
||||||
|
React.useEffect(() => {
|
||||||
|
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||||
|
}, [onSuccess])
|
||||||
|
|
||||||
|
return <div data-testid="turnstile-mock" />
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Test
|
||||||
|
```typescript
|
||||||
|
// ContactForm.test.tsx
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ContactForm } from './ContactForm'
|
||||||
|
|
||||||
|
test('submits form with Turnstile', async () => {
|
||||||
|
render(<ContactForm />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Email'), {
|
||||||
|
target: { value: 'test@example.com' },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Message'), {
|
||||||
|
target: { value: 'Test message' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Turnstile auto-solves (mocked)
|
||||||
|
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||||
|
expect(submitButton).not.toBeDisabled()
|
||||||
|
|
||||||
|
fireEvent.click(submitButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Success')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-Side Testing (Vitest)
|
||||||
|
|
||||||
|
### Validate Dummy Token
|
||||||
|
```typescript
|
||||||
|
// server.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { validateTurnstile } from './turnstile-server-validation'
|
||||||
|
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'
|
||||||
|
|
||||||
|
describe('Turnstile Validation', () => {
|
||||||
|
it('validates dummy token with test secret', async () => {
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
DUMMY_TOKEN,
|
||||||
|
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects real token with test secret', async () => {
|
||||||
|
const realToken = 'real-production-token'
|
||||||
|
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
realToken,
|
||||||
|
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles always-fail secret key', async () => {
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
DUMMY_TOKEN,
|
||||||
|
TEST_SECRET_KEYS.ALWAYS_FAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cloudflare Workers Testing (Miniflare)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```typescript
|
||||||
|
// vitest.config.ts
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'miniflare',
|
||||||
|
environmentOptions: {
|
||||||
|
bindings: {
|
||||||
|
TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA',
|
||||||
|
TURNSTILE_SITE_KEY: '1x00000000000000000000AA',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Worker Test
|
||||||
|
```typescript
|
||||||
|
// worker.test.ts
|
||||||
|
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import worker from '../src/index'
|
||||||
|
import { DUMMY_TOKEN } from './turnstile-test-config'
|
||||||
|
|
||||||
|
describe('Worker with Turnstile', () => {
|
||||||
|
it('validates test token successfully', async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('email', 'test@example.com')
|
||||||
|
formData.append('cf-turnstile-response', DUMMY_TOKEN)
|
||||||
|
|
||||||
|
const request = new Request('http://localhost/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'x-test-environment': 'true',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = createExecutionContext()
|
||||||
|
const response = await worker.fetch(request, env, ctx)
|
||||||
|
await waitOnExecutionContext(ctx)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD Configuration
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/test.yml
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||||
|
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
|
||||||
|
NODE_ENV: test
|
||||||
|
run: npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitLab CI
|
||||||
|
```yaml
|
||||||
|
# .gitlab-ci.yml
|
||||||
|
test:
|
||||||
|
image: node:20
|
||||||
|
script:
|
||||||
|
- npm ci
|
||||||
|
- npm test
|
||||||
|
variables:
|
||||||
|
TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
|
||||||
|
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
|
||||||
|
NODE_ENV: "test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Best Practices
|
||||||
|
|
||||||
|
✅ **Always use dummy keys** - Never use production credentials in tests
|
||||||
|
✅ **Test both success and failure** - Use both pass and fail test keys
|
||||||
|
✅ **Mock in unit tests** - Mock Turnstile component for fast unit tests
|
||||||
|
✅ **E2E with real widget** - Use test sitekeys in E2E tests
|
||||||
|
✅ **Separate environments** - Different config for test/dev/staging/prod
|
||||||
|
✅ **Test expiration** - Verify token expiration handling
|
||||||
|
✅ **Test error states** - Validate error callback behavior
|
||||||
|
|
||||||
|
❌ **Never commit production keys** - Use environment variables
|
||||||
|
❌ **Don't skip server validation tests** - Critical security component
|
||||||
|
❌ **Don't test with production sitekeys** - Can trigger rate limits
|
||||||
|
❌ **Don't hardcode test keys** - Use constants/config files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging Tests
|
||||||
|
|
||||||
|
### Enable Verbose Logging
|
||||||
|
```typescript
|
||||||
|
turnstile.render('#container', {
|
||||||
|
sitekey: TEST_SITEKEYS.ALWAYS_PASS,
|
||||||
|
callback: (token) => console.log('[TEST] Token:', token),
|
||||||
|
'error-callback': (error) => console.error('[TEST] Error:', error),
|
||||||
|
'expired-callback': () => console.warn('[TEST] Token expired'),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Network Requests
|
||||||
|
```typescript
|
||||||
|
// Playwright
|
||||||
|
await page.route('**/siteverify', route => {
|
||||||
|
console.log('Siteverify called with:', route.request().postData())
|
||||||
|
route.continue()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Environment Detection
|
||||||
|
```typescript
|
||||||
|
test('uses test credentials in test env', async ({ page }) => {
|
||||||
|
const response = await page.evaluate(() => {
|
||||||
|
return document.querySelector('.cf-turnstile')?.getAttribute('data-sitekey')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toBe('1x00000000000000000000AA')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-10-22
|
||||||
|
**Test Framework Support**: Playwright, Cypress, Jest, Vitest, Miniflare
|
||||||
412
references/widget-configs.md
Normal file
412
references/widget-configs.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
# Turnstile Widget Configuration Reference
|
||||||
|
|
||||||
|
**Complete reference for all widget configuration options**
|
||||||
|
|
||||||
|
**Official Docs**: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Modes
|
||||||
|
|
||||||
|
### Managed (Recommended)
|
||||||
|
Functions like a Managed Challenge Page. Selects a challenge based on visitor signals.
|
||||||
|
- Shows interactive checkbox only if bot is suspected
|
||||||
|
- Best balance of security and UX
|
||||||
|
- Use for most production deployments
|
||||||
|
|
||||||
|
### Non-Interactive
|
||||||
|
Widget is displayed but visitor never needs to interact.
|
||||||
|
- No checkbox required
|
||||||
|
- Runs challenge in background
|
||||||
|
- Use for minimal user friction
|
||||||
|
|
||||||
|
### Invisible
|
||||||
|
Widget is completely hidden from visitor.
|
||||||
|
- No visual presence
|
||||||
|
- Challenge runs invisibly
|
||||||
|
- Use for seamless UX, API protection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Parameters
|
||||||
|
|
||||||
|
### Core Parameters
|
||||||
|
|
||||||
|
#### `sitekey` (Required)
|
||||||
|
- **Type**: `string`
|
||||||
|
- **Description**: Your widget's public sitekey from Cloudflare Dashboard
|
||||||
|
- **Example**: `data-sitekey="YOUR_SITE_KEY"` or `{ sitekey: 'YOUR_SITE_KEY' }`
|
||||||
|
|
||||||
|
#### `action`
|
||||||
|
- **Type**: `string`
|
||||||
|
- **Max Length**: 32 characters
|
||||||
|
- **Valid Characters**: `a-z`, `A-Z`, `0-9`, `-`, `_`
|
||||||
|
- **Description**: Custom action name tracked in analytics
|
||||||
|
- **Example**: `action: 'login'`, `action: 'signup'`
|
||||||
|
|
||||||
|
#### `cdata`
|
||||||
|
- **Type**: `string`
|
||||||
|
- **Max Length**: 255 characters
|
||||||
|
- **Description**: Custom data passed through to server validation
|
||||||
|
- **Example**: `cdata: JSON.stringify({ userId: '123' })`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appearance
|
||||||
|
|
||||||
|
### `theme`
|
||||||
|
Controls widget color scheme.
|
||||||
|
|
||||||
|
- **`auto`** (default) - Matches system preference
|
||||||
|
- **`light`** - Light mode
|
||||||
|
- **`dark`** - Dark mode
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```html
|
||||||
|
<div class="cf-turnstile" data-theme="dark"></div>
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
{ theme: 'dark' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `appearance`
|
||||||
|
Controls when widget becomes visible.
|
||||||
|
|
||||||
|
- **`always`** (default) - Visible from page load
|
||||||
|
- **`execute`** - Visible only after challenge begins
|
||||||
|
- **`interaction-only`** - Visible only when user interaction required
|
||||||
|
|
||||||
|
**Note**: Only affects managed/non-interactive modes. Invisible widgets never show.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```html
|
||||||
|
<div class="cf-turnstile" data-appearance="interaction-only"></div>
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
{ appearance: 'interaction-only' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `size`
|
||||||
|
Controls widget dimensions.
|
||||||
|
|
||||||
|
- **`normal`** (default) - 300px × 65px
|
||||||
|
- **`compact`** - 150px × 140px
|
||||||
|
- **`flexible`** - 100% width, adapts to container
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```html
|
||||||
|
<div class="cf-turnstile" data-size="compact"></div>
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
{ size: 'flexible' }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
### `execution`
|
||||||
|
Controls when challenge runs and token is generated.
|
||||||
|
|
||||||
|
- **`render`** (default) - Runs automatically after rendering
|
||||||
|
- **`execute`** - Runs only when `turnstile.execute()` is called
|
||||||
|
|
||||||
|
**Use Cases**:
|
||||||
|
- `render`: Standard forms, immediate protection
|
||||||
|
- `execute`: Multi-step forms, conditional verification, performance optimization
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const widgetId = turnstile.render('#container', {
|
||||||
|
sitekey: SITE_KEY,
|
||||||
|
execution: 'execute', // Manual trigger
|
||||||
|
})
|
||||||
|
|
||||||
|
// Later, when needed:
|
||||||
|
turnstile.execute(widgetId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Callbacks
|
||||||
|
|
||||||
|
### `callback`
|
||||||
|
Called when challenge succeeds.
|
||||||
|
|
||||||
|
**Signature**: `(token: string) => void`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```html
|
||||||
|
<div class="cf-turnstile" data-callback="onSuccess"></div>
|
||||||
|
<script>
|
||||||
|
function onSuccess(token) {
|
||||||
|
console.log('Token:', token)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
callback: (token) => {
|
||||||
|
console.log('Success:', token)
|
||||||
|
document.getElementById('submit-btn').disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `error-callback`
|
||||||
|
Called when challenge fails or errors occur.
|
||||||
|
|
||||||
|
**Signature**: `(errorCode: string) => void`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
'error-callback': (error) => {
|
||||||
|
console.error('Turnstile error:', error)
|
||||||
|
showErrorMessage('Verification failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `expired-callback`
|
||||||
|
Called when token expires (after 5 minutes).
|
||||||
|
|
||||||
|
**Signature**: `() => void`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
'expired-callback': () => {
|
||||||
|
console.warn('Token expired')
|
||||||
|
turnstile.reset(widgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `timeout-callback`
|
||||||
|
Called when interactive challenge times out (user didn't interact).
|
||||||
|
|
||||||
|
**Signature**: `() => void`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
'timeout-callback': () => {
|
||||||
|
console.warn('Challenge timed out')
|
||||||
|
turnstile.reset(widgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retry Behavior
|
||||||
|
|
||||||
|
### `retry`
|
||||||
|
Controls automatic retry on errors.
|
||||||
|
|
||||||
|
- **`auto`** (default) - Automatically retries on transient errors
|
||||||
|
- **`never`** - No automatic retry, manual control via `turnstile.reset()`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
retry: 'never', // Manual control
|
||||||
|
'error-callback': (error) => {
|
||||||
|
if (shouldRetry(error)) {
|
||||||
|
turnstile.reset(widgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `retry-interval`
|
||||||
|
Milliseconds between automatic retries.
|
||||||
|
|
||||||
|
- **Default**: 8000ms (8 seconds)
|
||||||
|
- **Min**: 0
|
||||||
|
- **Max**: No limit
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
retry: 'auto',
|
||||||
|
'retry-interval': 5000, // 5 seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Configuration Example
|
||||||
|
|
||||||
|
### Implicit Rendering (HTML)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="cf-turnstile"
|
||||||
|
data-sitekey="YOUR_SITE_KEY"
|
||||||
|
data-callback="onSuccess"
|
||||||
|
data-error-callback="onError"
|
||||||
|
data-expired-callback="onExpired"
|
||||||
|
data-timeout-callback="onTimeout"
|
||||||
|
data-theme="auto"
|
||||||
|
data-size="normal"
|
||||||
|
data-appearance="always"
|
||||||
|
data-retry="auto"
|
||||||
|
data-retry-interval="8000"
|
||||||
|
data-action="login"
|
||||||
|
data-cdata='{"userId":"123"}'>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Explicit Rendering (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const widgetId = turnstile.render('#container', {
|
||||||
|
sitekey: 'YOUR_SITE_KEY',
|
||||||
|
callback: (token) => console.log('Success:', token),
|
||||||
|
'error-callback': (error) => console.error('Error:', error),
|
||||||
|
'expired-callback': () => turnstile.reset(widgetId),
|
||||||
|
'timeout-callback': () => console.warn('Timeout'),
|
||||||
|
theme: 'auto',
|
||||||
|
size: 'normal',
|
||||||
|
execution: 'render',
|
||||||
|
appearance: 'always',
|
||||||
|
retry: 'auto',
|
||||||
|
'retry-interval': 8000,
|
||||||
|
action: 'login',
|
||||||
|
cdata: JSON.stringify({ userId: '123' }),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Methods
|
||||||
|
|
||||||
|
### `turnstile.render()`
|
||||||
|
Renders a widget programmatically.
|
||||||
|
|
||||||
|
**Signature**:
|
||||||
|
```typescript
|
||||||
|
render(
|
||||||
|
container: string | HTMLElement,
|
||||||
|
options: TurnstileOptions
|
||||||
|
): string // Returns widgetId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const widgetId = turnstile.render('#my-container', {
|
||||||
|
sitekey: SITE_KEY,
|
||||||
|
callback: handleSuccess,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `turnstile.reset()`
|
||||||
|
Resets widget to initial state, clears current token.
|
||||||
|
|
||||||
|
**Signature**: `reset(widgetId: string): void`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
turnstile.reset(widgetId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `turnstile.remove()`
|
||||||
|
Completely removes widget from DOM.
|
||||||
|
|
||||||
|
**Signature**: `remove(widgetId: string): void`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
turnstile.remove(widgetId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `turnstile.execute()`
|
||||||
|
Manually triggers challenge (execution: 'execute' mode only).
|
||||||
|
|
||||||
|
**Signature**: `execute(widgetId: string): void`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
document.querySelector('#submit').addEventListener('click', () => {
|
||||||
|
turnstile.execute(widgetId)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `turnstile.getResponse()`
|
||||||
|
Gets current token value.
|
||||||
|
|
||||||
|
**Signature**: `getResponse(widgetId: string): string | undefined`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const token = turnstile.getResponse(widgetId)
|
||||||
|
if (token) {
|
||||||
|
submitForm(token)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `turnstile.isExpired()`
|
||||||
|
Checks if token has expired.
|
||||||
|
|
||||||
|
**Signature**: `isExpired(widgetId: string): boolean`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
if (turnstile.isExpired(widgetId)) {
|
||||||
|
turnstile.reset(widgetId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration from reCAPTCHA
|
||||||
|
|
||||||
|
Turnstile can be a drop-in replacement for reCAPTCHA v2.
|
||||||
|
|
||||||
|
### Compatibility Mode
|
||||||
|
|
||||||
|
Add `?compat=recaptcha` to script URL:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Implicit rendering for reCAPTCHA
|
||||||
|
- `g-recaptcha-response` input name
|
||||||
|
- Registers API as `grecaptcha`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```html
|
||||||
|
<!-- Old reCAPTCHA code -->
|
||||||
|
<div class="g-recaptcha" data-sitekey="OLD_RECAPTCHA_KEY"></div>
|
||||||
|
|
||||||
|
<!-- New Turnstile code (compatibility mode) -->
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
|
||||||
|
<div class="g-recaptcha" data-sitekey="NEW_TURNSTILE_KEY"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Change script URL and sitekey, everything else stays the same.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
✅ **Always validate server-side** - Client widget is not sufficient
|
||||||
|
✅ **Handle expiration** - Implement `expired-callback` to reset widget
|
||||||
|
✅ **Handle errors** - Use `error-callback` for user-friendly messages
|
||||||
|
✅ **Use actions** - Track different form types in analytics
|
||||||
|
✅ **Test with dummy keys** - Use `1x00000000000000000000AA` for development
|
||||||
|
✅ **Separate environments** - Different widgets for dev/staging/production
|
||||||
|
|
||||||
|
❌ **Never proxy api.js** - Must load from Cloudflare CDN
|
||||||
|
❌ **Never reuse tokens** - Each token is single-use
|
||||||
|
❌ **Never expose secret key** - Keep in backend only
|
||||||
|
❌ **Never skip expiration handling** - Tokens expire after 5 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-10-22
|
||||||
|
**API Version**: v0 (stable)
|
||||||
132
scripts/check-csp.sh
Executable file
132
scripts/check-csp.sh
Executable file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Turnstile CSP Checker
|
||||||
|
#
|
||||||
|
# Verifies that Content Security Policy headers allow Turnstile to function
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./check-csp.sh https://example.com
|
||||||
|
# ./check-csp.sh https://example.com/contact
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Check if URL is provided
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo -e "${RED}Error: Please provide a URL${NC}"
|
||||||
|
echo "Usage: $0 <url>"
|
||||||
|
echo "Example: $0 https://example.com"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
URL="$1"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Checking CSP for Turnstile compatibility...${NC}"
|
||||||
|
echo "URL: $URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Fetch headers
|
||||||
|
HEADERS=$(curl -sI "$URL")
|
||||||
|
|
||||||
|
# Extract CSP header
|
||||||
|
CSP=$(echo "$HEADERS" | grep -i "content-security-policy:" | sed 's/content-security-policy: //I')
|
||||||
|
|
||||||
|
if [ -z "$CSP" ]; then
|
||||||
|
echo -e "${YELLOW}No Content-Security-Policy header found${NC}"
|
||||||
|
echo "✅ Turnstile should work (no CSP restrictions)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "CSP Header found:"
|
||||||
|
echo "$CSP"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Initialize pass/fail counters
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
# Check script-src
|
||||||
|
echo -e "${GREEN}Checking script-src...${NC}"
|
||||||
|
if echo "$CSP" | grep -q "script-src"; then
|
||||||
|
if echo "$CSP" | grep -E "script-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
|
||||||
|
echo "✅ script-src allows https://challenges.cloudflare.com"
|
||||||
|
((PASS++))
|
||||||
|
elif echo "$CSP" | grep -E "script-src[^;]*\*" > /dev/null; then
|
||||||
|
echo "✅ script-src allows * (wildcard)"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ script-src does NOT allow https://challenges.cloudflare.com${NC}"
|
||||||
|
echo " Add: script-src https://challenges.cloudflare.com;"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No script-src directive found (defaults may apply)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check frame-src
|
||||||
|
echo -e "${GREEN}Checking frame-src...${NC}"
|
||||||
|
if echo "$CSP" | grep -q "frame-src"; then
|
||||||
|
if echo "$CSP" | grep -E "frame-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
|
||||||
|
echo "✅ frame-src allows https://challenges.cloudflare.com"
|
||||||
|
((PASS++))
|
||||||
|
elif echo "$CSP" | grep -E "frame-src[^;]*\*" > /dev/null; then
|
||||||
|
echo "✅ frame-src allows * (wildcard)"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ frame-src does NOT allow https://challenges.cloudflare.com${NC}"
|
||||||
|
echo " Add: frame-src https://challenges.cloudflare.com;"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No frame-src directive found (defaults may apply)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check connect-src
|
||||||
|
echo -e "${GREEN}Checking connect-src...${NC}"
|
||||||
|
if echo "$CSP" | grep -q "connect-src"; then
|
||||||
|
if echo "$CSP" | grep -E "connect-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
|
||||||
|
echo "✅ connect-src allows https://challenges.cloudflare.com"
|
||||||
|
((PASS++))
|
||||||
|
elif echo "$CSP" | grep -E "connect-src[^;]*\*" > /dev/null; then
|
||||||
|
echo "✅ connect-src allows * (wildcard)"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ connect-src does NOT allow https://challenges.cloudflare.com${NC}"
|
||||||
|
echo " Add: connect-src https://challenges.cloudflare.com;"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No connect-src directive found (defaults may apply)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Final verdict
|
||||||
|
echo "========================================"
|
||||||
|
if [ $FAIL -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✅ CSP is compatible with Turnstile!${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ CSP may block Turnstile${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Recommended CSP directives:"
|
||||||
|
echo ""
|
||||||
|
echo "<meta http-equiv=\"Content-Security-Policy\" content=\""
|
||||||
|
echo " script-src 'self' https://challenges.cloudflare.com;"
|
||||||
|
echo " frame-src 'self' https://challenges.cloudflare.com;"
|
||||||
|
echo " connect-src 'self' https://challenges.cloudflare.com;"
|
||||||
|
echo "\">"
|
||||||
|
echo ""
|
||||||
|
echo "Or in HTTP header:"
|
||||||
|
echo ""
|
||||||
|
echo "Content-Security-Policy: script-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
15
scripts/example-script.sh
Executable file
15
scripts/example-script.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# [TODO: Script Name]
|
||||||
|
# [TODO: Brief description of what this script does]
|
||||||
|
|
||||||
|
# Example script structure - delete if not needed
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# [TODO: Add your script logic here]
|
||||||
|
|
||||||
|
echo "Example script - replace or delete this file"
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/example-script.sh [args]
|
||||||
248
templates/turnstile-hono-route.ts
Normal file
248
templates/turnstile-hono-route.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Turnstile + Hono Route Handlers
|
||||||
|
*
|
||||||
|
* Complete examples for integrating Turnstile validation
|
||||||
|
* with Hono API routes in Cloudflare Workers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { validateTurnstile, type TurnstileResponse } from './turnstile-server-validation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment Bindings
|
||||||
|
*/
|
||||||
|
type Bindings = {
|
||||||
|
TURNSTILE_SECRET_KEY: string
|
||||||
|
TURNSTILE_SITE_KEY: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Bindings }>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Contact Form
|
||||||
|
*/
|
||||||
|
app.post('/api/contact', async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.formData()
|
||||||
|
const token = body.get('cf-turnstile-response')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return c.text('Missing Turnstile token', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
const verifyFormData = new FormData()
|
||||||
|
verifyFormData.append('secret', c.env.TURNSTILE_SECRET_KEY)
|
||||||
|
verifyFormData.append('response', token.toString())
|
||||||
|
verifyFormData.append('remoteip', c.req.header('CF-Connecting-IP') || '')
|
||||||
|
|
||||||
|
const verifyResult = await fetch(
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: verifyFormData,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const outcome = await verifyResult.json<{ success: boolean }>()
|
||||||
|
|
||||||
|
if (!outcome.success) {
|
||||||
|
return c.text('Invalid Turnstile token', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process contact form
|
||||||
|
const email = body.get('email')?.toString()
|
||||||
|
const message = body.get('message')?.toString()
|
||||||
|
|
||||||
|
console.log('Contact form submitted:', { email, message })
|
||||||
|
|
||||||
|
// Your business logic here (send email, save to DB, etc.)
|
||||||
|
|
||||||
|
return c.json({ message: 'Contact form submitted successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Contact form error:', error)
|
||||||
|
return c.text('Internal server error', 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Login with Turnstile
|
||||||
|
*/
|
||||||
|
app.post('/api/auth/login', async (c) => {
|
||||||
|
try {
|
||||||
|
const { username, password, 'cf-turnstile-response': token } = await c.req.json()
|
||||||
|
|
||||||
|
// Validate Turnstile token first
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
token,
|
||||||
|
c.env.TURNSTILE_SECRET_KEY,
|
||||||
|
{
|
||||||
|
remoteip: c.req.header('CF-Connecting-IP'),
|
||||||
|
expectedAction: 'login',
|
||||||
|
expectedHostname: new URL(c.req.url).hostname,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: 'Invalid Turnstile token',
|
||||||
|
codes: result['error-codes'],
|
||||||
|
},
|
||||||
|
401
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate credentials (example - use proper auth in production)
|
||||||
|
if (!username || !password) {
|
||||||
|
return c.json({ error: 'Missing credentials' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check credentials against database
|
||||||
|
// const user = await db.query('SELECT * FROM users WHERE username = ?', [username])
|
||||||
|
|
||||||
|
// Create session token
|
||||||
|
// const sessionToken = await createSession(user.id)
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: 'Login successful',
|
||||||
|
// token: sessionToken,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
return c.json({ error: 'Login failed' }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Signup with Turnstile + Rate Limiting
|
||||||
|
*/
|
||||||
|
app.post('/api/auth/signup', async (c) => {
|
||||||
|
try {
|
||||||
|
const { email, password, 'cf-turnstile-response': token } = await c.req.json()
|
||||||
|
|
||||||
|
// Validate Turnstile
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
token,
|
||||||
|
c.env.TURNSTILE_SECRET_KEY,
|
||||||
|
{
|
||||||
|
remoteip: c.req.header('CF-Connecting-IP'),
|
||||||
|
expectedAction: 'signup',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Bot detection failed' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!email || !password) {
|
||||||
|
return c.json({ error: 'Missing required fields' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
// const existingUser = await db.query('SELECT id FROM users WHERE email = ?', [email])
|
||||||
|
// if (existingUser) {
|
||||||
|
// return c.json({ error: 'User already exists' }, 409)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
// const hashedPassword = await hashPassword(password)
|
||||||
|
// await db.query('INSERT INTO users (email, password) VALUES (?, ?)', [email, hashedPassword])
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: 'Signup successful',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Signup error:', error)
|
||||||
|
return c.json({ error: 'Signup failed' }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Middleware for Turnstile Validation
|
||||||
|
*/
|
||||||
|
async function turnstileMiddleware(c: any, next: () => Promise<void>) {
|
||||||
|
const contentType = c.req.header('Content-Type')
|
||||||
|
|
||||||
|
let token: string | null = null
|
||||||
|
|
||||||
|
// Get token from FormData or JSON
|
||||||
|
if (contentType?.includes('multipart/form-data') || contentType?.includes('application/x-www-form-urlencoded')) {
|
||||||
|
const formData = await c.req.formData()
|
||||||
|
token = formData.get('cf-turnstile-response')?.toString() || null
|
||||||
|
} else if (contentType?.includes('application/json')) {
|
||||||
|
const body = await c.req.json()
|
||||||
|
token = body['cf-turnstile-response'] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return c.json({ error: 'Missing Turnstile token' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
token,
|
||||||
|
c.env.TURNSTILE_SECRET_KEY,
|
||||||
|
{
|
||||||
|
remoteip: c.req.header('CF-Connecting-IP'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({
|
||||||
|
error: 'Turnstile validation failed',
|
||||||
|
codes: result['error-codes'],
|
||||||
|
}, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store result in context for route handler
|
||||||
|
c.set('turnstileResult', result)
|
||||||
|
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 5: Using Middleware
|
||||||
|
*/
|
||||||
|
app.post('/api/protected/action', turnstileMiddleware, async (c) => {
|
||||||
|
const turnstileResult = c.get('turnstileResult') as TurnstileResponse
|
||||||
|
|
||||||
|
console.log('Turnstile validated:', turnstileResult)
|
||||||
|
|
||||||
|
// Your protected action here
|
||||||
|
return c.json({ message: 'Action completed successfully' })
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 6: Get Sitekey Endpoint (for frontend)
|
||||||
|
*/
|
||||||
|
app.get('/api/turnstile/sitekey', (c) => {
|
||||||
|
return c.json({
|
||||||
|
sitekey: c.env.TURNSTILE_SITE_KEY,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 7: Health Check (without Turnstile)
|
||||||
|
*/
|
||||||
|
app.get('/health', (c) => {
|
||||||
|
return c.json({ status: 'ok' })
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 8: CORS for Turnstile
|
||||||
|
*/
|
||||||
|
import { cors } from 'hono/cors'
|
||||||
|
|
||||||
|
app.use('/api/*', cors({
|
||||||
|
origin: ['https://yourdomain.com', 'http://localhost:5173'],
|
||||||
|
allowMethods: ['POST', 'GET', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Content-Type'],
|
||||||
|
credentials: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export
|
||||||
|
*/
|
||||||
|
export default app
|
||||||
353
templates/turnstile-react-component.tsx
Normal file
353
templates/turnstile-react-component.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* Turnstile React Component
|
||||||
|
*
|
||||||
|
* Uses @marsidev/react-turnstile (Cloudflare recommended)
|
||||||
|
* npm install @marsidev/react-turnstile
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic Example: Contact Form with Turnstile
|
||||||
|
*/
|
||||||
|
export function ContactForm() {
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError('Please complete the verification')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(undefined)
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
formData.append('cf-turnstile-response', token)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
setError(`Submission failed: ${errorText}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
alert('Message sent successfully!')
|
||||||
|
e.currentTarget.reset()
|
||||||
|
setToken(undefined)
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Network error: ${err.message}`)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="message">Message</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||||
|
onSuccess={setToken}
|
||||||
|
onError={() => setError('Verification failed')}
|
||||||
|
onExpire={() => setToken(undefined)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={!token || isSubmitting}>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced Example: With Ref for Manual Control
|
||||||
|
*/
|
||||||
|
export function AdvancedTurnstileForm() {
|
||||||
|
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
// Reset the Turnstile widget
|
||||||
|
turnstileRef.current?.reset()
|
||||||
|
setToken(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove() {
|
||||||
|
// Completely remove the widget
|
||||||
|
turnstileRef.current?.remove()
|
||||||
|
setToken(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExecute() {
|
||||||
|
// Manually trigger challenge (execution: 'execute' mode only)
|
||||||
|
turnstileRef.current?.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Turnstile
|
||||||
|
ref={turnstileRef}
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||||
|
onSuccess={setToken}
|
||||||
|
onError={(error) => console.error('Turnstile error:', error)}
|
||||||
|
options={{
|
||||||
|
theme: 'auto',
|
||||||
|
size: 'normal',
|
||||||
|
execution: 'render', // or 'execute' for manual trigger
|
||||||
|
action: 'login',
|
||||||
|
retry: 'auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button onClick={handleReset}>Reset Widget</button>
|
||||||
|
<button onClick={handleRemove}>Remove Widget</button>
|
||||||
|
<button onClick={handleExecute}>Execute Challenge</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{token && <div>Token: {token}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next.js App Router Example (Client Component)
|
||||||
|
*/
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
const [formError, setFormError] = useState<string>()
|
||||||
|
|
||||||
|
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setFormError('Please complete the challenge')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.get('username'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
'cf-turnstile-response': token,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
setFormError(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect on success
|
||||||
|
window.location.href = '/dashboard'
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(`Login failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleLogin}>
|
||||||
|
<input
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||||
|
onSuccess={setToken}
|
||||||
|
onError={() => setFormError('Verification failed')}
|
||||||
|
onExpire={() => setToken(undefined)}
|
||||||
|
options={{
|
||||||
|
theme: 'auto',
|
||||||
|
action: 'login',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formError && <div className="error">{formError}</div>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={!token}>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testing Example: Mock for Jest
|
||||||
|
*
|
||||||
|
* Add to jest.setup.ts:
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
jest.mock('@marsidev/react-turnstile', () => ({
|
||||||
|
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||||
|
// Auto-solve with dummy token
|
||||||
|
React.useEffect(() => {
|
||||||
|
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||||
|
}, [])
|
||||||
|
return <div data-testid="turnstile-mock" />
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment-Aware Sitekey
|
||||||
|
*
|
||||||
|
* Use dummy keys for development/testing
|
||||||
|
*/
|
||||||
|
export function useT turnstileSiteKey() {
|
||||||
|
// Development/Test: Use dummy sitekey
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
return '1x00000000000000000000AA' // Always passes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production: Use real sitekey
|
||||||
|
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example with Environment-Aware Sitekey
|
||||||
|
*/
|
||||||
|
export function SmartTurnstileForm() {
|
||||||
|
const siteKey = useTurnstileSiteKey()
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
{/* Form fields here */}
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
siteKey={siteKey}
|
||||||
|
onSuccess={setToken}
|
||||||
|
onError={(error) => console.error(error)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" disabled={!token}>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Hook: useTurnstile
|
||||||
|
*/
|
||||||
|
export function useTurnstile() {
|
||||||
|
const [token, setToken] = useState<string>()
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
turnstileRef.current?.reset()
|
||||||
|
setToken(undefined)
|
||||||
|
setError(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TurnstileWidget = () => (
|
||||||
|
<Turnstile
|
||||||
|
ref={turnstileRef}
|
||||||
|
siteKey={useTurnstileSiteKey()}
|
||||||
|
onSuccess={(token) => {
|
||||||
|
setToken(token)
|
||||||
|
setIsReady(true)
|
||||||
|
setError(undefined)
|
||||||
|
}}
|
||||||
|
onError={(err) => {
|
||||||
|
setError(err)
|
||||||
|
setIsReady(false)
|
||||||
|
}}
|
||||||
|
onExpire={() => {
|
||||||
|
setToken(undefined)
|
||||||
|
setIsReady(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
isReady,
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
TurnstileWidget,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage of useTurnstile Hook
|
||||||
|
*/
|
||||||
|
export function FormWithHook() {
|
||||||
|
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile()
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
// Use token for submission
|
||||||
|
console.log('Token:', token)
|
||||||
|
// Reset after submission
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Form fields */}
|
||||||
|
|
||||||
|
<TurnstileWidget />
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={!isReady}>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
240
templates/turnstile-server-validation.ts
Normal file
240
templates/turnstile-server-validation.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Turnstile Server-Side Validation
|
||||||
|
*
|
||||||
|
* CRITICAL: Server-side validation is MANDATORY.
|
||||||
|
* Client-side widget alone does NOT provide security.
|
||||||
|
*
|
||||||
|
* Tokens:
|
||||||
|
* - Expire after 5 minutes (300 seconds)
|
||||||
|
* - Are single-use only
|
||||||
|
* - Can be forged by attackers (must validate on server)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Siteverify API Response
|
||||||
|
*/
|
||||||
|
export interface TurnstileResponse {
|
||||||
|
success: boolean
|
||||||
|
challenge_ts?: string // ISO 8601 timestamp
|
||||||
|
hostname?: string // Hostname where challenge was solved
|
||||||
|
'error-codes'?: string[]
|
||||||
|
action?: string // Custom action if specified
|
||||||
|
cdata?: string // Custom data if specified
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation Options
|
||||||
|
*/
|
||||||
|
export interface ValidationOptions {
|
||||||
|
remoteip?: string
|
||||||
|
idempotency_key?: string
|
||||||
|
expectedAction?: string
|
||||||
|
expectedHostname?: string
|
||||||
|
timeout?: number // milliseconds (default: 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Turnstile Token
|
||||||
|
*
|
||||||
|
* @param token - The token from cf-turnstile-response
|
||||||
|
* @param secretKey - Your Turnstile secret key (from environment variable)
|
||||||
|
* @param options - Optional validation parameters
|
||||||
|
* @returns Promise<TurnstileResponse>
|
||||||
|
*/
|
||||||
|
export async function validateTurnstile(
|
||||||
|
token: string,
|
||||||
|
secretKey: string,
|
||||||
|
options?: ValidationOptions
|
||||||
|
): Promise<TurnstileResponse> {
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
'error-codes': ['missing-input-response'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secretKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
'error-codes': ['missing-input-secret'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare request body
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('secret', secretKey)
|
||||||
|
formData.append('response', token)
|
||||||
|
|
||||||
|
if (options?.remoteip) {
|
||||||
|
formData.append('remoteip', options.remoteip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.idempotency_key) {
|
||||||
|
formData.append('idempotency_key', options.idempotency_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timeout
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), options?.timeout || 5000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call Siteverify API
|
||||||
|
const response = await fetch(
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await response.json<TurnstileResponse>()
|
||||||
|
|
||||||
|
// Additional validation checks
|
||||||
|
if (result.success) {
|
||||||
|
// Validate action if specified
|
||||||
|
if (options?.expectedAction && result.action !== options.expectedAction) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
'error-codes': ['action-mismatch'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate hostname if specified
|
||||||
|
if (options?.expectedHostname && result.hostname !== options.expectedHostname) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
'error-codes': ['hostname-mismatch'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
'error-codes': ['timeout'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Turnstile validation error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
'error-codes': ['internal-error'],
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare Workers Example
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
if (request.method !== 'POST') {
|
||||||
|
return new Response('Method not allowed', { status: 405 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const token = formData.get('cf-turnstile-response')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new Response('Missing Turnstile token', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
token.toString(),
|
||||||
|
env.TURNSTILE_SECRET_KEY,
|
||||||
|
{
|
||||||
|
remoteip: request.headers.get('CF-Connecting-IP') || undefined,
|
||||||
|
expectedHostname: new URL(request.url).hostname,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Turnstile validation failed:', result['error-codes'])
|
||||||
|
return new Response('Invalid Turnstile token', {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token is valid - process the form
|
||||||
|
const email = formData.get('email')
|
||||||
|
const message = formData.get('message')
|
||||||
|
|
||||||
|
// Your business logic here
|
||||||
|
console.log('Form submitted:', { email, message })
|
||||||
|
|
||||||
|
return new Response('Success!', {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request handling error:', error)
|
||||||
|
return new Response('Internal server error', { status: 500 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced Example: Validation with Retry Logic
|
||||||
|
*/
|
||||||
|
export async function validateWithRetry(
|
||||||
|
token: string,
|
||||||
|
secretKey: string,
|
||||||
|
options?: ValidationOptions,
|
||||||
|
maxRetries: number = 3
|
||||||
|
): Promise<TurnstileResponse> {
|
||||||
|
let lastError: TurnstileResponse | null = null
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
const result = await validateTurnstile(token, secretKey, options)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry on permanent errors
|
||||||
|
const permanentErrors = [
|
||||||
|
'missing-input-secret',
|
||||||
|
'invalid-input-secret',
|
||||||
|
'missing-input-response',
|
||||||
|
'invalid-input-response',
|
||||||
|
'action-mismatch',
|
||||||
|
'hostname-mismatch',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (
|
||||||
|
result['error-codes']?.some((code) => permanentErrors.includes(code))
|
||||||
|
) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry on transient errors
|
||||||
|
lastError = result
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
// Exponential backoff
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastError || { success: false, 'error-codes': ['max-retries-exceeded'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type Definitions for Cloudflare Workers
|
||||||
|
*/
|
||||||
|
export interface Env {
|
||||||
|
TURNSTILE_SECRET_KEY: string
|
||||||
|
TURNSTILE_SITE_KEY: string
|
||||||
|
// Add other environment variables here
|
||||||
|
}
|
||||||
311
templates/turnstile-test-config.ts
Normal file
311
templates/turnstile-test-config.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Turnstile Testing Configuration
|
||||||
|
*
|
||||||
|
* Use dummy sitekeys and secret keys for automated testing
|
||||||
|
* to avoid hitting rate limits and ensure predictable behavior.
|
||||||
|
*
|
||||||
|
* Official Docs: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy Sitekeys (Client-Side)
|
||||||
|
*
|
||||||
|
* These can be used from any domain, including localhost.
|
||||||
|
* Production secret keys will reject dummy tokens.
|
||||||
|
*/
|
||||||
|
export const TEST_SITEKEYS = {
|
||||||
|
/** Always passes - visible widget */
|
||||||
|
ALWAYS_PASS: '1x00000000000000000000AA',
|
||||||
|
|
||||||
|
/** Always blocks - visible widget */
|
||||||
|
ALWAYS_BLOCK: '2x00000000000000000000AB',
|
||||||
|
|
||||||
|
/** Always passes - invisible widget */
|
||||||
|
ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB',
|
||||||
|
|
||||||
|
/** Always blocks - invisible widget */
|
||||||
|
ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',
|
||||||
|
|
||||||
|
/** Forces an interactive challenge - visible widget */
|
||||||
|
FORCE_INTERACTIVE: '3x00000000000000000000FF',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy Secret Keys (Server-Side)
|
||||||
|
*
|
||||||
|
* These only accept the dummy token XXXX.DUMMY.TOKEN.XXXX
|
||||||
|
* Real tokens will fail validation with these keys.
|
||||||
|
*/
|
||||||
|
export const TEST_SECRET_KEYS = {
|
||||||
|
/** Always returns success: true */
|
||||||
|
ALWAYS_PASS: '1x0000000000000000000000000000000AA',
|
||||||
|
|
||||||
|
/** Always returns success: false */
|
||||||
|
ALWAYS_FAIL: '2x0000000000000000000000000000000AA',
|
||||||
|
|
||||||
|
/** Returns "token already spent" error */
|
||||||
|
TOKEN_SPENT: '3x0000000000000000000000000000000AA',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy Token
|
||||||
|
*
|
||||||
|
* Generated by test sitekeys.
|
||||||
|
* Only valid with test secret keys.
|
||||||
|
*/
|
||||||
|
export const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment Detection
|
||||||
|
*
|
||||||
|
* Helper to determine if we're in a test environment
|
||||||
|
*/
|
||||||
|
export function isTestEnvironment(request: Request): boolean {
|
||||||
|
// Check for test headers
|
||||||
|
if (request.headers.get('x-test-environment') === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for localhost/test IPs
|
||||||
|
const ip = request.headers.get('CF-Connecting-IP') || ''
|
||||||
|
const testIPs = ['127.0.0.1', '::1', 'localhost']
|
||||||
|
if (testIPs.includes(ip)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for test query parameter
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.searchParams.get('test') === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Test Credentials
|
||||||
|
*
|
||||||
|
* Returns test or production credentials based on environment
|
||||||
|
*/
|
||||||
|
export function getTurnstileCredentials(
|
||||||
|
request: Request,
|
||||||
|
env: {
|
||||||
|
TURNSTILE_SITE_KEY?: string
|
||||||
|
TURNSTILE_SECRET_KEY?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (isTestEnvironment(request)) {
|
||||||
|
return {
|
||||||
|
sitekey: TEST_SITEKEYS.ALWAYS_PASS,
|
||||||
|
secretKey: TEST_SECRET_KEYS.ALWAYS_PASS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sitekey: env.TURNSTILE_SITE_KEY || '',
|
||||||
|
secretKey: env.TURNSTILE_SECRET_KEY || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright Test Example
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
// playwright.config.ts
|
||||||
|
export default {
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
'x-test-environment': 'true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// test/turnstile.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('form submission with Turnstile', async ({ page }) => {
|
||||||
|
await page.goto('/contact')
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await page.fill('input[name="email"]', 'test@example.com')
|
||||||
|
await page.fill('textarea[name="message"]', 'Test message')
|
||||||
|
|
||||||
|
// Turnstile auto-solves with dummy token in test mode
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
// Verify success
|
||||||
|
await expect(page.locator('.success-message')).toBeVisible()
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cypress Test Example
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
// cypress/e2e/turnstile.cy.ts
|
||||||
|
describe('Turnstile Form', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set test header
|
||||||
|
cy.intercept('**', (req) => {
|
||||||
|
req.headers['x-test-environment'] = 'true'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits form successfully', () => {
|
||||||
|
cy.visit('/contact')
|
||||||
|
|
||||||
|
cy.get('input[name="email"]').type('test@example.com')
|
||||||
|
cy.get('textarea[name="message"]').type('Test message')
|
||||||
|
|
||||||
|
// Turnstile auto-solves in test mode
|
||||||
|
cy.get('button[type="submit"]').click()
|
||||||
|
|
||||||
|
cy.contains('Success').should('be.visible')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jest Mock Example
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
// jest.setup.ts
|
||||||
|
jest.mock('@marsidev/react-turnstile', () => ({
|
||||||
|
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||||
|
// Auto-solve with dummy token
|
||||||
|
React.useEffect(() => {
|
||||||
|
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||||
|
}, [onSuccess])
|
||||||
|
return <div data-testid="turnstile-mock" />
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// component.test.tsx
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ContactForm } from './ContactForm'
|
||||||
|
|
||||||
|
test('submits form with Turnstile', async () => {
|
||||||
|
render(<ContactForm />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Email'), {
|
||||||
|
target: { value: 'test@example.com' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Turnstile auto-solves
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Success')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-Side Test Example (Vitest)
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { validateTurnstile } from './turnstile-server-validation'
|
||||||
|
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'
|
||||||
|
|
||||||
|
describe('Turnstile Validation', () => {
|
||||||
|
it('validates dummy token with test secret', async () => {
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
DUMMY_TOKEN,
|
||||||
|
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects real token with test secret', async () => {
|
||||||
|
const realToken = 'some-real-token-from-production'
|
||||||
|
|
||||||
|
const result = await validateTurnstile(
|
||||||
|
realToken,
|
||||||
|
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment-Aware Component (React)
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
import { Turnstile } from '@marsidev/react-turnstile'
|
||||||
|
import { TEST_SITEKEYS } from './turnstile-test-config'
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const sitekey = process.env.NODE_ENV === 'test'
|
||||||
|
? TEST_SITEKEYS.ALWAYS_PASS
|
||||||
|
: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<Turnstile
|
||||||
|
siteKey={sitekey}
|
||||||
|
onSuccess={(token) => console.log('Token:', token)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare Workers Test Example
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import worker from '../src/index'
|
||||||
|
import { DUMMY_TOKEN } from './turnstile-test-config'
|
||||||
|
|
||||||
|
describe('Worker with Turnstile', () => {
|
||||||
|
it('validates test token', async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('email', 'test@example.com')
|
||||||
|
formData.append('cf-turnstile-response', DUMMY_TOKEN)
|
||||||
|
|
||||||
|
const request = new Request('http://localhost/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'x-test-environment': 'true',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = createExecutionContext()
|
||||||
|
const response = await worker.fetch(request, env, ctx)
|
||||||
|
await waitOnExecutionContext(ctx)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CI/CD Environment Variables
|
||||||
|
*
|
||||||
|
* Set these in your CI/CD pipeline:
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
# GitHub Actions
|
||||||
|
env:
|
||||||
|
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||||
|
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
|
||||||
|
|
||||||
|
# GitLab CI
|
||||||
|
variables:
|
||||||
|
TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
|
||||||
|
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
TEST_SITEKEYS,
|
||||||
|
TEST_SECRET_KEYS,
|
||||||
|
DUMMY_TOKEN,
|
||||||
|
isTestEnvironment,
|
||||||
|
getTurnstileCredentials,
|
||||||
|
}
|
||||||
262
templates/turnstile-widget-explicit.ts
Normal file
262
templates/turnstile-widget-explicit.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* Turnstile Widget - Explicit Rendering
|
||||||
|
*
|
||||||
|
* Use explicit rendering when you need programmatic control over:
|
||||||
|
* - When the widget renders
|
||||||
|
* - Widget lifecycle (reset, remove)
|
||||||
|
* - Multiple widgets on the same page
|
||||||
|
* - Dynamic UI / Single Page Applications
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare const turnstile: {
|
||||||
|
render: (container: string | HTMLElement, options: TurnstileOptions) => string
|
||||||
|
reset: (widgetId: string) => void
|
||||||
|
remove: (widgetId: string) => void
|
||||||
|
execute: (widgetId: string) => void
|
||||||
|
getResponse: (widgetId: string) => string | undefined
|
||||||
|
isExpired: (widgetId: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnstileOptions {
|
||||||
|
sitekey: string
|
||||||
|
callback?: (token: string) => void
|
||||||
|
'error-callback'?: (error: string) => void
|
||||||
|
'expired-callback'?: () => void
|
||||||
|
'timeout-callback'?: () => void
|
||||||
|
theme?: 'light' | 'dark' | 'auto'
|
||||||
|
size?: 'normal' | 'flexible' | 'compact'
|
||||||
|
execution?: 'render' | 'execute'
|
||||||
|
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||||
|
retry?: 'auto' | 'never'
|
||||||
|
'retry-interval'?: number
|
||||||
|
action?: string
|
||||||
|
cdata?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TurnstileManager - Lifecycle management wrapper
|
||||||
|
*/
|
||||||
|
export class TurnstileManager {
|
||||||
|
private widgetId: string | null = null
|
||||||
|
private sitekey: string
|
||||||
|
|
||||||
|
constructor(sitekey: string) {
|
||||||
|
this.sitekey = sitekey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the Turnstile widget
|
||||||
|
*/
|
||||||
|
render(
|
||||||
|
containerId: string,
|
||||||
|
callbacks: {
|
||||||
|
onSuccess: (token: string) => void
|
||||||
|
onError: (error: string) => void
|
||||||
|
onExpired?: () => void
|
||||||
|
},
|
||||||
|
options?: Partial<TurnstileOptions>
|
||||||
|
): string {
|
||||||
|
// Reset if already rendered
|
||||||
|
if (this.widgetId !== null) {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.widgetId = turnstile.render(containerId, {
|
||||||
|
sitekey: this.sitekey,
|
||||||
|
callback: callbacks.onSuccess,
|
||||||
|
'error-callback': callbacks.onError,
|
||||||
|
'expired-callback': callbacks.onExpired || (() => this.reset()),
|
||||||
|
theme: options?.theme || 'auto',
|
||||||
|
size: options?.size || 'normal',
|
||||||
|
execution: options?.execution || 'render',
|
||||||
|
appearance: options?.appearance || 'always',
|
||||||
|
retry: options?.retry || 'auto',
|
||||||
|
action: options?.action,
|
||||||
|
cdata: options?.cdata,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.widgetId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the widget (clears current state)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
if (this.widgetId !== null) {
|
||||||
|
turnstile.reset(this.widgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the widget completely
|
||||||
|
*/
|
||||||
|
remove(): void {
|
||||||
|
if (this.widgetId !== null) {
|
||||||
|
turnstile.remove(this.widgetId)
|
||||||
|
this.widgetId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger challenge (execution: 'execute' mode only)
|
||||||
|
*/
|
||||||
|
execute(): void {
|
||||||
|
if (this.widgetId !== null) {
|
||||||
|
turnstile.execute(this.widgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current token
|
||||||
|
*/
|
||||||
|
getToken(): string | undefined {
|
||||||
|
if (this.widgetId === null) return undefined
|
||||||
|
return turnstile.getResponse(this.widgetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if token is expired
|
||||||
|
*/
|
||||||
|
isExpired(): boolean {
|
||||||
|
if (this.widgetId === null) return true
|
||||||
|
return turnstile.isExpired(this.widgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage Example
|
||||||
|
*/
|
||||||
|
export function initializeTurnstile() {
|
||||||
|
const SITE_KEY = 'YOUR_SITE_KEY' // Replace with actual sitekey
|
||||||
|
|
||||||
|
const manager = new TurnstileManager(SITE_KEY)
|
||||||
|
|
||||||
|
// Render widget when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
manager.render(
|
||||||
|
'#turnstile-container',
|
||||||
|
{
|
||||||
|
onSuccess: (token) => {
|
||||||
|
console.log('Turnstile success:', token)
|
||||||
|
// Enable submit button
|
||||||
|
const submitBtn = document.querySelector('#submit-btn') as HTMLButtonElement
|
||||||
|
if (submitBtn) submitBtn.disabled = false
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Turnstile error:', error)
|
||||||
|
// Show error message
|
||||||
|
const errorDiv = document.querySelector('#error-message')
|
||||||
|
if (errorDiv) {
|
||||||
|
errorDiv.textContent = 'Verification failed. Please try again.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onExpired: () => {
|
||||||
|
console.warn('Turnstile token expired')
|
||||||
|
// Disable submit button
|
||||||
|
const submitBtn = document.querySelector('#submit-btn') as HTMLButtonElement
|
||||||
|
if (submitBtn) submitBtn.disabled = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'auto',
|
||||||
|
size: 'normal',
|
||||||
|
action: 'login', // Optional: track action in analytics
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Example: Reset on form submission
|
||||||
|
const form = document.querySelector('#myForm') as HTMLFormElement
|
||||||
|
form?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const token = manager.getToken()
|
||||||
|
if (!token || manager.isExpired()) {
|
||||||
|
alert('Please complete the verification')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit form with token
|
||||||
|
const formData = new FormData(form)
|
||||||
|
formData.append('cf-turnstile-response', token)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Success!')
|
||||||
|
form.reset()
|
||||||
|
manager.reset() // Reset Turnstile for next submission
|
||||||
|
} else {
|
||||||
|
alert('Submission failed')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submission error:', error)
|
||||||
|
alert('Network error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced Example: Manual Execution Mode
|
||||||
|
*/
|
||||||
|
export function manualExecutionExample() {
|
||||||
|
const manager = new TurnstileManager('YOUR_SITE_KEY')
|
||||||
|
|
||||||
|
// Render in manual execution mode
|
||||||
|
manager.render(
|
||||||
|
'#turnstile-container',
|
||||||
|
{
|
||||||
|
onSuccess: (token) => {
|
||||||
|
console.log('Challenge complete:', token)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Challenge failed:', error)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
execution: 'execute', // Manual trigger
|
||||||
|
appearance: 'interaction-only', // Show only when needed
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trigger challenge when user clicks submit
|
||||||
|
document.querySelector('#submit-btn')?.addEventListener('click', () => {
|
||||||
|
manager.execute()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced Example: Multiple Widgets
|
||||||
|
*/
|
||||||
|
export function multipleWidgetsExample() {
|
||||||
|
const loginManager = new TurnstileManager('YOUR_SITE_KEY')
|
||||||
|
const signupManager = new TurnstileManager('YOUR_SITE_KEY')
|
||||||
|
|
||||||
|
// Login widget
|
||||||
|
loginManager.render(
|
||||||
|
'#login-turnstile',
|
||||||
|
{
|
||||||
|
onSuccess: (token) => console.log('Login token:', token),
|
||||||
|
onError: (error) => console.error('Login error:', error),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'login',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signup widget
|
||||||
|
signupManager.render(
|
||||||
|
'#signup-turnstile',
|
||||||
|
{
|
||||||
|
onSuccess: (token) => console.log('Signup token:', token),
|
||||||
|
onError: (error) => console.error('Signup error:', error),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'signup',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
163
templates/turnstile-widget-implicit.html
Normal file
163
templates/turnstile-widget-implicit.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Turnstile Example - Implicit Rendering</title>
|
||||||
|
|
||||||
|
<!-- CRITICAL: Load from Cloudflare CDN only - never proxy or cache -->
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
|
||||||
|
<!-- Optional: CSP headers if needed -->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
script-src 'self' https://challenges.cloudflare.com;
|
||||||
|
frame-src 'self' https://challenges.cloudflare.com;
|
||||||
|
connect-src 'self' https://challenges.cloudflare.com;
|
||||||
|
">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #0070f3;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #0051cc;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: green;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Contact Form with Turnstile</h1>
|
||||||
|
|
||||||
|
<form id="contactForm" action="/api/contact" method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="message">Message</label>
|
||||||
|
<textarea id="message" name="message" rows="5" required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Turnstile Widget - Implicit Rendering -->
|
||||||
|
<div class="cf-turnstile"
|
||||||
|
data-sitekey="YOUR_SITE_KEY"
|
||||||
|
data-callback="onTurnstileSuccess"
|
||||||
|
data-error-callback="onTurnstileError"
|
||||||
|
data-expired-callback="onTurnstileExpired"
|
||||||
|
data-theme="auto"
|
||||||
|
data-size="normal"></div>
|
||||||
|
|
||||||
|
<button type="submit" id="submitButton">Submit</button>
|
||||||
|
|
||||||
|
<div id="message"></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let turnstileToken = null;
|
||||||
|
|
||||||
|
function onTurnstileSuccess(token) {
|
||||||
|
console.log('Turnstile success:', token);
|
||||||
|
turnstileToken = token;
|
||||||
|
document.getElementById('submitButton').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTurnstileError(error) {
|
||||||
|
console.error('Turnstile error:', error);
|
||||||
|
showMessage('Verification failed. Please try again.', 'error');
|
||||||
|
document.getElementById('submitButton').disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTurnstileExpired() {
|
||||||
|
console.warn('Turnstile token expired');
|
||||||
|
turnstileToken = null;
|
||||||
|
document.getElementById('submitButton').disabled = true;
|
||||||
|
showMessage('Verification expired. Please try again.', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text, type) {
|
||||||
|
const messageDiv = document.getElementById('message');
|
||||||
|
messageDiv.textContent = text;
|
||||||
|
messageDiv.className = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('contactForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!turnstileToken) {
|
||||||
|
showMessage('Please complete the verification.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
// Token is automatically added as 'cf-turnstile-response' by Turnstile
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showMessage('Message sent successfully!', 'success');
|
||||||
|
e.target.reset();
|
||||||
|
turnstileToken = null;
|
||||||
|
// Reset Turnstile widget
|
||||||
|
turnstile.reset();
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
showMessage(`Error: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(`Network error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable submit button initially
|
||||||
|
document.getElementById('submitButton').disabled = true;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
templates/wrangler-turnstile-config.jsonc
Normal file
36
templates/wrangler-turnstile-config.jsonc
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "my-turnstile-app",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2025-10-22",
|
||||||
|
|
||||||
|
// Public sitekey - safe to commit to version control
|
||||||
|
// Use dummy keys for development, real keys for production
|
||||||
|
"vars": {
|
||||||
|
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA" // Test key - always passes
|
||||||
|
// Production: Replace with your real sitekey from https://dash.cloudflare.com/?to=/:account/turnstile
|
||||||
|
},
|
||||||
|
|
||||||
|
// Secret key - NEVER commit to version control
|
||||||
|
// Set using: wrangler secret put TURNSTILE_SECRET_KEY
|
||||||
|
"secrets": ["TURNSTILE_SECRET_KEY"],
|
||||||
|
|
||||||
|
// Optional: Environment-specific configuration
|
||||||
|
"env": {
|
||||||
|
"production": {
|
||||||
|
"vars": {
|
||||||
|
"TURNSTILE_SITE_KEY": "<YOUR_PRODUCTION_SITE_KEY>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"vars": {
|
||||||
|
"TURNSTILE_SITE_KEY": "<YOUR_STAGING_SITE_KEY>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"vars": {
|
||||||
|
// Use test sitekey for development (always passes)
|
||||||
|
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user