Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
884e15f
[Turnstile] Set autocomplete hints on client-side rendering examples …
Jun 17, 2026
758ec83
[Turnstile] Spin: document dashboard setup path alongside command-line
Jun 17, 2026
a58cd2c
[Turnstile] Spin docs: address style-bot suggestions
Jun 17, 2026
e19dffb
[Turnstile] Spin V2 docs: point references at prompt.md
Jun 17, 2026
a6edc4f
[Turnstile] Spin V2 docs: dashboard recovery is the primary path, age…
Jun 17, 2026
84fd4a2
[Turnstile] Spin docs: pivot to canonical siteverify
Jun 29, 2026
da161d3
[Turnstile] Spin V2 sync: prompt.md + Wrangler CLI setup path
Jul 9, 2026
e91c08b
[Turnstile] Drop Wrangler version note from Spin CLI section
Jul 9, 2026
2a360a6
[Turnstile] Fix Spin docs accuracy (removed column, local dev domains…
Jul 9, 2026
82cb7a8
Merge remote-tracking branch 'upstream/production' into spin-docs-fol…
Jul 10, 2026
b6cc3b6
[Turnstile] Drop Spin beta tag; bump reviewed date
Jul 10, 2026
2985122
[Turnstile] Redirect old Spin index.md to prompt.md
Jul 10, 2026
14662f3
Revert "[Turnstile] Redirect old Spin index.md to prompt.md"
Jul 10, 2026
05c95a8
[Turnstile] Spin docs: spell out insertion-preference examples per re…
Jul 10, 2026
e0c74a5
[Turnstile] Spin: sync prompt.md to SKILL.md and guide token reset fo…
Jul 17, 2026
7995c2d
[Turnstile] Spin docs: merge upstream/production and address review w…
Jul 21, 2026
f671609
[Turnstile] Spin docs: broaden scope beyond forms per review
Jul 21, 2026
7c87c00
Harden Turnstile Spin helper scripts (docs-bot findings)
Jul 21, 2026
c179505
Harden Turnstile Spin helper scripts (second pass on docs-bot findings)
Jul 21, 2026
fcb4f75
Guard mktemp, malformed errors list, and npx prerequisite
Jul 21, 2026
17dfa77
Add cleanup safety net to auth-probe Edit-scope probe
Jul 22, 2026
cd0bd66
Correct Step 2 + Troubleshooting prose to match auth-probe behavior
Jul 22, 2026
cf1d4e5
[Turnstile] Spin: sync prompt.md to stratus SPIN_SKILL_CONTENT (latest)
Jul 22, 2026
db5981f
[Turnstile] Spin: harden hosted prompt, scripts, and spin.mdx
Jul 23, 2026
6cb1ef5
Merge remote-tracking branch 'upstream/production' into spin-docs-fol…
Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 35 additions & 41 deletions public/turnstile/spin/prompt.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: turnstile-spin
description: Set up Cloudflare Turnstile end-to-end in a project — scan the codebase, create the widget via the Cloudflare API, embed it on the right forms, wire canonical server-side siteverify in the customer's existing backend, 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.
description: Set up Cloudflare Turnstile end-to-end in a project. Scan the codebase, create the widget via the Cloudflare API, embed it on the right forms, wire canonical server-side siteverify in the customer's existing backend, 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.
Comment thread
juleslemee marked this conversation as resolved.
Outdated
Comment thread
juleslemee marked this conversation as resolved.
Outdated
references:
- vanilla-html
- nextjs-app
Expand Down Expand Up @@ -72,18 +72,25 @@ The user pasted the prompt. You are in a multi-step dialog. Detect what you can,
Canonical server-side siteverify (Node / fetch idiom; adapt to the detected backend):

```js
const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET,
response: token, // cf-turnstile-response from the request
remoteip: clientIp, // X-Forwarded-For / req.ip / etc.
}),
});
const result = await r.json();
let result;
try {
const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET,
response: token, // cf-turnstile-response from the request
remoteip: clientIp, // X-Forwarded-For / req.ip / etc.
}),
});
if (!r.ok) throw new Error(`siteverify ${r.status}`);
result = await r.json();
} catch (err) {
// Network error, non-2xx, or non-JSON body from siteverify. Fail closed.
return res.status(403).send('forbidden'); // adapt to your framework
}
if (!result.success) {
return reject(403, 'forbidden'); // platform-appropriate equivalent
return res.status(403).send('forbidden');
}
// existing handler logic runs here, unchanged
```
Expand All @@ -104,13 +111,14 @@ The user pasted the prompt. You are in a multi-step dialog. Detect what you can,
- Do not call siteverify from the browser. Always: browser → user's backend → siteverify.
- Do not deploy any extra infrastructure (Workers, proxies, sidecars). The customer's existing backend calls siteverify directly.
- Do not use `sudo` or install global packages without asking.
- Do not propose features outside the wizard (custom Workers, custom domains, advanced WAF rules) unless asked.

### Hard scope boundary: DO NOT ask the user about

Spin validates the Turnstile token via canonical siteverify 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.
- **Adding a new backend.** If the form has no backend handler today (pure-static site, mailto-only contact form), say so and exit Spin requires a server-side place to put siteverify.
- **Adding a new backend.** If the form has no backend handler today (pure-static site, mailto-only contact form), say so and exit. Spin requires a server-side place to put siteverify.
- **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`.
Expand All @@ -128,8 +136,8 @@ If the user tells you they already have a Turnstile widget set up and want to wi
- `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 wire-up (Step 9).
- anything else: ask whether they want siteverify on top of pre-clearance, or exit per the scope boundary.
4. Continue from Step 9 (Wire the integration). Site key does not change. Dashboard's `Deployment` column flips from `Manual` to `Spin` on the first request carrying `data-action="turnstile-spin-v2"`.
- anything else: exit per the scope boundary. Spin does not apply to pre-clearance widgets; siteverify is optional there and the user should be redirected as described above. Do NOT prompt the user for permission to add siteverify on top.
4. Continue from Step 9 (Wire the integration). Site key does not change; the existing widget keeps working throughout.
5. Never recreate the widget to get a fresh secret. That breaks the existing sitekey everywhere it's deployed.

### The frontend-edit contract
Expand All @@ -141,33 +149,25 @@ Frontend (embeds the widget; submits to the user's existing endpoint):
```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form action="/signup" method="POST">
<form action="/signup" method="POST" id="cf-form">
<!-- existing inputs unchanged -->
<div class="cf-turnstile" data-sitekey="<SITEKEY>" data-action="turnstile-spin-v2"></div>
<button type="submit">Sign up</button>
</form>
<script>
// Turnstile tokens are single-use. If the page does not navigate after
// submit (server returned an inline error, client-side validation
// caught something), reset the widget so a retry gets a fresh token
// instead of being rejected as timeout-or-duplicate.
document.getElementById('cf-form').addEventListener('submit', () => {
setTimeout(() => window.turnstile?.reset(), 0);
});
</script>
```

Backend (inside the existing handler; reads the token from the request and gates):

```js
// In the existing POST /signup handler
const token = req.body['cf-turnstile-response'];
const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET,
response: token,
remoteip: req.ip,
}),
});
const { success } = await r.json();
if (!success) return res.status(403).end();
// existing handler logic runs here, unchanged
```
Backend: use the canonical siteverify fetch from Step 9 inside the existing handler. Read the token from `req.body['cf-turnstile-response']`, gate on `success === true`, and leave the rest of the handler alone. 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.

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.
**Token lifecycle: tokens are single-use.** A `cf-turnstile-response` token is redeemed exactly once at siteverify. If the server rejects (non-2xx or `success: false`), the browser still holds the redeemed token in the DOM; a naive retry submits the same token and Cloudflare's edge rejects the second attempt with `timeout-or-duplicate`. Always call `window.turnstile.reset()` before the user is allowed to retry. The framework references show the per-framework hook (submit listener for native forms, response handler for AJAX/SPA submits, `onError` for React components).

## Migrating from another CAPTCHA

Expand Down Expand Up @@ -207,10 +207,4 @@ Every `cf-turnstile` div this skill writes must include `data-action="turnstile-

Older widgets stamped `turnstile-spin-v1` (from the V1 agent flow that deployed a managed Worker) still exist in production accounts; preserve that marker if you encounter it on an existing widget you are modifying. Do not retag.

## Do not

- Do not write the secret to disk (other than the user's own env store).
- Do not skip validation (Step 10).
- Do not propose features outside the wizard (custom Workers, custom domains, advanced WAF rules) unless asked.
- Do not call siteverify from the browser.
- Do not deploy any extra infrastructure on the user's behalf.
73 changes: 73 additions & 0 deletions public/turnstile/spin/scripts/auth-probe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/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 the Turnstile scope probe
# "missing_token" ; no token set, or wrangler whoami failed
# "missing_scope" ; token lacks Account.Turnstile:Edit on the selected account
# "multiple_accounts" ; token covers >1 accounts and $CLOUDFLARE_ACCOUNT_ID is unset
# "account_mismatch" ; $CLOUDFLARE_ACCOUNT_ID is set but is not in the token's accounts list
#
# Human-readable diagnostics go to stderr.

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

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

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

emit "{\"status\":\"ok\",\"account_id\":\"$account_id\",\"accounts\":$accounts_json}"
67 changes: 67 additions & 0 deletions public/turnstile/spin/scripts/fetch-secret.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Retrieves the secret for an existing Turnstile widget via the Cloudflare API.
# Used by the recovery flow so the agent can wire canonical server-side
# siteverify against an existing widget without rotating the sitekey.
#
# 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
53 changes: 53 additions & 0 deletions public/turnstile/spin/scripts/persist-skill.sh
Original file line number Diff line number Diff line change
@@ -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
Loading