Skip to content

Commit 7548066

Browse files
juleslemeejules lemee
andauthored
Add Turnstile Spin skill (#58)
* Add Turnstile Spin skill Adds an agent skill at skills/turnstile-spin/ that scaffolds Cloudflare Turnstile end-to-end: scan the codebase, create the widget via the Cloudflare API, deploy the managed siteverify Worker (template bundled at skills/turnstile-spin/templates/worker/), write the frontend snippets, and validate the integration. Every emitted widget carries action='turnstile-spin-v1' for analytics segmentation. Mirrors developers.cloudflare.com/turnstile/spin/. * Refactor turnstile-spin skill: extract deterministic logic to scripts/ Per maintainer feedback (cf #58), SKILL.md is now a thin orchestration layer (~160 LoC, down from ~500) and the deterministic API calls + retry logic live in 6 helper scripts: - scripts/auth-probe.sh: token + scope + account-mismatch detection - scripts/widget-create.sh: Cloudflare API widget creation - scripts/worker-deploy.sh: degit + wrangler deploy + secret put + retry on name conflict - scripts/validate.sh: health + dummy-siteverify + hostname-domains end-to-end checks - scripts/persist-skill.sh: fetch canonical SKILL.md with HTTP 200 + frontmatter validation - scripts/fetch-secret.sh: recovery-flow secret retrieval via API Each script outputs structured JSON; the agent branches on the status field instead of parsing prose. The conversational flow, hard scope boundaries, recovery decision tree, and CAPTCHA-migration logic remain in SKILL.md since those are agent-behavior policy, not API mechanics. * fix(turnstile-spin): multi-account auth probe, Workers scope check, set_secret failure handling - auth-probe.sh now handles multi-account tokens: emits multiple_accounts when >1 accounts and CLOUDFLARE_ACCOUNT_ID is unset, checks membership (not [0] equality) when the env var is set - auth-probe.sh probes GET /workers/scripts after GET /challenges/widgets so a token missing Account.Workers Scripts:Edit fails before the agent creates a widget (irreversible) and then tries to deploy - worker-deploy.sh makes set_secret failure fatal: previously the script could emit status:ok with the Worker deployed but TURNSTILE_SECRET_KEY unset - SKILL.md Step 3 documents the new statuses (multiple_accounts, missing_workers_scope) and the re-run loop after the user picks an account - SKILL.md Step 9 documents the set_secret_failed recovery path * fix(turnstile-spin): capture both stdout and stderr in worker-deploy Wrangler emits the deployed Worker URL on stdout (progress indicators on stderr). The script captured only stderr and then grepped that file for the URL, so successful deploys could still emit url_parse_failed. Capture both streams. * fix(turnstile-spin): comprehensive sweep before reviewer round 3 Addresses 14 issues found in an internal adversarial review: Scripts: - auth-probe.sh: reword Workers scope comment (best-effort proxy, not a guarantee that Read implies Edit). - worker-deploy.sh: URL parser falls back from workers.dev regex to a broader match for custom domains and Workers for Platforms. set_secret failure now captures wrangler stderr into a temp file and includes a detail field in the error JSON instead of swallowing the diagnostic. - fetch-secret.sh: emit clearance_level and domains so the recovery flow's pre-clearance check is implementable. Without these the contract was documentation theatre. - validate.sh: hit /health (not /); the diagnostic was lying about the path. Also assert _worker metadata is present in the dummy siteverify response (Step 7b promised this; the script never checked). - persist-skill.sh: install canonical bundle (SKILL.md + scripts/ + references/ + templates/) via degit from cloudflare/skills instead of writing the inlined bootstrap index.md. Persisted skills now re-run without the /tmp/ bootstrap path. SKILL.md: - Recovery flow uses fetch-secret.sh's new clearance_level and domains fields. Drops the ?widget=<id> URL trigger (nothing parsed it; it was documentation theatre). Recovery is now invoked verbally. - Edge case table replaces $WORKER_NAME env var references with the worker_name field returned by worker-deploy.sh. - Adds explicit set_secret_failed recovery row. Worker template: - parseBody accepts the reCAPTCHA-style `response` field name alongside `token` and `cf-turnstile-response`, so backends migrated from reCAPTCHA/hCaptcha work without backend code changes (drop the secret field, keep the rest). * fix(turnstile-spin): match docs on suffix entropy + scope parallel-work hint - worker-deploy.sh now uses openssl rand -hex 3 for the name-conflict retry suffix, falling back to the previous date tail if openssl is absent. The docs prose already called this a hash; the script body now matches. - SKILL.md Step 3's parallel-work hint is conditional on option 3 (paste in chat). Options 1 and 2 restart the session and lose any pre-fetched scan state, so the agent no longer pre-fetches in those cases. --------- Co-authored-by: jules lemee <jules@cloudflare.com>
1 parent 60147cb commit 7548066

32 files changed

Lines changed: 3030 additions & 0 deletions

skills/turnstile-spin/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# turnstile-spin (skill)
2+
3+
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.
4+
5+
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.
6+
7+
## Layout
8+
9+
| File | Purpose |
10+
| --------------------------------- | ---------------------------------------------------------------------- |
11+
| `SKILL.md` | Main wizard instructions for the agent |
12+
| `references/vanilla-html.md` | Code snippet for static / vanilla HTML projects |
13+
| `references/nextjs-app.md` | Code snippet for Next.js App Router projects |
14+
| `references/nextjs-pages.md` | Code snippet for Next.js Pages Router projects |
15+
| `references/astro.md` | Code snippet for Astro projects |
16+
| `references/sveltekit.md` | Code snippet for SvelteKit projects |
17+
| `references/hugo.md` | Code snippet for Hugo projects |
18+
| `tests/validation.md` | Validation cases matching the MVP rows in the PRD |
19+
20+
## How agents load it
21+
22+
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:
23+
24+
```sh
25+
# Claude Code
26+
mkdir -p .claude/skills/turnstile-spin && \
27+
curl -sSL https://developers.cloudflare.com/turnstile/spin.md \
28+
-o .claude/skills/turnstile-spin/SKILL.md
29+
30+
# Or, install the whole skills bundle into a global location
31+
git clone https://github.com/cloudflare/skills ~/.config/cloudflare-skills
32+
ln -s ~/.config/cloudflare-skills/turnstile-spin ~/.claude/skills/turnstile-spin
33+
```
34+
35+
For other agents, see the table in [`SKILL.md`](./SKILL.md#step-8--persist-the-skill).
36+
37+
## Sync with the docs page
38+
39+
The canonical source of truth is `src/content/docs/turnstile/spin/index.mdx` in the `cloudflare-docs` repo. This skill mirrors that content with the JSX stripped out. CI keeps them in sync on each docs release; if you are hand-editing, mirror your change to both places.
40+
41+
## Related
42+
43+
- [Canonical docs page](https://developers.cloudflare.com/turnstile/spin/)
44+
- [`cloudflare/turnstile-siteverify`](https://github.com/cloudflare/turnstile-siteverify) — the managed Worker that this skill deploys
45+
- [`cloudflare/skills`](https://github.com/cloudflare/skills) — root index for all Cloudflare agent skills

skills/turnstile-spin/SKILL.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
---
2+
name: turnstile-spin
3+
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.
4+
references:
5+
- vanilla-html
6+
- nextjs-app
7+
- nextjs-pages
8+
- astro
9+
- sveltekit
10+
- hugo
11+
---
12+
13+
# Turnstile Spin skill
14+
15+
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.
16+
17+
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.
18+
19+
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.
20+
21+
## When to load this skill
22+
23+
Load when the user's prompt mentions any of:
24+
25+
- "Turnstile", "CAPTCHA", "bot protection"
26+
- "siteverify", "cf-turnstile-response"
27+
- "protect this form", "stop bot signups", "spam signups"
28+
- A specific signup, login, or contact form combined with "Cloudflare" or "bot"
29+
30+
Do not load for unrelated Cloudflare tasks (Workers, Pages, R2, etc.) unless Turnstile is also mentioned.
31+
32+
## Conversation flow
33+
34+
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.
35+
36+
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.
37+
38+
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/`.
39+
40+
3. **Auth + scope probe (FIRST irreversible action).** Run `scripts/auth-probe.sh`. Branch on `status`:
41+
- `ok`: continue to Step 4. The script already picked the account (single-account token, or one matching `$CLOUDFLARE_ACCOUNT_ID`).
42+
- `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:
43+
1. **Export + relaunch** (token never enters chat): `export CLOUDFLARE_API_TOKEN=<token>` then restart the agent from that terminal.
44+
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)`.
45+
3. **Paste in chat** (fastest, but token lands in conversation log; user should rotate it after if the log is ever shared).
46+
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.
47+
- `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`.
48+
- `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.
49+
50+
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.
51+
52+
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.
53+
54+
6. **Codebase scan.** Detect framework + insertion candidates silently.
55+
56+
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").
57+
58+
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.
59+
60+
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.
61+
62+
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).
63+
64+
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]**
65+
66+
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>`.
67+
68+
13. **Final report.** Print the structured summary: what was created, what was validated, what to do next.
69+
70+
### Things you must NOT do
71+
72+
- Do not write the Turnstile secret to disk. Only pass it via stdin to `wrangler secret put` (the worker-deploy.sh script handles this).
73+
- Do not skip validation.
74+
- Do not overwrite files without showing a diff.
75+
- Do not deploy a Worker to a different account than the widget was created in.
76+
- Do not call siteverify from the browser. Always: browser → user's Worker → siteverify.
77+
- Do not use `sudo` or install global packages without asking.
78+
79+
### Hard scope boundary: DO NOT ask the user about
80+
81+
Spin validates the Turnstile token via a managed Worker before the user's existing form handler runs. Everything else is out of scope:
82+
83+
- **Email / SMS / notification delivery.** Leave the existing submit handler alone (just gate it on `success === true`). Don't propose Resend, Mailchannels, SMTP, mailto.
84+
- **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).
85+
- **Database / payment / OAuth / form persistence.** Out of scope.
86+
- **Frontend framework migration, refactoring, or styling.** Edit only what's needed.
87+
- **reCAPTCHA v3 score thresholds.** Turnstile returns `success: true/false`.
88+
- **Pre-clearance-only setups.** If `clearance_level !== no_clearance`, siteverify is optional and Spin doesn't apply. Redirect the user and exit.
89+
90+
### Recovery flow: respect existing widget configuration
91+
92+
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>`"):
93+
94+
1. Skip Step 8 (widget creation). The sitekey already exists; get it from the user.
95+
2. Fetch the widget metadata via `scripts/fetch-secret.sh --account-id <id> --sitekey <key>`. Branch on `status`:
96+
- `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.
97+
- `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.
98+
3. Check `clearance_level` from the response (or the user's answer):
99+
- `no_clearance`: standard recovery (deploy Worker, wire siteverify).
100+
- anything else: ask whether they want siteverify on top of pre-clearance, or exit per the scope boundary.
101+
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"`.
102+
5. Never recreate the widget to get a fresh secret. That breaks the existing sitekey everywhere it's deployed.
103+
104+
### The frontend-edit contract
105+
106+
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.
107+
108+
```js
109+
form.addEventListener("submit", async (e) => {
110+
e.preventDefault();
111+
const token = /* read cf-turnstile-response */;
112+
const result = await fetch(WORKER_URL, { method: 'POST', body: JSON.stringify({ token }) });
113+
const data = await result.json();
114+
if (!data.success) return; // show failure
115+
// existing handler logic runs here, unchanged
116+
});
117+
```
118+
119+
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.
120+
121+
## Migrating from another CAPTCHA
122+
123+
During the Step 6 codebase scan, also look for existing reCAPTCHA or hCaptcha. If found, switch Step 7 to a migration plan.
124+
125+
Detection signals:
126+
- reCAPTCHA: `https://www.google.com/recaptcha/api.js`, `class="g-recaptcha"`, `data-sitekey="6L..."`, backend POST to `/recaptcha/api/siteverify`
127+
- hCaptcha: `https://js.hcaptcha.com/1/api.js`, `class="h-captcha"`, backend POST to `https://hcaptcha.com/siteverify`
128+
129+
Substitution:
130+
- Replace script tags with `https://challenges.cloudflare.com/turnstile/v0/api.js` (`async defer`).
131+
- 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"`.
132+
- Token field changes from `g-recaptcha-response` to `cf-turnstile-response`.
133+
- Backend siteverify URL points at the Spin-deployed Worker. Drop `RECAPTCHA_SECRET` / `HCAPTCHA_SECRET` env vars.
134+
135+
Edge cases to surface to the user:
136+
- **reCAPTCHA v3 score thresholds.** Turnstile has no score. Tell the user explicitly that migrated code will reject on `success === false`.
137+
- **reCAPTCHA Enterprise.** Don't auto-migrate. Point at [developers.cloudflare.com/turnstile/migration/recaptcha/](https://developers.cloudflare.com/turnstile/migration/recaptcha/).
138+
- **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.
139+
140+
## Edge cases
141+
142+
| Situation | Action |
143+
|---|---|
144+
| `wrangler` not installed | Install path: `npm install --save-dev wrangler` (Node project) or `npm install -g wrangler` (other) |
145+
| Multiple Cloudflare accounts | `scripts/auth-probe.sh` returns all accounts; ask the user to choose, export `CLOUDFLARE_ACCOUNT_ID` |
146+
| Cloudflare Pages project | Deploy the managed Worker anyway, OR suggest the [Pages Plugin](https://developers.cloudflare.com/pages/functions/plugins/turnstile/) |
147+
| `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":[...]}'` |
148+
| Worker name conflict | `worker-deploy.sh` retries automatically with a hash suffix |
149+
| Token expired mid-flow | Stop, re-run `scripts/auth-probe.sh`, prompt for fresh credentials |
150+
| 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. |
151+
| `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. |
152+
153+
## Telemetry marker
154+
155+
Every snippet you write must include `data-action="turnstile-spin-v1"`. 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.
156+
157+
## Do not
158+
159+
- Do not write the secret to disk.
160+
- Do not skip validation (Step 11).
161+
- Do not propose features outside the wizard (custom Worker code, custom domains, advanced WAF rules) unless asked.
162+
- Do not call siteverify from the browser.
163+
- Do not deploy the Worker into a different account than the widget.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Astro
2+
3+
For Astro projects. The form posts directly to the Worker. Astro frontmatter handles config substitution at build time.
4+
5+
```astro title="src/pages/signup.astro"
6+
---
7+
const WORKER_URL = import.meta.env.PUBLIC_TURNSTILE_WORKER_URL;
8+
const SITEKEY = import.meta.env.PUBLIC_TURNSTILE_SITEKEY;
9+
---
10+
11+
<html>
12+
<head>
13+
<script
14+
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
15+
async
16+
defer
17+
></script>
18+
</head>
19+
<body>
20+
<form action={`${WORKER_URL}/`} method="POST">
21+
<input name="email" type="email" required />
22+
<div
23+
class="cf-turnstile"
24+
data-sitekey={SITEKEY}
25+
data-action="turnstile-spin-v1"
26+
/>
27+
<button type="submit">Sign up</button>
28+
</form>
29+
</body>
30+
</html>
31+
```
32+
33+
In your `.env`:
34+
35+
```text
36+
PUBLIC_TURNSTILE_WORKER_URL=https://YOUR_WORKER_URL
37+
PUBLIC_TURNSTILE_SITEKEY=YOUR_SITEKEY
38+
```
39+
40+
The `PUBLIC_` prefix is mandatory for client-exposed variables in Astro.
41+
42+
## Variant: hardcoded values
43+
44+
If you do not use env vars, inline directly:
45+
46+
```astro title="src/pages/signup.astro"
47+
<html>
48+
<head>
49+
<script
50+
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
51+
async
52+
defer
53+
></script>
54+
</head>
55+
<body>
56+
<form action="https://YOUR_WORKER_URL/" method="POST">
57+
<div
58+
class="cf-turnstile"
59+
data-sitekey="YOUR_SITEKEY"
60+
data-action="turnstile-spin-v1"
61+
/>
62+
<button type="submit">Sign up</button>
63+
</form>
64+
</body>
65+
</html>
66+
```
67+
68+
## Variant: Astro Actions
69+
70+
If the project uses Astro Actions, call siteverify from the action:
71+
72+
```ts title="src/actions/index.ts"
73+
import { defineAction } from "astro:actions";
74+
import { z } from "astro:schema";
75+
76+
export const server = {
77+
signup: defineAction({
78+
accept: "form",
79+
input: z.object({
80+
email: z.string().email(),
81+
"cf-turnstile-response": z.string(),
82+
}),
83+
handler: async (input) => {
84+
const verify = await fetch("https://YOUR_WORKER_URL/", {
85+
method: "POST",
86+
headers: { "Content-Type": "application/json" },
87+
body: JSON.stringify({ token: input["cf-turnstile-response"] }),
88+
});
89+
const data = await verify.json();
90+
if (!data.success) throw new Error("Verification failed");
91+
// process signup
92+
},
93+
}),
94+
};
95+
```
96+
97+
## Substitutions
98+
99+
| Placeholder | Replace with |
100+
| ------------------ | ------------------------------------------- |
101+
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
102+
| `YOUR_SITEKEY` | Widget site key from Step 4 |

0 commit comments

Comments
 (0)