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