Repository navigation
feat(plugin-auth): opt-in Cloudflare Turnstile on email sign-up - #28365
Conversation
Off unless both OS_TURNSTILE_SITE_KEY and OS_TURNSTILE_SECRET_KEY are set. When on, better-auth's captcha plugin (cloudflare-turnstile) guards /sign-up/email only, the public auth config serves the site key as features.turnstileSiteKey (never the secret), the Console's /register, /setup and DefaultRegisterPage render the challenge and send its token as x-captcha-response, and the Console CSP admits challenges.cloudflare.com. With either unset, nothing changes. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cxKwptVz3gdUgWLyNb
📓 Docs Drift CheckThis PR changes 3 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 180 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5d32190efed8d46e7a84c82326efe07ad602ed07 && git checkout 5d32190efed8d46e7a84c82326efe07ad602ed07
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6212fdc6feb192e46b158613d68c4fe12c0b03c8 6672c00912dcd22be450fca94dd69dc3368e35f2 && git checkout -B drift-repro 6212fdc6feb192e46b158613d68c4fe12c0b03c8 && git merge --no-ff 6672c00912dcd22be450fca94dd69dc3368e35f2
node scripts/docs-audit/affected-docs.mjs --json 6212fdc6feb192e46b158613d68c4fe12c0b03c8
|
|
domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia Contract-review-claim: The lane seat runs the |
…ed for turnstileSiteKey The new features.turnstileSiteKey key moves the authorable-surface api.json shard, which the level axis reads as an added metadata-protocol key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cxKwptVz3gdUgWLyNb
|
domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia Contract reviewServed-tier: Delta reviewed: head moved from ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia Seat ACCEPT · PR #28365 · head
|
domain:server#3 · os-elon-musk · session_016WV4cxKwptVz3gdUgWLyNb
Requested by Elon · project thread
Fixes #28100
Clause-②: yes (widening: the public auth config gains
features.turnstileSiteKey, served only while the challenge is on;PUBLIC_AUTH_CONFIG_NON_FLAG_KEYSandAuthFeaturesConfigSchemagainturnstileSiteKey; with the challenge on,/sign-up/emailanswers better-auth'scaptcharefusals400 MISSING_RESPONSE,403 VERIFICATION_FAILEDand500 UNKNOWN_ERROR; two new env varsOS_TURNSTILE_SITE_KEY/OS_TURNSTILE_SECRET_KEY;@object-ui/authgainsTurnstileWidget,TURNSTILE_SCRIPT_URL, theTurnstileApi/TurnstileWidgetProps/TurnstileWidgetLabelstypes,RegisterForm'sturnstileSiteKeyprop andcaptchaFailed/captchaRetrylabels,SignUpData.captchaTokenand a fifthsignUpargument;@object-ui/app-shellgainssignUpCaptchaFailedMessage)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。只配一个或都不配,行为与现在完全一样。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 依赖闭包)。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.
packages/plugins/plugin-auth/src/{turnstile-sign-up.ts (new), auth-manager-plugin-list.ts, auth-manager.ts}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}ui/apps/console/src/pages/auth/{RegisterPage.tsx, SetupPage.tsx}ui/packages/app-shell/src/console/auth/{RegisterPage.tsx, signUpRefusalMessages.ts},ui/packages/app-shell/src/index.tsui/packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.tspackages/cli/src/utils/console.tscontent/docs/deployment/environment-variables.mdxpackages/spec/authorable-surface/api.jsonandcontent/docs/references/api/auth-endpoints.mdx; testspackages/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.mdWhat 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'scaptchaplugin with providercloudflare-turnstileandendpoints: ['/sign-up/email']. better-auth's own default list also guards/sign-in/emailand/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 afterbearer()when both keys are set. It is a plainplugins.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()servesfeatures.turnstileSiteKey(the public key only) from the same resolver.onRequeststage after the per-address rate limit, so a refused request reads and writes nothing. Server-sideauth.api.signUpEmailcallers (the development admin seed, demo staff) do not pass that stage and are unaffected. The client address Cloudflare receives asremoteipis the onehandleRequestestablishes by the platform's trust rule.Spec (
@objectstack/spec).PUBLIC_AUTH_CONFIG_NON_FLAG_KEYSgainsturnstileSiteKey(the plugin-auth drift guard holds served = declared), andAuthFeaturesConfigSchemagainsturnstileSiteKey: z.string().optional(). The generatedauthorable-surface/api.jsonandcontent/docs/references/api/auth-endpoints.mdxare regenerated.Console CSP (
@objectstack/cli,src/utils/console.ts). While both variables are set, the Console document's policy addshttps://challenges.cloudflare.comtoscript-srcand addsframe-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./consolesubpath imports Node built-ins only; only presence is read.Console (
ui/).@object-ui/auth:TurnstileWidgetloads 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.RegisterFormtakesturnstileSiteKey; 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 refusalMISSING_RESPONSE/VERIFICATION_FAILEDreads as the same retry message.createAuthClient.signUpsendsSignUpData.captchaTokenas thex-captcha-responseheader, never in the body./register(ui/apps/console/src/pages/auth/RegisterPage.tsx) and the exportedDefaultRegisterPage(ui/packages/app-shell) passfeatures.turnstileSiteKey./setup(ui/apps/console/src/pages/auth/SetupPage.tsx): the first owner registers through the same/sign-up/emaildoor, 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.captchaRetryandauth.register.errors.captchaFailedin all ten locales.Docs. One row in
content/docs/deployment/environment-variables.mdxnaming 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
mainb6c2ed9c:ui/packages/app-shell/src/console/auth/RegisterPage.tsx; the Console's live/registerisui/apps/console/src/pages/auth/RegisterPage.tsx, and both draw@object-ui/auth'sRegisterForm, where the token has to be sent. The first-run/setuppage posts the same endpoint. Triage extended the one-PR exception to these files and the two spec lines (6524682167, 6527231164).allowedHostnamesorexpectedActionis 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 throughfetch, no network): ON, a sign-up without a token is400 MISSING_RESPONSEwith no user row and no Cloudflare call; a token is verified with the secret and the account is created; a rejected token is403 VERIFICATION_FAILEDwith no row; Cloudflare unreachable is500and 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.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 increateAuthClientreds the two wire cases.packages/cli/src/utils/console.content-security-policy.test.ts(50): both keys set admits Cloudflare inscript-srcandframe-srcand 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 realAuthProviderandcreateAuthClient): no key, no widget and no header; with a key, submit waits for the challenge and the token goes out asx-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) andui/packages/app-shell/src/console/auth/__tests__/defaultRegisterTurnstile-28100.test.tsx(2):/register,/setupandDefaultRegisterPagerender the widget exactly when the key is served. Two existing/setuptest doubles gaingetAuthConfig.pnpm --filter <pkg> typecheckgreen for plugin-auth, cli and spec. Every familydispatch-gates --commandsderives was run and reconciled with--ran, pluspnpm lint, onmaina0f86549: 113 of 113 derived families exit 0 (0 NOT MEASURED), andpnpm lintexits 0. UItype-checkfor the touched packages and their console (74 tasks),check:i18n-keysandcheck:i18n-driftexit 0 (measured on the pre-rebase baseb6c2ed9c; CI re-runs them).Skills-docs:
grep -rniE "captcha|turnstile" skills content/docsfinds onlycontent/docs/ui/forms.mdx(embeddable public forms, a different surface) and the new environment-variables row; nothing inskills/describes sign-up's request shape. None owed.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_016WV4cxKwptVz3gdUgWLyNb
Generated by Claude Code