Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:24:31 +08:00
commit 0aa89c365d
19 changed files with 4303 additions and 0 deletions

559
references/error-codes.md Normal file
View 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)

View 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.

View 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
View 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

View 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)