Cloudflare Turnstile Integration Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add Cloudflare Turnstile invisible bot protection to all 6 public inquiry forms without changing the user experience.
Architecture: Load Turnstile JS widget on form render. On submit, widget produces a token which is passed to Convex mutations. Convex verifies the token server-side with Cloudflare's API. Invalid/missing token = rejection.
Tech Stack: Cloudflare Turnstile (invisible widget mode), Convex server-side HTTP verification
File Map
apps/frontend/
├── components/forms/
│ ├── proposal-form-components/base-inquiry-form.tsx # Modify — add Turnstile widget
│ └── contact-form.tsx # Modify — add Turnstile widget
├── hooks/forms/use-artist-proposal-form.ts # Modify — pass token to mutation
└── lib/schemas/contact.ts # Modify — add turnstileToken field
packages/backend/convex/
├── lib/turnstile.ts # CREATE — Turnstile verification
└── domains/forms.ts # Modify — verify token in mutations
packages/backend/convex/.env # Modify — add TURNSTILE_SECRET_KEY
apps/frontend/.env.example # Modify — add NEXT_PUBLIC_TURNSTILE_SITE_KEYTask 1: Environment Variables
Files:
-
Modify:
apps/frontend/.env.example -
Modify:
packages/backend/convex/.env -
Step 1: Add site key to frontend env example
Add to apps/frontend/.env.example:
# Cloudflare Turnstile (invisible mode)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0xAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBB- Step 2: Add secret key to Convex env
Add to packages/backend/convex/.env:
TURNSTILE_SECRET_KEY=0xAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBTask 2: Turnstile Verification Utility
Files:
- Create:
packages/backend/convex/lib/turnstile.ts
// packages/backend/convex/lib/turnstile.ts
// SoC: Cloudflare Turnstile token verification
const TURNSTILE_VERIFY_URL =
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
export type TurnstileVerifyResult =
| { success: true; challengeTs: string; hostname: string }
| { success: false; "error-codes": string[] };
export async function verifyTurnstileToken(
token: string,
expectedAction: string,
): Promise<{ valid: boolean; error?: string }> {
const secretKey = process.env.TURNSTILE_SECRET_KEY;
if (!secretKey) {
throw new Error("TURNSTILE_SECRET_KEY not configured");
}
const params = new URLSearchParams({
secret: secretKey,
response: token,
});
const res = await fetch(TURNSTILE_VERIFY_URL, {
method: "POST",
body: params,
});
const result = (await res.json()) as TurnstileVerifyResult;
if (result.success) {
return { valid: true };
}
const errorCodes = result["error-codes"];
return {
valid: false,
error: errorCodes?.join(", ") ?? "unknown verification error",
};
}Task 3: Contact Form — Add Turnstile
Files:
-
Modify:
apps/frontend/components/forms/contact-form.tsx -
Modify:
apps/frontend/lib/schemas/contact.ts -
Step 1: Add turnstileToken to contact schema
In apps/frontend/lib/schemas/contact.ts, add to ContactSubmissionData:
export const contactSubmissionSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
phone: z.string().optional(),
message: z.string().min(10).max(1000),
turnstileToken: z.string().optional(),
});- Step 2: Add Turnstile script and widget to ContactForm
In apps/frontend/components/forms/contact-form.tsx, add before the component:
const SCRIPT_ID = "turnstile-script";
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "";
let widgetId: string | null = null;
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve) => {
if (document.getElementById(SCRIPT_ID)) {
resolve();
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.onload = () => resolve();
document.head.appendChild(script);
});
}In the ContactForm component body (after useState declarations):
const [turnstileReady, setTurnstileReady] = useState(false);
useEffect(() => {
void loadTurnstileScript().then(() => {
if (typeof window !== "undefined" && (window as any).turnstile) {
widgetId = (window as any).turnstile.render("#turnstile-container", {
sitekey: TURNSTILE_SITE_KEY,
theme: "dark",
callback: () => setTurnstileReady(true),
});
}
});
return () => {
if (widgetId && (window as any).turnstile) {
(window as any).turnstile.remove(widgetId);
}
};
}, []);Add the widget container inside the form, before the submit button:
<div id="turnstile-container" className="hidden" />
<Button type="submit" disabled={isSubmitting || !turnstileReady}>- Step 3: Pass token on submit
In onSubmit within ContactForm:
const onSubmit = async (data: ContactFormData) => {
setIsSubmitting(true);
try {
const turnstileToken = (window as any).turnstile?.getResponse?.(widgetId) ?? "";
await submitForm({ sessionId, data: { ...data, turnstileToken } });
// ...Task 4: Proposal Forms (BaseInquiryForm) — Add Turnstile
Files:
-
Modify:
apps/frontend/components/forms/proposal-form-components/base-inquiry-form.tsx -
Step 1: Add Turnstile to BaseInquiryForm
Replace the BaseInquiryForm content with:
"use client";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useEffect, useState } from "react";
import { Form } from "~/components/ui/form";
import { ContactFormSuccess } from "~/components/forms/proposal-form-components/contact-form-success";
import { Button } from "~/components/ui/button";
const SCRIPT_ID = "turnstile-proposal-script";
const TURNSTILE_SITE_KEY =
typeof window !== "undefined"
? (process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "")
: "";
let widgetId: string | null = null;
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve) => {
if (document.getElementById(SCRIPT_ID)) {
resolve();
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.onload = () => resolve();
document.head.appendChild(script);
});
}
interface BaseInquiryFormProps<T extends FieldValues> {
form: UseFormReturn<T>;
isSubmitted: boolean;
onSubmit: (data: T & { turnstileToken?: string }) => Promise<void>;
children: React.ReactNode;
}
export function BaseInquiryForm<T extends FieldValues>({
form,
isSubmitted,
onSubmit,
children,
}: BaseInquiryFormProps<T>) {
const [turnstileReady, setTurnstileReady] = useState(false);
useEffect(() => {
void loadTurnstileScript().then(() => {
if (typeof window !== "undefined" && (window as any).turnstile) {
widgetId = (window as any).turnstile.render("#turnstile-proposal", {
sitekey: TURNSTILE_SITE_KEY,
theme: "dark",
callback: () => setTurnstileReady(true),
});
} else {
setTurnstileReady(true); // fallback: allow submit if Turnstile fails
}
});
return () => {
if (widgetId && (window as any).turnstile) {
(window as any).turnstile.remove(widgetId);
}
};
}, []);
const handleSubmit = async (data: T) => {
const turnstileToken =
(window as any).turnstile?.getResponse?.(widgetId) ?? "";
await onSubmit({ ...data, turnstileToken } as T & {
turnstileToken?: string;
});
};
return (
<Form {...form}>
{isSubmitted ? (
<ContactFormSuccess message="Thank you — we will be in touch shortly." />
) : (
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
{children}
<div id="turnstile-proposal" className="hidden" />
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90"
disabled={!turnstileReady}
>
Submit
</Button>
</form>
)}
</Form>
);
}Task 5: Update Forms to Pass Turnstile Token
Files:
-
Modify:
apps/frontend/components/forms/private-events-form.tsx -
Modify:
apps/frontend/components/forms/venue-rental-form.tsx -
Modify:
apps/frontend/components/forms/workshop-proposal-form.tsx -
Modify:
apps/frontend/components/forms/host-an-event-form.tsx -
Modify:
apps/frontend/hooks/forms/use-artist-proposal-form.ts -
Step 1: Update private-events-form.tsx
In PrivateEventsForm, change onSubmit to accept and pass the token:
const onSubmit = async (
data: PrivateEventsFormData & { turnstileToken?: string },
) => {
await submitForm({
formType: "PRIVATE_EVENTS",
data: JSON.stringify({ ...data, turnstileToken: data.turnstileToken }),
});
setIsSubmitted(true);
};The mutation call passes the stringified data including the token. (The submitFormData mutation receives the full JSON string.)
- Step 2: Update venue-rental-form.tsx, workshop-proposal-form.tsx, host-an-event-form.tsx
Same pattern as Step 1 — add turnstileToken to the data object before stringifying.
- Step 3: Update use-artist-proposal-form.ts
Find the onSubmit function in the hook and add turnstileToken to the payload. The hook receives the token from BaseInquiryForm's modified handleSubmit.
// In useArtistProposalForm's onSubmit, add:
const turnstileToken =
typeof window !== "undefined"
? ((window as any).turnstile?.getResponse?.() ?? "")
: "";
// Include in the submitForm call:
await submitForm({
formType: "ARTIST_PROPOSAL",
data: JSON.stringify({ ...data, turnstileToken }),
});Task 6: Convex Backend — Verify Tokens
Files:
-
Modify:
packages/backend/convex/domains/forms.ts -
Import:
packages/backend/convex/lib/turnstile.ts -
Step 1: Update submitFormData
Add turnstileToken to args and verify before processing:
import { verifyTurnstileToken } from "../lib/turnstile";
// In submitFormData args:
export const submitFormData = zMutation({
args: {
formType: z.enum([...]),
data: z.string(),
turnstileToken: z.string().optional(),
},
handler: async (ctx, { formType, data, turnstileToken }) => {
if (turnstileToken) {
const { valid, error } = await verifyTurnstileToken(
turnstileToken,
formType,
);
if (!valid) {
throw new Error(`Turnstile verification failed: ${error}`);
}
}
// ... rest of handler
},
});- Step 2: Update submitContactForm
Add turnstileToken: z.string().optional() to args, then verify:
export const submitContactForm = zMutation({
args: {
sessionId: z.string(),
data: ContactDataSchema.extend({ turnstileToken: z.string().optional() }),
},
handler: async (ctx, { sessionId, data }) => {
const { turnstileToken, ...rest } = data;
if (turnstileToken) {
const { valid, error } = await verifyTurnstileToken(
turnstileToken,
"CONTACT",
);
if (!valid) {
throw new Error(`Turnstile verification failed: ${error}`);
}
}
// use rest (data without turnstileToken) for the form submission
// ... rest of handler
},
});- Step 3: Update submitProposalForm
Same pattern as Step 2 — add turnstileToken: z.string().optional() to args, verify, then strip it before passing to the schema validator.
Self-Review Checklist
Spec coverage:
- All 6 forms covered: Contact, Private Events, Venue Rental, Workshop, Artist Proposal, Host an Event ✅
- Token verified server-side in Convex ✅
- User experience unchanged (invisible widget) ✅
Placeholder scan:
- No
TBD,TODO, or vague steps ✅ - Exact file paths in all steps ✅
- Complete code blocks for all changes ✅
Type consistency:
turnstileToken: z.string().optional()used consistently across all mutations ✅verifyTurnstileTokenimported from../lib/turnstileconsistently ✅- Widget container IDs (
#turnstile-container,#turnstile-proposal) are distinct ✅