Skip to content

Commit fc5d509

Browse files
feat(plugin-auth): opt-in Cloudflare Turnstile on email sign-up (#28365)
domain:server#3 · os-elon-musk · session_016WV4cxKwptVz3gdUgWLyNb <!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D825P5cyzb4mXdkgvzukAp1)_ Fixes #28100 Clause-②: yes (widening: the public auth config gains `features.turnstileSiteKey`, served only while the challenge is on; `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS` and `AuthFeaturesConfigSchema` gain `turnstileSiteKey`; with the challenge on, `/sign-up/email` answers better-auth's `captcha` refusals `400 MISSING_RESPONSE`, `403 VERIFICATION_FAILED` and `500 UNKNOWN_ERROR`; two new env vars `OS_TURNSTILE_SITE_KEY` / `OS_TURNSTILE_SECRET_KEY`; `@object-ui/auth` gains `TurnstileWidget`, `TURNSTILE_SCRIPT_URL`, the `TurnstileApi`/`TurnstileWidgetProps`/`TurnstileWidgetLabels` types, `RegisterForm`'s `turnstileSiteKey` prop and `captchaFailed`/`captchaRetry` labels, `SignUpData.captchaToken` and a fifth `signUp` argument; `@object-ui/app-shell` gains `signUpCaptchaFailedMessage`) Metadata-protocol: added Ruling-ref: maintainer 「同意 A」 on cloud#3367 (2026-10-30 12:46Z); triage 6523361600; one-PR exception extended by triage 6524682167 and 6527231164 (on retriage 6523679528 and 6526181490) ## 维护者速读 - 改了什么:同时配置 `OS_TURNSTILE_SITE_KEY` 和 `OS_TURNSTILE_SECRET_KEY` 时,邮箱注册(`/sign-up/email`)需要通过 Cloudflare Turnstile 校验;Console 的注册页和首次安装页会显示验证组件并带上 token。只配一个或都不配,行为与现在完全一样。 - 为什么改:公开发布前防止批量注册 Free 账号刷模型费用(cloud#3367 选项 A)。 - 风险与回滚:默认关闭;开启后只拦注册,登录与重置密码不拦(better-auth 默认也会拦这两个,已显式覆盖)。secret 不下发、不进日志、不进错误响应(有测试钉住)。回滚即 revert。 - 先看:`packages/plugins/plugin-auth/src/turnstile-sign-up.ts`,`ui/packages/auth/src/TurnstileWidget.tsx`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/turnstile-sign-up.test.ts`,预期 12 passed(需先构建 plugin-auth 依赖闭包)。 - 需要你做的:无;席位安排契约复审后落地。cloud 那半边(配密钥、staging 验收)是 cloud 的卡。 ## File surface Exactly the surface triage granted: 6523361600 (plugin-auth, the app-shell sign-up widget, the Console CSP file, one docs line, changeset), extended by 6524682167 and 6527231164. - Server: `packages/plugins/plugin-auth/src/{turnstile-sign-up.ts (new), auth-manager-plugin-list.ts, auth-manager.ts}` - Spec (6524682167 item 1): `packages/spec/src/kernel/public-auth-features.ts`, `packages/spec/src/api/auth-endpoints.zod.ts` - `@object-ui/auth` (6524682167 item 2, 6527231164 item 1): `ui/packages/auth/src/{types.ts, createAuthClient.ts, AuthProvider.tsx, AuthContext.ts, RegisterForm.tsx, TurnstileWidget.tsx (new), index.ts}` - Console pages (6524682167 items 2-3): `ui/apps/console/src/pages/auth/{RegisterPage.tsx, SetupPage.tsx}` - app-shell (6523361600; 6527231164 item 2): `ui/packages/app-shell/src/console/auth/{RegisterPage.tsx, signUpRefusalMessages.ts}`, `ui/packages/app-shell/src/index.ts` - i18n (6527231164 item 3, two keys each): `ui/packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.ts` - CSP (6524682167 item 4): `packages/cli/src/utils/console.ts` - Docs (6527231164 item 4, one row): `content/docs/deployment/environment-variables.mdx` - Ride-alongs (6527231164): generated `packages/spec/authorable-surface/api.json` and `content/docs/references/api/auth-endpoints.mdx`; tests `packages/plugins/plugin-auth/src/turnstile-sign-up.test.ts`, `packages/cli/src/utils/console.content-security-policy.test.ts`, `ui/packages/auth/src/__tests__/registerTurnstile-28100.test.tsx`, `ui/apps/console/src/pages/auth/__tests__/{turnstileWidget-28100.test.tsx, authExitBasename.test.tsx, setupCreateTimezone-11908.test.tsx}`, `ui/packages/app-shell/src/console/auth/__tests__/defaultRegisterTurnstile-28100.test.tsx`; `.changeset/28100-turnstile-sign-up.md` ## What changes **Server (`@objectstack/plugin-auth`).** - `src/turnstile-sign-up.ts` (new): `resolveTurnstileKeys()` returns both keys only when both env vars are set and non-blank; it is the one decision the plugin mount and the public config share. `buildTurnstileSignUpPlugin()` builds better-auth's `captcha` plugin with provider `cloudflare-turnstile` and `endpoints: ['/sign-up/email']`. better-auth's own default list also guards `/sign-in/email` and `/request-password-reset`; it is overridden because neither form has a widget, so the vendor default would refuse every password sign-in on a deployment that turned this on. `reportHalfConfiguredTurnstile()` warns once per build when exactly one variable is set, naming the variables only. - `src/auth-manager-plugin-list.ts`: mounts the plugin right after `bearer()` when both keys are set. It is a plain `plugins.push`, not an optional plugin: a guard that fails to build fails the build rather than leaving sign-up open while the Console renders a widget. - `src/auth-manager.ts`: `getPublicConfig()` serves `features.turnstileSiteKey` (the public key only) from the same resolver. - The plugin hooks the router's `onRequest` stage after the per-address rate limit, so a refused request reads and writes nothing. Server-side `auth.api.signUpEmail` callers (the development admin seed, demo staff) do not pass that stage and are unaffected. The client address Cloudflare receives as `remoteip` is the one `handleRequest` establishes by the platform's trust rule. **Spec (`@objectstack/spec`).** `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS` gains `turnstileSiteKey` (the plugin-auth drift guard holds served = declared), and `AuthFeaturesConfigSchema` gains `turnstileSiteKey: z.string().optional()`. The generated `authorable-surface/api.json` and `content/docs/references/api/auth-endpoints.mdx` are regenerated. **Console CSP (`@objectstack/cli`, `src/utils/console.ts`).** While both variables are set, the Console document's policy adds `https://challenges.cloudflare.com` to `script-src` and adds `frame-src 'self' https://challenges.cloudflare.com`. With the challenge off the policy is byte-identical to today. The two names are restated there because the published `./console` subpath imports Node built-ins only; only presence is read. **Console (`ui/`).** - `@object-ui/auth`: `TurnstileWidget` loads Cloudflare's script once in explicit-render mode, hands its token up, and on failure, expiry, timeout or a script that cannot load shows one retry message with a Retry button, never Cloudflare's error code. `RegisterForm` takes `turnstileSiteKey`; with it, the form renders the widget, holds submit until it is solved, sends the token, and draws a fresh challenge after every server answer (tokens are single-use). A server refusal `MISSING_RESPONSE` / `VERIFICATION_FAILED` reads as the same retry message. `createAuthClient.signUp` sends `SignUpData.captchaToken` as the `x-captcha-response` header, never in the body. - `/register` (`ui/apps/console/src/pages/auth/RegisterPage.tsx`) and the exported `DefaultRegisterPage` (`ui/packages/app-shell`) pass `features.turnstileSiteKey`. - `/setup` (`ui/apps/console/src/pages/auth/SetupPage.tsx`): the first owner registers through the same `/sign-up/email` door, so without the widget there a deployment that turned the challenge on before its first owner could not be set up. It reads the config, renders the widget and sends the token the same way. - `@object-ui/i18n`: `auth.register.captchaRetry` and `auth.register.errors.captchaFailed` in all ten locales. **Docs.** One row in `content/docs/deployment/environment-variables.mdx` naming the two variables. ## Why The maintainer ruled option A on cloud#3367: a Turnstile check on sign-up at the framework layer, because cloud's sign-up page is plugin-auth plus the Console. Triage graded it opt-in and byte-identical when off. Scope notes, measured on `main` `b6c2ed9c`: - The card named `ui/packages/app-shell/src/console/auth/RegisterPage.tsx`; the Console's live `/register` is `ui/apps/console/src/pages/auth/RegisterPage.tsx`, and both draw `@object-ui/auth`'s `RegisterForm`, where the token has to be sent. The first-run `/setup` page posts the same endpoint. Triage extended the one-PR exception to these files and the two spec lines (6524682167, 6527231164). - Sign-in and password reset are left unchallenged, as the card asks; this PR has no measurement that says they need it. - No `allowedHostnames` or `expectedAction` is set: Cloudflare already ties a token to its site key's domains, and neither has a configuration surface in the card. ## Verification - `packages/plugins/plugin-auth/src/turnstile-sign-up.test.ts` (12, real engine, Cloudflare's siteverify stubbed through `fetch`, no network): ON, a sign-up without a token is `400 MISSING_RESPONSE` with no user row and no Cloudflare call; a token is verified with the secret and the account is created; a rejected token is `403 VERIFICATION_FAILED` with no row; Cloudflare unreachable is `500` and the secret is in neither the body nor any log line. Sign-in and password reset need no token. OFF (neither key, only one, a blank secret): sign-up with no token succeeds, Cloudflare is never called, no site key is served; a half configuration warns naming the missing variable. The config serves the site key, never the secret, and the key is in the spec registry. - Ablations (`scripts/ablation-replace.mjs`): dropping the plugin mount reds the four ON cases; dropping the endpoint override reds the sign-in/reset case; dropping the header in `createAuthClient` reds the two wire cases. - `packages/cli/src/utils/console.content-security-policy.test.ts` (50): both keys set admits Cloudflare in `script-src` and `frame-src` and carries neither value; neither key, one key or a blank secret leaves the policy unchanged. - `ui/packages/auth/src/__tests__/registerTurnstile-28100.test.tsx` (4, on the wire through the real `AuthProvider` and `createAuthClient`): no key, no widget and no header; with a key, submit waits for the challenge and the token goes out as `x-captcha-response`; a failed or expired challenge shows the retry message, not the provider code; a refused token reads as the retry message and the challenge is redrawn. - `ui/apps/console/src/pages/auth/__tests__/turnstileWidget-28100.test.tsx` (4) and `ui/packages/app-shell/src/console/auth/__tests__/defaultRegisterTurnstile-28100.test.tsx` (2): `/register`, `/setup` and `DefaultRegisterPage` render the widget exactly when the key is served. Two existing `/setup` test doubles gain `getAuthConfig`. - `pnpm --filter <pkg> typecheck` green for plugin-auth, cli and spec. Every family `dispatch-gates --commands` derives was run and reconciled with `--ran`, plus `pnpm lint`, on `main` `a0f86549`: 113 of 113 derived families exit 0 (0 NOT MEASURED), and `pnpm lint` exits 0. UI `type-check` for the touched packages and their console (74 tasks), `check:i18n-keys` and `check:i18n-drift` exit 0 (measured on the pre-rebase base `b6c2ed9c`; CI re-runs them). Skills-docs: `grep -rniE "captcha|turnstile" skills content/docs` finds only `content/docs/ui/forms.mdx` (embeddable public forms, a different surface) and the new environment-variables row; nothing in `skills/` describes sign-up's request shape. None owed. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cxKwptVz3gdUgWLyNb --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cxKwptVz3gdUgWLyNb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 80b561b commit fc5d509

39 files changed

Lines changed: 1298 additions & 13 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/spec': minor
4+
'@objectstack/cli': patch
5+
'@object-ui/auth': patch
6+
'@object-ui/app-shell': patch
7+
'@object-ui/i18n': patch
8+
'@object-ui/console': patch
9+
---
10+
11+
feat(plugin-auth): opt-in Cloudflare Turnstile on email sign-up — off unless both `OS_TURNSTILE_SITE_KEY` and `OS_TURNSTILE_SECRET_KEY` are set
12+
13+
Clause-②: yes (widening: the public auth config gains `features.turnstileSiteKey`, served only while the challenge is on; `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS` and `AuthFeaturesConfigSchema` gain `turnstileSiteKey`; with the challenge on, `/sign-up/email` answers better-auth's `captcha` refusals `400 MISSING_RESPONSE`, `403 VERIFICATION_FAILED` and `500 UNKNOWN_ERROR`; two new env vars; `@object-ui/auth` gains `TurnstileWidget`, `TURNSTILE_SCRIPT_URL`, `RegisterForm`'s `turnstileSiteKey` prop and `captchaFailed`/`captchaRetry` labels, `SignUpData.captchaToken` and a fifth `signUp` argument; `@object-ui/app-shell` gains `signUpCaptchaFailedMessage`)
14+
Metadata-protocol: added
15+
16+
A deployment can now ask for a human check before an email/password account is created. Set both variables and:
17+
18+
- **The server** mounts better-auth's `captcha` plugin with the `cloudflare-turnstile` provider on `/sign-up/email` only. A sign-up without a token in the `x-captcha-response` header is refused before anything is read or written, a token is verified with Cloudflare using the secret, and a verification that cannot complete fails closed. Sign-in and password reset are not challenged (better-auth's default list would have included them; it is overridden). The per-address rate limit still runs first. Server-side registrations through `auth.api.signUpEmail` (the development admin seed, demo staff) are unaffected.
19+
- **The public auth config** (`GET /api/v1/auth/config`) serves `features.turnstileSiteKey`, the public site key, and never the secret.
20+
- **The Console** renders the challenge on `/register` and on the first-run `/setup` wizard, holds submit until it is solved, and sends the token. A failed, expired or refused challenge shows one retry message, never Cloudflare's code or the plugin's text. The Console document's content-security-policy admits `https://challenges.cloudflare.com` in `script-src` and `frame-src` only while both variables are set.
21+
22+
With either variable unset or blank nothing changes: no plugin, no config key, no widget, the same policy. A deployment that sets only one gets one boot `warn` naming the missing variable.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ Read by `@objectstack/service-print` when `PrintServicePlugin` is composed; see
110110
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
111111
| `OS_TERMS_URL` | url | — | URL shown next to the "Terms of Service" link on the sign-up form. Empty string hides the link. |
112112
| `OS_PRIVACY_URL` | url | — | URL shown next to the "Privacy Policy" link on the sign-up form. Empty string hides the link. |
113+
| `OS_TURNSTILE_SITE_KEY` / `OS_TURNSTILE_SECRET_KEY` | string | — | Opt-in Cloudflare Turnstile on email sign-up. Set **both** and `/sign-up/email` (the register page and the first-run setup) requires a verified Turnstile token, the Console renders the challenge, and its content-security-policy admits `https://challenges.cloudflare.com`; sign-in and password reset are not challenged. Only the site key is served to the browser (`features.turnstileSiteKey`); the secret never leaves the server. With either unset nothing changes (one boot `warn` names the missing one). |
113114

