mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-09 21:37:37 +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,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
|
||||
Reference in new issue
Block a user