Repository navigation
Commit fc5d509
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
File tree
- .changeset
- content/docs
- deployment
- references/api
- packages
- cli/src/utils
- plugins/plugin-auth/src
- spec
- authorable-surface
- src
- api
- kernel
- ui
- apps/console/src/pages/auth
- __tests__
- packages
- app-shell/src
- console/auth
- __tests__
- auth/src
- __tests__
- i18n/src/locales
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
110 | 110 | | |
111 | 111 | | |
112 | 112 | | |
| 113 | + | |
113 | 114 | | |
114 | 115 | | |
115 | 116 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
| 66 | + | |
66 | 67 | | |
67 | 68 | | |
68 | 69 | | |
| |||
189 | 190 | | |
190 | 191 | | |
191 | 192 | | |
| 193 | + | |
192 | 194 | | |
193 | 195 | | |
194 | 196 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| 52 | + | |
| 53 | + | |
52 | 54 | | |
53 | 55 | | |
54 | 56 | | |
| |||
516 | 518 | | |
517 | 519 | | |
518 | 520 | | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
838 | 838 | | |
839 | 839 | | |
840 | 840 | | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
841 | 846 | | |
842 | 847 | | |
843 | 848 | | |
844 | 849 | | |
| 850 | + | |
845 | 851 | | |
846 | 852 | | |
847 | 853 | | |
| |||
850 | 856 | | |
851 | 857 | | |
852 | 858 | | |
| 859 | + | |
853 | 860 | | |
854 | 861 | | |
855 | 862 | | |
| |||
864 | 871 | | |
865 | 872 | | |
866 | 873 | | |
| 874 | + | |
867 | 875 | | |
868 | 876 | | |
869 | 877 | | |
870 | 878 | | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
871 | 895 | | |
872 | 896 | | |
873 | 897 | | |
| |||
1085 | 1109 | | |
1086 | 1110 | | |
1087 | 1111 | | |
| 1112 | + | |
1088 | 1113 | | |
1089 | 1114 | | |
1090 | 1115 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
| 82 | + | |
82 | 83 | | |
83 | 84 | | |
84 | 85 | | |
| |||
216 | 217 | | |
217 | 218 | | |
218 | 219 | | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
219 | 231 | | |
220 | 232 | | |
221 | 233 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
208 | 208 | | |
209 | 209 | | |
210 | 210 | | |
| 211 | + | |
211 | 212 | | |
212 | 213 | | |
213 | 214 | | |
| |||
4551 | 4552 | | |
4552 | 4553 | | |
4553 | 4554 | | |
| 4555 | + | |
4554 | 4556 | | |
4555 | 4557 | | |
4556 | 4558 | | |
| |||
4616 | 4618 | | |
4617 | 4619 | | |
4618 | 4620 | | |
| 4621 | + | |
| 4622 | + | |
| 4623 | + | |
| 4624 | + | |
4619 | 4625 | | |
4620 | 4626 | | |
4621 | 4627 | | |
| |||
0 commit comments