diff --git a/skills/turnstile-spin/README.md b/skills/turnstile-spin/README.md index c498fed..1666382 100644 --- a/skills/turnstile-spin/README.md +++ b/skills/turnstile-spin/README.md @@ -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 @@ -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 diff --git a/skills/turnstile-spin/SKILL.md b/skills/turnstile-spin/SKILL.md index 6a1d5dd..44fcfe0 100644 --- a/skills/turnstile-spin/SKILL.md +++ b/skills/turnstile-spin/SKILL.md @@ -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, 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. +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. references: - vanilla-html - nextjs-app @@ -12,9 +12,9 @@ references: # 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. +Turns the prompt "set up Turnstile" into a working end-to-end integration: a widget, frontend snippets at every chosen insertion point, canonical server-side siteverify in the customer's existing backend, 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. +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 + backend 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. @@ -33,13 +33,13 @@ Do not load for unrelated Cloudflare tasks (Workers, Pages, R2, etc.) unless Tur 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. +1. **Brief acknowledge.** One sentence: "I'll run Turnstile setup end to end. That's: check auth, scan the codebase, create the widget, embed it on the right forms, wire server-side siteverify, 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/`. +2. **CLI check.** Spin's helper scripts use `curl` against `api.cloudflare.com` and `npx wrangler whoami` for account enumeration. Widget creation in Step 8 prefers `wrangler turnstile widget create` when the subcommand is available (Wrangler 4.109+), falling back to the bundled curl script otherwise. No persistent CLI install is required. 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: + - `missing_token` or `missing_scope`: ask the user to create a token at https://dash.cloudflare.com/profile/api-tokens → Custom token → permission `Account.Turnstile:Edit` → include the target account in Account Resources. **Do NOT direct them to `wrangler login`** unless wrangler's OAuth scope includes `Account.Turnstile:Edit` (varies by wrangler version). Offer three ways to hand the token over, cleanest first: 1. **Export + relaunch** (token never enters chat): `export CLOUDFLARE_API_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' '' > ~/.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). @@ -51,37 +51,67 @@ The user pasted the prompt. You are in a multi-step dialog. Detect what you can, 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 ``. OK?" **[wait for user]** If no production domain is found, ask. -6. **Codebase scan.** Detect framework + insertion candidates silently. +6. **Codebase scan.** Detect three things silently: + - **Frontend framework** (Next.js, Astro, SvelteKit, Hugo, vanilla, etc.) → drives the widget embed snippet. + - **Backend handler location** (Express route, Next.js API route, Rails controller, Workers fetch handler, Pages Function, etc.) → drives the siteverify snippet. + - **Existing CAPTCHA** (reCAPTCHA / hCaptcha) → switches Step 7 to migration mode. 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 --name --domains --mode managed`. Report the sitekey. The secret stays in env; never write it to disk. +8. **Widget creation.** Prefer the wrangler CLI when its `turnstile widget` subcommand is available: -9. **Worker deploy.** Run `scripts/worker-deploy.sh --name turnstile-siteverify-` 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 ` before running validation. + ```sh + npx wrangler turnstile widget create "" \ + --domain --domain ... --mode managed --json + ``` -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). + Parse `sitekey` and `secret` from stdout JSON. If wrangler is missing, older than the turnstile subcommand (`unknown command`), or otherwise fails, fall back to `scripts/widget-create.sh --account-id --name --domains --mode managed`, which uses `curl` against the Cloudflare API directly. Report the sitekey. Capture the secret into a shell variable `WIDGET_SECRET`; never write it to disk except into the user's own env / secret store in Step 9. -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]** +9. **Wire the integration.** State the contract: "I'll embed the widget on each chosen form and add a canonical siteverify call inside your existing submit handler, gated on `success === true`. The handler logic stays the same. The secret lives in your env as `TURNSTILE_SECRET`." Ask "yes" / "show". **[wait for user]** If "show", print unified diffs and ask again. Do NOT propose alternate behavior (mail delivery, custom backends). -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 `. + Canonical server-side siteverify (Node / fetch idiom; adapt to the detected backend): -13. **Final report.** Print the structured summary: what was created, what was validated, what to do next. + ```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(); + if (!result.success) { + return reject(403, 'forbidden'); // platform-appropriate equivalent + } + // existing handler logic runs here, unchanged + ``` + + Write the secret into the user's secret store (`.env` for Node/Rails/Python, `wrangler secret put TURNSTILE_SECRET` for Workers, the platform's secret manager for Vercel / Fly / Render / etc.). Never inline. + +10. **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]** + +11. **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 `. + +12. **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 write the Turnstile secret to disk except as part of the user's own env / secret store. - 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 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 a managed Worker before the user's existing form handler runs. Everything else is out of scope: +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. -- **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). +- **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`. @@ -89,7 +119,7 @@ Spin validates the Turnstile token via a managed Worker before the user's existi ### 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.). +When the user has Cloudflare dashboard access, the in-dashboard **Fix with Spin** banner is a one-click recovery path: it shows a curated agent prompt for the existing widget. This skill's recovery flow below is the equivalent when the user is driving from their editor. 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 ``"): @@ -98,29 +128,28 @@ If the user tells you they already have a Turnstile widget set up and want to wi - `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). + - `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 (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). +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 -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 -}); +When wiring an existing form (Step 9), 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. + +Frontend (embeds the widget; submits to the user's existing endpoint): + +```html + + +
+ +
+ +
``` -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. +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. ## Migrating from another CAPTCHA @@ -132,36 +161,32 @@ Detection signals: 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"`. +- 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-v2"`. - 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. +- Backend siteverify URL points at `https://challenges.cloudflare.com/turnstile/v0/siteverify`. Drop `RECAPTCHA_SECRET` / `HCAPTCHA_SECRET` env vars; add `TURNSTILE_SECRET`. 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. +- **Custom `action=` values.** Preserve any custom action the user passed to `grecaptcha.execute` as `data-action` on the widget. Use `turnstile-spin-v2` 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 `, 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 `. Surface the `detail` field to the user — it carries the wrangler error. | +| Situation | Action | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npx wrangler whoami` fails | The auth probe needs wrangler to enumerate accounts. Install path: `npm install --save-dev wrangler` (Node project) or `npm install -g wrangler` (other). If install is blocked, fall back to `curl https://api.cloudflare.com/client/v4/accounts -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"` and pass the chosen ID via `$CLOUDFLARE_ACCOUNT_ID`. | +| Multiple Cloudflare accounts | `scripts/auth-probe.sh` returns all accounts; ask the user to choose, export `CLOUDFLARE_ACCOUNT_ID` | +| Cloudflare Pages project | Wire siteverify inside a Pages Function (or the equivalent for your framework). The Pages Plugin at [developers.cloudflare.com/pages/functions/plugins/turnstile](https://developers.cloudflare.com/pages/functions/plugins/turnstile/) is a shortcut. | +| Cloudflare Workers backend | Use the canonical fetch idiom from Step 9 inside the Worker's request handler. `fetch` to `challenges.cloudflare.com` works the same way it does in Node. | +| `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":[...]}'` | +| Token expired mid-flow | Stop, re-run `scripts/auth-probe.sh`, prompt for fresh credentials | +| Validation returns `invalid-input-secret` | The secret didn't reach the backend. Re-check `TURNSTILE_SECRET` in the customer's env / secret manager. If it's a Workers backend, run `wrangler secret list` to confirm the secret is bound to the right script. | +| Validation returns `invalid-input-response` | Expected for a dummy probe token; that means the secret IS valid. validate.sh treats this as success. | ## 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. +Every `cf-turnstile` div this skill writes must include `data-action="turnstile-spin-v2"`. 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. + +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. -- 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. diff --git a/skills/turnstile-spin/references/astro.md b/skills/turnstile-spin/references/astro.md index a4c3944..7677c09 100644 --- a/skills/turnstile-spin/references/astro.md +++ b/skills/turnstile-spin/references/astro.md @@ -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; --- @@ -17,12 +16,12 @@ const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY; > -
+
@@ -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" - - - - - -
-
- - - - +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 @@ -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"); @@ -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. | diff --git a/skills/turnstile-spin/references/hugo.md b/skills/turnstile-spin/references/hugo.md index 0c832f5..400f91d 100644 --- a/skills/turnstile-spin/references/hugo.md +++ b/skills/turnstile-spin/references/hugo.md @@ -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" -
+
@@ -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: @@ -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: @@ -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.| diff --git a/skills/turnstile-spin/references/nextjs-app.md b/skills/turnstile-spin/references/nextjs-app.md index 24d4621..ac3fe19 100644 --- a/skills/turnstile-spin/references/nextjs-app.md +++ b/skills/turnstile-spin/references/nextjs-app.md @@ -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"; @@ -22,13 +22,13 @@ export default function SignupPage() { async function handleSubmit(e: React.FormEvent) { 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 } } @@ -44,7 +44,7 @@ export default function SignupPage() {
@@ -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. | diff --git a/skills/turnstile-spin/references/nextjs-pages.md b/skills/turnstile-spin/references/nextjs-pages.md index f10a9af..4267641 100644 --- a/skills/turnstile-spin/references/nextjs-pages.md +++ b/skills/turnstile-spin/references/nextjs-pages.md @@ -1,6 +1,6 @@ # 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. +For older Next.js projects using `pages/` rather than `app/`. The widget renders client-side; siteverify lives in the API route. ```tsx title="pages/signup.tsx" import Script from "next/script"; @@ -9,12 +9,12 @@ export default function SignupPage() { return ( <> - -
+
- +
``` -## Variant: Form action server-side - -If you use SvelteKit's form actions for the signup logic, do siteverify from the action: +Form action (canonical siteverify): ```ts title="src/routes/signup/+page.server.ts" import type { Actions } from "./$types"; import { fail } from "@sveltejs/kit"; +import { TURNSTILE_SECRET } from "$env/static/private"; export const actions: Actions = { - default: async ({ request }) => { + default: async ({ request, getClientAddress }) => { const data = await request.formData(); const token = data.get("cf-turnstile-response") as string; - const verify = await fetch("https://YOUR_WORKER_URL/", { + const verify = 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: TURNSTILE_SECRET, + response: token, + remoteip: getClientAddress(), + }), }); const result = await verify.json(); if (!result.success) { @@ -74,11 +54,43 @@ export const actions: Actions = { }; ``` -With this variant the form posts via SvelteKit's progressive enhancement; the page component drops the explicit `fetch` call and just uses a native `
`. +In `.env`: + +```text +TURNSTILE_SECRET=YOUR_SECRET +``` + +The `$env/static/private` import enforces that the secret never reaches the client bundle. + +## Variant: client-side fetch to an endpoint + +If you need a JSON API rather than progressive-enhancement form post, use `+server.ts`: + +```ts title="src/routes/api/signup/+server.ts" +import type { RequestHandler } from "./$types"; +import { TURNSTILE_SECRET } from "$env/static/private"; + +export const POST: RequestHandler = async ({ request, getClientAddress }) => { + const { token } = await request.json(); + 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: TURNSTILE_SECRET, + response: token, + remoteip: getClientAddress(), + }), + }); + const { success } = await verify.json(); + if (!success) return new Response("forbidden", { status: 403 }); + // process signup + return new Response(JSON.stringify({ ok: true }), { status: 200 }); +}; +``` ## 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. | diff --git a/skills/turnstile-spin/references/vanilla-html.md b/skills/turnstile-spin/references/vanilla-html.md index 43bf365..42470d7 100644 --- a/skills/turnstile-spin/references/vanilla-html.md +++ b/skills/turnstile-spin/references/vanilla-html.md @@ -1,6 +1,6 @@ # 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. +For static sites or any project without a JS framework. The widget renders client-side; the form submits to whatever backend handles your form (a Node/PHP/Ruby/Go server, a Cloudflare Worker, a Pages Function, a third-party form host that supports server-side hooks, etc.). ```html @@ -13,12 +13,12 @@ For static sites or any project without a JS framework. The form posts directly > - +
@@ -26,19 +26,48 @@ For static sites or any project without a JS framework. The form posts directly ``` -When the form is submitted, the browser includes `cf-turnstile-response` automatically. The Worker reads it (alias for `token`) and responds with siteverify JSON. +When the form submits, the browser includes `cf-turnstile-response` automatically. Your backend reads it and calls canonical siteverify. -## Where to put the snippet +## Backend (any language) -- Single-page site: in the `` of `index.html`, wherever the form lives. -- Multi-page site: in each page that has a form. The script tag in `` can live in a shared layout. +Add this to your existing `/api/subscribe` handler before the rest of its logic: -## Substitutions +```js +// Node / fetch idiom +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 +``` + +Equivalent calls in other backend languages: -| Placeholder | Replace with | -| ------------------ | ------------------------------------------------------- | -| `YOUR_WORKER_URL` | The deployed Worker URL from Step 5 | -| `YOUR_SITEKEY` | The widget site key from Step 4 | +```ruby +# Ruby +require 'net/http'; require 'uri'; require 'json' +res = Net::HTTP.post_form(URI('https://challenges.cloudflare.com/turnstile/v0/siteverify'), + secret: ENV['TURNSTILE_SECRET'], response: params['cf-turnstile-response'], remoteip: request.ip) +halt 403 unless JSON.parse(res.body)['success'] +``` + +```python +# Python (requests) +r = requests.post('https://challenges.cloudflare.com/turnstile/v0/siteverify', + data={'secret': os.environ['TURNSTILE_SECRET'], + 'response': form['cf-turnstile-response'], + 'remoteip': request.remote_addr}) +if not r.json()['success']: + return '', 403 +``` ## Variant: AJAX submit instead of form action @@ -49,7 +78,7 @@ If the form is submitted via `fetch` instead of a native form post, the snippet document.querySelector("form").addEventListener("submit", async (e) => { e.preventDefault(); const data = new FormData(e.target); - const res = await fetch("https://YOUR_WORKER_URL/", { + const res = await fetch("/api/subscribe", { method: "POST", body: data, }); @@ -60,3 +89,19 @@ If the form is submitted via `fetch` instead of a native form post, the snippet }); ``` + +## No backend? + +If your project is pure-static (no server-side handler — just HTML served from a CDN), Spin doesn't apply. Siteverify is server-side by design. Options: + +- Add a Cloudflare Pages Function (`functions/api/subscribe.js`) to host the siteverify call. +- Deploy a tiny Cloudflare Worker that does siteverify against your existing form host. +- Use a third-party form host that exposes a server-side webhook where you can wire siteverify. + +## Substitutions + +| Placeholder | Replace with | +| ------------------- | -------------------------------------------------------------------- | +| `YOUR_SITEKEY` | The widget site key from Step 8 | +| `/api/subscribe` | The path to your existing form-handling endpoint | +| `TURNSTILE_SECRET` | Env-var name. Value is the secret captured in Step 8, kept off disk. | diff --git a/skills/turnstile-spin/scripts/auth-probe.sh b/skills/turnstile-spin/scripts/auth-probe.sh index 918200f..0facf52 100755 --- a/skills/turnstile-spin/scripts/auth-probe.sh +++ b/skills/turnstile-spin/scripts/auth-probe.sh @@ -6,14 +6,13 @@ # $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 +# "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. The agent surfaces them to the user. +# Human-readable diagnostics go to stderr. set -uo pipefail @@ -36,7 +35,6 @@ if [ -z "$whoami_json" ] || [ "$(echo "$whoami_json" | head -c 1)" != "{" ]; the 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)))")) @@ -45,15 +43,10 @@ if [ -z "$account_count" ] || [ "$account_count" = "0" ] || [ "$account_count" = 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" @@ -64,7 +57,7 @@ else emit "{\"status\":\"multiple_accounts\",\"accounts\":$accounts_json}" fi -# Probe Turnstile scope on the selected account +# 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" \ @@ -77,20 +70,4 @@ if [ "$success" != "true" ]; then 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}" diff --git a/skills/turnstile-spin/scripts/fetch-secret.sh b/skills/turnstile-spin/scripts/fetch-secret.sh index 8b1b190..be93eff 100755 --- a/skills/turnstile-spin/scripts/fetch-secret.sh +++ b/skills/turnstile-spin/scripts/fetch-secret.sh @@ -1,6 +1,7 @@ #!/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. +# 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) diff --git a/skills/turnstile-spin/scripts/validate.sh b/skills/turnstile-spin/scripts/validate.sh index e5c49c4..e9f129d 100755 --- a/skills/turnstile-spin/scripts/validate.sh +++ b/skills/turnstile-spin/scripts/validate.sh @@ -1,24 +1,29 @@ #!/usr/bin/env bash -# Validates a deployed Turnstile siteverify Worker end-to-end. +# Validates a Turnstile siteverify integration end-to-end. # # Reads: -# $CLOUDFLARE_API_TOKEN (required for hostname check) +# $TURNSTILE_SECRET (required for the dummy-token check) +# $CLOUDFLARE_API_TOKEN (optional — when set, also runs the widget-domains +# sanity check; when unset, that check is skipped +# so the post-dashboard flow can validate without +# a manually-created token) # # Args: -# --worker-url Deployed Worker URL (from worker-deploy.sh) -# --account-id Cloudflare account ID -# --sitekey Widget sitekey (from widget-create.sh) -# --expected-domains Comma-separated domains that must appear in the widget's domains array +# --account-id Cloudflare account ID (only used when CLOUDFLARE_API_TOKEN is set) +# --sitekey Widget sitekey +# --expected-domains 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":""} +# Outputs JSON. Exit 0 if all checks pass, 1 otherwise. +# ok: {"status":"ok","hostname_check":"ran"|"skipped"} +# fail: {"status":"error","check":"dummy_siteverify|hostname","detail":""} set -uo pipefail +ACCOUNT_ID="" +SITEKEY="" +EXPECTED_DOMAINS="" 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 ;; @@ -26,45 +31,52 @@ while [[ $# -gt 0 ]]; do esac done -: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN must be set}" -: "${WORKER_URL:?--worker-url required}" -: "${ACCOUNT_ID:?--account-id required}" +: "${TURNSTILE_SECRET:?TURNSTILE_SECRET must be set (the secret captured in Step 8)}" : "${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 "") +# Check 1: dummy-token siteverify against challenges.cloudflare.com. +# A valid secret + dummy token returns success:false with +# error-codes:["invalid-input-response"]. That confirms the secret is +# correctly bound to the widget; anything else is a real misconfiguration. +dummy=$(curl -sS -X POST "https://challenges.cloudflare.com/turnstile/v0/siteverify" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "secret=${TURNSTILE_SECRET}" \ + --data-urlencode "response=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")) +codes=$(echo "$dummy" | (jq -r '.["error-codes"] // [] | join(",")' 2>/dev/null || echo "")) -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\"}" +if [ "$success" != "false" ]; then + echo "validate: siteverify returned unexpected shape for a dummy token: $dummy" >&2 + echo "{\"status\":\"error\",\"check\":\"dummy_siteverify\",\"detail\":\"expected success:false on a dummy token\"}" 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 +case ",$codes," in + *,invalid-input-secret,*) + echo "validate: siteverify rejected the secret. TURNSTILE_SECRET does not match the widget's secret." >&2 + echo "{\"status\":\"error\",\"check\":\"dummy_siteverify\",\"detail\":\"invalid-input-secret\"}" + exit 1 + ;; + *,invalid-input-response,*) + : # Expected. Continue. + ;; + *) + echo "validate: unexpected error codes from siteverify: $codes" >&2 + echo "{\"status\":\"error\",\"check\":\"dummy_siteverify\",\"detail\":\"unexpected codes: $codes\"}" + exit 1 + ;; +esac + +# Check 2: hostname / widget domains registered. Optional — requires a +# Cloudflare API token. When the token isn't available (e.g. post-dashboard +# success-card flow), skip this check and report `hostname_check: skipped`. +if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] || [ -z "$ACCOUNT_ID" ] || [ -z "$EXPECTED_DOMAINS" ]; then + echo "validate: skipping hostname check (CLOUDFLARE_API_TOKEN, --account-id, or --expected-domains not provided)" >&2 + echo '{"status":"ok","hostname_check":"skipped"}' + exit 0 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) @@ -84,4 +96,4 @@ if [ -n "$missing" ]; then exit 1 fi -echo '{"status":"ok"}' +echo '{"status":"ok","hostname_check":"ran"}' diff --git a/skills/turnstile-spin/scripts/worker-deploy.sh b/skills/turnstile-spin/scripts/worker-deploy.sh deleted file mode 100755 index 381a852..0000000 --- a/skills/turnstile-spin/scripts/worker-deploy.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/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 Base name; appends a hash suffix if taken -# --deploy-dir 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":"","worker_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":""} -# url_parse: {"status":"error","reason":"url_parse_failed","worker_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\"}" diff --git a/skills/turnstile-spin/templates/worker/.gitignore b/skills/turnstile-spin/templates/worker/.gitignore deleted file mode 100644 index 612346c..0000000 --- a/skills/turnstile-spin/templates/worker/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules/ -.dev.vars -.wrangler/ -.dist/ -.vitest-cache/ -*.log -.DS_Store -.env -.env.local diff --git a/skills/turnstile-spin/templates/worker/LICENSE b/skills/turnstile-spin/templates/worker/LICENSE deleted file mode 100644 index dcdadd6..0000000 --- a/skills/turnstile-spin/templates/worker/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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. diff --git a/skills/turnstile-spin/templates/worker/README.md b/skills/turnstile-spin/templates/worker/README.md deleted file mode 100644 index 46e8756..0000000 --- a/skills/turnstile-spin/templates/worker/README.md +++ /dev/null @@ -1,196 +0,0 @@ -# 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:///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 `
` 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 - - - - -
- -
-``` - -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 diff --git a/skills/turnstile-spin/templates/worker/package.json b/skills/turnstile-spin/templates/worker/package.json deleted file mode 100644 index 6b03a15..0000000 --- a/skills/turnstile-spin/templates/worker/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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" - } -} diff --git a/skills/turnstile-spin/templates/worker/public/post-deploy.html b/skills/turnstile-spin/templates/worker/public/post-deploy.html deleted file mode 100644 index 4bd2606..0000000 --- a/skills/turnstile-spin/templates/worker/public/post-deploy.html +++ /dev/null @@ -1,287 +0,0 @@ - - - - - - Turnstile Spin — Deploy complete - - - -
-
Deploy complete
-