114115
Auth settings precedence:
115116

‎content/docs/references/api/auth-endpoints.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const result = AuthEndpointSchema.parse(data);
6363
| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) |
6464
| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service |
6565
| **cloudManaged** | `boolean` | optional | Managed (cloud-hosted, SSO-only) mode: people arrive through the identity provider, so the UI hides inviting, creating users and setting their passwords; absent means not managed |
66+
| **turnstileSiteKey** | `string` | optional | Public Cloudflare Turnstile site key, present only while the opt-in sign-up challenge is on: the sign-up form renders the widget and sends its token in the x-captcha-response header; absent means no challenge |
6667

6768

6869
---
@@ -189,6 +190,7 @@ This schema accepts one of the following structures:
189190
| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) |
190191
| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service |
191192
| **cloudManaged** | `boolean` | optional | Managed (cloud-hosted, SSO-only) mode: people arrive through the identity provider, so the UI hides inviting, creating users and setting their passwords; absent means not managed |
193+
| **turnstileSiteKey** | `string` | optional | Public Cloudflare Turnstile site key, present only while the opt-in sign-up challenge is on: the sign-up form renders the widget and sends its token in the x-captcha-response header; absent means no challenge |
192194

193195

194196
---

‎packages/cli/src/utils/console.content-security-policy.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const ENV = [
4949
'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN',
5050
'AWS_ACCESS_KEY_ID',
5151
'AWS_SECRET_ACCESS_KEY',
52+
'OS_TURNSTILE_SITE_KEY',
53+
'OS_TURNSTILE_SECRET_KEY',
5254
];
5355

