Skip to content

feat(plugin-auth): opt-in Cloudflare Turnstile on email sign-up - #28365

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-7tkjc8
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-7tkjc8

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

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_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

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4cxKwptVz3gdUgWLyNb


Generated by Claude Code

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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 31, 2026
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/plugin-auth, @objectstack/spec, touching 25 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/connectors.mdx (via secretKey (symbol, a field of interface TurnstileKeys))
  • content/docs/deployment/environment-variables.mdx (via OS_TURNSTILE_SECRET_KEY (literal, a string literal in TURNSTILE_SECRET_KEY_ENV; a string literal in readTurnstileEnabled), OS_TURNSTILE_SITE_KEY (literal, a string literal in TURNSTILE_SITE_KEY_ENV; a string literal in readTurnstileEnabled))
  • content/docs/permissions/sso.mdx (via getPublicConfig (symbol, a method of class AuthManager))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 59 of 211 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 180 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6212fdc6feb192e46b158613d68c4fe12c0b03c8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d32190efed8d46e7a84c82326efe07ad602ed07 — the merge of head 6672c00912dcd22be450fca94dd69dc3368e35f2 into base 6212fdc6feb192e46b158613d68c4fe12c0b03c8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6212fdc6feb192e46b158613d68c4fe12c0b03c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Contract-review-claim: session_016WV4czuTWNgKR22fKv7jia · head 2cbc7763c768c53093be9b0d4db98b3e162bdbd3 · 2026-10-31T06:49Z

