Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 9 additions & 4 deletions skills/turnstile-spin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ This is a mirror of the canonical docs page at [`developers.cloudflare.com/turns
| File | Purpose |
| --------------------------------- | ---------------------------------------------------------------------- |
| `SKILL.md` | Main wizard instructions for the agent |
| `scripts/auth-probe.sh` | Probes the customer's Cloudflare API token for Turnstile scope |
| `scripts/widget-create.sh` | Creates the Turnstile widget via the Cloudflare API |
| `scripts/fetch-secret.sh` | Retrieves the secret for an existing widget (recovery flow) |
| `scripts/validate.sh` | Dummy-siteverify + hostname check at the end of the wizard |
| `scripts/persist-skill.sh` | Installs the canonical skill bundle into the user's repo |
| `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 |
| `tests/validation.md` | Validation cases matching the assertions in the PRD |

## How agents load it

Expand All @@ -32,14 +37,14 @@ 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).
For other agents, see the table in [`SKILL.md`](./SKILL.md#step-11--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.
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.

## 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
- [Turnstile server-side validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) — canonical siteverify reference
135 changes: 80 additions & 55 deletions skills/turnstile-spin/SKILL.md

Large diffs are not rendered by default.

75 changes: 39 additions & 36 deletions skills/turnstile-spin/references/astro.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# Astro

For Astro projects. The form posts directly to the Worker. Astro frontmatter handles config substitution at build time.
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.

```astro title="src/pages/signup.astro"
---
const WORKER_URL = import.meta.env.PUBLIC_TURNSTILE_WORKER_URL;
const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
---

Expand All @@ -17,12 +16,12 @@ const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
></script>
</head>
<body>
<form action={`${WORKER_URL}/`} method="POST">
<form action="/api/signup" method="POST">
<input name="email" type="email" required />
<div
class="cf-turnstile"
data-sitekey={SITEKEY}
data-action="turnstile-spin-v1"
data-action="turnstile-spin-v2"
/>
<button type="submit">Sign up</button>
</form>
Expand All @@ -33,36 +32,36 @@ const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
In your `.env`:

```text
PUBLIC_TURNSTILE_WORKER_URL=https://YOUR_WORKER_URL
PUBLIC_TURNSTILE_SITEKEY=YOUR_SITEKEY
TURNSTILE_SECRET=YOUR_SECRET
```

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

## Variant: hardcoded values
## API route (canonical siteverify)

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

```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>
export const POST: APIRoute = async ({ request, clientAddress }) => {
const form = await request.formData();
const token = form.get("cf-turnstile-response") as string;

const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: import.meta.env.TURNSTILE_SECRET,
response: token,
remoteip: clientAddress,
}),
});
const { success } = await verify.json();
if (!success) return new Response("forbidden", { status: 403 });

// process signup
return Response.json({ ok: true });
};
```

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

## Substitutions

| Placeholder | Replace with |
| ------------------ | ------------------------------------------- |
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | Widget site key from Step 4 |
| Placeholder | Replace with |
| ------------------- | -------------------------------------------------------------------- |
| `YOUR_SITEKEY` | The widget site key from Step 8 |
| `YOUR_SECRET` | The secret captured in Step 8. Stays in env, never inlined. |
49 changes: 41 additions & 8 deletions skills/turnstile-spin/references/hugo.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Hugo

For Hugo static sites. Use a partial for the widget so it can be referenced from any layout or content file.
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).

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

<form action="{{ .Site.Params.turnstileWorkerUrl }}/" method="POST">
<form action="{{ .Site.Params.turnstileFormEndpoint }}" method="POST">
<input name="email" type="email" required />
<div
class="cf-turnstile"
data-sitekey="{{ .Site.Params.turnstileSitekey }}"
data-action="turnstile-spin-v1"
data-action="turnstile-spin-v2"
></div>
<button type="submit">Subscribe</button>
</form>
Expand All @@ -25,7 +25,7 @@ Add the params to your site config:
```toml title="hugo.toml"
[params]
turnstileSitekey = "YOUR_SITEKEY"
turnstileWorkerUrl = "https://YOUR_WORKER_URL"
turnstileFormEndpoint = "/api/subscribe" # path to your existing form handler
```

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

## Backend (where siteverify lives)

Hugo doesn't host server-side code, so the form endpoint must live elsewhere. Two common setups:

**Cloudflare Pages Function** (`functions/api/subscribe.js`):

```js
export async function onRequestPost({ request, env }) {
const form = await request.formData();
const token = form.get("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: env.TURNSTILE_SECRET,
response: token,
remoteip: request.headers.get("CF-Connecting-IP"),
}),
});
const { success } = await r.json();
if (!success) return new Response("forbidden", { status: 403 });

// process subscribe
return new Response("ok");
}
```

Set the secret with `npx wrangler pages secret put TURNSTILE_SECRET` (or via the dashboard's Pages → your project → Settings → Environment variables → Add secret).

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

## Variant: shortcode for content files

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

## Substitutions

| Placeholder | Replace with |
| ------------------ | ------------------------------------------- |
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | Widget site key from Step 4 |
| Placeholder | Replace with |
| ------------------------ | -------------------------------------------------------------------- |
| `YOUR_SITEKEY` | The widget site key from Step 8 |
| `turnstileFormEndpoint` | The path or URL to your form handler (Pages Function, Worker, etc.) |
| `TURNSTILE_SECRET` | Env-var name in your backend. Value is the secret captured in Step 8.|
62 changes: 45 additions & 17 deletions skills/turnstile-spin/references/nextjs-app.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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"`.
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.

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

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const res = await fetch("https://YOUR_WORKER_URL/", {
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (data.success) {
if (data.ok) {
// proceed
}
}
Expand All @@ -44,7 +44,7 @@ export default function SignupPage() {
<div
className="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
data-action="turnstile-spin-v2"
data-callback="onTurnstileSuccess"
/>
<button type="submit" disabled={!token}>
Expand All @@ -58,32 +58,60 @@ export default function SignupPage() {

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

API route (canonical siteverify):

```ts title="app/api/signup/route.ts"
export async function POST(req: Request) {
const { token } = await req.json();
const remoteip = req.headers.get("x-forwarded-for") ?? undefined;

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 ? { remoteip } : {}),
}),
});
const { success } = await r.json();
if (!success) return new Response("forbidden", { status: 403 });

// existing signup logic runs here
return Response.json({ ok: true });
}
```

## 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";
import { headers } from "next/headers";

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

const res = await fetch("https://YOUR_WORKER_URL/", {
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET!,
response: token,
...(remoteip ? { remoteip } : {}),
}),
});
const data = await res.json();
if (!data.success) {
return { error: "Verification failed" };
}
const { success } = await r.json();
if (!success) return { error: "Verification failed" };

// process signup
return { ok: true };
}
```

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

Expand All @@ -94,7 +122,7 @@ export default function SignupPage() {
<div
className="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-action="turnstile-spin-v1"
data-action="turnstile-spin-v2"
/>
<button type="submit">Sign up</button>
</form>
Expand All @@ -104,7 +132,7 @@ export default function SignupPage() {

## Substitutions

| Placeholder | Replace with |
| ------------------ | ------------------------------------------- |
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
| `YOUR_SITEKEY` | Widget site key from Step 4 |
| Placeholder | Replace with |
| ------------------- | -------------------------------------------------------------------- |
| `YOUR_SITEKEY` | The widget site key from Step 8 |
| `TURNSTILE_SECRET` | Env-var name. Value is the secret captured in Step 8, kept off disk. |
Loading