5456
let saved: Record<string, string | undefined>;
@@ -516,3 +518,43 @@ describe('connect-src names the host the storage service presigns browser upload
516518
]);
517519
});
518520
});
521+
522+
describe('the opt-in Turnstile challenge on sign-up', () => {
523+
const TURNSTILE = 'https://challenges.cloudflare.com';
524+
525+
it('admits Cloudflare\'s script and frame only while both keys are set', async () => {
526+
process.env.OS_TURNSTILE_SITE_KEY = 'site-key-csp';
527+
process.env.OS_TURNSTILE_SECRET_KEY = 'secret-key-csp';
528+
const request = await mount();
529+
const res = await request('/_console/register');
530+
const raw = res.headers.get('content-security-policy');
531+
const policy = directives(raw);
532+
533+
expect(policy.get('script-src')).toContain(TURNSTILE);
534+
expect(policy.get('frame-src')).toEqual(["'self'", TURNSTILE]);
535+
// The nonce boundary is unchanged: no inline allowance rides along.
536+
expect(policy.get('script-src')).not.toContain("'unsafe-inline'");
537+
// Presence is read, never the values.
538+
expect(raw).not.toContain('site-key-csp');
539+
expect(raw).not.toContain('secret-key-csp');
540+
});
541+
542+
for (const [label, env] of [
543+
['neither key', {}],
544+
['the site key alone', { OS_TURNSTILE_SITE_KEY: 'site-key-csp' }],
545+
['the secret alone', { OS_TURNSTILE_SECRET_KEY: 'secret-key-csp' }],
546+
['a blank secret', { OS_TURNSTILE_SITE_KEY: 'site-key-csp', OS_TURNSTILE_SECRET_KEY: ' ' }],
547+
] as const) {
548+
it(`leaves the policy as it was with ${label}`, async () => {
549+
Object.assign(process.env, env);
550+
const request = await mount();
551+
const raw = res(await request('/_console/register'));
552+
expect(raw).not.toContain(TURNSTILE);
553+
expect(directives(raw).has('frame-src')).toBe(false);
554+
});
555+
}
556+
557+
function res(response: Response): string | null {
558+
return response.headers.get('content-security-policy');
559+
}
560+
});

