config(git,claude,ssh): templated gitconfig; claude settings/commands/hooks/skills; ssh config

gitconfig: name/email from chezmoi data, gh credential helper by PATH
lookup (portable to darwin). claude: no credentials, history, projects,
sessions, or plugin caches. ssh config as private_ (0600).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Prad NukalaandClaude Fable 5 committed 2026-08-09 14:53:46 -04:00
1 parent 3c691e8dc5
commit 20891f3910
476 files changed
+59086 -7

No files matched your search

@@ -0,0 +1,45 @@
# turnstile-spin (skill)
End-to-end setup skill for Cloudflare Turnstile. Loads when an agent is asked to add Turnstile, set up CAPTCHA, or protect a form from bots.
This is a mirror of the canonical docs page at [`developers.cloudflare.com/turnstile/spin`](https://developers.cloudflare.com/turnstile/spin/). If the two disagree, the docs page wins.
## Layout
| File | Purpose |
| --------------------------------- | ---------------------------------------------------------------------- |
| `SKILL.md` | Main wizard instructions for the agent |
| `references/vanilla-html.md` | Code snippet for static / vanilla HTML projects |
| `references/nextjs-app.md` | Code snippet for Next.js App Router projects |
| `references/nextjs-pages.md` | Code snippet for Next.js Pages Router projects |
| `references/astro.md` | Code snippet for Astro projects |
| `references/sveltekit.md` | Code snippet for SvelteKit projects |
| `references/hugo.md` | Code snippet for Hugo projects |
| `tests/validation.md` | Validation cases matching the MVP rows in the PRD |
## How agents load it
Agents that load skill bundles from `github.com/cloudflare/skills` will pick this up automatically. For agents that load skills out of a local directory:
```sh
# Claude Code
mkdir -p .claude/skills/turnstile-spin && \
curl -sSL https://developers.cloudflare.com/turnstile/spin.md \
-o .claude/skills/turnstile-spin/SKILL.md
# Or, install the whole skills bundle into a global location
git clone https://github.com/cloudflare/skills ~/.config/cloudflare-skills
ln -s ~/.config/cloudflare-skills/turnstile-spin ~/.claude/skills/turnstile-spin
```
For other agents, see the table in [`SKILL.md`](./SKILL.md#step-8--persist-the-skill).
## Sync with the docs page
The canonical source of truth is `src/content/docs/turnstile/spin/index.mdx` in the `cloudflare-docs` repo. This skill mirrors that content with the JSX stripped out. CI keeps them in sync on each docs release; if you are hand-editing, mirror your change to both places.
## Related
- [Canonical docs page](https://developers.cloudflare.com/turnstile/spin/)
- [`cloudflare/turnstile-siteverify`](https://github.com/cloudflare/turnstile-siteverify) — the managed Worker that this skill deploys
- [`cloudflare/skills`](https://github.com/cloudflare/skills) — root index for all Cloudflare agent skills
+167
View File
@@ -0,0 +1,167 @@
---
name: turnstile-spin
description: Set up Cloudflare Turnstile end-to-end in a project — scan the codebase, create the widget via the Cloudflare API, deploy the managed siteverify Worker, write the frontend snippets, validate, and persist the skill. Load this when a user asks to add Turnstile, set up CAPTCHA, protect a form from bots, or fix a Turnstile integration. Mirrors developers.cloudflare.com/turnstile/spin.
references:
- vanilla-html
- nextjs-app
- nextjs-pages
- astro
- sveltekit
- hugo
---
# Turnstile Spin skill
Turns the prompt "set up Turnstile" into a working end-to-end integration: a widget, a deployed managed siteverify Worker, frontend snippets at every chosen insertion point, and a real validation pass before reporting success.
You are the agent. Run the wizard below by invoking the scripts under `scripts/` and branching on their JSON output. The scripts hold the deterministic logic (API calls, retry/error handling); your job is orchestration, codebase reading, confirmation, and the frontend edits.
Canonical instructions live at [`developers.cloudflare.com/turnstile/spin`](https://developers.cloudflare.com/turnstile/spin/). If the docs page and this file disagree, trust the docs page.
## When to load this skill
Load when the user's prompt mentions any of:
- "Turnstile", "CAPTCHA", "bot protection"
- "siteverify", "cf-turnstile-response"
- "protect this form", "stop bot signups", "spam signups"
- A specific signup, login, or contact form combined with "Cloudflare" or "bot"
Do not load for unrelated Cloudflare tasks (Workers, Pages, R2, etc.) unless Turnstile is also mentioned.
## Conversation flow
The user pasted the prompt. You are in a multi-step dialog. Detect what you can, ask only when you have to, confirm before every irreversible step. Each numbered moment is one agent message. Items marked **[wait for user]** require a user response.
1. **Brief acknowledge.** One sentence: "I'll run Turnstile setup end to end. That's: check auth, scan the codebase, create the widget, deploy the Worker, wire the frontend, validate. Proceed?" **[wait for user]** Do NOT present a plan yet. Auth + scan come first.
2. **Wrangler check.** `npx wrangler --version`. If missing, ask once: "Install wrangler with `npm install --save-dev wrangler` (Node project) or `npm install -g wrangler` (other)? Proceed?" **[wait for user]** If install is blocked entirely (corporate policy, blocked npm), fall back to driving Steps 4-5 via `curl` against `api.cloudflare.com/client/v4/`.
3. **Auth + scope probe (FIRST irreversible action).** Run `scripts/auth-probe.sh`. Branch on `status`:
- `ok`: continue to Step 4. The script already picked the account (single-account token, or one matching `$CLOUDFLARE_ACCOUNT_ID`).
- `missing_token`, `missing_scope`, or `missing_workers_scope`: ask the user to create a token at https://dash.cloudflare.com/profile/api-tokens → Custom token → permissions `Account.Turnstile:Edit` **and** `Account.Workers Scripts:Edit` → include the target account in Account Resources. **Do NOT direct them to `wrangler login`**. Its OAuth scope doesn't include `Account.Turnstile:Edit` or `Account.Workers Scripts:Edit`. Offer three ways to hand the token over, cleanest first:
1. **Export + relaunch** (token never enters chat): `export CLOUDFLARE_API_TOKEN=<token>` then restart the agent from that terminal.
2. **Save to file** (token in file with user-only perms, not in chat): `umask 077 && printf '%s' '<token>' > ~/.cf-turnstile-token`, then read with `TOKEN=$(cat ~/.cf-turnstile-token)`.
3. **Paste in chat** (fastest, but token lands in conversation log; user should rotate it after if the log is ever shared).
If the user picks option 3 (paste in chat), you can use the wait to run Steps 5, 6, 7 (Domain, Codebase scan, Insertion plan). Options 1 and 2 will restart your session, so do not pre-fetch state in those cases. When auth is established, re-run `auth-probe.sh`, then continue to Step 8.
- `multiple_accounts`: the token covers more than one account and `$CLOUDFLARE_ACCOUNT_ID` is unset. Present the numbered `accounts` list. **[wait for user]** Then export `CLOUDFLARE_ACCOUNT_ID=<chosen>` and re-run `auth-probe.sh`.
- `account_mismatch`: `$CLOUDFLARE_ACCOUNT_ID` is set but isn't one of the token's accounts. Show the `accounts` list and ask the user to either `unset CLOUDFLARE_ACCOUNT_ID` or set it to one of those IDs.
4. **Account selection.** If `auth-probe.sh` returned `ok` after a `multiple_accounts` round-trip, this is already done. Otherwise the script picked the single account silently and you continue to Step 5.
5. **Domain.** Always include `localhost` and `127.0.0.1`. For production, scan `package.json` `homepage`, `wrangler.toml`, `README.md`, `AGENTS.md`, git remote. Confirm: "I'll register for `localhost`, `127.0.0.1`, and `<domain>`. OK?" **[wait for user]** If no production domain is found, ask.
6. **Codebase scan.** Detect framework + insertion candidates silently.
7. **Insertion plan.** Show the candidate list with `[recommended]` / `[skip by default]` markers; ask the user to confirm (numbers, "all", "recommended", or a list). **[wait for user]** If an existing CAPTCHA was detected, present a migration plan instead (see "Migrating from another CAPTCHA").
8. **Widget creation.** Run `scripts/widget-create.sh --account-id <id> --name <name> --domains <list> --mode managed`. Report the sitekey. The secret stays in env; never write it to disk.
9. **Worker deploy.** Run `scripts/worker-deploy.sh --name turnstile-siteverify-<project-slug>` with `WIDGET_SECRET` exported. Report the Worker URL on `status: ok`. On `set_secret_failed`, the Worker deployed but `TURNSTILE_SECRET_KEY` is not set on it; surface the error, then retry with `echo "$WIDGET_SECRET" | npx wrangler secret put TURNSTILE_SECRET_KEY --name <returned worker_name>` before running validation.
10. **Frontend edits.** State the contract: "I'll add the widget + gate the existing submit handler on `success === true`. The existing handler logic stays the same." Ask "yes" / "show". **[wait for user]** If "show", print unified diffs and ask again. Do NOT propose alternate behavior (mail delivery, custom backends).
11. **Validation.** Run `scripts/validate.sh`. Report each check as it passes. If any fails, surface the error and stop. **[wait for user if anything fails]**
12. **Persist skill.** Ask: "Save the Spin skill to `.claude/skills/turnstile-spin/SKILL.md` so I can reuse it on follow-up tasks?" Default yes. **[wait for user]** Then run `scripts/persist-skill.sh --path <agent-specific-path>`.
13. **Final report.** Print the structured summary: what was created, what was validated, what to do next.
### Things you must NOT do
- Do not write the Turnstile secret to disk. Only pass it via stdin to `wrangler secret put` (the worker-deploy.sh script handles this).
- Do not skip validation.
- Do not overwrite files without showing a diff.
- Do not deploy a Worker to a different account than the widget was created in.
- Do not call siteverify from the browser. Always: browser → user's Worker → siteverify.
- Do not use `sudo` or install global packages without asking.
### Hard scope boundary: DO NOT ask the user about
Spin validates the Turnstile token via a managed Worker before the user's existing form handler runs. Everything else is out of scope:
- **Email / SMS / notification delivery.** Leave the existing submit handler alone (just gate it on `success === true`). Don't propose Resend, Mailchannels, SMTP, mailto.
- **Custom Worker code.** Deploy the stock Worker template bundled at `templates/worker/`. Don't write a new Worker. Don't add features (rate limiting, custom routing, third-party integrations).
- **Database / payment / OAuth / form persistence.** Out of scope.
- **Frontend framework migration, refactoring, or styling.** Edit only what's needed.
- **reCAPTCHA v3 score thresholds.** Turnstile returns `success: true/false`.
- **Pre-clearance-only setups.** If `clearance_level !== no_clearance`, siteverify is optional and Spin doesn't apply. Redirect the user and exit.
### Recovery flow: respect existing widget configuration
When the user has Cloudflare dashboard access, the in-dashboard **Fix with Spin** banner is the preferred recovery path; it deploys the managed siteverify Worker server-side without requiring `wrangler` or an API token. This skill's recovery flow below is the fallback: use it when the user is driving from their editor without leaving it, or when the dashboard recovery does not apply (FedRAMP, gate-restricted account, etc.).
If the user tells you they already have a Turnstile widget set up and want to wire siteverify to it without rotating the sitekey (e.g. "I have a sitekey but siteverify never worked", "set up Spin against my existing widget `<sitekey>`"):
1. Skip Step 8 (widget creation). The sitekey already exists; get it from the user.
2. Fetch the widget metadata via `scripts/fetch-secret.sh --account-id <id> --sitekey <key>`. Branch on `status`:
- `ok`: read `secret`, `clearance_level`, and `domains` from the response. Confirm `domains` includes the user's production hostname; if not, surface the gap before proceeding.
- `missing_read_scope`: tell the user to add `Account.Turnstile:Read` to the token, or fall back to asking them to paste the secret. In the paste path, you do not have `clearance_level` or `domains`; ask the user to confirm both.
3. Check `clearance_level` from the response (or the user's answer):
- `no_clearance`: standard recovery (deploy Worker, wire siteverify).
- anything else: ask whether they want siteverify on top of pre-clearance, or exit per the scope boundary.
4. Continue from Step 9 (Worker deploy). Site key does not change. Dashboard's `Deployment` column flips from `Manual` to `Spin` on the first request carrying `data-action="turnstile-spin-v1"` (this skill) or `data-action="turnstile-spin-v2"` (dashboard "Set up with Spin" button).
If the user is recovering a widget that was created via the dashboard "Set up with Spin" button, a managed Worker is likely already deployed in the account. Confirm with the user whether they want a fresh Worker (proceed) or just need help wiring the frontend to the existing one (skip to Step 10 and ask for the existing Worker URL).
5. Never recreate the widget to get a fresh secret. That breaks the existing sitekey everywhere it's deployed.
### The frontend-edit contract
When wiring an existing form to the Worker (Step 10), the contract is: **gate, don't replace.** The user's existing submit handler keeps doing what it did. Spin only adds a validation step before it.
```js
form.addEventListener("submit", async (e) => {
e.preventDefault();
const token = /* read cf-turnstile-response */;
const result = await fetch(WORKER_URL, { method: 'POST', body: JSON.stringify({ token }) });
const data = await result.json();
if (!data.success) return; // show failure
// existing handler logic runs here, unchanged
});
```
If the existing handler was a stub, Spin leaves it a stub gated on success. The user can replace the stub later; that's not Spin's job.
## Migrating from another CAPTCHA
During the Step 6 codebase scan, also look for existing reCAPTCHA or hCaptcha. If found, switch Step 7 to a migration plan.
Detection signals:
- reCAPTCHA: `https://www.google.com/recaptcha/api.js`, `class="g-recaptcha"`, `data-sitekey="6L..."`, backend POST to `/recaptcha/api/siteverify`
- hCaptcha: `https://js.hcaptcha.com/1/api.js`, `class="h-captcha"`, backend POST to `https://hcaptcha.com/siteverify`
Substitution:
- Replace script tags with `https://challenges.cloudflare.com/turnstile/v0/api.js` (`async defer`).
- Replace `class="g-recaptcha"` / `class="h-captcha"` divs with `class="cf-turnstile"`, update `data-sitekey` to the new Turnstile sitekey, add `data-action="turnstile-spin-v1"`.
- Token field changes from `g-recaptcha-response` to `cf-turnstile-response`.
- Backend siteverify URL points at the Spin-deployed Worker. Drop `RECAPTCHA_SECRET` / `HCAPTCHA_SECRET` env vars.
Edge cases to surface to the user:
- **reCAPTCHA v3 score thresholds.** Turnstile has no score. Tell the user explicitly that migrated code will reject on `success === false`.
- **reCAPTCHA Enterprise.** Don't auto-migrate. Point at [developers.cloudflare.com/turnstile/migration/recaptcha/](https://developers.cloudflare.com/turnstile/migration/recaptcha/).
- **Custom `action=` values.** Preserve any custom action the user passed to `grecaptcha.execute` as `data-action` on the widget. Use `turnstile-spin-v1` only when no custom action exists.
## Edge cases
| Situation | Action |
|---|---|
| `wrangler` not installed | Install path: `npm install --save-dev wrangler` (Node project) or `npm install -g wrangler` (other) |
| Multiple Cloudflare accounts | `scripts/auth-probe.sh` returns all accounts; ask the user to choose, export `CLOUDFLARE_ACCOUNT_ID` |
| Cloudflare Pages project | Deploy the managed Worker anyway, OR suggest the [Pages Plugin](https://developers.cloudflare.com/pages/functions/plugins/turnstile/) |
| `EXPECTED_HOSTNAME` mismatch | Update widget domains via PUT, not PATCH (PATCH returns `10405 Method not allowed`): `curl -X PUT .../widgets/$SITEKEY -d '{"name":"...","mode":"managed","domains":[...]}'` |
| Worker name conflict | `worker-deploy.sh` retries automatically with a hash suffix |
| Token expired mid-flow | Stop, re-run `scripts/auth-probe.sh`, prompt for fresh credentials |
| Step 11 returns `missing-input-secret` | Secret didn't propagate. Re-set: `echo "$WIDGET_SECRET" \| npx wrangler secret put TURNSTILE_SECRET_KEY --name <worker_name from worker-deploy.sh output>`, wait 10s, re-validate. Use the `worker_name` field returned by `worker-deploy.sh`; do not rely on a `$WORKER_NAME` env var. |
| `worker-deploy.sh` returns `set_secret_failed` | Worker is deployed but secret is not set. Re-run only the secret-put using the returned `worker_name`: `echo "$WIDGET_SECRET" \| npx wrangler secret put TURNSTILE_SECRET_KEY --name <worker_name>`. Surface the `detail` field to the user — it carries the wrangler error. |
## Telemetry marker
Every snippet this skill writes must include `data-action="turnstile-spin-v1"`. Widgets created via the dashboard "Set up with Spin" button use `data-action="turnstile-spin-v2"` instead; preserve that marker if you encounter it on an existing widget you are modifying. Both feed the same account-level aggregate telemetry, never per-user. Cloudflare uses it to measure activation. If the user removes the attribute, the integration still works; only the analytics segmentation is lost.
## Do not
- Do not write the secret to disk.
- Do not skip validation (Step 11).
- Do not propose features outside the wizard (custom Worker code, custom domains, advanced WAF rules) unless asked.
- Do not call siteverify from the browser.
- Do not deploy the Worker into a different account than the widget.
@@ -0,0 +1,102 @@
# Astro
For Astro projects. The form posts directly to the Worker. Astro frontmatter handles config substitution at build time.
```astro title="src/pages/signup.astro"
---
const WORKER_URL = import.meta.env.PUBLIC_TURNSTILE_WORKER_URL;
const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
---
<html>
<head>
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
</head>
<body>
<form action={`${WORKER_URL}/`} method="POST">
<input name="email" type="email" required />
<div
class="cf-turnstile"
data-sitekey={SITEKEY}
data-action="turnstile-spin-v1"
/>
<button type="submit">Sign up</button>
</form>
</body>
</html>
```
In your `.env`:
```text
PUBLIC_TURNSTILE_WORKER_URL=https://YOUR_WORKER_URL
PUBLIC_TURNSTILE_SITEKEY=YOUR_SITEKEY
```
The `PUBLIC_` prefix is mandatory for client-exposed variables in Astro.
## Variant: hardcoded values
If you do not use env vars, inline directly:
```astro title="src/pages/signup.astro"
<html>
<head>
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
</head>
<body>
<form action="https://YOUR_WORKER_URL/" method="POST">
<div
class="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
/>
<button type="submit">Sign up</button>
</form>
</body>
</html>
```
## Variant: Astro Actions
If the project uses Astro Actions, call siteverify from the action:
```ts title="src/actions/index.ts"
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
export const server = {
signup: defineAction({
accept: "form",
input: z.object({
email: z.string().email(),
"cf-turnstile-response": z.string(),
}),
handler: async (input) => {
const verify = await fetch("https://YOUR_WORKER_URL/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: input["cf-turnstile-response"] }),
});
const data = await verify.json();
if (!data.success) throw new Error("Verification failed");
// process signup
},
}),
};
```
## Substitutions
| Placeholder | Replace with |
| ------------------ | ------------------------------------------- |
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | Widget site key from Step 4 |
@@ -0,0 +1,62 @@
# Hugo
For Hugo static sites. Use a partial for the widget so it can be referenced from any layout or content file.
```html title="layouts/partials/turnstile.html"
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
<form action="{{ .Site.Params.turnstileWorkerUrl }}/" method="POST">
<input name="email" type="email" required />
<div
class="cf-turnstile"
data-sitekey="{{ .Site.Params.turnstileSitekey }}"
data-action="turnstile-spin-v1"
></div>
<button type="submit">Subscribe</button>
</form>
```
Add the params to your site config:
```toml title="hugo.toml"
[params]
turnstileSitekey = "YOUR_SITEKEY"
turnstileWorkerUrl = "https://YOUR_WORKER_URL"
```
Reference the partial from any layout or content file:
```text
{{ partial "turnstile.html" . }}
```
## Variant: shortcode for content files
If you want to drop the widget into Markdown content (not just layouts), create a shortcode:
```html title="layouts/shortcodes/turnstile-form.html"
{{ partial "turnstile.html" . }}
```
Use in content:
```markdown title="content/contact.md"
---
title: Contact
---
Contact us:
{{< turnstile-form >}}
```
## Substitutions
| Placeholder | Replace with |
| ------------------ | ------------------------------------------- |
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | Widget site key from Step 4 |
@@ -0,0 +1,110 @@
# Next.js (App Router)
For `app/`-directory Next.js projects. The widget needs to run on the client, so the page or component must be `"use client"`.
```tsx title="app/signup/page.tsx"
"use client";
import Script from "next/script";
import { useEffect, useState } from "react";
declare global {
interface Window {
onTurnstileSuccess?: (token: string) => void;
}
}
export default function SignupPage() {
const [token, setToken] = useState("");
useEffect(() => {
window.onTurnstileSuccess = (t: string) => setToken(t);
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const res = await fetch("https://YOUR_WORKER_URL/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (data.success) {
// proceed
}
}
return (
<>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
/>
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<div
className="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
data-callback="onTurnstileSuccess"
/>
<button type="submit" disabled={!token}>
Sign up
</button>
</form>
</>
);
}
```
`data-callback` expects a string referencing a global function. The `useEffect` wires `window.onTurnstileSuccess` so the widget can call back into React state.
## Variant: Server Action
If you are using Server Actions, do the siteverify call from the action itself. The widget still goes in a client component, but the verify call moves server-side:
```tsx title="app/signup/actions.ts"
"use server";
export async function submitSignup(formData: FormData) {
const token = formData.get("cf-turnstile-response") as string;
const res = await fetch("https://YOUR_WORKER_URL/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (!data.success) {
return { error: "Verification failed" };
}
// process signup
return { ok: true };
}
```
```tsx title="app/signup/page.tsx"
"use client";
import { submitSignup } from "./actions";
export default function SignupPage() {
return (
<form action={submitSignup}>
<input name="email" type="email" required />
<div
className="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
/>
<button type="submit">Sign up</button>
</form>
);
}
```
## Substitutions
| Placeholder | Replace with |
| ------------------ | ------------------------------------------- |
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | Widget site key from Step 4 |
@@ -0,0 +1,59 @@
# Next.js (Pages Router)
For older Next.js projects using `pages/` rather than `app/`. The form posts directly to the Worker; no React state required.
```tsx title="pages/signup.tsx"
import Script from "next/script";
export default function SignupPage() {
return (
<>
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" />
<form action="https://YOUR_WORKER_URL/" method="POST">
<input name="email" type="email" required />
<div
className="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
/>
<button type="submit">Sign up</button>
</form>
</>
);
}
```
## Variant: API route on your own backend
If you want to call siteverify from your own API route (e.g. you have application logic between siteverify and the actual signup), proxy the call through `pages/api/signup.ts`:
```ts title="pages/api/signup.ts"
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const token = req.body["cf-turnstile-response"] ?? req.body.token;
const verify = await fetch("https://YOUR_WORKER_URL/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await verify.json();
if (!data.success) {
return res.status(403).json({ error: "Verification failed" });
}
// process signup
return res.json({ ok: true });
}
```
Note: This pattern uses the Spin Worker as a siteverify proxy from your Node backend. It still works, just adds a hop. For pure SPA-style forms, the direct-post pattern above is simpler.
## Substitutions
| Placeholder | Replace with |
| ------------------ | ------------------------------------------- |
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | Widget site key from Step 4 |
@@ -0,0 +1,84 @@
# SvelteKit
For SvelteKit projects. Widget renders in the page; siteverify can be called from a form action server-side, or directly against the Worker from the client.
```svelte title="src/routes/signup/+page.svelte"
<script lang="ts">
import { onMount } from "svelte";
let token = "";
onMount(() => {
(window as any).onTurnstileSuccess = (t: string) => (token = t);
});
async function handleSubmit(e: Event) {
e.preventDefault();
const res = await fetch("https://YOUR_WORKER_URL/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (data.success) {
// proceed
}
}
</script>
<svelte:head>
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
</svelte:head>
<form on:submit={handleSubmit}>
<input name="email" type="email" required />
<div
class="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
data-callback="onTurnstileSuccess"
></div>
<button type="submit" disabled={!token}>Sign up</button>
</form>
```
## Variant: Form action server-side
If you use SvelteKit's form actions for the signup logic, do siteverify from the action:
```ts title="src/routes/signup/+page.server.ts"
import type { Actions } from "./$types";
import { fail } from "@sveltejs/kit";
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const token = data.get("cf-turnstile-response") as string;
const verify = await fetch("https://YOUR_WORKER_URL/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const result = await verify.json();
if (!result.success) {
return fail(403, { error: "Verification failed" });
}
// process signup
return { ok: true };
},
};
```
With this variant the form posts via SvelteKit's progressive enhancement; the page component drops the explicit `fetch` call and just uses a native `<form method="POST">`.
## Substitutions
| Placeholder | Replace with |
| ------------------ | ------------------------------------------- |
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | Widget site key from Step 4 |
@@ -0,0 +1,62 @@
# Vanilla HTML
For static sites or any project without a JS framework. The form posts directly to the Worker, no client-side JavaScript required for the integration itself.
```html
<!doctype html>
<html>
<head>
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
</head>
<body>
<form action="https://YOUR_WORKER_URL/" method="POST">
<input name="email" type="email" required />
<div
class="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
></div>
<button type="submit">Subscribe</button>
</form>
</body>
</html>
```
When the form is submitted, the browser includes `cf-turnstile-response` automatically. The Worker reads it (alias for `token`) and responds with siteverify JSON.
## Where to put the snippet
- Single-page site: in the `<body>` of `index.html`, wherever the form lives.
- Multi-page site: in each page that has a form. The script tag in `<head>` can live in a shared layout.
## Substitutions
| Placeholder | Replace with |
| ------------------ | ------------------------------------------------------- |
| `YOUR_WORKER_URL` | The deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | The widget site key from Step 4 |
## Variant: AJAX submit instead of form action
If the form is submitted via `fetch` instead of a native form post, the snippet still works. The widget div populates a hidden `cf-turnstile-response` input that you can read from `new FormData(form)`.
```html
<script>
document.querySelector("form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = new FormData(e.target);
const res = await fetch("https://YOUR_WORKER_URL/", {
method: "POST",
body: data,
});
const json = await res.json();
if (json.success) {
// proceed
}
});
</script>
```
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Probes Cloudflare API auth state for the Turnstile Spin agent.
#
# Reads:
# $CLOUDFLARE_API_TOKEN (required)
# $CLOUDFLARE_ACCOUNT_ID (optional; if set, must be one of the token's accounts)
#
# Outputs JSON to stdout, always exits 0. The agent reads `status`:
# "ok" ; selected account passed both Turnstile and Workers scope probes
# "missing_token" ; no token set, or wrangler whoami failed
# "missing_scope" ; token lacks Account.Turnstile:Edit on the selected account
# "missing_workers_scope"; token has Turnstile scope but lacks Workers Scripts on the selected account
# "multiple_accounts" ; token covers >1 accounts and $CLOUDFLARE_ACCOUNT_ID is unset; agent must ask user to pick, set it, and re-run
# "account_mismatch" ; $CLOUDFLARE_ACCOUNT_ID is set but is not in the token's accounts list
#
# Human-readable diagnostics go to stderr. The agent surfaces them to the user.
set -uo pipefail
emit() {
echo "$1"
exit 0
}
token="${CLOUDFLARE_API_TOKEN:-}"
declared_account="${CLOUDFLARE_ACCOUNT_ID:-}"
if [ -z "$token" ]; then
echo "auth-probe: \$CLOUDFLARE_API_TOKEN is not set." >&2
emit '{"status":"missing_token","reason":"no_env_var"}'
fi
whoami_json=$(npx wrangler whoami --json 2>/dev/null || true)
if [ -z "$whoami_json" ] || [ "$(echo "$whoami_json" | head -c 1)" != "{" ]; then
echo "auth-probe: wrangler whoami returned no JSON. Token may be invalid or expired." >&2
emit '{"status":"missing_token","reason":"whoami_failed"}'
fi
# Extract the accounts array. Fall back to python3 if jq is missing.
accounts_json=$(echo "$whoami_json" | (jq -c '.accounts' 2>/dev/null || python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['accounts']))"))
account_count=$(echo "$accounts_json" | (jq 'length' 2>/dev/null || python3 -c "import sys,json; print(len(json.load(sys.stdin)))"))
if [ -z "$account_count" ] || [ "$account_count" = "0" ] || [ "$account_count" = "null" ]; then
echo "auth-probe: wrangler whoami succeeded but no accounts found on the token." >&2
emit '{"status":"missing_token","reason":"no_accounts"}'
fi
# Pick the account to probe:
# - $CLOUDFLARE_ACCOUNT_ID set: must be in the token's accounts list, else account_mismatch
# - unset, exactly 1 account: use it silently
# - unset, >1 accounts: emit multiple_accounts; agent picks and re-runs
if [ -n "$declared_account" ]; then
in_list=$(echo "$accounts_json" | (jq --arg id "$declared_account" 'map(.id) | index($id) != null' 2>/dev/null || python3 -c "import sys,json; print('true' if any(a['id']==sys.argv[1] for a in json.load(sys.stdin)) else 'false')" "$declared_account"))
if [ "$in_list" != "true" ]; then
echo "auth-probe: \$CLOUDFLARE_ACCOUNT_ID ($declared_account) is not one of the token's accounts." >&2
echo "auth-probe: Either unset \$CLOUDFLARE_ACCOUNT_ID, or set it to an account included in the token's Account Resources." >&2
emit "{\"status\":\"account_mismatch\",\"declared\":\"$declared_account\",\"accounts\":$accounts_json}"
fi
account_id="$declared_account"
elif [ "$account_count" = "1" ]; then
account_id=$(echo "$accounts_json" | (jq -r '.[0].id' 2>/dev/null || python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])"))
else
echo "auth-probe: token covers $account_count accounts; ask the user to pick one, then export \$CLOUDFLARE_ACCOUNT_ID and re-run." >&2
emit "{\"status\":\"multiple_accounts\",\"accounts\":$accounts_json}"
fi
# Probe Turnstile scope on the selected account
tmp=$(mktemp)
http_code=$(curl -sS -w "%{http_code}" -o "$tmp" \
"https://api.cloudflare.com/client/v4/accounts/$account_id/challenges/widgets" \
-H "Authorization: Bearer $token" 2>/dev/null || echo "000")
body=$(cat "$tmp"); rm -f "$tmp"
success=$(echo "$body" | (jq -r '.success' 2>/dev/null || echo "false"))
if [ "$success" != "true" ]; then
echo "auth-probe: token cannot read /challenges/widgets on account $account_id (HTTP $http_code). Missing Account.Turnstile:Edit." >&2
emit "{\"status\":\"missing_scope\",\"account_id\":\"$account_id\",\"http_code\":$http_code}"
fi
# Probe Workers scope on the selected account. GET /workers/scripts requires
# Account.Workers Scripts:Read, which is a best-effort proxy for Edit. Tokens
# granted Edit-only (without Read) will fail this probe and emit a confusing
# missing_workers_scope; the agent should suggest adding Read alongside Edit.
tmp=$(mktemp)
workers_code=$(curl -sS -w "%{http_code}" -o "$tmp" \
"https://api.cloudflare.com/client/v4/accounts/$account_id/workers/scripts" \
-H "Authorization: Bearer $token" 2>/dev/null || echo "000")
workers_body=$(cat "$tmp"); rm -f "$tmp"
workers_success=$(echo "$workers_body" | (jq -r '.success' 2>/dev/null || echo "false"))
if [ "$workers_success" != "true" ]; then
echo "auth-probe: token cannot read /workers/scripts on account $account_id (HTTP $workers_code). Missing Account.Workers Scripts:Edit." >&2
emit "{\"status\":\"missing_workers_scope\",\"account_id\":\"$account_id\",\"http_code\":$workers_code}"
fi
emit "{\"status\":\"ok\",\"account_id\":\"$account_id\",\"accounts\":$accounts_json}"
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Retrieves the secret for an existing Turnstile widget via the Cloudflare API.
# Used by the recovery flow when binding the secret to a freshly deployed Worker.
#
# Reads:
# $CLOUDFLARE_API_TOKEN (required)
#
# Args:
# --account-id <id> Cloudflare account ID
# --sitekey <key> Widget sitekey to look up
#
# Outputs JSON. Exit 0 on success, 1 on failure.
# ok: {"status":"ok","secret":"<secret>","clearance_level":"<level>","domains":[<list>]}
# no_scope: {"status":"missing_read_scope","detail":"token lacks Account.Turnstile:Read"}
# not_found: {"status":"error","reason":"widget_not_found","http_code":<code>}
#
# The agent uses clearance_level to enforce the pre-clearance scope boundary
# (Spin only applies to widgets where clearance_level == "no_clearance"; for
# other levels siteverify is optional and the recovery flow should exit).
#
# Never propose recreating the widget to get a fresh secret; that breaks
# the existing sitekey everywhere the user has it deployed in their frontend.
set -uo pipefail
while [[ $# -gt 0 ]]; do
case $1 in
--account-id) ACCOUNT_ID="$2"; shift 2 ;;
--sitekey) SITEKEY="$2"; shift 2 ;;
*) echo "fetch-secret: unknown arg $1" >&2; exit 2 ;;
esac
done
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN must be set}"
: "${ACCOUNT_ID:?--account-id required}"
: "${SITEKEY:?--sitekey required}"
tmp=$(mktemp)
http_code=$(curl -sS -w "%{http_code}" -o "$tmp" \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/challenges/widgets/$SITEKEY" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" 2>/dev/null || echo "000")
body=$(cat "$tmp"); rm -f "$tmp"
if [ "$http_code" = "200" ]; then
secret=$(echo "$body" | (jq -r '.result.secret' 2>/dev/null || python3 -c "import sys,json; print(json.load(sys.stdin)['result']['secret'])"))
clearance=$(echo "$body" | (jq -r '.result.clearance_level // "no_clearance"' 2>/dev/null || python3 -c "import sys,json; print(json.load(sys.stdin)['result'].get('clearance_level','no_clearance'))"))
domains=$(echo "$body" | (jq -c '.result.domains // []' 2>/dev/null || python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['result'].get('domains',[])))"))
if [ -n "$secret" ] && [ "$secret" != "null" ]; then
echo "{\"status\":\"ok\",\"secret\":\"$secret\",\"clearance_level\":\"$clearance\",\"domains\":$domains}"
exit 0
fi
fi
if [ "$http_code" = "403" ]; then
code=$(echo "$body" | (jq -r '.errors[0].code // 0' 2>/dev/null || echo "0"))
if [ "$code" = "10000" ]; then
echo "fetch-secret: token can edit Turnstile widgets but cannot read this one's secret." >&2
echo "fetch-secret: add Account.Turnstile:Read to the token, or fall back to user paste." >&2
echo "{\"status\":\"missing_read_scope\",\"detail\":\"token lacks Account.Turnstile:Read\"}"
exit 1
fi
fi
echo "fetch-secret: widget lookup failed (HTTP $http_code)." >&2
echo "{\"status\":\"error\",\"reason\":\"widget_not_found\",\"http_code\":$http_code}"
exit 1
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Persists the canonical Spin skill bundle (SKILL.md + scripts/ + references/)
# from cloudflare/skills to the user's repo so the agent can re-load it on
# follow-up tasks without re-pasting the bootstrap prompt.
#
# Args:
# --path <path> SKILL.md destination, e.g. .claude/skills/turnstile-spin/SKILL.md.
# The bundle is extracted into the parent directory of <path>,
# so scripts land at e.g. .claude/skills/turnstile-spin/scripts/.
#
# Outputs JSON. Exit 0 if the bundle was written, 1 on failure.
# ok: {"status":"ok","path":"<path>","bundle_root":"<dir>","scripts":[<list>]}
# fail: {"status":"error","reason":"<reason>"}
set -uo pipefail
PATH_ARG=""
while [[ $# -gt 0 ]]; do
case $1 in
--path) PATH_ARG="$2"; shift 2 ;;
*) echo "persist-skill: unknown arg $1" >&2; exit 2 ;;
esac
done
: "${PATH_ARG:?--path required}"
TARGET_DIR=$(dirname "$PATH_ARG")
mkdir -p "$TARGET_DIR"
# Install the canonical bundle from cloudflare/skills via degit. This writes
# SKILL.md, scripts/, references/, templates/, tests/ into $TARGET_DIR.
if ! npx --yes degit cloudflare/skills/skills/turnstile-spin "$TARGET_DIR" >/dev/null 2>&1; then
echo "persist-skill: degit failed; cannot fetch cloudflare/skills/skills/turnstile-spin." >&2
echo "persist-skill: ensure your network can reach github.com and try again, or install manually." >&2
echo "{\"status\":\"error\",\"reason\":\"degit_failed\"}"
exit 1
fi
if [ ! -f "$TARGET_DIR/SKILL.md" ]; then
echo "persist-skill: bundle extracted but SKILL.md is missing at $TARGET_DIR/SKILL.md." >&2
echo "{\"status\":\"error\",\"reason\":\"skill_missing\"}"
exit 1
fi
# Make scripts executable so the agent can invoke them directly.
if [ -d "$TARGET_DIR/scripts" ]; then
chmod +x "$TARGET_DIR/scripts"/*.sh 2>/dev/null || true
fi
scripts_list=$(ls "$TARGET_DIR/scripts" 2>/dev/null | sed 's/.*/"&"/' | paste -sd, -)
echo "persist-skill: wrote bundle to $TARGET_DIR" >&2
echo "{\"status\":\"ok\",\"path\":\"$PATH_ARG\",\"bundle_root\":\"$TARGET_DIR\",\"scripts\":[$scripts_list]}"
exit 0
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Validates a deployed Turnstile siteverify Worker end-to-end.
#
# Reads:
# $CLOUDFLARE_API_TOKEN (required for hostname check)
#
# Args:
# --worker-url <url> Deployed Worker URL (from worker-deploy.sh)
# --account-id <id> Cloudflare account ID
# --sitekey <key> Widget sitekey (from widget-create.sh)
# --expected-domains <a,b,c> Comma-separated domains that must appear in the widget's domains array
#
# Outputs JSON. Exit 0 if all three checks pass, 1 otherwise.
# ok: {"status":"ok"}
# fail: {"status":"error","check":"health|dummy_siteverify|worker_metadata|hostname","detail":"<msg>"}
set -uo pipefail
while [[ $# -gt 0 ]]; do
case $1 in
--worker-url) WORKER_URL="$2"; shift 2 ;;
--account-id) ACCOUNT_ID="$2"; shift 2 ;;
--sitekey) SITEKEY="$2"; shift 2 ;;
--expected-domains) EXPECTED_DOMAINS="$2"; shift 2 ;;
*) echo "validate: unknown arg $1" >&2; exit 2 ;;
esac
done
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN must be set}"
: "${WORKER_URL:?--worker-url required}"
: "${ACCOUNT_ID:?--account-id required}"
: "${SITEKEY:?--sitekey required}"
: "${EXPECTED_DOMAINS:?--expected-domains required}"
# Check 1: health endpoint
health=$(curl -sSf "${WORKER_URL}/health" 2>/dev/null || echo "")
if [ -z "$health" ] || ! echo "$health" | grep -q '"ok":true'; then
echo "validate: health check failed; $WORKER_URL/health did not return {ok:true,version:...}" >&2
echo "{\"status\":\"error\",\"check\":\"health\",\"detail\":\"worker /health did not respond ok:true\"}"
exit 1
fi
# Check 2: dummy siteverify; should return success:false + error-codes array
dummy=$(curl -sS -X POST "${WORKER_URL}/" \
-H "Content-Type: application/json" \
-d '{"token":"XXXX.DUMMY.TOKEN.XXXX"}' 2>/dev/null || echo "")
success=$(echo "$dummy" | (jq -r '.success // "missing"' 2>/dev/null || echo "missing"))
errors=$(echo "$dummy" | (jq -r '.["error-codes"] | length // 0' 2>/dev/null || echo "0"))
if [ "$success" != "false" ] || [ "$errors" = "0" ]; then
echo "validate: dummy siteverify check failed; expected success:false + error-codes; got: $dummy" >&2
echo "{\"status\":\"error\",\"check\":\"dummy_siteverify\",\"detail\":\"unexpected response shape\"}"
exit 1
fi
# Check 2b: confirm the Worker is the managed template (not a customer-written
# replacement) by looking for the _worker metadata field. If absent, the user
# deployed a custom Worker; surface it so the agent can alert them.
worker_meta=$(echo "$dummy" | (jq -r '._worker.worker_version // "missing"' 2>/dev/null || echo "missing"))
if [ "$worker_meta" = "missing" ]; then
echo "validate: _worker metadata missing from response; this is not the managed Spin Worker template." >&2
echo "{\"status\":\"error\",\"check\":\"worker_metadata\",\"detail\":\"_worker field missing; user may have deployed a custom Worker\"}"
exit 1
fi
# Check 3: hostname / widget domains registered
widget=$(curl -sS \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/challenges/widgets/$SITEKEY" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" 2>/dev/null)
registered=$(echo "$widget" | (jq -r '.result.domains[]' 2>/dev/null || python3 -c "import sys,json; [print(d) for d in json.load(sys.stdin)['result']['domains']]"))
missing=""
IFS=',' read -ra DOMS <<< "$EXPECTED_DOMAINS"
for d in "${DOMS[@]}"; do
if ! echo "$registered" | grep -qFx "$d"; then
missing="${missing}${d} "
fi
done
if [ -n "$missing" ]; then
echo "validate: hostname check failed; domains not on widget: $missing" >&2
echo "{\"status\":\"error\",\"check\":\"hostname\",\"detail\":\"missing domains: ${missing% }\"}"
exit 1
fi
echo '{"status":"ok"}'
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Creates a Turnstile widget via the Cloudflare API.
#
# Reads:
# $CLOUDFLARE_API_TOKEN (required)
#
# Args:
# --account-id <id> Cloudflare account ID
# --name <name> Widget name (e.g. "myproject (Spin)")
# --domains <a,b,c> Comma-separated domain list (include localhost,127.0.0.1)
# --mode <managed|invisible|non-interactive> Default: managed
#
# Outputs JSON to stdout. Exit 0 on success, 1 on failure. Diagnostics on stderr.
# ok: {"status":"ok","sitekey":"<key>","secret":"<secret>"}
# error: {"status":"error","code":<code>,"message":"<msg>"}
# code 10000 → token lacks Account.Turnstile:Edit
set -uo pipefail
MODE="managed"
while [[ $# -gt 0 ]]; do
case $1 in
--account-id) ACCOUNT_ID="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--domains) DOMAINS="$2"; shift 2 ;;
--mode) MODE="$2"; shift 2 ;;
*) echo "widget-create: unknown arg $1" >&2; exit 2 ;;
esac
done
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN must be set}"
: "${ACCOUNT_ID:?--account-id required}"
: "${NAME:?--name required}"
: "${DOMAINS:?--domains required}"
domains_json=$(python3 -c "import sys; print(__import__('json').dumps(sys.argv[1].split(',')))" "$DOMAINS")
body=$(curl -sS -X POST \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/challenges/widgets" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$NAME\",\"domains\":$domains_json,\"mode\":\"$MODE\"}" 2>/dev/null)
success=$(echo "$body" | (jq -r '.success' 2>/dev/null || python3 -c "import sys,json; print(str(json.load(sys.stdin).get('success',False)).lower())"))
if [ "$success" = "true" ]; then
sitekey=$(echo "$body" | (jq -r '.result.sitekey' 2>/dev/null || python3 -c "import sys,json; print(json.load(sys.stdin)['result']['sitekey'])"))
secret=$(echo "$body" | (jq -r '.result.secret' 2>/dev/null || python3 -c "import sys,json; print(json.load(sys.stdin)['result']['secret'])"))
echo "{\"status\":\"ok\",\"sitekey\":\"$sitekey\",\"secret\":\"$secret\"}"
exit 0
fi
code=$(echo "$body" | (jq -r '.errors[0].code // 0' 2>/dev/null || echo "0"))
message=$(echo "$body" | (jq -r '.errors[0].message // "unknown"' 2>/dev/null || echo "unknown") | tr -d '"')
echo "widget-create: failed (code=$code): $message" >&2
echo "{\"status\":\"error\",\"code\":$code,\"message\":\"$message\"}"
exit 1
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Deploys the managed siteverify Worker template to the user's account
# and sets TURNSTILE_SECRET_KEY as a Worker secret.
#
# Reads:
# $CLOUDFLARE_API_TOKEN (required)
# $WIDGET_SECRET (required; secret captured from widget-create.sh)
#
# Args:
# --name <worker-name> Base name; appends a hash suffix if taken
# --deploy-dir <path> Where to extract the template. Default: /tmp/turnstile-siteverify-deploy
#
# Outputs JSON. Exit 0 on success, non-zero on failure.
# ok: {"status":"ok","worker_url":"<url>","worker_name":"<name>"}
# conflict: {"status":"error","reason":"name_conflict_after_retry"}
# deploy_failed: {"status":"error","reason":"deploy_failed"}
# set_secret: {"status":"error","reason":"set_secret_failed","worker_name":"<name>"}
# url_parse: {"status":"error","reason":"url_parse_failed","worker_name":"<name>"}
set -uo pipefail
NAME="${WORKER_NAME:-turnstile-siteverify}"
DEPLOY_DIR="/tmp/turnstile-siteverify-deploy"
while [[ $# -gt 0 ]]; do
case $1 in
--name) NAME="$2"; shift 2 ;;
--deploy-dir) DEPLOY_DIR="$2"; shift 2 ;;
*) echo "worker-deploy: unknown arg $1" >&2; exit 2 ;;
esac
done
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN must be set}"
: "${WIDGET_SECRET:?WIDGET_SECRET must be set}"
deploy_log=$(mktemp)
deploy() {
local target_name="$1"
rm -rf "$DEPLOY_DIR"
npx --yes degit cloudflare/skills/skills/turnstile-spin/templates/worker "$DEPLOY_DIR" >/dev/null 2>&1
# Capture both streams. Wrangler prints the success URL and version ID on
# stdout; progress indicators on stderr. Capturing only stderr loses the URL.
(cd "$DEPLOY_DIR" && npx wrangler deploy --name "$target_name") >"$deploy_log" 2>&1
}
if ! deploy "$NAME"; then
if grep -q "script name already in use" "$deploy_log"; then
NAME="${NAME}-$(openssl rand -hex 3 2>/dev/null || date +%s | tail -c 5)"
echo "worker-deploy: name conflict; retrying as $NAME" >&2
if ! deploy "$NAME"; then
echo "worker-deploy: deploy failed even with new name" >&2
cat "$deploy_log" >&2
rm -f "$deploy_log"
echo "{\"status\":\"error\",\"reason\":\"name_conflict_after_retry\"}"
exit 1
fi
else
cat "$deploy_log" >&2
rm -f "$deploy_log"
echo "{\"status\":\"error\",\"reason\":\"deploy_failed\"}"
exit 1
fi
fi
# Set the secret. Use `echo` (not `printf '%s'`); wrangler secret put expects
# newline-terminated stdin; printf without a trailing newline lands an empty
# secret in the runtime even though wrangler reports success.
secret_log=$(mktemp)
set_secret() {
echo "$WIDGET_SECRET" | (cd "$DEPLOY_DIR" && npx wrangler secret put TURNSTILE_SECRET_KEY --name "$NAME") >"$secret_log" 2>&1
}
if ! set_secret; then
echo "worker-deploy: failed to set TURNSTILE_SECRET_KEY on $NAME" >&2
cat "$secret_log" >&2
detail=$(tail -3 "$secret_log" | tr '\n' ' ' | sed 's/"/\\"/g' | head -c 200)
rm -f "$deploy_log" "$secret_log"
echo "{\"status\":\"error\",\"reason\":\"set_secret_failed\",\"worker_name\":\"$NAME\",\"detail\":\"$detail\"}"
exit 1
fi
rm -f "$secret_log"
sleep 5
# Extract the deployed URL. Try workers.dev first, then any https URL in the
# log that is not the well-known cloudflare.com host (custom domain deploys
# and Workers for Platforms don't always land at a workers.dev hostname).
worker_url=$(grep -oE 'https://[a-zA-Z0-9._-]+\.workers\.dev' "$deploy_log" | head -1)
if [ -z "$worker_url" ]; then
worker_url=$(grep -oE 'https://[a-zA-Z0-9.-]+(/[^[:space:]]*)?' "$deploy_log" \
| grep -v -E 'cloudflare\.com|workers-sdk|github\.com' \
| head -1)
fi
rm -f "$deploy_log"
if [ -z "$worker_url" ]; then
echo "worker-deploy: deployed but could not parse Worker URL from wrangler output" >&2
echo "worker-deploy: ask the user for the URL printed by wrangler deploy and pass it to validate.sh manually" >&2
echo "{\"status\":\"error\",\"reason\":\"url_parse_failed\",\"worker_name\":\"$NAME\"}"
exit 1
fi
echo "{\"status\":\"ok\",\"worker_url\":\"$worker_url\",\"worker_name\":\"$NAME\"}"
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Cloudflare, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,196 @@
# turnstile-siteverify (Worker template)
The managed siteverify Worker template that ships inside the [Turnstile Spin](https://developers.cloudflare.com/turnstile/spin/) skill. The Spin agent deploys this template into the user's Cloudflare account as the backend for `siteverify` token validation.
You are reading this either because the Spin skill copied it into your project directory, or because you navigated to `cloudflare/skills/skills/turnstile-spin/templates/worker/` directly.
## Manual deploy
If you have this directory on disk and want to deploy it yourself:
```sh
npm install
# Set your Turnstile secret as a Worker secret (never commit this).
npx wrangler secret put TURNSTILE_SECRET_KEY
# Deploy.
npx wrangler deploy
```
Wrangler prints the deployed URL. Point your frontend's form `action` (or `fetch` target) at it. Confirm the integration with a curl:
```sh
curl -sf https://<your-worker-url>/health
# {"ok":true,"version":"1.0.0"}
```
## Endpoints
| Method | Path | Purpose |
| ------ | -------------- | ---------------------------------------------------------------------- |
| POST | `/` | Siteverify proxy. Accepts JSON or form-encoded body. |
| POST | `/siteverify` | Alias of `POST /`. |
| GET | `/health` | Health check. Returns `{"ok": true, "version": "..."}`. |
| GET | `/` | Same as `/health`. |
### Request
JSON body:
```json
{
"token": "TURNSTILE_TOKEN_FROM_WIDGET",
"remoteip": "1.2.3.4",
"idempotency_key": "optional-uuid"
}
```
Form-encoded body:
```
token=...&remoteip=1.2.3.4
```
`cf-turnstile-response` is accepted as an alias for `token` in both body types, so a `<form>` posting directly to the Worker works without any client-side JavaScript.
### Response
```json
{
"success": true,
"challenge_ts": "2026-05-29T12:00:00Z",
"hostname": "example.com",
"action": "turnstile-spin-v1",
"error-codes": [],
"_worker": {
"duration_ms": 87,
"worker_version": "1.0.0"
}
}
```
On validation failure, `success` is `false` and `error-codes` lists the reasons. The HTTP status is `200` regardless of validation outcome (callers should check `response.success`). On Worker-level failures, the HTTP status reflects the error class (400 for bad input, 415 for unsupported content type, 500 for missing secret, 502/504 for upstream issues).
#### Error codes
| Code | Meaning |
| -------------------------- | ------------------------------------------------------------------- |
| `missing-input-secret` | `TURNSTILE_SECRET_KEY` not set |
| `invalid-input-secret` | Secret rejected by upstream |
| `missing-token` | No token in the request body |
| `invalid-input-response` | Token rejected by upstream |
| `timeout-or-duplicate` | Token expired (>5min) or already validated |
| `invalid-content-type` | Body content-type was not JSON or form-encoded |
| `upstream-unreachable` | Could not reach `challenges.cloudflare.com` |
| `upstream-timeout` | Upstream took longer than 5 seconds |
| `hostname-mismatch` | `EXPECTED_HOSTNAME` is set and the response hostname did not match |
| `bad-request` | Generic catch-all for malformed input |
| `internal-error` | Unhandled error in the Worker |
## Configuration
| Variable | Type | Default | Purpose |
| ----------------------- | ------- | --------- | ------------------------------------------------------------------- |
| `TURNSTILE_SECRET_KEY` | secret | (required)| Widget secret. Set via `wrangler secret put TURNSTILE_SECRET_KEY`. |
| `ALLOWED_ORIGIN` | var | `*` | CORS allowed origin. Lock down to your customer-facing domain. |
| `EXPECTED_HOSTNAME` | var | (unset) | If set, reject siteverify responses where `hostname` differs. |
| `LOG_LEVEL` | var | `info` | One of `debug`, `info`, `warn`, `error`. Filters structured logs. |
Set vars in `wrangler.toml` (`[vars]` block) or via `wrangler deploy --var KEY:value`. Secrets only via `wrangler secret put`.
## Frontend snippet
The minimum HTML that works against this Worker:
```html
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
<form action="https://YOUR_WORKER_URL/" method="POST">
<input name="email" type="email" required />
<div
class="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
></div>
<button type="submit">Submit</button>
</form>
```
The `data-action="turnstile-spin-v1"` attribute is the Spin telemetry marker. Cloudflare uses it to measure activation rates for Spin-deployed widgets. It is account-level and aggregate; no PII. See [the docs page](https://developers.cloudflare.com/turnstile/spin/#telemetry-marker).
For React, SvelteKit, Astro, Hugo, and other framework examples, see [`developers.cloudflare.com/turnstile/spin`](https://developers.cloudflare.com/turnstile/spin/#code-examples).
## Production hardening
After the first deploy, before pointing real traffic:
| Step | Why |
| -------------------------------------------------------------- | ------------------------------------------------------------------- |
| Set `ALLOWED_ORIGIN` to your real domain in `wrangler.toml` | Locks CORS so other sites cannot proxy through your Worker |
| Set `EXPECTED_HOSTNAME` if your widget is single-domain | Defends against cross-site token replay |
| Configure Workers Observability alerts on error rate > 1% | Catches upstream outages or secret-misconfig events |
| Rotate `TURNSTILE_SECRET_KEY` periodically | Standard secret hygiene |
| Add a [Workers rate limit](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) if abuse warrants | Defends the Worker itself |
## Testing
```sh
npm test # unit tests, mocks fetch
npm run test:integration # hits real siteverify with test secrets
npm run typecheck
npm run openapi:lint
```
Cloudflare's documented test secrets:
| Secret | Behavior |
| --------------------------------------- | ----------------------------------------- |
| `1x0000000000000000000000000000000AA` | Always succeeds |
| `2x0000000000000000000000000000000AA` | Always fails |
| `3x0000000000000000000000000000000AA` | Returns `timeout-or-duplicate` |
## Project layout
```
src/
├── index.ts # Fetch handler, routing, CORS, body parsing
├── validate.ts # siteverify call with retry + timeout
├── observability.ts # Structured log emission
├── errors.ts # SiteverifyError + response shaping
└── types.ts # Request/response types, error codes
test/
├── unit.test.ts # Mocked siteverify
├── integration.test.ts # Real network, test secrets
├── deploy.test.ts # Deploy-to-clean-account speed assertion
└── validation.test.ts # Validation surface (health, hostname, structured errors)
public/
└── post-deploy.html # Post-deploy form for the Deploy button path
openapi.yaml # OpenAPI 3.1 spec
wrangler.toml # Worker config
```
## Contributing
Two rules:
1. Keep the request/response contract stable. Customers will deploy this Worker as-is.
2. Do not add features that require additional secrets or external services. The point is "drop in, set one secret, you are done."
PRs welcome.
## License
MIT. See [LICENSE](./LICENSE).
## Related
- [Turnstile Spin docs](https://developers.cloudflare.com/turnstile/spin/): the canonical setup flow
- [Cloudflare Turnstile docs](https://developers.cloudflare.com/turnstile/): product overview
- [Server-side validation reference](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/): siteverify API
- [Cloudflare Skills repo](https://github.com/cloudflare/skills): agent skill bundles including `turnstile-spin/`
- [Pages Plugin for Turnstile](https://developers.cloudflare.com/pages/functions/plugins/turnstile/): for sites hosted on Cloudflare Pages
@@ -0,0 +1,9 @@
node_modules/
.dev.vars
.wrangler/
.dist/
.vitest-cache/
*.log
.DS_Store
.env
.env.local
@@ -0,0 +1,39 @@
{
"name": "turnstile-siteverify",
"version": "1.0.0",
"description": "Managed siteverify Worker template for Turnstile Spin. Deploys via the Spin agent skill or directly via wrangler.",
"license": "MIT",
"private": false,
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run test/integration.test.ts",
"test:validation": "vitest run test/validation.test.ts",
"test:deploy": "vitest run test/deploy.test.ts",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240502.0",
"typescript": "^5.4.0",
"vitest": "^2.1.0",
"wrangler": "^3.60.0"
},
"keywords": [
"cloudflare",
"turnstile",
"siteverify",
"workers",
"captcha",
"bot-protection"
],
"repository": {
"type": "git",
"url": "https://github.com/cloudflare/skills",
"directory": "skills/turnstile-spin/templates/worker"
}
}
@@ -0,0 +1,287 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Turnstile Spin — Deploy complete</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #1d1f20;
--muted: #6b6f72;
--border: #e5e7eb;
--accent: #f6821f;
--accent-fg: #ffffff;
--code-bg: #f5f5f5;
--ok: #16a34a;
--err: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1010;
--fg: #f1f1f1;
--muted: #9aa0a3;
--border: #2a2a2a;
--code-bg: #1a1a1a;
}
}
* {
box-sizing: border-box;
}
body {
font:
16px/1.5 -apple-system,
"Segoe UI",
Inter,
system-ui,
sans-serif;
color: var(--fg);
background: var(--bg);
margin: 0;
padding: 0;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 48px 24px 96px;
}
h1 {
font-size: 28px;
margin: 0 0 8px;
letter-spacing: -0.01em;
}
h2 {
font-size: 18px;
margin: 32px 0 12px;
}
p {
margin: 0 0 16px;
color: var(--fg);
}
.muted {
color: var(--muted);
}
.pill {
display: inline-block;
background: var(--ok);
color: white;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
margin-bottom: 16px;
}
.card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin: 16px 0;
background: var(--bg);
}
label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
color: var(--muted);
}
input[type="text"],
input[type="url"] {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font-size: 14px;
font-family: "SF Mono", Menlo, Consolas, monospace;
}
input[readonly] {
background: var(--code-bg);
}
button {
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: 6px;
padding: 10px 18px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
margin-top: 12px;
}
button:hover {
opacity: 0.92;
}
button.secondary {
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
}
code,
pre {
font-family: "SF Mono", Menlo, Consolas, monospace;
background: var(--code-bg);
border-radius: 4px;
}
code {
padding: 1px 5px;
font-size: 13px;
}
pre {
padding: 14px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
border: 1px solid var(--border);
}
.status {
margin-top: 12px;
font-size: 13px;
}
.status.ok {
color: var(--ok);
}
.status.err {
color: var(--err);
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
.row input {
flex: 1;
}
a {
color: var(--accent);
}
</style>
</head>
<body>
<main>
<div class="pill">Deploy complete</div>
<h1>Your Turnstile Spin backend is live.</h1>
<p class="muted">
This Worker is the managed siteverify backend for
<a href="https://developers.cloudflare.com/turnstile/spin/">Turnstile Spin</a>.
You have two remaining steps: get a site key, and paste both values
into your site.
</p>
<h2>1. Your Worker URL</h2>
<div class="card">
<label for="worker-url">Worker URL (the form action / fetch target)</label>
<div class="row">
<input id="worker-url" type="url" readonly value="" />
<button class="secondary" id="copy-worker">Copy</button>
</div>
<div class="status" id="health-status">Checking <code>/health</code>...</div>
</div>
<h2>2. Your site key</h2>
<div class="card">
<p>
You can either reuse an existing Turnstile widget or create a new
one. Both options take you through Cloudflare's dashboard; the secret
never leaves your account.
</p>
<button
id="create-widget"
onclick="window.open('https://dash.cloudflare.com/?to=/:account/turnstile', '_blank')"
>
Create a widget in the dashboard
</button>
<p class="muted" style="margin-top: 16px; font-size: 13px;">
After creating the widget, copy its <strong>site key</strong> (public,
goes in your HTML) and its <strong>secret</strong> (private, goes in
the Worker). Then run:
</p>
<pre>npx wrangler secret put TURNSTILE_SECRET_KEY --name turnstile-siteverify
# paste the secret when prompted</pre>
</div>
<h2>3. Paste this into your site</h2>
<div class="card">
<label for="sitekey-input">Your site key</label>
<input
id="sitekey-input"
type="text"
placeholder="0x4AAAAAAA..."
oninput="updateSnippet()"
/>
<label for="snippet" style="margin-top: 16px;">HTML to paste</label>
<pre id="snippet">&lt;!-- enter your site key above to generate the snippet --&gt;</pre>
<button class="secondary" id="copy-snippet">Copy snippet</button>
</div>
<h2>Next</h2>
<p>
Open your site in a browser. Solve the Turnstile widget. Confirm the
form submission lands at your Worker and that the network response
includes <code>"success": true</code>.
</p>
<p class="muted" style="font-size: 13px;">
If you have a codebase and a coding agent, the AI agent path is faster.
See <a href="https://developers.cloudflare.com/turnstile/spin/">developers.cloudflare.com/turnstile/spin</a>.
</p>
</main>
<script>
const workerUrl = window.location.origin;
document.getElementById("worker-url").value = workerUrl;
document.getElementById("copy-worker").onclick = () => {
navigator.clipboard.writeText(workerUrl);
};
fetch(workerUrl + "/health")
.then((r) => r.json())
.then((j) => {
const el = document.getElementById("health-status");
if (j.ok) {
el.className = "status ok";
el.innerHTML =
"&#10003; Worker is healthy (version " +
j.version +
")";
} else {
el.className = "status err";
el.textContent = "Health check returned a non-ok response.";
}
})
.catch((e) => {
const el = document.getElementById("health-status");
el.className = "status err";
el.textContent = "Could not reach /health: " + e.message;
});
function updateSnippet() {
const sitekey =
document.getElementById("sitekey-input").value.trim() ||
"YOUR_SITEKEY";
const html = [
'<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></sc' +
"ript>",
"",
'<form action="' + workerUrl + '/" method="POST">',
' <div class="cf-turnstile"',
' data-sitekey="' + sitekey + '"',
' data-action="turnstile-spin-v1"></div>',
' <button type="submit">Submit</button>',
"</form>",
].join("\n");
document.getElementById("snippet").textContent = html;
}
document.getElementById("copy-snippet").onclick = () => {
navigator.clipboard.writeText(
document.getElementById("snippet").textContent,
);
};
updateSnippet();
</script>
</body>
</html>
@@ -0,0 +1,29 @@
/**
* Error formatting + helpers. Keep error responses consistent with
* Cloudflare's documented siteverify shape so callers can use one handler.
*/
import type { ErrorCode, SiteverifyResponse } from './types.js';
export class SiteverifyError extends Error {
constructor(
public readonly status: number,
public readonly code: ErrorCode,
message: string,
) {
super(message);
this.name = 'SiteverifyError';
}
}
/** Build a siteverify-shaped response for a Worker-level failure. */
export function errorResponse(code: ErrorCode, durationMs: number, workerVersion: string): SiteverifyResponse {
return {
success: false,
'error-codes': [code],
_worker: {
duration_ms: durationMs,
worker_version: workerVersion,
},
};
}
@@ -0,0 +1,199 @@
/**
* turnstile-siteverify — the managed siteverify backend for Turnstile Spin.
*
* A drop-in implementation of Cloudflare Turnstile server-side validation
* built entirely on Workers. Customers deploy this Worker into their account
* via Turnstile Spin (AI agent or Deploy-to-Cloudflare button) and point
* their frontend at it.
*
* Accepts both `application/x-www-form-urlencoded` and `application/json`
* bodies. Forwards the token to Cloudflare's siteverify endpoint using a
* secret stored as a Worker secret. Emits structured logs visible in Workers
* Observability.
*
* Endpoints:
* POST / siteverify proxy (main entry point)
* POST /siteverify alias of POST /
* GET /health health check, returns {ok: true, version}
* GET / same as /health
* GET /post-deploy the post-deploy form (served from public/)
*/
import { SiteverifyError, errorResponse } from './errors.js';
import { logSiteverify } from './observability.js';
import { validate } from './validate.js';
import type { Env, SiteverifyRequest } from './types.js';
export const WORKER_VERSION = '1.0.0';
function corsHeaders(env: Env): Record<string, string> {
return {
'Access-Control-Allow-Origin': env.ALLOWED_ORIGIN || '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
};
}
function jsonResponse(body: unknown, status: number, env: Env): Response {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
...corsHeaders(env),
},
});
}
async function parseBody(request: Request): Promise<SiteverifyRequest> {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const body = (await request.json()) as Record<string, unknown>;
return {
// `response` is accepted for compatibility with code migrated from reCAPTCHA/hCaptcha,
// whose backends POST {secret, response, remoteip}. Spin's Worker holds the secret,
// so callers should send {token} or {cf-turnstile-response}; we accept {response}
// too so migrated backends work without backend code changes.
token: String(body.token ?? body['cf-turnstile-response'] ?? body.response ?? ''),
remoteip: body.remoteip ? String(body.remoteip) : undefined,
idempotency_key: body.idempotency_key ? String(body.idempotency_key) : undefined,
};
}
if (contentType.includes('application/x-www-form-urlencoded') || contentType.includes('multipart/form-data')) {
const form = await request.formData();
return {
token: String(form.get('token') ?? form.get('cf-turnstile-response') ?? form.get('response') ?? ''),
remoteip: form.get('remoteip') ? String(form.get('remoteip')) : undefined,
idempotency_key: form.get('idempotency_key') ? String(form.get('idempotency_key')) : undefined,
};
}
throw new SiteverifyError(
415,
'invalid-content-type',
`Unsupported Content-Type: ${contentType || '(missing)'}. Use application/json or application/x-www-form-urlencoded.`,
);
}
async function handleSiteverify(request: Request, env: Env): Promise<Response> {
const startedAt = Date.now();
let parsed: SiteverifyRequest;
try {
parsed = await parseBody(request);
} catch (err) {
if (err instanceof SiteverifyError) {
const body = errorResponse(err.code, Date.now() - startedAt, WORKER_VERSION);
logSiteverify('error', {
durationMs: Date.now() - startedAt,
errorCodes: [err.code],
remoteipPresent: false,
idempotencyKeyPresent: false,
});
return jsonResponse(body, err.status, env);
}
// Body parse failure (malformed JSON, etc.)
const body = errorResponse('bad-request', Date.now() - startedAt, WORKER_VERSION);
logSiteverify('error', {
durationMs: Date.now() - startedAt,
errorCodes: ['bad-request'],
remoteipPresent: false,
idempotencyKeyPresent: false,
});
return jsonResponse(body, 400, env);
}
// Fall back to CF-Connecting-IP if customer didn't pass remoteip explicitly.
const remoteip = parsed.remoteip ?? request.headers.get('CF-Connecting-IP') ?? undefined;
const requestForValidate: SiteverifyRequest = { ...parsed, remoteip };
try {
const { response, upstreamStatus, durationMs } = await validate(requestForValidate, env, WORKER_VERSION);
logSiteverify(response.success ? 'ok' : 'fail', {
durationMs,
upstreamStatus,
errorCodes: response['error-codes'],
cdataValue: response.cdata,
remoteipPresent: !!remoteip,
idempotencyKeyPresent: !!parsed.idempotency_key,
});
// Per spec, return 200 even on validation failure — callers should check
// response.success, not the HTTP status. (Matches Cloudflare's own
// siteverify behavior; the Worker is a thin proxy.)
return jsonResponse(response, 200, env);
} catch (err) {
if (err instanceof SiteverifyError) {
const body = errorResponse(err.code, Date.now() - startedAt, WORKER_VERSION);
logSiteverify('error', {
durationMs: Date.now() - startedAt,
errorCodes: [err.code],
remoteipPresent: !!remoteip,
idempotencyKeyPresent: !!parsed.idempotency_key,
});
return jsonResponse(body, err.status, env);
}
const body = errorResponse('internal-error', Date.now() - startedAt, WORKER_VERSION);
logSiteverify('error', {
durationMs: Date.now() - startedAt,
errorCodes: ['internal-error'],
remoteipPresent: !!remoteip,
idempotencyKeyPresent: !!parsed.idempotency_key,
});
return jsonResponse(body, 500, env);
}
}
function handleHealth(env: Env): Response {
return jsonResponse(
{
ok: true,
version: WORKER_VERSION,
service: 'turnstile-siteverify',
docs: 'https://developers.cloudflare.com/turnstile/spin/',
},
200,
env,
);
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(env) });
}
const url = new URL(request.url);
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/health' || url.pathname === '/healthz')) {
return handleHealth(env);
}
// Post-deploy form (served from public/post-deploy.html via the ASSETS binding).
// The form prefills the Worker URL and lets a Deploy-button user create a widget
// without leaving the flow.
if (request.method === 'GET' && url.pathname === '/post-deploy') {
if (env.ASSETS) {
return env.ASSETS.fetch(new Request(new URL('/post-deploy.html', url.origin)));
}
}
const isSiteverifyPath = url.pathname === '/' || url.pathname === '/siteverify';
if (request.method === 'POST' && isSiteverifyPath) {
return handleSiteverify(request, env);
}
return jsonResponse(
{
success: false,
'error-codes': ['bad-request'],
hint: 'POST a Turnstile token to / (or /siteverify) as JSON or form-encoded body.',
},
404,
env,
);
},
} satisfies ExportedHandler<Env>;
@@ -0,0 +1,34 @@
/**
* Structured logging helpers. Output is JSON-per-line so Workers Observability
* + log-shipping pipelines can parse + index without regex hacks.
*/
import type { SiteverifyLogEntry, ErrorCode } from './types.js';
export interface LogContext {
durationMs: number;
upstreamStatus?: number;
errorCodes?: ErrorCode[];
cdataValue?: string;
remoteipPresent: boolean;
idempotencyKeyPresent: boolean;
}
export function logSiteverify(outcome: 'ok' | 'fail' | 'error', ctx: LogContext): void {
const entry: SiteverifyLogEntry = {
level: outcome === 'ok' ? 'info' : outcome === 'fail' ? 'warn' : 'error',
event: 'siteverify',
outcome,
duration_ms: ctx.durationMs,
upstream_status: ctx.upstreamStatus,
error_codes: ctx.errorCodes ?? [],
cdata_present: typeof ctx.cdataValue === 'string' && ctx.cdataValue.length > 0,
cdata_value: ctx.cdataValue,
remoteip_present: ctx.remoteipPresent,
idempotency_key_present: ctx.idempotencyKeyPresent,
ts: new Date().toISOString(),
};
// console.log goes to Workers logs and Observability.
// Using a JSON line for machine-readability.
console.log(JSON.stringify(entry));
}
@@ -0,0 +1,95 @@
/**
* Types for Turnstile siteverify request + response.
*
* Mirrors Cloudflare's documented siteverify API:
* https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
*/
export interface Env {
/** Turnstile secret key. Set via `wrangler secret put TURNSTILE_SECRET_KEY`. */
TURNSTILE_SECRET_KEY: string;
/** CORS allowed origin. Default "*" for dev; must be set explicitly for prod. */
ALLOWED_ORIGIN: string;
/**
* Optional. If set, the Worker rejects siteverify responses whose `hostname`
* does not match this value. Defends against cross-site token replay.
* Leave empty to accept any hostname (default; for multi-site deployments).
*/
EXPECTED_HOSTNAME?: string;
/** Log verbosity. One of: debug, info, warn, error. Defaults to info. */
LOG_LEVEL?: 'debug' | 'info' | 'warn' | 'error';
/** Static assets binding (post-deploy form). Set automatically by [assets] config. */
ASSETS?: Fetcher;
}
/**
* Input we accept from the customer's frontend.
* Both form-encoded and JSON bodies are supported.
*/
export interface SiteverifyRequest {
/** The Turnstile token from the widget. */
token: string;
/** Optional visitor IP. If omitted, we use CF-Connecting-IP. */
remoteip?: string;
/** Optional idempotency key for retries. */
idempotency_key?: string;
}
/**
* Cloudflare's siteverify response shape.
* See: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#accepted-parameters
*/
export interface SiteverifyResponse {
success: boolean;
challenge_ts?: string;
hostname?: string;
action?: string;
cdata?: string;
'error-codes': ErrorCode[];
/** Free-form metadata appended by this Worker. Not from upstream. */
_worker?: WorkerMetadata;
}
export interface WorkerMetadata {
/** Latency of the upstream siteverify call, milliseconds. */
duration_ms: number;
/** This Worker's version, from package.json. */
worker_version: string;
}
/**
* Documented error codes from Turnstile siteverify.
* See: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
*/
export type ErrorCode =
| 'missing-input-secret'
| 'invalid-input-secret'
| 'missing-input-response'
| 'invalid-input-response'
| 'bad-request'
| 'timeout-or-duplicate'
| 'internal-error'
// Worker-side codes (not from upstream)
| 'missing-token'
| 'invalid-content-type'
| 'upstream-unreachable'
| 'upstream-timeout'
| 'hostname-mismatch';
/**
* Structured log emitted per siteverify call.
* These show up in Workers Observability.
*/
export interface SiteverifyLogEntry {
level: 'info' | 'warn' | 'error';
event: 'siteverify';
outcome: 'ok' | 'fail' | 'error';
duration_ms: number;
upstream_status?: number;
error_codes: ErrorCode[];
cdata_present: boolean;
cdata_value?: string;
remoteip_present: boolean;
idempotency_key_present: boolean;
ts: string;
}
@@ -0,0 +1,101 @@
/**
* Calls Cloudflare's siteverify endpoint. One retry on transient (5xx + network)
* failures, with a tight timeout. Returns the upstream JSON unmodified plus
* Worker metadata.
*/
import { SiteverifyError } from './errors.js';
import type { Env, SiteverifyRequest, SiteverifyResponse } from './types.js';
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const UPSTREAM_TIMEOUT_MS = 5000;
const RETRY_ON_STATUSES = new Set([500, 502, 503, 504]);
export interface ValidateResult {
response: SiteverifyResponse;
upstreamStatus: number;
durationMs: number;
}
/**
* Validate a Turnstile token against Cloudflare's siteverify endpoint.
*
* @throws SiteverifyError on Worker-level failures (missing secret, unreachable upstream, timeout)
*/
export async function validate(req: SiteverifyRequest, env: Env, workerVersion: string): Promise<ValidateResult> {
if (!env.TURNSTILE_SECRET_KEY) {
throw new SiteverifyError(500, 'missing-input-secret', 'TURNSTILE_SECRET_KEY not configured');
}
if (!req.token) {
throw new SiteverifyError(400, 'missing-token', 'token is required');
}
const body = new FormData();
body.append('secret', env.TURNSTILE_SECRET_KEY);
body.append('response', req.token);
if (req.remoteip) body.append('remoteip', req.remoteip);
if (req.idempotency_key) body.append('idempotency_key', req.idempotency_key);
const start = Date.now();
let lastErr: unknown = null;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const result = await fetchWithTimeout(SITEVERIFY_URL, body, UPSTREAM_TIMEOUT_MS);
const durationMs = Date.now() - start;
if (result.status >= 200 && result.status < 300) {
const data = (await result.json()) as SiteverifyResponse;
data._worker = {
duration_ms: durationMs,
worker_version: workerVersion,
};
// Hostname-mismatch defense. If the customer set EXPECTED_HOSTNAME,
// reject responses whose hostname doesn't match — defends against
// cross-site token replay even if a token leaks.
if (data.success && env.EXPECTED_HOSTNAME && data.hostname && data.hostname !== env.EXPECTED_HOSTNAME) {
const rejected: SiteverifyResponse = {
...data,
success: false,
'error-codes': ['hostname-mismatch'],
};
return { response: rejected, upstreamStatus: result.status, durationMs };
}
return { response: data, upstreamStatus: result.status, durationMs };
}
if (!RETRY_ON_STATUSES.has(result.status) || attempt === 1) {
// Don't retry on 4xx, return the upstream body as-is so customers
// see exactly what siteverify said.
const data = (await result.json().catch(() => ({}))) as Partial<SiteverifyResponse>;
const response: SiteverifyResponse = {
success: false,
'error-codes': data['error-codes'] ?? ['bad-request'],
...data,
_worker: { duration_ms: durationMs, worker_version: workerVersion },
};
return { response, upstreamStatus: result.status, durationMs };
}
// 5xx + first attempt: retry
} catch (err) {
lastErr = err;
if (attempt === 1) break;
}
}
if (lastErr instanceof DOMException && lastErr.name === 'AbortError') {
throw new SiteverifyError(504, 'upstream-timeout', `Upstream timeout after ${UPSTREAM_TIMEOUT_MS}ms`);
}
throw new SiteverifyError(502, 'upstream-unreachable', `Upstream siteverify unreachable: ${String(lastErr)}`);
}
async function fetchWithTimeout(url: string, body: FormData, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
method: 'POST',
body,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,71 @@
/**
* tests/3 — Deploy-to-clean-account smoke test.
*
* Asserts that a clean `wrangler deploy` of this Worker into an empty account
* completes in under 4 seconds (Spin's MVP target). This test is gated on
* CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID being set in the environment;
* skipped in normal CI runs.
*
* Run with:
* CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... \
* npx vitest run test/deploy.test.ts
*/
import { describe, expect, it } from 'vitest';
import { execSync, spawnSync } from 'node:child_process';
const HAS_CREDS = Boolean(process.env.CLOUDFLARE_API_TOKEN && process.env.CLOUDFLARE_ACCOUNT_ID);
const TARGET_MS = 4000;
const describeIfCreds = HAS_CREDS ? describe : describe.skip;
describeIfCreds('deploy-to-clean-account speed', () => {
const workerName = `turnstile-siteverify-deploytest-${Date.now()}`;
it(`completes wrangler deploy in under ${TARGET_MS}ms`, () => {
const start = Date.now();
const result = spawnSync(
'npx',
['wrangler', 'deploy', '--name', workerName, '--dry-run', '--outdir', '/tmp/spin-deploy-test'],
{
cwd: process.cwd(),
env: { ...process.env },
encoding: 'utf-8',
},
);
const elapsed = Date.now() - start;
if (result.status !== 0) {
throw new Error(`wrangler deploy --dry-run failed:\n${result.stderr}`);
}
expect(elapsed).toBeLessThan(TARGET_MS);
}, 30_000);
it('cleans up the test Worker', () => {
// Idempotent — if the dry-run never produced a real Worker, this is a no-op.
execSync(`npx wrangler delete --name ${workerName} 2>&1 || true`, { stdio: 'ignore' });
});
});
describe('deploy artifacts present', () => {
it('wrangler.toml has the expected name', () => {
const fs = require('node:fs');
const text = fs.readFileSync('wrangler.toml', 'utf-8');
expect(text).toMatch(/^name\s*=\s*"turnstile-siteverify"/m);
});
it('wrangler.toml configures the post-deploy assets directory', () => {
const fs = require('node:fs');
const text = fs.readFileSync('wrangler.toml', 'utf-8');
expect(text).toMatch(/\[assets\]/);
expect(text).toMatch(/directory\s*=\s*"\.\/public"/);
});
it('public/post-deploy.html exists', () => {
const fs = require('node:fs');
expect(fs.existsSync('public/post-deploy.html')).toBe(true);
});
});
@@ -0,0 +1,87 @@
/**
* Integration tests that hit Cloudflare's real siteverify endpoint using the
* documented test sitekeys + test secrets.
*
* Test keys reference:
* https://developers.cloudflare.com/turnstile/troubleshooting/testing/
*
* Test secrets:
* 1x0000000000000000000000000000000AA — always passes
* 2x0000000000000000000000000000000AA — always fails
* 3x0000000000000000000000000000000AA — already-spent token (timeout-or-duplicate)
*
* Test sitekeys:
* 1x00000000000000000000AA — visible, always passes
* 2x00000000000000000000AB — visible, always blocks
* 1x00000000000000000000BB — invisible, always passes
* 2x00000000000000000000BB — invisible, always blocks
* 3x00000000000000000000FF — forces an interactive challenge
*
* These tests make real network calls. Run with `npm run test:integration`.
*/
import { describe, expect, it } from 'vitest';
import worker from '../src/index.js';
import type { Env } from '../src/types.js';
const PASSING_SECRET = '1x0000000000000000000000000000000AA';
const FAILING_SECRET = '2x0000000000000000000000000000000AA';
const SPENT_TOKEN_SECRET = '3x0000000000000000000000000000000AA';
// The widget produces some opaque token string. For test secrets the actual
// content doesn't matter; siteverify decides based on the secret.
const FAKE_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX';
function envWith(secret: string): Env {
return {
TURNSTILE_SECRET_KEY: secret,
ALLOWED_ORIGIN: '*',
};
}
describe('integration: real siteverify with test secrets', () => {
it('passing-secret returns success=true', async () => {
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: FAKE_TOKEN }),
}),
envWith(PASSING_SECRET),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; _worker?: { duration_ms: number } };
expect(body.success).toBe(true);
expect(body._worker?.duration_ms).toBeGreaterThan(0);
}, 15_000);
it('failing-secret returns success=false', async () => {
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: FAKE_TOKEN }),
}),
envWith(FAILING_SECRET),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; 'error-codes': string[] };
expect(body.success).toBe(false);
expect(body['error-codes'].length).toBeGreaterThan(0);
}, 15_000);
it('spent-token secret returns timeout-or-duplicate', async () => {
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: FAKE_TOKEN }),
}),
envWith(SPENT_TOKEN_SECRET),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; 'error-codes': string[] };
expect(body.success).toBe(false);
expect(body['error-codes']).toContain('timeout-or-duplicate');
}, 15_000);
});
@@ -0,0 +1,372 @@
/**
* Unit tests for the Worker. These don't hit Cloudflare's real siteverify —
* see integration.test.ts for that.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import worker from '../src/index.js';
import type { Env } from '../src/types.js';
const TEST_ENV: Env = {
TURNSTILE_SECRET_KEY: 'test-secret-key',
ALLOWED_ORIGIN: '*',
};
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
function mockFetchOnce(response: { status?: number; body: unknown }): void {
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => {
return new Response(JSON.stringify(response.body), {
status: response.status ?? 200,
headers: { 'Content-Type': 'application/json' },
});
});
}
describe('CORS', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('responds to OPTIONS preflight with CORS headers', async () => {
const res = await worker.fetch(new Request('https://w/', { method: 'OPTIONS' }), TEST_ENV);
expect(res.status).toBe(204);
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(res.headers.get('Access-Control-Allow-Methods')).toContain('POST');
});
it('echoes ALLOWED_ORIGIN when set explicitly', async () => {
const env = { ...TEST_ENV, ALLOWED_ORIGIN: 'https://example.com' };
const res = await worker.fetch(new Request('https://w/', { method: 'OPTIONS' }), env);
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com');
});
});
describe('Health endpoints', () => {
it('GET / returns {ok: true, version}', async () => {
const res = await worker.fetch(new Request('https://w/'), TEST_ENV);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; version: string; service: string };
expect(body.ok).toBe(true);
expect(body.version).toMatch(/^\d+\.\d+\.\d+$/);
expect(body.service).toBe('turnstile-siteverify');
});
it('GET /health returns the same shape', async () => {
const res = await worker.fetch(new Request('https://w/health'), TEST_ENV);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; version: string };
expect(body.ok).toBe(true);
expect(typeof body.version).toBe('string');
});
it('GET /healthz still returns health (legacy alias)', async () => {
const res = await worker.fetch(new Request('https://w/healthz'), TEST_ENV);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
});
describe('POST / (siteverify proxy) — JSON body', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('forwards a JSON token to siteverify and returns success', async () => {
mockFetchOnce({
body: {
success: true,
hostname: 'example.com',
challenge_ts: '2026-05-22T12:00:00Z',
'error-codes': [],
},
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'abc' }),
}),
TEST_ENV,
);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; _worker?: { worker_version: string } };
expect(body.success).toBe(true);
expect(body._worker?.worker_version).toMatch(/^\d+\.\d+\.\d+$/);
// Verify it called the right upstream
expect(globalThis.fetch).toHaveBeenCalledOnce();
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe(SITEVERIFY_URL);
});
it('passes through siteverify failure with success=false', async () => {
mockFetchOnce({
body: { success: false, 'error-codes': ['invalid-input-response'] },
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'bad' }),
}),
TEST_ENV,
);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; 'error-codes': string[] };
expect(body.success).toBe(false);
expect(body['error-codes']).toContain('invalid-input-response');
});
it('returns 400 when token is missing', async () => {
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
}),
TEST_ENV,
);
expect(res.status).toBe(400);
const body = (await res.json()) as { success: boolean; 'error-codes': string[] };
expect(body.success).toBe(false);
expect(body['error-codes']).toContain('missing-token');
});
it('returns 400 on malformed JSON', async () => {
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{not json',
}),
TEST_ENV,
);
expect(res.status).toBe(400);
});
it('accepts cf-turnstile-response as alias for token', async () => {
mockFetchOnce({
body: { success: true, 'error-codes': [] },
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 'cf-turnstile-response': 'xyz' }),
}),
TEST_ENV,
);
expect(res.status).toBe(200);
});
});
describe('POST / (siteverify proxy) — form body', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('accepts urlencoded body', async () => {
mockFetchOnce({
body: { success: true, 'error-codes': [] },
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'token=abc',
}),
TEST_ENV,
);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean };
expect(body.success).toBe(true);
});
it('accepts cf-turnstile-response from form body', async () => {
mockFetchOnce({
body: { success: true, 'error-codes': [] },
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'cf-turnstile-response=xyz',
}),
TEST_ENV,
);
expect(res.status).toBe(200);
});
});
describe('Content-Type handling', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('returns 415 for unsupported content type', async () => {
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: 'token=abc',
}),
TEST_ENV,
);
expect(res.status).toBe(415);
const body = (await res.json()) as { 'error-codes': string[] };
expect(body['error-codes']).toContain('invalid-content-type');
});
});
describe('Path routing', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('accepts POST /siteverify as alias of POST /', async () => {
mockFetchOnce({
body: { success: true, 'error-codes': [] },
});
const res = await worker.fetch(
new Request('https://w/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'abc' }),
}),
TEST_ENV,
);
expect(res.status).toBe(200);
});
it('returns 404 for unknown paths', async () => {
const res = await worker.fetch(new Request('https://w/random'), TEST_ENV);
expect(res.status).toBe(404);
});
});
describe('CF-Connecting-IP fallback', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('uses CF-Connecting-IP if customer did not pass remoteip', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
const form = init?.body as FormData;
expect(form.get('remoteip')).toBe('1.2.3.4');
return new Response(JSON.stringify({ success: true, 'error-codes': [] }), { status: 200 });
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'CF-Connecting-IP': '1.2.3.4',
},
body: JSON.stringify({ token: 'abc' }),
}),
TEST_ENV,
);
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledOnce();
});
it('prefers explicit remoteip over CF-Connecting-IP', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_url, init) => {
const form = init?.body as FormData;
expect(form.get('remoteip')).toBe('9.9.9.9');
return new Response(JSON.stringify({ success: true, 'error-codes': [] }), { status: 200 });
});
await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'CF-Connecting-IP': '1.2.3.4',
},
body: JSON.stringify({ token: 'abc', remoteip: '9.9.9.9' }),
}),
TEST_ENV,
);
});
});
describe('Hostname-mismatch defense', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('rejects success response when hostname does not match EXPECTED_HOSTNAME', async () => {
mockFetchOnce({
body: { success: true, hostname: 'attacker.com', 'error-codes': [] },
});
const env: Env = { ...TEST_ENV, EXPECTED_HOSTNAME: 'mysite.com' };
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'abc' }),
}),
env,
);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; 'error-codes': string[] };
expect(body.success).toBe(false);
expect(body['error-codes']).toContain('hostname-mismatch');
});
it('accepts success response when hostname matches EXPECTED_HOSTNAME', async () => {
mockFetchOnce({
body: { success: true, hostname: 'mysite.com', 'error-codes': [] },
});
const env: Env = { ...TEST_ENV, EXPECTED_HOSTNAME: 'mysite.com' };
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'abc' }),
}),
env,
);
const body = (await res.json()) as { success: boolean };
expect(body.success).toBe(true);
});
it('does not apply hostname check when EXPECTED_HOSTNAME is unset', async () => {
mockFetchOnce({
body: { success: true, hostname: 'anywhere.com', 'error-codes': [] },
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'abc' }),
}),
TEST_ENV,
);
const body = (await res.json()) as { success: boolean };
expect(body.success).toBe(true);
});
});
describe('Missing secret', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('returns 500 with missing-input-secret when TURNSTILE_SECRET_KEY is empty', async () => {
const env = { ...TEST_ENV, TURNSTILE_SECRET_KEY: '' };
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'abc' }),
}),
env,
);
expect(res.status).toBe(500);
const body = (await res.json()) as { 'error-codes': string[] };
expect(body['error-codes']).toContain('missing-input-secret');
});
});
@@ -0,0 +1,201 @@
/**
* tests/4 — Validation surface.
*
* Tests the endpoints that Turnstile Spin's wizard relies on during its
* post-deploy validation step:
*
* - GET /health returns {ok: true, version}
* - GET / returns the same shape
* - POST / with dummy returns a structured error (success: false, error-codes,
* _worker metadata) rather than a bare 500
* - hostname mismatch when EXPECTED_HOSTNAME is set
*
* These match the assertions in the wizard's Step 7. If any of these fail,
* the wizard's validation step will fail, and the customer will see a
* misleading "setup complete" message followed by a broken integration.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import worker, { WORKER_VERSION } from '../src/index.js';
import type { Env } from '../src/types.js';
const ENV: Env = {
TURNSTILE_SECRET_KEY: 'test-secret-key',
ALLOWED_ORIGIN: '*',
};
describe('validation surface — /health', () => {
it('returns ok:true and a semver version', async () => {
const res = await worker.fetch(new Request('https://w/health'), ENV);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; version: string };
expect(body.ok).toBe(true);
expect(body.version).toMatch(/^\d+\.\d+\.\d+$/);
expect(body.version).toBe(WORKER_VERSION);
});
it('GET / returns the same shape as /health', async () => {
const [a, b] = await Promise.all([
worker.fetch(new Request('https://w/'), ENV),
worker.fetch(new Request('https://w/health'), ENV),
]);
const ja = (await a.json()) as { ok: boolean; version: string };
const jb = (await b.json()) as { ok: boolean; version: string };
expect(ja.ok).toBe(jb.ok);
expect(ja.version).toBe(jb.version);
});
it('responds in well under the wizard timeout (1s)', async () => {
const start = Date.now();
await worker.fetch(new Request('https://w/health'), ENV);
expect(Date.now() - start).toBeLessThan(1000);
});
});
describe('validation surface — dummy siteverify returns structured error', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('returns success:false + error-codes + _worker for a dummy token', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => {
return new Response(
JSON.stringify({ success: false, 'error-codes': ['invalid-input-response'] }),
{ status: 200 },
);
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'XXXX.DUMMY.TOKEN.XXXX' }),
}),
ENV,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
success: boolean;
'error-codes': string[];
_worker?: { duration_ms: number; worker_version: string };
};
// Wizard's required assertions:
expect(body.success).toBe(false);
expect(Array.isArray(body['error-codes'])).toBe(true);
expect(body['error-codes'].length).toBeGreaterThan(0);
expect(body._worker).toBeDefined();
expect(typeof body._worker?.duration_ms).toBe('number');
expect(body._worker?.worker_version).toBe(WORKER_VERSION);
});
it('returns structured error on Worker-level failure (no bare 500)', async () => {
// Force the upstream to be unreachable.
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => {
throw new TypeError('network unreachable');
});
// Disable the retry loop by also failing the second attempt.
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => {
throw new TypeError('network unreachable');
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'whatever' }),
}),
ENV,
);
// Worker-level failure still returns a *parseable* error body.
const body = (await res.json()) as { success: boolean; 'error-codes': string[]; _worker?: unknown };
expect(body.success).toBe(false);
expect(body['error-codes'][0]).toBe('upstream-unreachable');
expect(body._worker).toBeDefined();
});
});
describe('validation surface — hostname mismatch', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('rejects success when EXPECTED_HOSTNAME does not match', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => {
return new Response(
JSON.stringify({ success: true, hostname: 'attacker.example.com', 'error-codes': [] }),
{ status: 200 },
);
});
const env: Env = { ...ENV, EXPECTED_HOSTNAME: 'www.example.com' };
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'abc' }),
}),
env,
);
const body = (await res.json()) as { success: boolean; 'error-codes': string[] };
expect(body.success).toBe(false);
expect(body['error-codes']).toContain('hostname-mismatch');
});
});
describe('validation surface — telemetry marker passthrough', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('passes data-action through to siteverify and surfaces it in the response', async () => {
// Upstream echoes the action field. Spin marker should survive.
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => {
return new Response(
JSON.stringify({
success: true,
hostname: 'example.com',
action: 'turnstile-spin-v1',
'error-codes': [],
}),
{ status: 200 },
);
});
const res = await worker.fetch(
new Request('https://w/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'abc' }),
}),
ENV,
);
const body = (await res.json()) as { success: boolean; action?: string };
expect(body.success).toBe(true);
expect(body.action).toBe('turnstile-spin-v1');
});
});
describe('validation surface — post-deploy form is reachable', () => {
it('GET /post-deploy returns 404 when ASSETS binding is missing (test env)', async () => {
// In the unit-test env we don't have the ASSETS binding wired, so the
// handler falls through. The real Worker has the binding from wrangler.toml.
const res = await worker.fetch(new Request('https://w/post-deploy'), ENV);
expect(res.status).toBe(404);
});
it('GET /post-deploy serves the HTML file when ASSETS is bound', async () => {
const html = '<!doctype html><title>Post-deploy</title>';
const mockAssets: Fetcher = {
fetch: async () =>
new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } }),
} as unknown as Fetcher;
const envWithAssets: Env = { ...ENV, ASSETS: mockAssets };
const res = await worker.fetch(new Request('https://w/post-deploy'), envWithAssets);
expect(res.status).toBe(200);
expect(await res.text()).toContain('Post-deploy');
});
});
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types", "vitest/globals"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowJs": false,
"noEmit": true
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
// We run unit tests under Node (mocking fetch) and integration tests
// hit real network. Workers test pool would let us run against the
// real Workers runtime; defer that to fast-follow.
environment: 'node',
include: ['test/**/*.test.ts'],
},
});
@@ -0,0 +1,36 @@
name = "turnstile-siteverify"
main = "src/index.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]
# Serve the post-deploy form (public/post-deploy.html) as a static asset.
# After deploy, customers land on /post-deploy.html with their Worker URL
# prefilled, and can create a Turnstile widget without leaving the flow.
[assets]
directory = "./public"
binding = "ASSETS"
# Workers Observability — structured logs, latency tracking, error rates
# all surface in the Worker's Observability tab in the dashboard.
[observability]
enabled = true
[vars]
# CORS allowed origin. Override per-environment.
# In production, set this to your customer-facing domain (e.g. "https://example.com").
# Leaving as "*" for development; production deploys MUST set this explicitly.
ALLOWED_ORIGIN = "*"
# Optional: defense against cross-site token replay. If set, the Worker
# rejects siteverify responses whose hostname doesn't match. Set this to
# your customer-facing domain (e.g. "www.example.com") for additional safety
# on single-domain deployments. Leave empty for multi-site deployments.
# EXPECTED_HOSTNAME = ""
# Log verbosity. One of: debug, info, warn, error.
LOG_LEVEL = "info"
# Secrets — never commit. Set via:
# wrangler secret put TURNSTILE_SECRET_KEY
# For local dev, create .dev.vars (gitignored) with:
# TURNSTILE_SECRET_KEY=your-secret-here
@@ -0,0 +1,69 @@
# Skill validation cases
These cases match the MVP-row assertions in the Turnstile Spin PRD. Run them after editing this skill to confirm an agent loading it can still execute the wizard end-to-end.
## Test 1 — Health check parses cleanly
Given a deployed Worker, the agent should be able to parse `/health` without ambiguity.
```sh
curl -sf "${WORKER_URL}/health" | jq -e '.ok == true and (.version | type) == "string"'
```
Expected exit code: 0.
## Test 2 — Dummy siteverify returns a structured error
The wizard's Step 7b sends a deliberately-invalid token. The Worker must return `success: false`, a non-empty `error-codes` array, and a `_worker` block — not a bare 500.
```sh
curl -s -X POST "${WORKER_URL}/" \
-H "Content-Type: application/json" \
-d '{"token":"XXXX.DUMMY.TOKEN.XXXX"}' | \
jq -e '.success == false and (.["error-codes"] | length) > 0 and (._worker | type) == "object"'
```
Expected exit code: 0.
## Test 3 — Hostname configuration
```sh
npx wrangler turnstile widget show "${WIDGET_ID}" | \
jq -e '.result.domains | contains(["example.com"])'
```
Expected exit code: 0.
## Test 4 — Telemetry marker is in every written snippet
After the wizard completes, grep the written files:
```sh
rg -l 'data-action="turnstile-spin-v1"' <(echo "$WRITTEN_FILES")
```
Expected: every written file matches. If a snippet was written without the marker, the wizard skipped Step 6 (or the agent edited the template). Re-run.
## Test 5 — Skill persists to the right location
After Step 8:
```sh
test -f .claude/skills/turnstile-spin/SKILL.md \
|| test -f .cursor/rules/turnstile-spin.md \
|| test -f .codex/skills/turnstile-spin/SKILL.md \
|| test -f .opencode/skills/turnstile-spin/SKILL.md \
|| test -f .github/copilot/skills/turnstile-spin.md \
|| test -f .windsurf/rules/turnstile-spin.md
```
Expected exit code: 0.
## Running all cases
```sh
WORKER_URL=https://your-worker.workers.dev WIDGET_ID=0x4AAAAAAA... \
bash tests/run-all.sh
```
(`run-all.sh` is not bundled with this skill; the cases above are intended to be wired into the consuming agent's own test harness, or run by hand after a deploy.)