The lane seat runs the CONTRACT_REVIEW_TIER review this PR owes (Clause-②: yes, packages/spec/src/** paths).

…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6672c00912dcd22be450fca94dd69dc3368e35f2
Local-runs: none
Scope: packages/spec/src/api/auth-endpoints.zod.ts, packages/spec/src/kernel/public-auth-features.ts + packages/spec/authorable-surface/api.json, content/docs/references/api/auth-endpoints.mdx, packages/plugins/plugin-auth/src/{turnstile-sign-up.ts, auth-manager-plugin-list.ts, auth-manager.ts, public-feature-registry.test.ts, turnstile-sign-up.test.ts}, packages/cli/src/utils/console.ts (+ its CSP test), ui/packages/auth/src/{types.ts, AuthContext.ts, AuthProvider.tsx, createAuthClient.ts, RegisterForm.tsx, TurnstileWidget.tsx, index.ts}, ui/packages/app-shell/src/{index.ts, console/auth/RegisterPage.tsx, console/auth/signUpRefusalMessages.ts}, ui/apps/console/src/pages/auth/{RegisterPage.tsx, SetupPage.tsx}, ui/packages/i18n/src/locales/*.ts, content/docs/deployment/environment-variables.mdx, .changeset/28100-turnstile-sign-up.md; better-auth 1.7.12 plugins/captcha read as the cited vendor code (tag source, not installed locally)

Delta reviewed: head moved from 2cbc7763 to 6672c009 by one changeset-only commit (2 lines: @objectstack/spec patch → minor, Metadata-protocol: none → added; the PR body line matches). No source changed; the code judgments below were made on 2cbc7763 and hold on 6672c009.

① Derived judgments

  • File surface vs grant — all 39 files are inside triage's grants 6523361600 + 6524682167 + 6527231164 (ride-alongs included: regenerated api.json, auth-endpoints.mdx, tests beside changed files, the two /setup doubles gaining getAuthConfig, the changeset). No file outside the granted list. The PR body states the surface as the grant required.
  • Spec change is logic-free (Prime Directive ✨ Set up Copilot instructions #2): one optional z.string() key with a describe, one literal appended to PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS, plus JSDoc. api.json and auth-endpoints.mdx carry exactly the one generated row each, consistent with the schema; nothing hand-edited.
  • Inert when unconfigured — one resolver (resolveTurnstileKeys) feeds both the plugin mount and getPublicConfig(); it returns a value only when BOTH env vars are set and non-blank (trimmed). Neither / one / blank ⇒ no plugin pushed, no features.turnstileSiteKey key present, CSP unchanged (the CLI reads presence of the same two names with the same trim rule). Pinned by plugin-auth test ③ (4 OFF variants) and the CSP test (4 OFF variants). Half-configuration is not silently half-on: it stays OFF and emits one build-time warn naming the variables only (pinned, and the no-Turnstile-noise case pinned too).
  • Scope when on — captcha({ endpoints: ['/sign-up/email'] }); the vendor replaces its default list (/sign-up/email, /sign-in/email, /request-password-reset) when a non-empty list is given, so sign-in and reset stay unchallenged as the ruling orders; OAuth paths are not in the list. Pinned by test ②. Server-side auth.api.signUpEmail callers bypass the router stage the plugin hooks, so seeds are unaffected (matches vendor onRequest placement).
  • Secret handling — the secret reaches only the vendor plugin's options and the siteverify POST body. getPublicConfig() spreads siteKey only; the half-config warning names variables not values; the vendor's failure log lines carry fixed messages / Cloudflare error codes / hostname, never the secret. Pinned by tests ①(4) and ④ (JSON.stringify(config) free of the secret; log lines free of it on the unreachable path).
  • Fail-closed — vendor code read: no header ⇒ 400 MISSING_RESPONSE; siteverify success:false ⇒ 403 VERIFICATION_FAILED; fetch error / no data / 10s timeout ⇒ thrown ⇒ 500 UNKNOWN_ERROR. All three are the vendor's $ERROR_CODES constants (stable), named in the changeset, PR body and module docblock, and the first two map to one retry message in the Console (signUpRefusalMessages, RegisterForm, SetupPage).
  • Clause-②: yes (widening) completeness — checked against the diff: spec keys ✓, two env vars ✓, @object-ui/auth exports (TurnstileWidget, TURNSTILE_SCRIPT_URL, three types) ✓, RegisterForm prop + two labels ✓, SignUpData.captchaToken ✓, signUp fifth optional arg (AuthContextValue and AuthProvider) ✓, @object-ui/app-shell signUpCaptchaFailedMessage ✓, the three refusal codes ✓. The changeset body omits the three type names the PR body lists; a documentation gap, not a contract gap (the exports are typed re-exports). Nothing narrowed: every new prop/arg/field is optional, existing signUp(name, email, password[, callbackURL]) callers and RegisterForm users compile and behave as before; getAuthConfig already existed on AuthContextValue on main.
  • Env-var naming (Prime Directive [WIP] Create a new release version #9): OS_TURNSTILE_SITE_KEY / OS_TURNSTILE_SECRET_KEY group as OS_{DOMAIN}_{NAME} with the feature as domain, the shape OS_SSO_* / OS_SCIM_* already use; the card ordered these names and cloud's blocked card will configure them.
  • CSP — minimal: one origin (https://challenges.cloudflare.com) in script-src, and frame-src 'self' + that origin, both only while readTurnstileEnabled(); off, frame-src is not emitted and default-src 'self' governs as before. No connect-src, no unsafe-inline; values never enter the header (pinned). ADR-0081's unsafe-eval rule for the react tier is untouched.
  • Docs / i18n — one row in environment-variables.mdx, accurate to the code (both set ⇒ /sign-up/email incl. register + setup challenged; sign-in/reset not; only the site key served; one warn on half-config). Ten locales × exactly two keys (auth.register.captchaRetry, auth.register.errors.captchaFailed); en matches the in-code fallbacks. Docs-drift's connectors.mdx hit is a secretKey name collision; sso.mdx mentions only features.sso; neither needs a change. Skills-docs: None owed is correct: the grep over skills/ and content/docs finds only ui/forms.mdx (embeddable public forms, a different surface).
  • Tests — each guard is pinned: ON ×4 (incl. no DB row and no siteverify call on refusal), scope ×1, OFF ×4 + warn ×2, public-config ×1, CSP ×5, UI wire ×4 (real AuthProvider + createAuthClient, header asserted on the wire, provider code never rendered, redraw after answer), page ×6 (render iff key served, submit held). The three ablations named in the PR body (mount, endpoint override, header) each red a distinct case, so they are meaningful.

② Semver level

③ Boundary flags

  • Author is objectstack-fleet[bot]; no external-author note.
  • open_questions: [] on the dev report; none raised here.
  • CI on the superseded head 2cbc7763 showed Check Changeset red (the baseline ruling this delta answers) and Lint & Repo Gates, TypeScript Type Check, Test UI red with the rest cancelled; job logs are not readable from this read-only seat. Landing precondition ② (every check green) is judged on 6672c009's own runs by the lane seat, not by this record.
  • Opt-in posture is a rule, not a probe: with both variables set, /sign-up/email is the only challenged door; with either unset or blank, nothing is mounted, served, rendered or added to the policy. The configured branch has no local run behind it in this record (stubbed verifier in the PR's tests).
  • Deviation 3 (plain plugins.push, not an optional plugin) is the right fail-closed choice: a guard that cannot build fails the auth build rather than leaving the door open under a rendered widget.

④ Design judgment

  • auth-endpoints.zod.ts — turnstileSiteKey?: string on AuthFeaturesConfigSchema is the smallest contract that lets the Console learn "challenge on + which key" from the existing public config, following the termsUrl/privacyUrl pattern the card named. Presence-as-signal is sound because the server derives both mount and advertisement from one resolver, so the advertised widget and the enforced check cannot disagree. First consumer (AuthPublicConfig.features in @object-ui/auth, then RegisterForm/SetupPage) reads it exactly that way.
  • public-auth-features.ts — appending to PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS keeps the plugin-auth drift guard (served non-boolean keys ⊆ registry) honest; without it the guard would red when the key is served. The registry's own rule (served = declared) is preserved; no new flag class or parallel whitelist introduced (PD Remove explicit pnpm version from workflows to fix version conflict #7).
  • Vendor fit: better-auth's captcha plugin is the mechanism the card named; the only vendor behaviour the design leans on is the endpoint-list override, which test ② pins so a future list change is caught. allowedHostnames / expectedAction left unset is consistent with the card (no configuration surface ordered); a follow-up card may add them.

Implemented-by: claude/project-thread-7tkjc8
Reviewed-by: session_016WV4czuTWNgKR22fKv7jia (seat subagent)

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Seat ACCEPT · PR #28365 · head 6672c00912dcd22be450fca94dd69dc3368e35f2 · 2026-10-31T08:08Z

维护者速读

  • 改了什么: 邮箱注册可选接入 Cloudflare Turnstile 人机验证。只有 OS_TURNSTILE_SITE_KEY 和 OS_TURNSTILE_SECRET_KEY 都配上才生效;生效后只拦 /sign-up/email,登录、重置密码、OAuth 不受影响;Console 注册页和首次安装页显示验证组件。
  • 为什么改: 公开注册的站点需要挡住机器批量注册(plugin-auth: opt-in Cloudflare Turnstile on email sign-up (server verify + Console widget), off unless a site key and secret are configured #28100,按你的「同意 A」)。
  • 风险与回滚: 两个都不配时行为与现在逐字节一致(已钉);只配一个会按名告警一次。secret 不下发、不记日志,只公开 site key。验证失败一律拒绝注册。Clause-②: yes (widening),Metadata-protocol: added(spec 多一个可选键)。回滚即还原本 PR。
  • 先看: plugin-auth 的 Turnstile 解析与挂载,以及 @object-ui/auth 的 TurnstileWidget。
  • 两分钟验证: 跑 plugin-auth 的 turnstile 测试(不配、配一个、配两个三种情况)。
  • 需要你做的: 无(Tier S,未命中受管面;契约复核 PASS 6540345632,按本期落地授权由席位落地)。

席位核对

  • Shape: draft → main, Fixes #28100. 39 files (+1298/−13). check-governed-merges --pr 28365: NOT governed (0 of 39 paths).
  • File surface: within the claim (6523573640) and the two cross-domain exceptions triage granted (6524682167, 6527231164). The review confirmed all 39 files are inside them.
  • Declarations: Clause-②: yes (widening), Metadata-protocol: added. @objectstack/spec is minor because the key moves the authorable-surface shard; the other packages are patch, lifted by the fixed group.
  • Contract review: CONTRACT_REVIEW_TIER, PASS at this head, 6540345632. The final commit only changes the changeset level; the code judgments were made at 2cbc7763 and carry over.
  • Security wording: security card; the PR body, changeset and records stay at rule level.
  • Local (card thread, after rebasing onto a0f86549): whole-tree build green; generated outputs unchanged; dispatch-gates 113/113, 0 NOT-MEASURED; pnpm lint 0; key suites green. Report 6540281599.
  • Gate check-runs on this head: all green except one Check Changeset failure, which came from the 06:52Z run that read the PR body before the Metadata-protocol line was patched. The same check went green in the 06:53Z run on this head. mergeable_state: clean.
  • Landing: Tier S, under the maintainer's landing grant for this term (2026-10-29T22:05Z, seat thread). Ready + auto-merge through the relay.

ACCEPT

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 08:09
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 31, 2026 08:09
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit fc5d509 Oct 31, 2026
69 of 70 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-7tkjc8 branch October 31, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: opt-in Cloudflare Turnstile on email sign-up (server verify + Console widget), off unless a site key and secret are configured

2 participants