Skip to content

Commit 91a6a81

Browse files
author
Jules Lemee
committed
turnstile-spin: pivot SKILL to canonical siteverify
Drop the Worker deploy step from the wizard. Spin's job is now widget creation plus a curated agent prompt that wires canonical server-side siteverify into the customer's existing backend. Changes: - SKILL.md rewritten end-to-end (167 -> 211 lines) - worker-deploy.sh deleted - templates/worker/ deleted (14 files) - auth-probe.sh drops Workers Scripts scope check - validate.sh rewritten to dummy-token siteverify + optional hostname check - All 6 framework references rewritten for canonical siteverify - README.md + tests/validation.md updated Pairs with cloudflare/cloudflare-docs and cloudflare/fe/stratus !39261.
1 parent 8525237 commit 91a6a81

30 files changed

Lines changed: 431 additions & 2192 deletions

‎skills/turnstile-spin/README.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ This is a mirror of the canonical docs page at [`developers.cloudflare.com/turns
99
| File | Purpose |
1010
| --------------------------------- | ---------------------------------------------------------------------- |
1111
| `SKILL.md` | Main wizard instructions for the agent |
12+
| `scripts/auth-probe.sh` | Probes the customer's Cloudflare API token for Turnstile scope |
13+
| `scripts/widget-create.sh` | Creates the Turnstile widget via the Cloudflare API |
14+
| `scripts/fetch-secret.sh` | Retrieves the secret for an existing widget (recovery flow) |
15+
| `scripts/validate.sh` | Dummy-siteverify + hostname check at the end of the wizard |
16+
| `scripts/persist-skill.sh` | Installs the canonical skill bundle into the user's repo |
1217
| `references/vanilla-html.md` | Code snippet for static / vanilla HTML projects |
1318
| `references/nextjs-app.md` | Code snippet for Next.js App Router projects |
1419
| `references/nextjs-pages.md` | Code snippet for Next.js Pages Router projects |
1520
| `references/astro.md` | Code snippet for Astro projects |
1621
| `references/sveltekit.md` | Code snippet for SvelteKit projects |
1722
| `references/hugo.md` | Code snippet for Hugo projects |
18-
| `tests/validation.md` | Validation cases matching the MVP rows in the PRD |
23+
| `tests/validation.md` | Validation cases matching the assertions in the PRD |
1924

2025
## How agents load it
2126

@@ -32,14 +37,14 @@ git clone https://github.com/cloudflare/skills ~/.config/cloudflare-skills
3237
ln -s ~/.config/cloudflare-skills/turnstile-spin ~/.claude/skills/turnstile-spin
3338
```
3439

35-
For other agents, see the table in [`SKILL.md`](./SKILL.md#step-8--persist-the-skill).
40+
For other agents, see the table in [`SKILL.md`](./SKILL.md#step-11--persist-the-skill).
3641

3742
## Sync with the docs page
3843

39-
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.
44+
The canonical source of truth is `src/content/docs/turnstile/spin.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.
4045

4146
## Related
4247

4348
- [Canonical docs page](https://developers.cloudflare.com/turnstile/spin/)
44-
- [`cloudflare/turnstile-siteverify`](https://github.com/cloudflare/turnstile-siteverify) — the managed Worker that this skill deploys
4549
- [`cloudflare/skills`](https://github.com/cloudflare/skills) — root index for all Cloudflare agent skills
50+
- [Turnstile server-side validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) — canonical siteverify reference

‎skills/turnstile-spin/SKILL.md‎

Lines changed: 91 additions & 49 deletions
Large diffs are not rendered by default.
Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
# Astro
22

3-
For Astro projects. The form posts directly to the Worker. Astro frontmatter handles config substitution at build time.
3+
For Astro projects. The widget renders in a page; siteverify lives in an Astro Action, an API route, or a Pages Function. Astro frontmatter reads the sitekey from env at build time; the secret stays server-only.
44

55
```astro title="src/pages/signup.astro"
66
---
7-
const WORKER_URL = import.meta.env.PUBLIC_TURNSTILE_WORKER_URL;
87
const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
98
---
109
@@ -17,12 +16,12 @@ const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
1716
></script>
1817
</head>
1918
<body>
20-
<form action={`${WORKER_URL}/`} method="POST">
19+
<form action="/api/signup" method="POST">
2120
<input name="email" type="email" required />
2221
<div
2322
class="cf-turnstile"
2423
data-sitekey={SITEKEY}
25-
data-action="turnstile-spin-v1"
24+
data-action="turnstile-spin-v2"
2625
/>
2726
<button type="submit">Sign up</button>
2827
</form>
@@ -33,36 +32,36 @@ const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
3332
In your `.env`:
3433

3534
```text
36-
PUBLIC_TURNSTILE_WORKER_URL=https://YOUR_WORKER_URL
3735
PUBLIC_TURNSTILE_SITEKEY=YOUR_SITEKEY
36+
TURNSTILE_SECRET=YOUR_SECRET
3837
```
3938

40-
The `PUBLIC_` prefix is mandatory for client-exposed variables in Astro.
39+
The `PUBLIC_` prefix is mandatory for client-exposed variables in Astro. The secret has **no** prefix; it stays server-only.
4140

42-
## Variant: hardcoded values
41+
## API route (canonical siteverify)
4342

44-
If you do not use env vars, inline directly:
43+
```ts title="src/pages/api/signup.ts"
44+
import type { APIRoute } from "astro";
4545

46-
```astro title="src/pages/signup.astro"
47-
<html>
48-
<head>
49-
<script
50-
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
51-
async
52-
defer
53-
></script>
54-
</head>
55-
<body>
56-
<form action="https://YOUR_WORKER_URL/" method="POST">
57-
<div
58-
class="cf-turnstile"
59-
data-sitekey="YOUR_SITEKEY"
60-
data-action="turnstile-spin-v1"
61-
/>
62-
<button type="submit">Sign up</button>
63-
</form>
64-
</body>
65-
</html>
46+
export const POST: APIRoute = async ({ request, clientAddress }) => {
47+
const form = await request.formData();
48+
const token = form.get("cf-turnstile-response") as string;
49+
50+
const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
51+
method: "POST",
52+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
53+
body: new URLSearchParams({
54+
secret: import.meta.env.TURNSTILE_SECRET,
55+
response: token,
56+
remoteip: clientAddress,
57+
}),
58+
});
59+
const { success } = await verify.json();
60+
if (!success) return new Response("forbidden", { status: 403 });
61+
62+
// process signup
63+
return Response.json({ ok: true });
64+
};
6665
```
6766

6867
## Variant: Astro Actions
@@ -80,11 +79,15 @@ export const server = {
8079
email: z.string().email(),
8180
"cf-turnstile-response": z.string(),
8281
}),
83-
handler: async (input) => {
84-
const verify = await fetch("https://YOUR_WORKER_URL/", {
82+
handler: async (input, ctx) => {
83+
const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
8584
method: "POST",
86-
headers: { "Content-Type": "application/json" },
87-
body: JSON.stringify({ token: input["cf-turnstile-response"] }),
85+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
86+
body: new URLSearchParams({
87+
secret: import.meta.env.TURNSTILE_SECRET,
88+
response: input["cf-turnstile-response"],
89+
remoteip: ctx.clientAddress,
90+
}),
8891
});
8992
const data = await verify.json();
9093
if (!data.success) throw new Error("Verification failed");
@@ -96,7 +99,7 @@ export const server = {
9699

97100
## Substitutions
98101

99-
| Placeholder | Replace with |
100-
| ------------------ | ------------------------------------------- |
101-
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
102-
| `YOUR_SITEKEY` | Widget site key from Step 4 |
102+
| Placeholder | Replace with |
103+
| ------------------- | -------------------------------------------------------------------- |
104+
| `YOUR_SITEKEY` | The widget site key from Step 8 |
105+
| `YOUR_SECRET` | The secret captured in Step 8. Stays in env, never inlined. |

‎skills/turnstile-spin/references/hugo.md‎

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Hugo
22

3-
For Hugo static sites. Use a partial for the widget so it can be referenced from any layout or content file.
3+
For Hugo static sites. The widget renders on any page that includes the partial; siteverify happens at whatever backend handles your form submissions (a Cloudflare Pages Function, a Worker, an external API, or a form host with a server-side hook).
44

55
```html title="layouts/partials/turnstile.html"
66
<script
@@ -9,12 +9,12 @@ For Hugo static sites. Use a partial for the widget so it can be referenced from
99
defer
1010
></script>
1111

12-
<form action="{{ .Site.Params.turnstileWorkerUrl }}/" method="POST">
12+
<form action="{{ .Site.Params.turnstileFormEndpoint }}" method="POST">
1313
<input name="email" type="email" required />
1414
<div
1515
class="cf-turnstile"
1616
data-sitekey="{{ .Site.Params.turnstileSitekey }}"
17-
data-action="turnstile-spin-v1"
17+
data-action="turnstile-spin-v2"
1818
></div>
1919
<button type="submit">Subscribe</button>
2020
</form>
@@ -25,7 +25,7 @@ Add the params to your site config:
2525
```toml title="hugo.toml"
2626
[params]
2727
turnstileSitekey = "YOUR_SITEKEY"
28-
turnstileWorkerUrl = "https://YOUR_WORKER_URL"
28+
turnstileFormEndpoint = "/api/subscribe" # path to your existing form handler
2929
```
3030

3131
Reference the partial from any layout or content file:
@@ -34,6 +34,38 @@ Reference the partial from any layout or content file:
3434
{{ partial "turnstile.html" . }}
3535
```
3636

37+
## Backend (where siteverify lives)
38+
39+
Hugo doesn't host server-side code, so the form endpoint must live elsewhere. Two common setups:
40+
41+
**Cloudflare Pages Function** (`functions/api/subscribe.js`):
42+
43+
```js
44+
export async function onRequestPost({ request, env }) {
45+
const form = await request.formData();
46+
const token = form.get("cf-turnstile-response");
47+
48+
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
49+
method: "POST",
50+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
51+
body: new URLSearchParams({
52+
secret: env.TURNSTILE_SECRET,
53+
response: token,
54+
remoteip: request.headers.get("CF-Connecting-IP"),
55+
}),
56+
});
57+
const { success } = await r.json();
58+
if (!success) return new Response("forbidden", { status: 403 });
59+
60+
// process subscribe
61+
return new Response("ok");
62+
}
63+
```
64+
65+
Set the secret with `npx wrangler pages secret put TURNSTILE_SECRET` (or via the dashboard's Pages → your project → Settings → Environment variables → Add secret).
66+
67+
**External backend**: any Node/Ruby/Python/Go handler can do the same call. See the [vanilla-html reference](./vanilla-html.md) for non-Cloudflare-specific snippets.
68+
3769
## Variant: shortcode for content files
3870

3971
If you want to drop the widget into Markdown content (not just layouts), create a shortcode:
@@ -56,7 +88,8 @@ Contact us:
5688

5789
## Substitutions
5890

59-
| Placeholder | Replace with |
60-
| ------------------ | ------------------------------------------- |
61-
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
62-
| `YOUR_SITEKEY` | Widget site key from Step 4 |
91+
| Placeholder | Replace with |
92+
| ------------------------ | -------------------------------------------------------------------- |
93+
| `YOUR_SITEKEY` | The widget site key from Step 8 |
94+
| `turnstileFormEndpoint` | The path or URL to your form handler (Pages Function, Worker, etc.) |
95+
| `TURNSTILE_SECRET` | Env-var name in your backend. Value is the secret captured in Step 8.|

‎skills/turnstile-spin/references/nextjs-app.md‎

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Next.js (App Router)
22

3-
For `app/`-directory Next.js projects. The widget needs to run on the client, so the page or component must be `"use client"`.
3+
For `app/`-directory Next.js projects. The widget needs to run on the client, so the page or component must be `"use client"`. The siteverify call lives server-side, either in a Server Action or an API route.
44

55
```tsx title="app/signup/page.tsx"
66
"use client";
@@ -22,13 +22,13 @@ export default function SignupPage() {
2222

2323
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
2424
e.preventDefault();
25-
const res = await fetch("https://YOUR_WORKER_URL/", {
25+
const res = await fetch("/api/signup", {
2626
method: "POST",
2727
headers: { "Content-Type": "application/json" },
2828
body: JSON.stringify({ token }),
2929
});
3030
const data = await res.json();
31-
if (data.success) {
31+
if (data.ok) {
3232
// proceed
3333
}
3434
}
@@ -44,7 +44,7 @@ export default function SignupPage() {
4444
<div
4545
className="cf-turnstile"
4646
data-sitekey="YOUR_SITEKEY"
47-
data-action="turnstile-spin-v1"
47+
data-action="turnstile-spin-v2"
4848
data-callback="onTurnstileSuccess"
4949
/>
5050
<button type="submit" disabled={!token}>
@@ -58,32 +58,60 @@ export default function SignupPage() {
5858

5959
`data-callback` expects a string referencing a global function. The `useEffect` wires `window.onTurnstileSuccess` so the widget can call back into React state.
6060

61+
API route (canonical siteverify):
62+
63+
```ts title="app/api/signup/route.ts"
64+
export async function POST(req: Request) {
65+
const { token } = await req.json();
66+
const remoteip = req.headers.get("x-forwarded-for") ?? undefined;
67+
68+
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
69+
method: "POST",
70+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
71+
body: new URLSearchParams({
72+
secret: process.env.TURNSTILE_SECRET!,
73+
response: token,
74+
...(remoteip ? { remoteip } : {}),
75+
}),
76+
});
77+
const { success } = await r.json();
78+
if (!success) return new Response("forbidden", { status: 403 });
79+
80+
// existing signup logic runs here
81+
return Response.json({ ok: true });
82+
}
83+
```
84+
6185
## Variant: Server Action
6286

6387
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:
6488

6589
```tsx title="app/signup/actions.ts"
6690
"use server";
91+
import { headers } from "next/headers";
6792

6893
export async function submitSignup(formData: FormData) {
6994
const token = formData.get("cf-turnstile-response") as string;
95+
const remoteip = (await headers()).get("x-forwarded-for") ?? undefined;
7096

71-
const res = await fetch("https://YOUR_WORKER_URL/", {
97+
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
7298
method: "POST",
73-
headers: { "Content-Type": "application/json" },
74-
body: JSON.stringify({ token }),
99+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
100+
body: new URLSearchParams({
101+
secret: process.env.TURNSTILE_SECRET!,
102+
response: token,
103+
...(remoteip ? { remoteip } : {}),
104+
}),
75105
});
76-
const data = await res.json();
77-
if (!data.success) {
78-
return { error: "Verification failed" };
79-
}
106+
const { success } = await r.json();
107+
if (!success) return { error: "Verification failed" };
80108

81109
// process signup
82110
return { ok: true };
83111
}
84112
```
85113

86-
```tsx title="app/signup/page.tsx"
114+
```tsx title="app/signup/page.tsx (server-action variant)"
87115
"use client";
88116
import { submitSignup } from "./actions";
89117

@@ -94,7 +122,7 @@ export default function SignupPage() {
94122
<div
95123
className="cf-turnstile"
96124
data-sitekey="YOUR_SITEKEY"
97-
data-action="turnstile-spin-v1"
125+
data-action="turnstile-spin-v2"
98126
/>
99127
<button type="submit">Sign up</button>
100128
</form>
@@ -104,7 +132,7 @@ export default function SignupPage() {
104132

105133
## Substitutions
106134

107-
| Placeholder | Replace with |
108-
| ------------------ | ------------------------------------------- |
109-
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
110-
| `YOUR_SITEKEY` | Widget site key from Step 4 |
135+
| Placeholder | Replace with |
136+
| ------------------- | -------------------------------------------------------------------- |
137+
| `YOUR_SITEKEY` | The widget site key from Step 8 |
138+
| `TURNSTILE_SECRET` | Env-var name. Value is the secret captured in Step 8, kept off disk. |

0 commit comments

Comments
 (0)