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:
Prad NukalaandClaude Fable 5 committed 2026-08-09 14:53:46 -04:00
1 parent 3c691e8dc5
commit 20891f3910
476 files changed
+59086 -7

No files matched your search

@@ -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">&lt;!-- enter your site key above to generate the snippet --&gt;</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 =
"&#10003; 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