Repository navigation
fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in - #30298
Conversation
… sign-in method Turning email and password off while no other sign-in method is in force used to save, and the next sign-out locked everyone out. The plugin now registers write predicates on the settings service for email_password_enabled and the Google keys, so such a save is refused before anything is written, with a localized reason naming what to set up first. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
…ial adapter Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
… floor Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
…rrowings are Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
📓 Docs Drift CheckThis PR changes 2 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 26 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 2a885324de7aece53599367c9e2546c992f07e2d && git checkout 2a885324de7aece53599367c9e2546c992f07e2d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1d90b7062af18eda616f875f9efc1ffeaac645a ba1977cb24eb0f34a1dff1b7237958f11618885e && git checkout -B drift-repro d1d90b7062af18eda616f875f9efc1ffeaac645a && git merge --no-ff ba1977cb24eb0f34a1dff1b7237958f11618885e
node scripts/docs-audit/affected-docs.mjs --json d1d90b7062af18eda616f875f9efc1ffeaac645a
|
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: Inputs: card #30202 (body; comments 6648434001, 6648660209, 6650530417), PR #30298 body, file list and the net diff against ① Derived judgmentsAccept-set changes the diff implies, each judged against the cited code:
Tests (11 cases, real ② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on
Must-fix 1 is a FAIL. Check-runs on Implemented-by: VERDICT: FAIL |
…le save, and count a provider only with its credential pair A write predicate is now handed the whole write as batch, so the auth sign-in floor judges the state after the save: email and password off together with Google off is refused at both keys, and Google filled in within the same save is accepted. A social or OIDC provider counts only with a client ID and secret. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: Delta review of the re-fix after record 6650756385 (FAIL on The delta is two commits on the first-parent line: the fix ① Derived judgmentsAccept-set and public-surface changes of the delta, each judged against the cited code:
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on
No must-fix. Check-runs on Implemented-by: VERDICT: PASS |
… in only as the settings-added Google Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL 维护者速读
席位核对 — ACCEPT · domain:server#2 · head
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 41991741368 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6CXhZcHuALPAjkDCiiT
维护者速读
batch字段,里面是这次保存的全部键值。/auth/config。这是收窄:以前能存下的「关掉最后一种登录方式」现在存不下;batch是可选的新增字段,已有谓词不用改。已经锁死的部署用OS_AUTH_EMAIL_PASSWORD_ENABLED=true重启即可恢复。回滚即撤本 PR。packages/plugins/plugin-auth/src/sign-in-method-floor.ts的signInMethodFloorPredicate与googleAfter(保存后的状态怎么算)、otherSignInMethods(哪些算其他登录方式);service-settings的refuseByWritePredicates(batch从哪来)。pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts预期 17/17;pnpm --filter @objectstack/service-settings exec vitest run src/settings-write-predicate.test.ts预期 9/9。Fixes #30202
Clause-②: yes (narrowing) — a save an administrator could make before is now refused at the settings save door with the existing
SETTINGS_VALIDATION/invalid_valuefield error: a save after which email and password is off and nothing else signs anyone in, judged on the whole save, including the Google change that removes the last provider while email and password is off. No new error code or settings key. The widening half:SettingsWritePredicateInput, exported from@objectstack/service-settings, gains an optionalbatchfield, the whole write a predicate is asked about.Metadata-protocol: none
Skills-docs: none — no file under
skills/**teachesauth.email_password_enabledor the settings write predicate;content/docs/deployment/environment-variables.mdxalready names theOS_AUTH_EMAIL_PASSWORD_ENABLEDoverride the recovery uses.What changes
bindAuthSettingsapplies a value after it is stored, so a refusal there (therefusablepieces, plugin-auth: one refused key in the auth settings pass drops every sibling setting, and says so only atwarn#20412) would leave the console showing "saved" while the runtime ignores the value. The settings service already has the door for this: a write predicate (SettingsService.registerWritePredicate, the ADR-0144 D4 currency-lock seam), asked bysetManyafter the declared constraints and before any row or secret is written, refusing the whole batch withSettingsValidationError(400 SETTINGS_VALIDATION,details.fields[]ADR-0114 field errors).AuthPlugin.bindAuthSettingsregisters one predicate, from the newsign-in-method-floor.ts, onauth.email_password_enabled,auth.google_enabled,auth.google_client_idandauth.google_client_secret.service-settings).SettingsWritePredicateInputgainsbatch?: Readonly<Record<string, unknown>>: every key the write sets in the namespace, the asked key among them, with the value it would store (nullclears).refuseByWritePredicatesbuilds it once from the patch, frozen, and passes it to every predicate it asks. Optional, so a caller that builds an input by hand and every existing predicate (the currency lock) are unchanged. It is reachable from the package entry (index.tsre-exports the type; the builtdist/index.d.tscarriesbatch?: Readonly<Record<string, unknown>>), pinned by anexpectTypeOfcase that imports it from./index.js.{ email_password_enabled: false, google_enabled: false }on a deployment with email and password plus Google passed both keys, since each saw the other way in still standing, and wrote both (the review's must-fix). Now each key's value after the save is: the environment override when one is in force for it (it wins over what is saved); else the batch's value; else the value as it stands (the running configuration foremail_password_enabled, the resolved setting for thegoogle_*keys, read withSettingsService.get). A cleared key reads as its default (on, for both toggles), asvalidatePatch's own post-write view does. Google after the save follows the rule the apply uses: off whengoogle_enabledis off; else kept when declared at boot; else in force with a client ID and secret, each from the setting or itsGOOGLE_CLIENT_*variable. A save that sets nogoogle_*key leaves Google as it stands.email_password_enabledwhen the save turns it off and nothing is in force after it; at agoogle_*key when, with email and password off after the save, the save removes a Google provider that was in force and leaves nothing else. The batch above is refused at both keys.ui/packages/auth/src/LoginForm.tsx, console login: with email+password sign-in off and no SSO provider, the card says "Sign in with your organization's single sign-on" but shows no SSO button and no working method #29159), plus the one path the host states:getPublicConfig().socialProviders) whose client ID and client secret are both non-blank in the configuration it came from (the review's should-fix).AuthPluginindexes the providers declared at boot (stack config, theGOOGLE_CLIENT_*fallback,auth:configurehandlers) withproviderCredentialIndex; Google added from the settings has a pair by construction, since the apply adds it only with both. A provider listed without them draws a button that signs nobody in;plugins.sso/OS_SSO_ENABLED) AND at least onesys_sso_providerrow, read with the boot report'sprobeSsoProvidersPresence. The switch alone signs nobody in, and an unreadable table proves no row, so neither counts;features.phoneNumber): better-auth's/sign-in/phone-numberdoes not checkemailAndPassword.enabled, and the code sign-in works when SMS can deliver;AuthPluginOptions.hostSignInHandoff).ssoOnlyModealone (it says an identity provider is in front; that provider is one of the above or nothing), and magic link and passkeys, which no login page offers (PUBLIC_AUTH_FEATURES_NOT_ADVERTISED, public-auth-features: passkeys/magicLink exempt.reason points at objectui#2514, which closes as "documented reserved" — decide stop-advertising vs keep-as-reserved #7481).code: invalid_valueat the key,constraint: { lastSignInMethod: 'email_password' | 'google' }, and a message in the caller's language (en,zh-CN,ja-JP,es-ES, the settings bundle's locales, picked withresolveLocaleas the currency lock does) saying to keep or set up another sign-in method before or in that save. Turning email and password on, clearing it (its default is on), or re-saving a value in force is never refused.OS_AUTH_EMAIL_PASSWORD_ENABLED=trueis the settings env override for the key, it wins over the storedfalse(sourceenv), and the apply turns email and password back on at the next boot. Named in the changeset and pinned below; the refusal message speaks to the administrator in Setup, who is not locked out, so it names what to configure instead.auth-plugin.ts: the import, the registration at the top ofbindAuthSettings, and one field (bootProviderCredentials) set besideconfiguredSocialProvidersafterauth:configure, clear of themfa_requiredandallowed_ip_rangeshunks and ofisExplicit.scripts/engine-double-contract.pinned.json: one row for the new test'sfindOnedouble, written bycheck-engine-double-contract.mjs --write(outside the claimed file surface; the gate requires it for a new test file with an engine double)..changeset/30202-keep-a-sign-in-method.md:@objectstack/plugin-authand@objectstack/service-settings, bothpatch.Tests
packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts(new, 17 cases). The realSettingsService(aliased toservice-settings/srcby this package's vitest config), the realAuthPluginandAuthManager; a stub engine answerssys_sso_providerfrom each case's rows.setManyrejects withSettingsValidationError(SETTINGS_VALIDATION), one field error atemail_password_enabled(invalid_value,lastSignInMethod: email_password); the stored value stays the defaulttrue, a sibling in the same batch (password_min_length) is not written, the manager keeps email and password on; turning it on is accepted.registerSettingsRoutes:PUT /api/settings/authwithAccept-Language: zh-CNanswers400,error.code: SETTINGS_VALIDATION, the field error at the key, its message thezh-CNcopy.google_enabled: false,google_client_secret: nullandgoogle_client_id: ''are each refused (lastSignInMethod: google), Google stays in force; with email and password back on, Google can go.GOOGLE_CLIENT_SECRETstill supplies is accepted and removes nothing.falsestored before the predicate exists rules at the next boot (control), and withOS_AUTH_EMAIL_PASSWORD_ENABLED=truein the settings env the manager has email and password on.{ email_password_enabled: false, google_enabled: false }and{ email_password_enabled: false, google_client_secret: null }, with email and password plus Google in force, are each refused with exactly two field errors (email_password_enabled/email_password, the Google key /google); nothing is written and both stay on.google_enabled, client ID and secret) in the same save asemail_password_enabled: falseis accepted: email and password off, Google on.{ google_enabled: true, email_password_enabled: false }over a client ID and secret already stored is accepted (the pair is read as stored).{ google_enabled: true, google_client_id, email_password_enabled: false }with no secret anywhere is refused atemail_password_enabled.providerCredentialIndexkeys social and OIDC boot providers by whether both credentials are set.packages/services/service-settings/src/settings-write-predicate.test.ts(+2):setManyhands a predicate the whole write asbatch, frozen, a clear asnull; andSettingsWritePredicateInput['batch'], imported from the package entry, isReadonly<Record<string, unknown>> | undefined.Acceptance notes
validatePatch's post-write view, reads the default (on, for both toggles). Measured cases do not reach it; named so a review can weigh it.domain:ui, the lane is off duty; for the seat to file). The Setup page shows no confirmation before the switch and shows the refusal only as the field error the console's extractor already reads. The card also saw the login page draw the email and password form underos dev, with better-auth's English "Email and password is not enabled" on submit; at797f9257LoginForm.tsxhas the no-method state from fix(auth): the sign-in card says no sign-in method is available instead of SSO-only copy when email+password is off #29257 (noSignInMethod, :351), so the remaining shape is what the console dev server draws there. Not reproduced here.sys_sso_providerrow, or a redeploy that drops the last stack-config provider orplugins.phoneNumber, while email and password is off. They are not settings saves, so this predicate cannot see them.permission-set-clone-door.dogfood.test.tswasmain's red main red: Dogfood Regression Gate (2/3) fails permission-set-clone-door after PR #30222 (member_default grants sys_comment) #30255 (reverted by revert: PR #30222 (main red #30255) #30262; red with the floor neutralized too). On this head, fourpackage-commit-revert-path-package.dogfood.test.tscases timed out at 5000 ms while the 73 gate families ran beside the battery; the same file alone under the lock passes 12/12./auth/configand the login page are unchanged:emailPassword.enabledreports the runtime truthfully.Verification
Final head
0b89bdf1(main977be380merged in, cleanly).mainhas three commits since, none touchingservice-settings,plugin-author the ledger; a trial merge is clean.Tests
pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts: 17/17.pnpm --filter @objectstack/service-settings exec vitest run src/settings-write-predicate.test.ts src/localization-currency-lock.test.ts: 33/33.vitest run --maxWorkers=2:@objectstack/plugin-authTest Files 221 passed, Tests 3893 passed and 10 skipped;@objectstack/service-settingsTest Files 67 passed, Tests 1153 passed.pnpm --filter @objectstack/service-settings typecheckandpnpm --filter @objectstack/plugin-auth typecheck(both tsconfigs pluscheck:test-typecheck): exit 0.pnpm lint: exit 0.(narrowing)arm),pnpm --filter @objectstack/dogfood testunder the lock afterturbo run build --filter='!@objectstack/docs', on0b89bdf1: Test Files 418 passed, 1 failed, 3 skipped; Tests 3488 passed, 4 failed, 36 skipped; the lock's VERDICT command-exit 1. The four arepackage-commit-revert-path-package.dogfood.test.tscases (metadata-protocol: a commit revert or package-commit rollback can restore a row bound to a read-only base package that the #28905 close now refuses to write #29390, metadata-protocol: a pair commit's revert beside a pre-close base-bound row answers success while writing nothing, because the commit plan and the restore both read any package's active row #29414 ×3), each "Test timed out in 5000ms", run while the gate loop shared the box. The file alone under the lock on the same head: Tests 12 passed, VERDICT command-exit 0.Ablations (each negative pin)
scripts/ablation-replace.mjs, each anchor 1 to 0 on disk, each run restored to the HEAD blob withgit diff HEADempty. The test imports./auth-plugin.jsand./sign-in-method-floor.jsfromsrc, and@objectstack/service-settingsis aliased to itssrc, so no dist leg applies.setManypasses nobatch: service-settings 1 red (the batch case); plugin-auth 3 red (both-keys refusal, Google filled in the same save, switch over a stored pair).batch(judges the asked key alone): 3 red (the same three).Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon0b89bdf1: 73 derived families (the first head's 72 pluscheck:package-naming, from theservice-settingspath). Each run with its exit code recorded before any pipe, after a fullturbo run build --filter='!@objectstack/docs'; all 73 exit 0, and--rananswers 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN.Human Minimum
packages/specholds no runtime logic:packages/specuntouched.pnpm lintpasses.main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code