@pithy-sh/turnstile 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +47 -0
- package/pithy.manifest.json +30 -0
- package/src/capability.ts +89 -0
- package/src/client/projection.ts +48 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +138 -0
- package/src/error/errors.ts +82 -0
- package/src/http/middleware.ts +306 -0
- package/src/index.ts +12 -0
- package/src/provision/provisionTurnstile.ts +206 -0
- package/src/provision/testKeys.ts +62 -0
- package/src/secret/registry.ts +100 -0
- package/src/version.generated.ts +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @pithy-sh/turnstile
|
|
2
|
+
|
|
3
|
+
A Cloudflare Turnstile humanity check for Pithy. One piece of stackable middleware. No tables of its own.
|
|
4
|
+
|
|
5
|
+
It answers one question — *is this a human?* — and stacks on top of any route's real verification strategy. It is not one itself.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pithy add turnstile
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Documentation: [pithy.sh/docs/capabilities/turnstile](https://pithy.sh/docs/capabilities/turnstile).** Overview, adding it, using it, and the reference: provisioning, the widget rules, dev values.
|
|
12
|
+
|
|
13
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT — adopter-side app value. The root `LICENSE` covers it.
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/turnstile",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/turnstile"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"!src/**/*.test.*"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
"./src/*": "./src/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:node": "vitest run --project=node",
|
|
27
|
+
"test:workers": "vitest run --project=workers",
|
|
28
|
+
"clean": "rm -rf dist .turbo",
|
|
29
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@pithy-sh/core": "workspace:*",
|
|
33
|
+
"@pithy-sh/secrets": "workspace:*",
|
|
34
|
+
"hono": "^4.13.2",
|
|
35
|
+
"zod": "^4.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
39
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
40
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
41
|
+
"@types/node": "^22.15.0",
|
|
42
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
43
|
+
"typescript": "^7.0.2",
|
|
44
|
+
"vitest": "^4.1.0",
|
|
45
|
+
"wrangler": "^4.115.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "turnstile",
|
|
3
|
+
"package": "@pithy-sh/turnstile",
|
|
4
|
+
"requiredBindings": [],
|
|
5
|
+
"peerCapabilities": ["secrets"],
|
|
6
|
+
"optionalCapabilities": ["auth"],
|
|
7
|
+
"secrets": [
|
|
8
|
+
{
|
|
9
|
+
"name": "turnstile-secret-keys",
|
|
10
|
+
"origin": {
|
|
11
|
+
"kind": "obtained",
|
|
12
|
+
"issuer": "cloudflare",
|
|
13
|
+
"documentation": "https://developers.cloudflare.com/turnstile/get-started/widget-management/dashboard/"
|
|
14
|
+
},
|
|
15
|
+
"rotation": {
|
|
16
|
+
"kind": "provider",
|
|
17
|
+
"issuer": "cloudflare",
|
|
18
|
+
"documentation": "https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/rotate_secret/"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"whenToEnable": "Stop bots at credential-entry and form-submit points without asking real users to prove themselves. Stacks a Cloudflare Turnstile humanity check on any route — magic-link and OTP login, signup, lead capture, contact. dev and staging wire Cloudflare's test keys automatically; only `prod` provisions a real widget. The widget secret is stored and read through @pithy-sh/secrets, so the secrets capability is required.",
|
|
23
|
+
"scaffold": [
|
|
24
|
+
"Edit `turnstile({ ... })` in pithy.config.ts: declare the widget mode(s) you use (`visible` for pages where the challenge should be seen, like login; `invisible` for silent forms) and the `protect` map (action → mode). `login` is gated by the visible widget by default. Social/OAuth login is never gated.",
|
|
25
|
+
"Ensure the secrets capability is provisioned (`pithy add secrets` then `pithy secrets provision`) — the turnstile widget secret is stored and read through @pithy-sh/secrets, which the secrets capability sets up.",
|
|
26
|
+
"Run `pithy turnstile provision` — it writes Cloudflare's documented test secret for dev (into .dev.vars) and staging (into the staging secrets store), and provisions the real prod widget, storing its secret in the prod secrets store and writing the public sitekeys to config.",
|
|
27
|
+
"Stack the gate on a route: `import { turnstile } from \"@pithy-sh/turnstile/src/http/middleware\"` and `app.use(\"/signup\", turnstile())`. It reads the token from the `cf-turnstile-response` body field (or a header) and resolves the secret through @pithy-sh/secrets. Render the widget on your front-end with the public sitekey — Pithy ships no front-end component."
|
|
28
|
+
],
|
|
29
|
+
"configOptions": []
|
|
30
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import type { TurnstileClientProjection } from "./client/projection";
|
|
6
|
+
import { TURNSTILE_LOGIN_ACTION, TurnstileConfig, type TurnstileConfigInput } from "./config/config";
|
|
7
|
+
import { turnstileSecretsRegistry } from "./secret/registry";
|
|
8
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
9
|
+
|
|
10
|
+
/** The turnstile capability, with its resolved config attached for inspection (e.g. by `@pithy-sh/auth`). */
|
|
11
|
+
export interface TurnstileCapability extends Capability {
|
|
12
|
+
turnstileConfig: TurnstileConfig;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The turnstile capability. It is **stateless** — no tables, no migrations, no routes, no global
|
|
17
|
+
* middleware, and **no bindings of its own**. Its widget secret is read through `@pithy-sh/secrets`
|
|
18
|
+
* (CLAUDE.md §secrets) — whatever bindings that read needs are contributed by the `secrets` capability,
|
|
19
|
+
* which this one depends on; turnstile contributes only its validated config. A humanity check stacks
|
|
20
|
+
* per-route via the `turnstile()` middleware (`@pithy-sh/turnstile/src/http/middleware`), never as a
|
|
21
|
+
* blanket middleware or an identity strategy.
|
|
22
|
+
*
|
|
23
|
+
* `@pithy-sh/auth` reads `turnstileConfig.protect` to decide which of its routes (magic-link, OTP) get a
|
|
24
|
+
* gate and at which widget mode; this package never imports auth.
|
|
25
|
+
*/
|
|
26
|
+
export function turnstile(config: TurnstileConfigInput = {}): TurnstileCapability {
|
|
27
|
+
const resolved = TurnstileConfig.parse(config);
|
|
28
|
+
const capability = defineCapability({
|
|
29
|
+
name: "turnstile",
|
|
30
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
31
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
32
|
+
version: PACKAGE_VERSION,
|
|
33
|
+
config: TurnstileConfig,
|
|
34
|
+
// The widget secret is read through @pithy-sh/secrets, so the secrets capability must be composed;
|
|
35
|
+
// createBackend fails fast if it isn't (rather than 500-ing each gated request).
|
|
36
|
+
dependsOn: ["secrets"],
|
|
37
|
+
// The slice of secrets turnstile reads — aggregated into the shared per-invocation accessor at startup.
|
|
38
|
+
secretRegistry: turnstileSecretsRegistry,
|
|
39
|
+
/**
|
|
40
|
+
* The client-safe projection — exactly what renders the login widget: the mode `protect.login`
|
|
41
|
+
* names, that widget's **public** sitekey for the environment being built, the **action** the widget
|
|
42
|
+
* must solve for, and where the front end must put the response token so the middleware finds it.
|
|
43
|
+
*
|
|
44
|
+
* The widget *secret* is never here: it lives in the secrets store (`turnstileSecretsRegistry`) and
|
|
45
|
+
* is read only inside the Worker, so a sitekey is the whole of what a browser sees — which is what
|
|
46
|
+
* it is for. Every unrenderable shape projects `{ enabled: false }` (no `login` gate, the named
|
|
47
|
+
* widget unconfigured, or no sitekey for this environment) so a screen branches instead of
|
|
48
|
+
* mounting a widget that cannot solve.
|
|
49
|
+
*
|
|
50
|
+
* **`action` rides here because the boundary was already being crossed** (#377). The label is baked
|
|
51
|
+
* into the token at render and asserted by the route, so it is one contract with two ends, and it
|
|
52
|
+
* was written out at both — where nothing before production could catch them disagreeing. See
|
|
53
|
+
* {@link TURNSTILE_LOGIN_ACTION} for why that is worse than it sounds and which gates hold it.
|
|
54
|
+
*
|
|
55
|
+
* The return type is {@link TurnstileClientProjection} — **declared, not inferred**. `ClientProjection`
|
|
56
|
+
* is `{ enabled: boolean }` plus a JSON catchall, which accepts anything this closure could return.
|
|
57
|
+
* The declared type is what makes a dropped field, and a `mode` widened by a third widget in
|
|
58
|
+
* `TurnstileConfig`, a compile error here rather than a browser's problem.
|
|
59
|
+
*/
|
|
60
|
+
client: ({ environment }): TurnstileClientProjection => {
|
|
61
|
+
const mode = resolved.protect[TURNSTILE_LOGIN_ACTION];
|
|
62
|
+
if (!mode) return { enabled: false };
|
|
63
|
+
const widget = resolved.widgets[mode];
|
|
64
|
+
if (!widget) return { enabled: false };
|
|
65
|
+
// Indexed as a record: `environment` is any adopter name, not just the three documented keys.
|
|
66
|
+
const sitekeys: Record<string, string | undefined> = widget.sitekeys;
|
|
67
|
+
const sitekey = sitekeys[environment];
|
|
68
|
+
if (!sitekey) return { enabled: false };
|
|
69
|
+
return {
|
|
70
|
+
enabled: true,
|
|
71
|
+
mode,
|
|
72
|
+
sitekey,
|
|
73
|
+
// What the widget solves for, and what the route asserts. One statement, carried across.
|
|
74
|
+
action: TURNSTILE_LOGIN_ACTION,
|
|
75
|
+
// Shaped like the config it comes from (`token.field` / `token.header`), so a screen reads the
|
|
76
|
+
// same two names the middleware does. `header` is null rather than absent: `undefined` is not
|
|
77
|
+
// JSON, and the projection is inlined into a bundle with JSON.stringify.
|
|
78
|
+
token: { field: resolved.token.field, header: resolved.token.header ?? null },
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
requiredBindings: [],
|
|
82
|
+
});
|
|
83
|
+
return Object.assign(capability, { turnstileConfig: resolved });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether a capability is the turnstile capability — carries its resolved config. */
|
|
87
|
+
export function isTurnstileCapability(capability: Capability): capability is TurnstileCapability {
|
|
88
|
+
return capability.name === "turnstile" && "turnstileConfig" in capability;
|
|
89
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a browser may know about this project's Turnstile — the shape of `virtual:pithy/turnstile`.
|
|
6
|
+
*
|
|
7
|
+
* **This declaration is the contract, and the projection is checked against it.** It is written here
|
|
8
|
+
* rather than inferred from the closure that builds it, and that is the whole point: an inferred type
|
|
9
|
+
* follows whatever the producer last happened to say, so a projection that dropped a field, or widened
|
|
10
|
+
* `mode` because the config grew a third widget, would take the type with it and nothing would go red.
|
|
11
|
+
* Declared, the arrow is the thing that has to change. Adding a widget mode to `TurnstileConfig` is a
|
|
12
|
+
* compile error at `client:` until somebody decides, on purpose, whether a browser should see it.
|
|
13
|
+
*
|
|
14
|
+
* **This is the only statement of the shape.** `@pithy-sh/ui-react`'s `templates/client-env.d.ts` — the
|
|
15
|
+
* ambient declaration `pithy ui add react` copies into an adopter's Worker — is generated from this type
|
|
16
|
+
* by `@pithy-sh/vite`'s `clientEnvDeclaration.ts` (#398). The unions and the per-field doc comments below
|
|
17
|
+
* are emitted verbatim, so what is written here is what a screen author reads.
|
|
18
|
+
*/
|
|
19
|
+
export type TurnstileClientProjection =
|
|
20
|
+
| {
|
|
21
|
+
/**
|
|
22
|
+
* Turnstile is not composed, or has no renderable login widget for this environment. A screen
|
|
23
|
+
* branches rather than mounting a widget that cannot solve.
|
|
24
|
+
*/
|
|
25
|
+
enabled: false;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
/** Turnstile is composed AND has a renderable login widget for this environment. */
|
|
29
|
+
enabled: true;
|
|
30
|
+
/** The public sitekey for the build's environment. The widget secret stays in the secrets store. */
|
|
31
|
+
sitekey: string;
|
|
32
|
+
/** The widget mode `protect.login` names. */
|
|
33
|
+
mode: "visible" | "invisible";
|
|
34
|
+
/**
|
|
35
|
+
* The action label the widget must be solved for. Render it, never retype it: the sign-in route
|
|
36
|
+
* asserts this exact string against the token, and dev and staging cannot notice a copy that has
|
|
37
|
+
* drifted — Cloudflare's test keys answer with no action at all, so the first environment that
|
|
38
|
+
* can tell is the one where a mismatch refuses every sign-in. #377.
|
|
39
|
+
*/
|
|
40
|
+
action: string;
|
|
41
|
+
/** Where the response token goes: a body field, or a header when one is configured. */
|
|
42
|
+
token: {
|
|
43
|
+
/** The body field the middleware reads the token from. */
|
|
44
|
+
field: string;
|
|
45
|
+
/** The header it reads instead, or null when none is configured. */
|
|
46
|
+
header: string | null;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/// <reference types="@cloudflare/vitest-plugin/types" />
|
|
5
|
+
|
|
6
|
+
// Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
|
|
7
|
+
// config in `vitest.workers.config.ts`: the dedicated `SECRETS` D1 the widget secret's row lives in,
|
|
8
|
+
// and the master key that decrypts it. `cloudflare:test` types its `env` as `Cloudflare.Env`, so test
|
|
9
|
+
// bindings are declared by augmenting that interface.
|
|
10
|
+
declare namespace Cloudflare {
|
|
11
|
+
interface Env {
|
|
12
|
+
SECRETS: D1Database;
|
|
13
|
+
/** The master-key config as a string (the `.dev.vars` shape), set in `vitest.workers.config.ts`. */
|
|
14
|
+
SECRETS_ENCRYPTION_KEYS: string;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The default request body field a Turnstile widget posts its response token in. Cloudflare's
|
|
8
|
+
* client-side widget names the hidden input `cf-turnstile-response`, so this is the field the
|
|
9
|
+
* middleware reads unless the app overrides it (or reads from a header instead).
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_TOKEN_FIELD = "cf-turnstile-response";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* **The action label of the passwordless sign-in gate, stated once for the whole kit (#377).**
|
|
15
|
+
*
|
|
16
|
+
* Turnstile bakes an action into the token at widget render and echoes it back from siteverify, and the
|
|
17
|
+
* gate refuses a token whose action is not the one the route expects. So the string is a contract with
|
|
18
|
+
* two ends — the widget that solves for it and the route that asserts it — and it used to be written out
|
|
19
|
+
* at both: `createAuthRoutes` stacked `turnstile({ action: "login" })` and the scaffolded
|
|
20
|
+
* `turnstile.tsx` declared its own `const ACTION = "login"`.
|
|
21
|
+
*
|
|
22
|
+
* **Nothing before production can notice the two disagreeing**, which is what makes a second copy
|
|
23
|
+
* unaffordable here rather than merely untidy. Cloudflare's always-pass test secret — the one
|
|
24
|
+
* `pithy turnstile provision` wires into dev and staging — answers with **no `action` field at all**, and
|
|
25
|
+
* the gate accepts exactly that answer in exactly those two environments (#374, {@link
|
|
26
|
+
* ../http/middleware.testKeyCarriesNoAction}). A drifted pair is therefore invisible in dev, invisible in
|
|
27
|
+
* staging, and in prod refuses **every** sign-in with a 403 that says the challenge failed — pointing an
|
|
28
|
+
* operator at the user rather than at the mismatch.
|
|
29
|
+
*
|
|
30
|
+
* So there is one statement, and both ends read it: the client projection carries it to the browser
|
|
31
|
+
* (`capability.ts`'s `client`, reaching the widget as `turnstileConfig.action`), and `@pithy-sh/auth`
|
|
32
|
+
* imports it for the gate it stacks. It is also the `protect` key the login mode is configured under, so
|
|
33
|
+
* the config default below is built from it too — three readers, no second literal.
|
|
34
|
+
*
|
|
35
|
+
* The gates that keep it that way, and where the blindness above is restated for whoever is reading one:
|
|
36
|
+
* `@pithy-sh/auth`'s `src/http/turnstileActionBinding.test.ts` (the route asserts the projected action)
|
|
37
|
+
* and `@pithy-sh/ui-react`'s `src/turnstileAction.test.tsx` (the widget solves for the projected action).
|
|
38
|
+
*/
|
|
39
|
+
export const TURNSTILE_LOGIN_ACTION = "login";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The two widget modes Pithy provisions, in the app's own terms. `visible` is a Cloudflare *managed*
|
|
43
|
+
* widget (CF decides whether to show an interaction) — for a surface where the challenge should be
|
|
44
|
+
* seen, like a login page. `invisible` runs silently — for a form that should not interrupt, like a
|
|
45
|
+
* lead capture. The logical maximum is one of each per domain.
|
|
46
|
+
*/
|
|
47
|
+
export const TurnstileMode = z
|
|
48
|
+
.enum(["visible", "invisible"])
|
|
49
|
+
.describe("A Turnstile widget mode: `visible` (a CF managed widget) or `invisible` (runs silently).");
|
|
50
|
+
export type TurnstileMode = z.infer<typeof TurnstileMode>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The public sitekey for one widget, per environment. The sitekey is public — the front-end renders
|
|
54
|
+
* the widget with it — so it lives in config, not in secrets. dev and staging carry Cloudflare's
|
|
55
|
+
* documented test keys (wired automatically, no real widget); `prod` carries the real widget's
|
|
56
|
+
* sitekey, written by `pithy turnstile provision`.
|
|
57
|
+
*
|
|
58
|
+
* The keys are Pithy's environment names verbatim, because that is what the client projection indexes
|
|
59
|
+
* them by — a bundle built for `prod` reads `sitekeys.prod`. They are not free-form labels.
|
|
60
|
+
*/
|
|
61
|
+
export const TurnstileSitekeys = z
|
|
62
|
+
.object({
|
|
63
|
+
dev: z.string().describe("Dev sitekey — a Cloudflare test key, wired automatically (no real widget is created)."),
|
|
64
|
+
staging: z
|
|
65
|
+
.string()
|
|
66
|
+
.describe("Staging sitekey — a Cloudflare test key, wired automatically (no real widget is created)."),
|
|
67
|
+
prod: z.string().describe("Prod sitekey — the real widget's public key, set by `pithy turnstile provision`."),
|
|
68
|
+
})
|
|
69
|
+
.describe("Per-environment public sitekeys the front-end renders the widget with.");
|
|
70
|
+
export type TurnstileSitekeys = z.infer<typeof TurnstileSitekeys>;
|
|
71
|
+
|
|
72
|
+
/** One provisioned widget: its per-environment public sitekeys. The mode is the key under `widgets`. */
|
|
73
|
+
export const TurnstileWidget = z
|
|
74
|
+
.object({
|
|
75
|
+
sitekeys: TurnstileSitekeys.describe("Per-environment public sitekeys for this widget."),
|
|
76
|
+
})
|
|
77
|
+
.describe("A single Turnstile widget (one mode), with its per-environment public sitekeys.");
|
|
78
|
+
export type TurnstileWidget = z.infer<typeof TurnstileWidget>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Where the middleware reads the response token from. By default it reads the `cf-turnstile-response`
|
|
82
|
+
* body field (form or JSON). Set `header` to read the token from a request header instead — useful for
|
|
83
|
+
* a JSON API or a mobile client that sends the token out-of-band.
|
|
84
|
+
*/
|
|
85
|
+
export const TurnstileTokenSource = z
|
|
86
|
+
.object({
|
|
87
|
+
field: z
|
|
88
|
+
.string()
|
|
89
|
+
.default(DEFAULT_TOKEN_FIELD)
|
|
90
|
+
.describe(
|
|
91
|
+
"Request body field carrying the token (form or JSON). Cloudflare's widget uses `cf-turnstile-response`.",
|
|
92
|
+
),
|
|
93
|
+
header: z
|
|
94
|
+
.string()
|
|
95
|
+
.optional()
|
|
96
|
+
.describe("If set, read the token from this request header instead of the body field."),
|
|
97
|
+
})
|
|
98
|
+
.describe("Where the middleware reads the Turnstile response token from.");
|
|
99
|
+
export type TurnstileTokenSource = z.infer<typeof TurnstileTokenSource>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Configuration for the turnstile capability, authored in `pithy.config.ts`. It declares which widgets
|
|
103
|
+
* the app uses (up to one `visible` and one `invisible` per domain), which protected actions get a
|
|
104
|
+
* humanity gate and at which mode, and where the token is read from. The widget *secret* is never here —
|
|
105
|
+
* it is stored and read through `@pithy-sh/secrets`; only the public sitekeys live in config.
|
|
106
|
+
*
|
|
107
|
+
* Social/OAuth login is deliberately never gated: the provider runs its own bot defense and the redirect
|
|
108
|
+
* flow carries no token. `@pithy-sh/auth` reads `protect` to stack `turnstile()` on its magic-link/OTP
|
|
109
|
+
* routes; this package never imports auth.
|
|
110
|
+
*/
|
|
111
|
+
export const TurnstileConfig = z
|
|
112
|
+
.object({
|
|
113
|
+
widgets: z
|
|
114
|
+
.object({
|
|
115
|
+
visible: TurnstileWidget.optional().describe(
|
|
116
|
+
"The visible (managed) widget — shows a challenge where it should be seen, e.g. a login page.",
|
|
117
|
+
),
|
|
118
|
+
invisible: TurnstileWidget.optional().describe(
|
|
119
|
+
"The invisible widget — runs silently where a form should not be interrupted, e.g. lead capture.",
|
|
120
|
+
),
|
|
121
|
+
})
|
|
122
|
+
.default({})
|
|
123
|
+
.describe("Up to two widgets per domain: one `visible`, one `invisible`. Declare only the modes you need."),
|
|
124
|
+
protect: z
|
|
125
|
+
.record(z.string(), TurnstileMode)
|
|
126
|
+
// Built from the constant, not written out again: the key an action is configured under and the
|
|
127
|
+
// action label a token is solved for are the same string, and #377 is what a second copy costs.
|
|
128
|
+
.default({ [TURNSTILE_LOGIN_ACTION]: "visible" })
|
|
129
|
+
.describe(
|
|
130
|
+
"Protected action → widget mode. `login` (magic-link, OTP) defaults to the visible widget; add your own form actions. Social/OAuth is never gated.",
|
|
131
|
+
),
|
|
132
|
+
token: TurnstileTokenSource.default({ field: DEFAULT_TOKEN_FIELD }).describe(
|
|
133
|
+
"Where the response token is read from (body field by default, or a header).",
|
|
134
|
+
),
|
|
135
|
+
})
|
|
136
|
+
.describe("Configuration for the turnstile humanity-check capability.");
|
|
137
|
+
export type TurnstileConfig = z.output<typeof TurnstileConfig>;
|
|
138
|
+
export type TurnstileConfigInput = z.input<typeof TurnstileConfig>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `@pithy-sh/turnstile` throw sugar. The `turnstile/*` codes live in core's closed `KitErrorPayload`
|
|
9
|
+
* union (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses are the
|
|
10
|
+
* package-local vehicles that set one of those members — the same pattern as `@pithy-sh/email`.
|
|
11
|
+
* Runtime code in this package throws one of these, never a plain `new Error`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
|
|
15
|
+
interface TurnstileErrorArgs {
|
|
16
|
+
/** Override the public, safe-to-expose message. */
|
|
17
|
+
message?: string;
|
|
18
|
+
/** A remediation hint (CLI action line). */
|
|
19
|
+
action?: string;
|
|
20
|
+
/** Internal context for logs + audit. Never serialized to clients. */
|
|
21
|
+
detail?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
24
|
+
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
25
|
+
*/
|
|
26
|
+
params?: MessageParams;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The request carried no Turnstile response token where one was required. */
|
|
30
|
+
export class TurnstileMissingTokenError extends PithyError {
|
|
31
|
+
constructor(args: TurnstileErrorArgs = {}, options?: { cause?: unknown }) {
|
|
32
|
+
super(
|
|
33
|
+
{
|
|
34
|
+
code: "turnstile/missing_token",
|
|
35
|
+
status: 400,
|
|
36
|
+
message: args.message ?? "A humanity-check token is required.",
|
|
37
|
+
action: args.action,
|
|
38
|
+
detail: args.detail,
|
|
39
|
+
params: args.params,
|
|
40
|
+
},
|
|
41
|
+
options,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The Turnstile token did not pass siteverify, or the check could not complete. The middleware fails
|
|
48
|
+
* closed — a siteverify network error or malformed response also raises this, so a bot gate never
|
|
49
|
+
* silently opens.
|
|
50
|
+
*/
|
|
51
|
+
export class TurnstileFailedError extends PithyError {
|
|
52
|
+
constructor(args: TurnstileErrorArgs = {}, options?: { cause?: unknown }) {
|
|
53
|
+
super(
|
|
54
|
+
{
|
|
55
|
+
code: "turnstile/failed",
|
|
56
|
+
status: 403,
|
|
57
|
+
message: args.message ?? "The humanity check did not pass.",
|
|
58
|
+
action: args.action,
|
|
59
|
+
detail: args.detail,
|
|
60
|
+
params: args.params,
|
|
61
|
+
},
|
|
62
|
+
options,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The middleware is misconfigured — the secret-key binding is missing or empty. */
|
|
68
|
+
export class TurnstileConfigError extends PithyError {
|
|
69
|
+
constructor(args: TurnstileErrorArgs = {}, options?: { cause?: unknown }) {
|
|
70
|
+
super(
|
|
71
|
+
{
|
|
72
|
+
code: "turnstile/config",
|
|
73
|
+
status: 500,
|
|
74
|
+
message: args.message ?? "Turnstile is not configured.",
|
|
75
|
+
action: args.action,
|
|
76
|
+
detail: args.detail,
|
|
77
|
+
params: args.params,
|
|
78
|
+
},
|
|
79
|
+
options,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { workerIdentity } from "@pithy-sh/core/src/worker/identity";
|
|
5
|
+
import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
|
|
6
|
+
import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
|
|
7
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { DEFAULT_TOKEN_FIELD, type TurnstileMode } from "../config/config";
|
|
10
|
+
import { TurnstileConfigError, TurnstileFailedError, TurnstileMissingTokenError } from "../error/errors";
|
|
11
|
+
import { isTestKeyEnvironment, TEST_KEY_ENVIRONMENTS } from "../provision/testKeys";
|
|
12
|
+
import { selectTurnstileSecret, TURNSTILE_SECRET_NAME, turnstileSecretsRegistry } from "../secret/registry";
|
|
13
|
+
|
|
14
|
+
/** Cloudflare's server-side endpoint a Turnstile token is validated against. */
|
|
15
|
+
const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The siteverify error codes that name the **secret** rather than the token.
|
|
19
|
+
*
|
|
20
|
+
* The distinction is the whole point: every other code is a verdict about the caller, and these two are
|
|
21
|
+
* a verdict about the deployment. Cloudflare answers HTTP 400 for a secret it does not recognize, which
|
|
22
|
+
* the fail-closed branch used to render as `turnstile/failed` — a 403 telling an operator that a user
|
|
23
|
+
* failed a challenge, when the truth was that nobody could ever pass one here.
|
|
24
|
+
*/
|
|
25
|
+
const SECRET_FAULT_CODES = ["invalid-input-secret", "missing-input-secret"];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The siteverify response, narrowed to what the gate checks. Cloudflare may add fields; Zod strips the
|
|
29
|
+
* rest. Validating the body is itself part of the security boundary — an unexpected shape is treated as
|
|
30
|
+
* a failure, never as a pass.
|
|
31
|
+
*/
|
|
32
|
+
export const SiteverifyResult = z
|
|
33
|
+
.object({
|
|
34
|
+
success: z.boolean().describe("Whether the token passed the humanity challenge."),
|
|
35
|
+
"error-codes": z.array(z.string()).default([]).describe("Machine-readable failure reasons; empty on success."),
|
|
36
|
+
action: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("The action label baked into the token at widget render — compared against the expected action."),
|
|
40
|
+
metadata: z
|
|
41
|
+
.object({
|
|
42
|
+
result_with_testing_key: z
|
|
43
|
+
.boolean()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Cloudflare's own flag: this verdict came from a documented test key, not a real widget."),
|
|
46
|
+
})
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Cloudflare's metadata about how the verdict was reached."),
|
|
49
|
+
})
|
|
50
|
+
.describe(
|
|
51
|
+
"The server-side Turnstile siteverify response, narrowed to the success flag, error codes, action, and test-key metadata.",
|
|
52
|
+
);
|
|
53
|
+
export type SiteverifyResult = z.output<typeof SiteverifyResult>;
|
|
54
|
+
|
|
55
|
+
/** Whether a verdict was produced by one of Cloudflare's documented test keys, by Cloudflare's own flag. */
|
|
56
|
+
function fromTestingKey(result: SiteverifyResult): boolean {
|
|
57
|
+
return result.metadata?.result_with_testing_key === true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A secret Cloudflare does not recognize is a deployment fault, and is reported as one.
|
|
62
|
+
*
|
|
63
|
+
* `turnstile/config` (500) rather than `turnstile/failed` (403), because the two go to different people:
|
|
64
|
+
* a 403 sends the operator looking at the user who was refused, and every such request is refused, so
|
|
65
|
+
* the search never converges. The `action` line names the command that fixes it.
|
|
66
|
+
*/
|
|
67
|
+
function assertSecretRecognized(codes: string[], status: number): void {
|
|
68
|
+
const fault = codes.find((code) => SECRET_FAULT_CODES.includes(code));
|
|
69
|
+
if (fault === undefined) return;
|
|
70
|
+
throw new TurnstileConfigError({
|
|
71
|
+
message: "The humanity check is not configured.",
|
|
72
|
+
action:
|
|
73
|
+
"Cloudflare does not recognize this widget's secret key. Run `pithy turnstile provision` for this environment.",
|
|
74
|
+
detail: `siteverify answered ${status} with "${fault}" — the secret, not the token, was refused.`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The one exception to the action binding, and the only thing that made dev and staging sign-in
|
|
80
|
+
* possible: **a documented test key returns no action at all** (#374).
|
|
81
|
+
*
|
|
82
|
+
* Cloudflare's always-pass secret answers `success: true` with no `action` field, verified live against
|
|
83
|
+
* siteverify. `createAuthRoutes` stacks the gate as `turnstile({ action: "login" })`, so the binding
|
|
84
|
+
* compared `"login"` against nothing and denied every sign-in in the two environments provisioning
|
|
85
|
+
* wires that key into — a token that *was* valid, refused by a field the key never populates.
|
|
86
|
+
*
|
|
87
|
+
* Three conditions, and the exception needs all of them:
|
|
88
|
+
*
|
|
89
|
+
* 1. **Cloudflare says it is a test key** — `metadata.result_with_testing_key`, its own flag on its own
|
|
90
|
+
* answer, not a comparison against a list of key strings we keep. A real widget's answer never
|
|
91
|
+
* carries it, so no real deployment can reach this branch however its secret is spelled.
|
|
92
|
+
* 2. **No action came back at all.** An action that came back and *differs* is a token minted for
|
|
93
|
+
* another action, which is exactly what the binding exists to refuse, and it is refused here too.
|
|
94
|
+
* 3. **The Worker says it is dev or staging** — {@link isTestKeyEnvironment}, off the stamped
|
|
95
|
+
* `ENVIRONMENT` var, which nothing in a request can influence. `prod` is not in that list and an
|
|
96
|
+
* unstamped Worker is not either, so production keeps the binding exactly as it was.
|
|
97
|
+
*
|
|
98
|
+
* The alternative was to relax the binding itself, which would have traded a real protection — a token
|
|
99
|
+
* solved for one action must not be replayable against another — for a developer's convenience.
|
|
100
|
+
*/
|
|
101
|
+
function testKeyCarriesNoAction(result: SiteverifyResult, environment: string | null): boolean {
|
|
102
|
+
return fromTestingKey(result) && result.action === undefined && isTestKeyEnvironment(environment);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The `error-codes` of a body we are not otherwise going to parse. Absent or unreadable reads as none. */
|
|
106
|
+
async function errorCodesOf(response: Response): Promise<string[]> {
|
|
107
|
+
const raw: unknown = await response.json().catch(() => null);
|
|
108
|
+
const codes = (raw as { "error-codes"?: unknown } | null)?.["error-codes"];
|
|
109
|
+
return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === "string") : [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Options the `turnstile()` middleware accepts when a route stacks it. */
|
|
113
|
+
export interface TurnstileOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Which widget this route gates. Required only when the app runs both a `visible` and an `invisible`
|
|
116
|
+
* widget; with a single widget it is inferred. Selects the entry from the resolved turnstile secret.
|
|
117
|
+
*/
|
|
118
|
+
mode?: TurnstileMode;
|
|
119
|
+
/** Body field carrying the token (form or JSON). Defaults to `cf-turnstile-response`. */
|
|
120
|
+
field?: string;
|
|
121
|
+
/** If set, read the token from this request header instead of the body field. */
|
|
122
|
+
header?: string;
|
|
123
|
+
/**
|
|
124
|
+
* The expected action label. Turnstile bakes the action into the token at widget render and returns it
|
|
125
|
+
* from siteverify; when set, the middleware asserts the returned action matches and denies on mismatch —
|
|
126
|
+
* binding a token to the route it was solved for (no cross-action reuse on a shared widget).
|
|
127
|
+
*/
|
|
128
|
+
action?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Verify a Turnstile token server-side against `/siteverify`. **Fails closed:** a transport error, a
|
|
133
|
+
* non-OK status, a non-JSON body, or an unexpected shape all raise `turnstile/failed` rather than
|
|
134
|
+
* letting the request through — a bot gate must never silently open. A well-formed response (whether the
|
|
135
|
+
* token passed or not) is returned for the caller to act on.
|
|
136
|
+
*
|
|
137
|
+
* The one failure that does **not** come back as `turnstile/failed` is a secret Cloudflare does not
|
|
138
|
+
* recognize: that is `turnstile/config`, because it is a verdict about the deployment rather than about
|
|
139
|
+
* the caller. See {@link assertSecretRecognized}. Both directions still deny.
|
|
140
|
+
*/
|
|
141
|
+
export async function siteverify(
|
|
142
|
+
secret: string,
|
|
143
|
+
token: string,
|
|
144
|
+
options?: { remoteIp?: string },
|
|
145
|
+
): Promise<SiteverifyResult> {
|
|
146
|
+
// Note: Turnstile siteverify takes no `action` request param — the action is a *response* field the
|
|
147
|
+
// caller compares (done by the middleware). Only secret, response, and remoteip are sent.
|
|
148
|
+
const body = new URLSearchParams({ secret, response: token });
|
|
149
|
+
if (options?.remoteIp) body.set("remoteip", options.remoteIp);
|
|
150
|
+
|
|
151
|
+
let response: Response;
|
|
152
|
+
try {
|
|
153
|
+
response = await fetch(SITEVERIFY_URL, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
156
|
+
body,
|
|
157
|
+
});
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
throw new TurnstileFailedError(
|
|
160
|
+
{ detail: `siteverify request failed: ${cause instanceof Error ? cause.message : String(cause)}` },
|
|
161
|
+
{ cause },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
assertSecretRecognized(await errorCodesOf(response), response.status);
|
|
167
|
+
throw new TurnstileFailedError({ detail: `siteverify responded ${response.status} ${response.statusText}.` });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let raw: unknown;
|
|
171
|
+
try {
|
|
172
|
+
raw = await response.json();
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
throw new TurnstileFailedError({ detail: "siteverify returned a non-JSON body." }, { cause });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const parsed = SiteverifyResult.safeParse(raw);
|
|
178
|
+
if (!parsed.success) {
|
|
179
|
+
throw new TurnstileFailedError({ detail: `siteverify response had an unexpected shape: ${parsed.error.message}` });
|
|
180
|
+
}
|
|
181
|
+
// A 200 carrying a secret-side code is not what Cloudflare answers today (it uses 400), but the
|
|
182
|
+
// classification belongs to the code rather than to the status, so both routes reach the same verdict.
|
|
183
|
+
assertSecretRecognized(parsed.data["error-codes"], response.status);
|
|
184
|
+
return parsed.data;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read the response token from the configured header, or the body field. Form posts (the widget's own
|
|
189
|
+
* submit) parse as form data; everything else — JSON, a `+json` media type, or a request with no/odd
|
|
190
|
+
* content-type (some mobile/`fetch` clients) — is read as JSON. Any parse failure yields `null`, which the
|
|
191
|
+
* caller turns into a missing-token denial, so this stays fail-closed.
|
|
192
|
+
*
|
|
193
|
+
* **The body is read off a clone, never off the request itself.** This gate is stacked on top of a route
|
|
194
|
+
* it does not own — `@pithy-sh/auth` mounts it on the magic-link and OTP paths, which are Better Auth's,
|
|
195
|
+
* and the handler there forwards `c.req.raw` untouched. Reading through `c.req.json()`/`c.req.parseBody()`
|
|
196
|
+
* consumes that stream (Hono's body cache calls `raw.json()`), so a request that PASSED the humanity check
|
|
197
|
+
* would then fail downstream with "Body has already been read" — the gate would work only when it denied.
|
|
198
|
+
* Cloning costs one buffer copy of a token-sized body and keeps the original readable by whoever follows.
|
|
199
|
+
*/
|
|
200
|
+
async function readToken(c: Context, field: string, header?: string): Promise<string | null> {
|
|
201
|
+
if (header) {
|
|
202
|
+
return c.req.header(header) ?? null;
|
|
203
|
+
}
|
|
204
|
+
const contentType = (c.req.header("content-type") ?? "").toLowerCase();
|
|
205
|
+
const isForm =
|
|
206
|
+
contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data");
|
|
207
|
+
const probe = c.req.raw.clone();
|
|
208
|
+
const body = isForm
|
|
209
|
+
? await probe
|
|
210
|
+
.formData()
|
|
211
|
+
.then((form) => Object.fromEntries(form.entries()) as Record<string, unknown>)
|
|
212
|
+
.catch(() => null)
|
|
213
|
+
: ((await probe.json().catch(() => null)) as Record<string, unknown> | null);
|
|
214
|
+
const value = body?.[field];
|
|
215
|
+
return typeof value === "string" ? value : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The Turnstile humanity-check middleware. Stack it on **any** route, on top of that route's real
|
|
220
|
+
* verification strategy (`public`, `bearer`, `session`, …) — it answers "is this a human?", never
|
|
221
|
+
* "who is this?", so it is never an identity strategy of its own (CLAUDE.md §HTTP).
|
|
222
|
+
*
|
|
223
|
+
* It resolves the widget secret through the one `secretsStore` reader (CLAUDE.md §secrets: every secret is
|
|
224
|
+
* declared in a registry and read through the reader; the registry — `turnstileSecretsRegistry` — decides
|
|
225
|
+
* where it lives), reads the response token from the request, verifies it against Cloudflare siteverify,
|
|
226
|
+
* and on success lets the request continue to its real strategy. The app must have the `secrets`
|
|
227
|
+
* capability (it provides whatever the read needs); the secret is the encrypted row in its `SECRETS` D1,
|
|
228
|
+
* in local dev exactly as deployed, which is what `pithy turnstile provision` writes. It
|
|
229
|
+
* **fails closed** — every failure throws a `PithyError` subclass (all carrying a `turnstile/*` code), so
|
|
230
|
+
* a bot gate never silently opens. Register `pithyErrorHandler` on the app to map these to HTTP responses.
|
|
231
|
+
*
|
|
232
|
+
* **Two failures are the deployment's, not the caller's, and say so.** A secret Cloudflare does not
|
|
233
|
+
* recognize, and a documented test key outside dev/staging, both raise `turnstile/config` — see
|
|
234
|
+
* {@link assertSecretRecognized} and {@link testKeyCarriesNoAction} for why blaming the caller for
|
|
235
|
+
* either one costs an operator an hour.
|
|
236
|
+
*
|
|
237
|
+
* @throws {@link TurnstileMissingTokenError} (`turnstile/missing_token`, 400) — no token in the request.
|
|
238
|
+
* @throws {@link TurnstileFailedError} (`turnstile/failed`, 403) — the token did not pass siteverify, its
|
|
239
|
+
* action did not match a configured `action`, or the check could not complete (an unreachable/malformed
|
|
240
|
+
* siteverify response also lands here, fail-closed).
|
|
241
|
+
* @throws {@link TurnstileConfigError} (`turnstile/config`, 500) — the secret is missing, malformed, has
|
|
242
|
+
* no entry for the route's widget mode, is one Cloudflare does not recognize, or is a test key in an
|
|
243
|
+
* environment that has no business holding one (the `secretsStore` read is rewrapped to this too, so
|
|
244
|
+
* the gate's contract stays `turnstile/*`).
|
|
245
|
+
*/
|
|
246
|
+
export function turnstile(options: TurnstileOptions = {}): MiddlewareHandler {
|
|
247
|
+
const field = options.field ?? DEFAULT_TOKEN_FIELD;
|
|
248
|
+
return async (c, next) => {
|
|
249
|
+
let secret: string;
|
|
250
|
+
try {
|
|
251
|
+
const store = await sharedSecretsStore(c.env as unknown as SecretsStoreEnv, turnstileSecretsRegistry);
|
|
252
|
+
secret = selectTurnstileSecret(store.get(TURNSTILE_SECRET_NAME), options.mode);
|
|
253
|
+
} catch (cause) {
|
|
254
|
+
// selectTurnstileSecret already throws turnstile/config; the reader throws secrets/* — rewrap those
|
|
255
|
+
// so the gate fails closed under its own contract (a missing secret is a misconfig, not a 404 route).
|
|
256
|
+
if (cause instanceof TurnstileConfigError) throw cause;
|
|
257
|
+
throw new TurnstileConfigError(
|
|
258
|
+
{ detail: `Could not resolve the turnstile secret: ${cause instanceof Error ? cause.message : String(cause)}` },
|
|
259
|
+
{ cause },
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const token = await readToken(c, field, options.header);
|
|
264
|
+
if (!token) {
|
|
265
|
+
throw new TurnstileMissingTokenError({
|
|
266
|
+
detail: options.header
|
|
267
|
+
? `No token in the ${options.header} header.`
|
|
268
|
+
: `No "${field}" field in the request body.`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const environment = workerIdentity(c.env).environment;
|
|
273
|
+
const result = await siteverify(secret, token, { remoteIp: c.req.header("CF-Connecting-IP") });
|
|
274
|
+
|
|
275
|
+
// A documented test key answers for everybody who asks, so where it is wired is the whole of what
|
|
276
|
+
// makes it acceptable. Outside the two environments provisioning writes one into, its presence is a
|
|
277
|
+
// misconfiguration and is reported as one — an always-pass secret on a production login page is a
|
|
278
|
+
// door, and it should be the loudest thing in the log rather than a quiet 200.
|
|
279
|
+
if (fromTestingKey(result) && !isTestKeyEnvironment(environment)) {
|
|
280
|
+
throw new TurnstileConfigError({
|
|
281
|
+
message: "The humanity check is not configured.",
|
|
282
|
+
action: `This widget secret is a Cloudflare Turnstile test key, which passes every caller. Run \`pithy turnstile provision\` for the real widget, or stamp ENVIRONMENT as ${TEST_KEY_ENVIRONMENTS.join(" or ")} in this Worker's wrangler.jsonc if that is what this deployment is.`,
|
|
283
|
+
detail: `siteverify set metadata.result_with_testing_key on environment "${environment ?? "unstamped"}".`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!result.success) {
|
|
288
|
+
throw new TurnstileFailedError({
|
|
289
|
+
detail: `siteverify rejected the token: ${result["error-codes"].join(", ") || "no error codes"}.`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// Bind the token to the expected action when one is configured — fail closed on mismatch so a token
|
|
293
|
+
// solved for another action on the same widget can't be replayed here.
|
|
294
|
+
if (
|
|
295
|
+
options.action !== undefined &&
|
|
296
|
+
result.action !== options.action &&
|
|
297
|
+
!testKeyCarriesNoAction(result, environment)
|
|
298
|
+
) {
|
|
299
|
+
throw new TurnstileFailedError({
|
|
300
|
+
detail: `Turnstile action mismatch: expected "${options.action}", got "${result.action ?? "none"}".`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await next();
|
|
305
|
+
};
|
|
306
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package entrypoint — the surface `pithy add turnstile` wires into `pithy.config.ts`. Deliberately
|
|
6
|
+
* narrow: the capability factory plus the config types an app declares. The `turnstile()` *middleware* is
|
|
7
|
+
* imported by deep path (`@pithy-sh/turnstile/src/http/middleware`) at the route that stacks it — this is
|
|
8
|
+
* the documented contract, not a barrel over the package.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { isTurnstileCapability, type TurnstileCapability, turnstile } from "./capability";
|
|
12
|
+
export { type TurnstileConfig, type TurnstileConfigInput, TurnstileMode } from "./config/config";
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { resourceName } from "@pithy-sh/core/src/naming/resource";
|
|
6
|
+
import type { TurnstileConfig, TurnstileMode } from "../config/config";
|
|
7
|
+
import type { TurnstileSecrets } from "../secret/registry";
|
|
8
|
+
import { TEST_SECRET, testSitekey } from "./testKeys";
|
|
9
|
+
|
|
10
|
+
/** The widget modes a config enables, in `visible`-then-`invisible` order. */
|
|
11
|
+
export function enabledModes(config: TurnstileConfig): TurnstileMode[] {
|
|
12
|
+
const modes: TurnstileMode[] = [];
|
|
13
|
+
if (config.widgets.visible) modes.push("visible");
|
|
14
|
+
if (config.widgets.invisible) modes.push("invisible");
|
|
15
|
+
return modes;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The deployed environments whose secret is written to the managed store (dev is local, via `.dev.vars`). */
|
|
19
|
+
export const MANAGED_ENVIRONMENTS = ["staging", "prod"] as const;
|
|
20
|
+
export type ManagedTurnstileEnv = (typeof MANAGED_ENVIRONMENTS)[number];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The one environment a real widget is created for, named once rather than spelled at four call sites.
|
|
24
|
+
*
|
|
25
|
+
* It appears in the widget's own name, in the secret write, and in the sitekey write, and those three
|
|
26
|
+
* have to agree: a widget provisioned under one environment and a secret written under another is a
|
|
27
|
+
* production login page verifying against a key nobody holds.
|
|
28
|
+
*/
|
|
29
|
+
const REAL_WIDGET_ENV = "prod" satisfies ManagedTurnstileEnv;
|
|
30
|
+
|
|
31
|
+
/** The Worker var the public sitekey for a mode is surfaced under, for the front-end to render with. */
|
|
32
|
+
export function sitekeyVarName(mode: TurnstileMode): string {
|
|
33
|
+
return `TURNSTILE_SITEKEY_${mode.toUpperCase()}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The production widget's name: `<project>-prod-turnstile-<mode>` (docs/NAMING.md).
|
|
38
|
+
*
|
|
39
|
+
* Stable per project and mode, because provisioning is reuse-or-create **by name** — which is exactly
|
|
40
|
+
* why the project segment is not optional. Turnstile widgets are account-scoped and the account's widget
|
|
41
|
+
* list is flat, so an unscoped name means a second Pithy project in one account adopts the first's widget
|
|
42
|
+
* and `turnstile deprovision` deletes it out from under them.
|
|
43
|
+
*
|
|
44
|
+
* `prod` sits in the environment slot because that is the environment this widget serves, and it is the
|
|
45
|
+
* only one: dev and staging wire Cloudflare's documented test keys and create no widget at all. If a real
|
|
46
|
+
* staging widget ever lands, it takes `staging` in the same slot and nothing else moves.
|
|
47
|
+
*
|
|
48
|
+
* **The generic composer rather than the facade**, and for the one reason the facade allows: a Turnstile
|
|
49
|
+
* widget is not a namespace `@pithy-sh/core/src/naming/limits` carries a verified Cloudflare cap for.
|
|
50
|
+
* The facade's premise is that a kind of thing brings its own number; inventing one here would be the
|
|
51
|
+
* flaw it was built to remove. So this takes the conservative default until that namespace lands.
|
|
52
|
+
*/
|
|
53
|
+
export function productionWidgetName(project: string, mode: TurnstileMode): string {
|
|
54
|
+
return resourceName({ project, env: REAL_WIDGET_ENV, thing: `turnstile-${mode}` });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build the combined secret object for the enabled modes, all set to one key value. */
|
|
58
|
+
function buildSecrets(modes: TurnstileMode[], key: string): TurnstileSecrets {
|
|
59
|
+
const secrets: TurnstileSecrets = {};
|
|
60
|
+
for (const mode of modes) secrets[mode] = { key };
|
|
61
|
+
return secrets;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The public test sitekeys for the enabled modes, keyed by their Worker var name. */
|
|
65
|
+
function testSitekeyVars(modes: TurnstileMode[]): Record<string, string> {
|
|
66
|
+
const vars: Record<string, string> = {};
|
|
67
|
+
for (const mode of modes) vars[sitekeyVarName(mode)] = testSitekey(mode);
|
|
68
|
+
return vars;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The side-effecting steps provisioning performs, injected so the orchestration is pure and unit-testable
|
|
73
|
+
* (the live implementation lives in the CLI). The secret is the one `d1`, JSON turnstile secret read by the
|
|
74
|
+
* middleware through `@pithy-sh/secrets` (CLAUDE.md §secrets); dev gets it via `.dev.vars`, deployed
|
|
75
|
+
* environments via the manager's write Workflow. Every step is idempotent.
|
|
76
|
+
*/
|
|
77
|
+
export interface TurnstileProvisioner {
|
|
78
|
+
/**
|
|
79
|
+
* Refuse the run when a widget **outside this project** already claims the production domain. Cloudflare
|
|
80
|
+
* permits several widgets per domain; Pithy does not, because a second widget on one domain is almost
|
|
81
|
+
* always someone's forgotten first attempt, and the two are indistinguishable to a front-end holding one
|
|
82
|
+
* sitekey. This project's own widgets are the expected steady state and never trip it.
|
|
83
|
+
*/
|
|
84
|
+
assertDomainAvailable(domain: string): Promise<void>;
|
|
85
|
+
/** dev: upsert the turnstile secret (JSON) and the public sitekey vars into `.dev.vars`. */
|
|
86
|
+
writeDev(secret: string, sitekeys: Record<string, string>): Promise<void>;
|
|
87
|
+
/** Write the turnstile secret (JSON) to a deployed environment's managed store (via the manager). */
|
|
88
|
+
writeManagedSecret(env: ManagedTurnstileEnv, secret: string): Promise<void>;
|
|
89
|
+
/** Write the public sitekey vars into a deployed environment's worker vars. */
|
|
90
|
+
writeManagedSitekeys(env: ManagedTurnstileEnv, sitekeys: Record<string, string>): Promise<void>;
|
|
91
|
+
/** Reuse the production widget by name, else create it bound to the domain. `secret` is null on reuse. */
|
|
92
|
+
ensureProductionWidget(mode: TurnstileMode, domain: string): Promise<{ sitekey: string; secret: string | null }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The inverse steps, for teardown — each guarded so a missing resource is a no-op. */
|
|
96
|
+
export interface TurnstileDeprovisioner {
|
|
97
|
+
/** Delete the production widget for a mode if it exists. */
|
|
98
|
+
deleteProductionWidget(mode: TurnstileMode): Promise<void>;
|
|
99
|
+
/** Delete the turnstile secret from every deployed environment's managed store. */
|
|
100
|
+
deleteManagedSecret(): Promise<void>;
|
|
101
|
+
/** Clear the turnstile secret + sitekey vars from `.dev.vars`. */
|
|
102
|
+
clearDev(modes: TurnstileMode[]): Promise<void>;
|
|
103
|
+
/** Clear the sitekey vars from the deployed environments' worker vars. */
|
|
104
|
+
clearManagedSitekeys(modes: TurnstileMode[]): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What provisioning resolved for one widget mode. */
|
|
108
|
+
export interface ProvisionedWidget {
|
|
109
|
+
mode: TurnstileMode;
|
|
110
|
+
/** The production public sitekey. */
|
|
111
|
+
sitekey: string;
|
|
112
|
+
/** True if the production widget was created this run (false on idempotent reuse). */
|
|
113
|
+
created: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface TurnstileProvisionResult {
|
|
117
|
+
modes: TurnstileMode[];
|
|
118
|
+
widgets: ProvisionedWidget[];
|
|
119
|
+
/**
|
|
120
|
+
* Whether the production secret was (re)written this run. False on idempotent reuse — Cloudflare never
|
|
121
|
+
* returns an existing widget's secret, so it can't be recomposed; the caller should warn that an absent
|
|
122
|
+
* production secret won't be healed by re-running (a deprovision + provision is needed).
|
|
123
|
+
*/
|
|
124
|
+
productionSecretWritten: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The modes to provision and the production domain the real widget binds to. */
|
|
128
|
+
export interface TurnstilePlan {
|
|
129
|
+
/** The enabled widget modes (from `config.widgets`). At least one. */
|
|
130
|
+
modes: TurnstileMode[];
|
|
131
|
+
/** The production domain the real widget is bound to (resolved from per-environment config). */
|
|
132
|
+
productionDomain: string;
|
|
133
|
+
/**
|
|
134
|
+
* Provision even though a foreign widget already covers the domain (`--allow-shared-domain`). The
|
|
135
|
+
* escape hatch for the one legitimate case: an adopter who already runs a hand-made widget on that
|
|
136
|
+
* host and is not ready to retire it. Off by default — the refusal is the useful answer.
|
|
137
|
+
*/
|
|
138
|
+
allowSharedDomain?: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Provision Turnstile across environments. **dev and staging** get Cloudflare's documented test secret
|
|
143
|
+
* (written per-environment — dev to `.dev.vars`, staging to its managed store); **`prod`** gets a real
|
|
144
|
+
* widget per mode, bound to the domain, its secret written to the production managed store, and its public
|
|
145
|
+
* sitekey to the production worker vars. Idempotent: a re-run reuses existing production widgets and skips
|
|
146
|
+
* the production secret write (whose value can't be recovered from Cloudflare). A *mixed* production state
|
|
147
|
+
* (some widgets new, some pre-existing) can't compose a consistent secret, so it errors with guidance
|
|
148
|
+
* rather than write a half-secret. Before anything is written, a production domain a *foreign* widget
|
|
149
|
+
* already covers is refused (`allowSharedDomain` opts out).
|
|
150
|
+
*/
|
|
151
|
+
export async function provisionTurnstile(
|
|
152
|
+
provisioner: TurnstileProvisioner,
|
|
153
|
+
plan: TurnstilePlan,
|
|
154
|
+
): Promise<TurnstileProvisionResult> {
|
|
155
|
+
// First, before a single write: a domain already covered by a foreign widget is refused, so a refusal
|
|
156
|
+
// leaves no half-wired `.dev.vars` or staging secret behind.
|
|
157
|
+
if (!plan.allowSharedDomain) await provisioner.assertDomainAvailable(plan.productionDomain);
|
|
158
|
+
|
|
159
|
+
const testSecret = JSON.stringify(buildSecrets(plan.modes, TEST_SECRET));
|
|
160
|
+
const sitekeys = testSitekeyVars(plan.modes);
|
|
161
|
+
|
|
162
|
+
await provisioner.writeDev(testSecret, sitekeys);
|
|
163
|
+
await provisioner.writeManagedSecret("staging", testSecret);
|
|
164
|
+
await provisioner.writeManagedSitekeys("staging", sitekeys);
|
|
165
|
+
|
|
166
|
+
const widgets: ProvisionedWidget[] = [];
|
|
167
|
+
const realSecrets: TurnstileSecrets = {};
|
|
168
|
+
const prodSitekeys: Record<string, string> = {};
|
|
169
|
+
for (const mode of plan.modes) {
|
|
170
|
+
const { sitekey, secret } = await provisioner.ensureProductionWidget(mode, plan.productionDomain);
|
|
171
|
+
prodSitekeys[sitekeyVarName(mode)] = sitekey;
|
|
172
|
+
widgets.push({ mode, sitekey, created: secret !== null });
|
|
173
|
+
if (secret !== null) realSecrets[mode] = { key: secret };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const created = widgets.filter((widget) => widget.created).length;
|
|
177
|
+
if (created > 0 && created < plan.modes.length) {
|
|
178
|
+
throw new ValidationError({
|
|
179
|
+
message: "Turnstile production widgets are in a mixed state — some exist, some were just created.",
|
|
180
|
+
action: "Run `pithy turnstile deprovision`, then provision again to write a consistent production secret.",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// All new → write the freshly-composed production secret. All reused → Cloudflare won't return the
|
|
184
|
+
// existing widgets' secret, so it can't be recomposed and is left as-is (the caller warns).
|
|
185
|
+
const productionSecretWritten = created === plan.modes.length;
|
|
186
|
+
if (productionSecretWritten) {
|
|
187
|
+
await provisioner.writeManagedSecret(REAL_WIDGET_ENV, JSON.stringify(realSecrets));
|
|
188
|
+
}
|
|
189
|
+
await provisioner.writeManagedSitekeys(REAL_WIDGET_ENV, prodSitekeys);
|
|
190
|
+
|
|
191
|
+
return { modes: plan.modes, widgets, productionSecretWritten };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Tear down Turnstile: delete each mode's production widget, the managed secret, and all config entries. */
|
|
195
|
+
export async function deprovisionTurnstile(
|
|
196
|
+
deprovisioner: TurnstileDeprovisioner,
|
|
197
|
+
modes: TurnstileMode[],
|
|
198
|
+
): Promise<{ modes: TurnstileMode[] }> {
|
|
199
|
+
for (const mode of modes) {
|
|
200
|
+
await deprovisioner.deleteProductionWidget(mode);
|
|
201
|
+
}
|
|
202
|
+
await deprovisioner.deleteManagedSecret();
|
|
203
|
+
await deprovisioner.clearDev(modes);
|
|
204
|
+
await deprovisioner.clearManagedSitekeys(modes);
|
|
205
|
+
return { modes };
|
|
206
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { TurnstileMode } from "../config/config";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cloudflare's documented Turnstile test keys. dev and staging wire these instead of provisioning a
|
|
8
|
+
* real widget — they need no CF round-trip and make the positive/negative/challenge paths trivially
|
|
9
|
+
* testable. The *sitekeys* are public (the front-end renders with them); the *secrets* feed siteverify.
|
|
10
|
+
* https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
|
11
|
+
*/
|
|
12
|
+
export const TURNSTILE_TEST_KEYS = {
|
|
13
|
+
/** Public sitekeys, by widget mode and verdict. */
|
|
14
|
+
sitekey: {
|
|
15
|
+
/** Visible (managed) widget that always passes. */
|
|
16
|
+
visiblePass: "1x00000000000000000000AA",
|
|
17
|
+
/** Visible (managed) widget that always blocks. */
|
|
18
|
+
visibleBlock: "2x00000000000000000000AB",
|
|
19
|
+
/** Invisible widget that always passes. */
|
|
20
|
+
invisiblePass: "1x00000000000000000000BB",
|
|
21
|
+
/** A widget that always forces an interactive challenge. */
|
|
22
|
+
forceChallenge: "3x00000000000000000000FF",
|
|
23
|
+
},
|
|
24
|
+
/** Secret keys for siteverify, by verdict. */
|
|
25
|
+
secret: {
|
|
26
|
+
/** Secret that makes siteverify always pass. */
|
|
27
|
+
pass: "1x0000000000000000000000000000000AA",
|
|
28
|
+
/** Secret that makes siteverify always fail. */
|
|
29
|
+
fail: "2x0000000000000000000000000000000AA",
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/** The test sitekey for a mode's always-pass widget — what dev and staging render with. */
|
|
34
|
+
export function testSitekey(mode: TurnstileMode): string {
|
|
35
|
+
return mode === "visible" ? TURNSTILE_TEST_KEYS.sitekey.visiblePass : TURNSTILE_TEST_KEYS.sitekey.invisiblePass;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The always-pass test secret — what dev and staging verify against. */
|
|
39
|
+
export const TEST_SECRET = TURNSTILE_TEST_KEYS.secret.pass;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The environments a test key belongs in: the two `provisionTurnstile` writes {@link TEST_SECRET} to.
|
|
43
|
+
* `prod` gets a real widget, and everything else gets nothing.
|
|
44
|
+
*
|
|
45
|
+
* Named here rather than spelled at the gate, because the gate and the provisioner have to agree about
|
|
46
|
+
* exactly one thing: a key that passes everybody is acceptable only where somebody deliberately wired
|
|
47
|
+
* one. `packages/turnstile/src/provision/provisionTurnstile.test.ts` pins the two lists together — the
|
|
48
|
+
* provisioner names its environments itself, so that test compares two independent statements rather
|
|
49
|
+
* than one constant with itself.
|
|
50
|
+
*/
|
|
51
|
+
export const TEST_KEY_ENVIRONMENTS = ["dev", "staging"] as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a Worker's stamped `ENVIRONMENT` is one a test key belongs in.
|
|
55
|
+
*
|
|
56
|
+
* `null` — an unstamped Worker — is **not** one. The environment is the whole of what separates "a key
|
|
57
|
+
* that passes everybody, on purpose, locally" from "a production login page anybody can walk through",
|
|
58
|
+
* so a Worker that cannot say which it is gets the strict answer.
|
|
59
|
+
*/
|
|
60
|
+
export function isTestKeyEnvironment(environment: string | null): boolean {
|
|
61
|
+
return environment !== null && (TEST_KEY_ENVIRONMENTS as readonly string[]).includes(environment);
|
|
62
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { defineSecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { TurnstileMode } from "../config/config";
|
|
7
|
+
import { TurnstileConfigError } from "../error/errors";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One widget's secret. `key` is the value siteverify checks a token against; `lastRotatedAt` is
|
|
11
|
+
* informational and exists so a future rotation can track and overlap secrets without a reshape (the
|
|
12
|
+
* issue keeps rotation out of scope, but the storage must not preclude it).
|
|
13
|
+
*/
|
|
14
|
+
export const TurnstileSecretEntry = z
|
|
15
|
+
.object({
|
|
16
|
+
key: z.string().min(1).describe("The widget's secret key, used server-side at Cloudflare siteverify."),
|
|
17
|
+
lastRotatedAt: z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("ISO-8601 timestamp of the last rotation, if any. Informational; enables future rotation."),
|
|
21
|
+
})
|
|
22
|
+
.describe("One widget's secret key plus optional rotation metadata.");
|
|
23
|
+
export type TurnstileSecretEntry = z.infer<typeof TurnstileSecretEntry>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The decrypted value of the turnstile secret: each configured widget's secret, keyed by mode. One
|
|
27
|
+
* secret serves one *or* both widgets, so adding the second never reshapes storage. The `secretsStore`
|
|
28
|
+
* reader parses and validates this against the registry entry's `valueType: "json"` schema; the
|
|
29
|
+
* middleware then selects the entry for the route's mode via {@link selectTurnstileSecret}.
|
|
30
|
+
*/
|
|
31
|
+
export const TurnstileSecrets = z
|
|
32
|
+
.strictObject({
|
|
33
|
+
visible: TurnstileSecretEntry.optional().describe("The visible (managed) widget's secret, if that widget is used."),
|
|
34
|
+
invisible: TurnstileSecretEntry.optional().describe("The invisible widget's secret, if that widget is used."),
|
|
35
|
+
})
|
|
36
|
+
.describe("The turnstile secret value: each configured widget's secret, keyed by mode.");
|
|
37
|
+
export type TurnstileSecrets = z.infer<typeof TurnstileSecrets>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The registry name (D1 row key) the turnstile secret is stored under. The secret is a `d1`-backed,
|
|
41
|
+
* per-environment, JSON value — read through the one `secretsStore` reader like every other secret
|
|
42
|
+
* (CLAUDE.md §secrets), never off a raw binding.
|
|
43
|
+
*/
|
|
44
|
+
export const TURNSTILE_SECRET_NAME = "turnstile-secret-keys";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where a widget's secret key comes from, and where it is replaced.
|
|
48
|
+
*
|
|
49
|
+
* `obtained`: a widget is created in Cloudflare's dashboard and the secret key is handed over with the
|
|
50
|
+
* sitekey. Nothing can mint one — the value only verifies because Cloudflare already knows it, which is
|
|
51
|
+
* equally why the test keys dev and staging wire are a published pair rather than a generated one.
|
|
52
|
+
*
|
|
53
|
+
* `provider`: Cloudflare's API rotates a widget's secret and returns the new value, so this secret can
|
|
54
|
+
* genuinely replace itself. That is also why `rotatable` is true: a rotation leaves tokens minted under
|
|
55
|
+
* the old secret in flight, and both versions have to verify until they drain.
|
|
56
|
+
*/
|
|
57
|
+
// The dashboard page, not `turnstile/get-started/`. That one is a hub: it offers three implementation
|
|
58
|
+
// methods and names no console, so an operator holding an expired secret key spends the click choosing
|
|
59
|
+
// between links. This one carries the dashboard URL, `Add widget`, and "copy your sitekey and secret key".
|
|
60
|
+
// #332 — `documentation` says "the specific settings page, not a product homepage", and a hub is a homepage.
|
|
61
|
+
const TURNSTILE_WIDGETS_PAGE = "https://developers.cloudflare.com/turnstile/get-started/widget-management/dashboard/";
|
|
62
|
+
const TURNSTILE_ROTATE_SECRET =
|
|
63
|
+
"https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/rotate_secret/";
|
|
64
|
+
|
|
65
|
+
/** The minimal registry the turnstile middleware reads its secret through (mirrors email's signing-key registry). */
|
|
66
|
+
export const turnstileSecretsRegistry = defineSecretRegistry({
|
|
67
|
+
[TURNSTILE_SECRET_NAME]: {
|
|
68
|
+
backend: "d1",
|
|
69
|
+
scope: "environment",
|
|
70
|
+
rotatable: true,
|
|
71
|
+
valueType: "json",
|
|
72
|
+
schema: TurnstileSecrets,
|
|
73
|
+
origin: { kind: "obtained", issuer: "cloudflare", documentation: TURNSTILE_WIDGETS_PAGE },
|
|
74
|
+
rotation: { kind: "provider", issuer: "cloudflare", documentation: TURNSTILE_ROTATE_SECRET },
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Select the secret key for the route's widget from the resolved secrets object. With one widget
|
|
80
|
+
* configured the only entry is used; with both, `mode` chooses. A missing/ambiguous selection is a
|
|
81
|
+
* `turnstile/config` — the gate is misconfigured, never silently open.
|
|
82
|
+
*/
|
|
83
|
+
export function selectTurnstileSecret(secrets: TurnstileSecrets, mode?: TurnstileMode): string {
|
|
84
|
+
const present = (Object.keys(secrets) as TurnstileMode[]).filter((m) => secrets[m]);
|
|
85
|
+
if (present.length === 0) {
|
|
86
|
+
throw new TurnstileConfigError({ detail: "The turnstile secret holds no widget secrets." });
|
|
87
|
+
}
|
|
88
|
+
if (!mode && present.length > 1) {
|
|
89
|
+
throw new TurnstileConfigError({
|
|
90
|
+
detail: "Two widgets are configured; pass turnstile({ mode }) to select which one this route gates.",
|
|
91
|
+
action: 'Set mode to "visible" or "invisible" on the turnstile() middleware for this route.',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const chosen = mode ?? present[0];
|
|
95
|
+
const entry = chosen ? secrets[chosen] : undefined;
|
|
96
|
+
if (!entry) {
|
|
97
|
+
throw new TurnstileConfigError({ detail: `The turnstile secret has no "${chosen}" widget entry.` });
|
|
98
|
+
}
|
|
99
|
+
return entry.key;
|
|
100
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
|
|
5
|
+
//
|
|
6
|
+
// A Worker cannot read its own package.json, so this is how @pithy-sh/turnstile knows its own version at
|
|
7
|
+
// runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
|
|
8
|
+
// which is what answers "should this project upgrade" and "is this customer exposed to what we just
|
|
9
|
+
// fixed". Those questions are only answerable per module, because a project composes some capabilities
|
|
10
|
+
// and not others.
|
|
11
|
+
|
|
12
|
+
/** This package's npm name — the join key against a release feed. */
|
|
13
|
+
export const PACKAGE_NAME = "@pithy-sh/turnstile";
|
|
14
|
+
|
|
15
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
16
|
+
export const PACKAGE_VERSION = "0.1.0";
|