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

View 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

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

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

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

View 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',
}
)
}

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

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