Your Turnstile Spin backend is live.

-

- This Worker is the managed siteverify backend for - Turnstile Spin. - You have two remaining steps: get a site key, and paste both values - into your site. -

- -

1. Your Worker URL

-
- -
- - -
-
Checking /health...
-
- -

2. Your site key

-
-

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

- -

- After creating the widget, copy its site key (public, - goes in your HTML) and its secret (private, goes in - the Worker). Then run: -

-
npx wrangler secret put TURNSTILE_SECRET_KEY --name turnstile-siteverify
-# paste the secret when prompted
-
- -

3. Paste this into your site

-
- - - -
<!-- enter your site key above to generate the snippet -->
- -
- -

Next

-

- Open your site in a browser. Solve the Turnstile widget. Confirm the - form submission lands at your Worker and that the network response - includes "success": true. -

-

- If you have a codebase and a coding agent, the AI agent path is faster. - See developers.cloudflare.com/turnstile/spin. -

-
- - - - diff --git a/skills/turnstile-spin/templates/worker/src/errors.ts b/skills/turnstile-spin/templates/worker/src/errors.ts deleted file mode 100644 index f28888a..0000000 --- a/skills/turnstile-spin/templates/worker/src/errors.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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, - }, - }; -} diff --git a/skills/turnstile-spin/templates/worker/src/index.ts b/skills/turnstile-spin/templates/worker/src/index.ts deleted file mode 100644 index 262db59..0000000 --- a/skills/turnstile-spin/templates/worker/src/index.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * 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 { - 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 { - const contentType = request.headers.get('content-type') || ''; - - if (contentType.includes('application/json')) { - const body = (await request.json()) as Record; - 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 { - 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 { - 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; diff --git a/skills/turnstile-spin/templates/worker/src/observability.ts b/skills/turnstile-spin/templates/worker/src/observability.ts deleted file mode 100644 index 8c5eff8..0000000 --- a/skills/turnstile-spin/templates/worker/src/observability.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 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)); -} diff --git a/skills/turnstile-spin/templates/worker/src/types.ts b/skills/turnstile-spin/templates/worker/src/types.ts deleted file mode 100644 index 250d02b..0000000 --- a/skills/turnstile-spin/templates/worker/src/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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; -} diff --git a/skills/turnstile-spin/templates/worker/src/validate.ts b/skills/turnstile-spin/templates/worker/src/validate.ts deleted file mode 100644 index ac8fdd5..0000000 --- a/skills/turnstile-spin/templates/worker/src/validate.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 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 { - 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; - 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 { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { - method: 'POST', - body, - signal: controller.signal, - }); - } finally { - clearTimeout(timer); - } -} diff --git a/skills/turnstile-spin/templates/worker/test/deploy.test.ts b/skills/turnstile-spin/templates/worker/test/deploy.test.ts deleted file mode 100644 index 57ae4f8..0000000 --- a/skills/turnstile-spin/templates/worker/test/deploy.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 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); - }); -}); diff --git a/skills/turnstile-spin/templates/worker/test/integration.test.ts b/skills/turnstile-spin/templates/worker/test/integration.test.ts deleted file mode 100644 index 4509079..0000000 --- a/skills/turnstile-spin/templates/worker/test/integration.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * 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); -}); diff --git a/skills/turnstile-spin/templates/worker/test/unit.test.ts b/skills/turnstile-spin/templates/worker/test/unit.test.ts deleted file mode 100644 index 5db04eb..0000000 --- a/skills/turnstile-spin/templates/worker/test/unit.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * 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).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'); - }); -}); diff --git a/skills/turnstile-spin/templates/worker/test/validation.test.ts b/skills/turnstile-spin/templates/worker/test/validation.test.ts deleted file mode 100644 index 3d4c63f..0000000 --- a/skills/turnstile-spin/templates/worker/test/validation.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * 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 = 'Post-deploy'; - 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'); - }); -}); diff --git a/skills/turnstile-spin/templates/worker/tsconfig.json b/skills/turnstile-spin/templates/worker/tsconfig.json deleted file mode 100644 index 155c5df..0000000 --- a/skills/turnstile-spin/templates/worker/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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"] -} diff --git a/skills/turnstile-spin/templates/worker/vitest.config.ts b/skills/turnstile-spin/templates/worker/vitest.config.ts deleted file mode 100644 index 6030868..0000000 --- a/skills/turnstile-spin/templates/worker/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -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'], - }, -}); diff --git a/skills/turnstile-spin/templates/worker/wrangler.toml b/skills/turnstile-spin/templates/worker/wrangler.toml deleted file mode 100644 index b286f51..0000000 --- a/skills/turnstile-spin/templates/worker/wrangler.toml +++ /dev/null @@ -1,36 +0,0 @@ -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 diff --git a/skills/turnstile-spin/tests/validation.md b/skills/turnstile-spin/tests/validation.md index 081bfae..d2b4b5e 100644 --- a/skills/turnstile-spin/tests/validation.md +++ b/skills/turnstile-spin/tests/validation.md @@ -1,52 +1,44 @@ # 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. +These cases match the 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 +## Test 1 — Dummy siteverify returns a structured error -Given a deployed Worker, the agent should be able to parse `/health` without ambiguity. +Step 10's `validate.sh` sends a deliberately-invalid token directly to `challenges.cloudflare.com/turnstile/v0/siteverify` using the captured secret. The expected response is `success: false` with `error-codes: ["invalid-input-response"]`. Anything else means the secret is wrong or the widget is misconfigured. ```sh -curl -sf "${WORKER_URL}/health" | jq -e '.ok == true and (.version | type) == "string"' +curl -s -X POST "https://challenges.cloudflare.com/turnstile/v0/siteverify" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "secret=${TURNSTILE_SECRET}" \ + --data-urlencode "response=XXXX.DUMMY.TOKEN.XXXX" | \ + jq -e '.success == false and (.["error-codes"] | index("invalid-input-response"))' ``` 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 +## Test 2 — Hostname configuration ```sh -npx wrangler turnstile widget show "${WIDGET_ID}" | \ +curl -s "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/challenges/widgets/${SITEKEY}" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | \ jq -e '.result.domains | contains(["example.com"])' ``` Expected exit code: 0. -## Test 4 — Telemetry marker is in every written snippet +## Test 3 — 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") +rg -l 'data-action="turnstile-spin-v2"' <(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. +Expected: every written file matches. If a snippet was written without the marker, the wizard skipped the Step 9 contract (or the agent edited the template). Re-run. -## Test 5 — Skill persists to the right location +## Test 4 — Skill persists to the right location -After Step 8: +After Step 11: ```sh test -f .claude/skills/turnstile-spin/SKILL.md \ @@ -62,7 +54,7 @@ Expected exit code: 0. ## Running all cases ```sh -WORKER_URL=https://your-worker.workers.dev WIDGET_ID=0x4AAAAAAA... \ +ACCOUNT_ID=... SITEKEY=... TURNSTILE_SECRET=... CLOUDFLARE_API_TOKEN=... \ bash tests/run-all.sh ```