‎packages/cli/src/utils/console.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,10 +838,16 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
838838
* - `frame-ancestors 'self'`, except on the public form page: the Console's
839839
* public-forms screen hands authors an `<iframe>` snippet to embed that page
840840
* on their own site, so it keeps loading in any frame.
841+
* - {@link TURNSTILE_ORIGIN} in `script-src` and `frame-src` only while the
842+
* opt-in Turnstile challenge on sign-up is on (`readTurnstileEnabled`): the
843+
* sign-up form loads Cloudflare's script, which draws the challenge in a
844+
* frame from the same origin. With the challenge off, neither is listed and
845+
* `frame-src` is left to `default-src 'self'`, exactly as before.
841846
*/
842847
function consoleContentSecurityPolicy(input: {
843848
nonce: string;
844849
reactPages: boolean;
850+
turnstile: boolean;
845851
errorReportingOrigin: string | undefined;
846852
mapStyleOrigin: string | undefined;
847853
storageUploadOrigin: string | undefined;
@@ -850,6 +856,7 @@ function consoleContentSecurityPolicy(input: {
850856
}): string {
851857
const scriptSrc = ["'self'", `'nonce-${input.nonce}'`, "'wasm-unsafe-eval'"];
852858
if (input.reactPages) scriptSrc.push("'unsafe-eval'");
859+
if (input.turnstile) scriptSrc.push(TURNSTILE_ORIGIN);
853860
const connectSrc = ["'self'", DEFAULT_MAP_STYLE_ORIGIN];
854861
if (input.mapStyleOrigin !== undefined) connectSrc.push(input.mapStyleOrigin);
855862
if (input.errorReportingOrigin !== undefined) connectSrc.push(input.errorReportingOrigin);
@@ -864,10 +871,27 @@ function consoleContentSecurityPolicy(input: {
864871
['object-src', "'none'"],
865872
['base-uri', "'self'"],
866873
];
874+
if (input.turnstile) directives.push(['frame-src', "'self'", TURNSTILE_ORIGIN]);
867875
if (!input.embeddable) directives.push(['frame-ancestors', "'self'"]);
868876
return directives.map((directive) => directive.join(' ')).join('; ');
869877
}
870878

879+
/** Cloudflare Turnstile's origin: its loader script and its challenge frame. */
880+
const TURNSTILE_ORIGIN = 'https://challenges.cloudflare.com';
881+
882+
/**
883+
* Whether the opt-in Turnstile challenge on sign-up is on: BOTH variables set
884+
* and non-blank, the rule `@objectstack/plugin-auth` mounts the challenge and
885+
* serves `features.turnstileSiteKey` by (its `turnstile-sign-up.ts`). The two
886+
* names are spelled here rather than imported because this module is behind
887+
* the published `./console` subpath, which imports Node built-ins only. Only
888+
* presence is read; the values never reach the policy.
889+
*/
890+
function readTurnstileEnabled(): boolean {
891+
const set = (name: string) => String(process.env[name] ?? '').trim() !== '';
892+
return set('OS_TURNSTILE_SITE_KEY') && set('OS_TURNSTILE_SECRET_KEY');
893+
}
894+
871895
/** The variable a deployment sets to turn the `kind:'react'` page tier on. */
872896
const REACT_PAGES_ENV = 'OS_PAGE_REACT_ENABLED';
873897
/** Its earlier name, read when the new one is unset. */
@@ -1085,6 +1109,7 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
10851109
const policy = consoleContentSecurityPolicy({
10861110
nonce,
10871111
reactPages,
1112+
turnstile: readTurnstileEnabled(),
10881113
errorReportingOrigin: shell?.errorReportingOrigin,
10891114
mapStyleOrigin,
10901115
storageUploadOrigin: await readStorageUploadOrigin(ctx, reportStorageUploadOriginFailure),

‎packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
} from './auth-schema-config.js';
8080
import { isPlaceholderEmail } from './placeholder-email.js';
8181
import { userInfoLocaleClaim } from './oidc-userinfo-locale-claim.js';
82+
import { buildTurnstileSignUpPlugin, reportHalfConfiguredTurnstile, resolveTurnstileKeys } from './turnstile-sign-up.js';
8283

8384
/**
8485
* The `AuthManager` members the function below reads — every one of them, and
@@ -216,6 +217,17 @@ export async function buildPluginList(this: PluginListHost): Promise<any[]> {
216217
const { bearer } = await import('better-auth/plugins/bearer');
217218
plugins.push(bearer());
218219

220+
// Opt-in Cloudflare Turnstile on email sign-up: mounted only when BOTH keys
221+
// are configured, through the same resolver `getPublicConfig()` advertises
222+
// `features.turnstileSiteKey` from. Not an optional plugin: a guard that
223+
// fails to build must fail the build, never leave sign-up open while the
224+
// Console renders a widget. See `turnstile-sign-up.ts`.
225+
reportHalfConfiguredTurnstile();
226+
const turnstileKeys = resolveTurnstileKeys();
227+
if (turnstileKeys) {
228+
plugins.push(await buildTurnstileSignUpPlugin(turnstileKeys));
229+
}
230+
219231
if (enabled.organization) {
220232
// [#25224 cut 15] The body of this factory moved to
221233
// `auth-manager-organization-plugin.ts`; this arrow hands it the members

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ import { createAuthInstance, type AuthInstanceHost } from './auth-manager-auth-i
208208
// plugin's factory (cut 15) is now imported there, by the arrow that moved
209209
// with it.
210210
import { buildPluginList, type PluginListHost } from './auth-manager-plugin-list.js';
211+
import { resolveTurnstileKeys } from './turnstile-sign-up.js';
211212

212213
/**
213214
* Detect WebContainer (StackBlitz) environment.
@@ -4551,6 +4552,7 @@ export class AuthManager {
45514552
const privacyUrl = resolveLegalUrl(rawPrivacyUrl, DEFAULT_PRIVACY_URL);
45524553

45534554
const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR');
4555+
const turnstileKeys = resolveTurnstileKeys();
45544556

45554557
// [#7481, maintainer ruling 2026-08-11] `passkeys` / `magicLink` are
45564558
// deliberately NOT advertised here — see PUBLIC_AUTH_FEATURES_NOT_ADVERTISED
@@ -4616,6 +4618,10 @@ export class AuthManager {
46164618
cloudManaged: isCloudManaged(),
46174619
...(termsUrl ? { termsUrl } : {}),
46184620
...(privacyUrl ? { privacyUrl } : {}),
4621+
// The PUBLIC Turnstile site key, served only when the sign-up challenge
4622+
// is wired (both keys set; the resolver `buildPluginList()` mounts the
4623+
// plugin from). The secret is never part of this payload.
4624+
...(turnstileKeys ? { turnstileSiteKey: turnstileKeys.siteKey } : {}),
46194625
};
46204626

46214627
// Dev-only login hint (15.1 third-party eval): the empty-DB auto-seeded

0 commit comments

Comments
 (0)