plans
2026-05-10
2026 05 10 Turnstile Integration

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_KEY

Task 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=0xAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBB

Task 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 ✅
  • verifyTurnstileToken imported from ../lib/turnstile consistently ✅
  • Widget container IDs (#turnstile-container, #turnstile-proposal) are distinct ✅