Repository navigation
Commit 870ebce
fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in (#30298)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4dtXyZ5LXdjKJLUCWRNk76)_
domain:server#2 · os-bill · session_01Utp6CXhZcHuALPAjkDCiiT
## 维护者速读
- 改了什么:保存认证设置时,按「这次保存写完以后」的状态判断:如果邮箱/密码登录关着,又没有任何其他能用的登录方式(凭据齐全的社交或 OIDC
登录、已登记身份提供方的企业
SSO、手机号登录、宿主声明的登录交接),这次保存被拒,整批都不写入,字段下用中英日西四语说明先配好哪一种。同一次保存里既关邮箱/密码又关
Google 会被拒;同一次保存里填好 Google 再关邮箱/密码可以通过。设置服务的写入谓词入参多了一个 `batch`
字段,里面是这次保存的全部键值。
- 为什么改:#30202 实测:没配好 SSO
就关掉邮箱/密码,提示「设置已保存」,下次退出后所有人(包括管理员)都登不进来,只能改环境变量或数据库。契约复审还指出,逐键判断时,一次同时关掉邮箱/密码和
Google 的保存会被放行。
- 风险与回滚:只在保存门加一道拒收,不改已存的值,不改登录页和
`/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_value` field 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 optional `batch` field, the
whole write a predicate is asked about.
Metadata-protocol: none
Skills-docs: none — no file under `skills/**` teaches
`auth.email_password_enabled` or the settings write predicate;
`content/docs/deployment/environment-variables.mdx` already names the
`OS_AUTH_EMAIL_PASSWORD_ENABLED` override the recovery uses.
## What changes
- **The refusal sits at the save door, before anything is written.**
`bindAuthSettings` applies a value after it is stored, so a refusal
there (the `refusable` pieces, #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 by `setMany` after the declared constraints and before any
row or secret is written, refusing the whole batch with
`SettingsValidationError` (`400 SETTINGS_VALIDATION`, `details.fields[]`
ADR-0114 field errors). `AuthPlugin.bindAuthSettings` registers one
predicate, from the new `sign-in-method-floor.ts`, on
`auth.email_password_enabled`, `auth.google_enabled`,
`auth.google_client_id` and `auth.google_client_secret`.
- **The whole save is judged (`service-settings`).**
`SettingsWritePredicateInput` gains `batch?: Readonly<Record<string,
unknown>>`: every key the write sets in the namespace, the asked key
among them, with the value it would store (`null` clears).
`refuseByWritePredicates` builds 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.ts` re-exports
the type; the built `dist/index.d.ts` carries `batch?:
Readonly<Record<string, unknown>>`), pinned by an `expectTypeOf` case
that imports it from `./index.js`.
- **The floor reads the state after the save.** Asked one key at a time
against the configuration in force, `{ 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 for `email_password_enabled`, the resolved
setting for the `google_*` keys, read with `SettingsService.get`). A
cleared key reads as its default (on, for both toggles), as
`validatePatch`'s own post-write view does. Google after the save
follows the rule the apply uses: off when `google_enabled` is off; else
kept when declared at boot; else in force with a client ID and secret,
each from the setting or its `GOOGLE_CLIENT_*` variable. A save that
sets no `google_*` key leaves Google as it stands.
- **Where the refusal lands.** At `email_password_enabled` when the save
turns it off and nothing is in force after it; at a `google_*` 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.
- **What counts as another way in**, read live at save time from the
running manager: the methods the login page still offers with email and
password off, the same facts its "No sign-in method is available" state
reads (`ui/packages/auth/src/LoginForm.tsx`, #29159), plus the one path
the host states:
- a social or OIDC provider in force
(`getPublicConfig().socialProviders`) **whose client ID and client
secret are both non-blank** in the configuration it came from (the
review's should-fix). `AuthPlugin` indexes the providers declared at
boot (stack config, the `GOOGLE_CLIENT_*` fallback, `auth:configure`
handlers) with `providerCredentialIndex`; 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;
- enterprise SSO switched on (`plugins.sso` / `OS_SSO_ENABLED`) AND at
least one `sys_sso_provider` row, read with the boot report's
`probeSsoProvidersPresence`. The switch alone signs nobody in, and an
unreadable table proves no row, so neither counts;
- phone-number sign-in (`features.phoneNumber`): better-auth's
`/sign-in/phone-number` does not check `emailAndPassword.enabled`, and
the code sign-in works when SMS can deliver;
- a host sign-in handoff the host declared
(`AuthPluginOptions.hostSignInHandoff`).
- Not counted: `ssoOnlyMode` alone (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`, #7481).
- **The refusal.** `code: invalid_value` at 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 with `resolveLocale` as 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.
- **Recovery for a deployment already locked out.** No new variable:
`OS_AUTH_EMAIL_PASSWORD_ENABLED=true` is the settings env override for
the key, it wins over the stored `false` (source `env`), 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.
- The edits in `auth-plugin.ts`: the import, the registration at the top
of `bindAuthSettings`, and one field (`bootProviderCredentials`) set
beside `configuredSocialProviders` after `auth:configure`, clear of the
`mfa_required` and `allowed_ip_ranges` hunks and of `isExplicit`.
- `scripts/engine-double-contract.pinned.json`: one row for the new
test's `findOne` double, written by `check-engine-double-contract.mjs
--write` (outside the claimed file surface; the gate requires it for a
new test file with an engine double).
- Changeset `.changeset/30202-keep-a-sign-in-method.md`:
`@objectstack/plugin-auth` and `@objectstack/service-settings`, both
`patch`.
## Tests
`packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts` (new, 17
cases). The real `SettingsService` (aliased to `service-settings/src` by
this package's vitest config), the real `AuthPlugin` and `AuthManager`;
a stub engine answers `sys_sso_provider` from each case's rows.
1. Email and password off with nothing else: `setMany` rejects with
`SettingsValidationError` (`SETTINGS_VALIDATION`), one field error at
`email_password_enabled` (`invalid_value`, `lastSignInMethod:
email_password`); the stored value stays the default `true`, a sibling
in the same batch (`password_min_length`) is not written, the manager
keeps email and password on; turning it on is accepted.
2. The HTTP door, through `registerSettingsRoutes`: `PUT
/api/settings/auth` with `Accept-Language: zh-CN` answers `400`,
`error.code: SETTINGS_VALIDATION`, the field error at the key, its
message the `zh-CN` copy.
3. Each way in that counts lets it turn off (stack-config GitHub,
phone-number sign-in, host handoff, SSO with a registered provider; and
Google saved on the page first).
4. The SSO switch with no provider row, and with a provider table that
throws, is refused.
5. With email and password off and Google the last way in:
`google_enabled: false`, `google_client_secret: null` and
`google_client_id: ''` are each refused (`lastSignInMethod: google`),
Google stays in force; with email and password back on, Google can go.
6. The same Google change is accepted where a stack-config GitHub keeps
a way in, and a cleared secret that `GOOGLE_CLIENT_SECRET` still
supplies is accepted and removes nothing.
7. Recovery: a `false` stored before the predicate exists rules at the
next boot (control), and with `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` in
the settings env the manager has email and password on.
8. The whole save: `{ 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.
9. Google filled in (`google_enabled`, client ID and secret) in the same
save as `email_password_enabled: false` is accepted: email and password
off, Google on.
10. `{ google_enabled: true, email_password_enabled: false }` over a
client ID and secret already stored is accepted (the pair is read as
stored).
11. `{ google_enabled: true, google_client_id, email_password_enabled:
false }` with no secret anywhere is refused at `email_password_enabled`.
12. A stack-config GitHub listed with a blank secret is no way in: email
and password off is refused.
13. `providerCredentialIndex` keys social and OIDC boot providers by
whether both credentials are set.
`packages/services/service-settings/src/settings-write-predicate.test.ts`
(+2): `setMany` hands a predicate the whole write as `batch`, frozen, a
clear as `null`; and `SettingsWritePredicateInput['batch']`, imported
from the package entry, is `Readonly<Record<string, unknown>> |
undefined`.
## Acceptance notes
- **A cleared key reads as its default.** Clearing a key at organization
scope can fall back to a global row rather than the default; the floor,
like `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.
- **The ui half (`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 under `os dev`, with better-auth's English "Email and
password is not enabled" on submit; at `797f9257` `LoginForm.tsx` has
the no-method state from #29257 (`noSignInMethod`, :351), so the
remaining shape is what the console dev server draws there. Not
reproduced here.
- **Other doors to the same lockout, not covered (read, not measured).**
Deleting the last `sys_sso_provider` row, or a redeploy that drops the
last stack-config provider or `plugins.phoneNumber`, while email and
password is off. They are not settings saves, so this predicate cannot
see them.
- **Dogfood reds that were not this change.** On the first head,
`permission-set-clone-door.dogfood.test.ts` was `main`'s red #30255
(reverted by #30262; red with the floor neutralized too). On this head,
four `package-commit-revert-path-package.dogfood.test.ts` cases 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/config` and the login page are unchanged:
`emailPassword.enabled` reports the runtime truthfully.
## Verification
Final head `0b89bdf1` (`main` `977be380` merged in, cleanly). `main` has
three commits since, none touching `service-settings`, `plugin-auth` or
the ledger; a trial merge is clean.
<details><summary>Tests</summary>
- `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.
- Whole packages under the lock, `vitest run --maxWorkers=2`:
`@objectstack/plugin-auth` Test Files 221 passed, Tests 3893 passed and
10 skipped; `@objectstack/service-settings` Test Files 67 passed, Tests
1153 passed.
- `pnpm --filter @objectstack/service-settings typecheck` and `pnpm
--filter @objectstack/plugin-auth typecheck` (both tsconfigs plus
`check:test-typecheck`): exit 0.
- `pnpm lint`: exit 0.
- Full dogfood (owed by the `(narrowing)` arm), `pnpm --filter
@objectstack/dogfood test` under the lock after `turbo run build
--filter='!@objectstack/docs'`, on `0b89bdf1`: Test Files 418 passed, 1
failed, 3 skipped; Tests 3488 passed, 4 failed, 36 skipped; the lock's
VERDICT command-exit 1. The four are
`package-commit-revert-path-package.dogfood.test.ts` cases (#29390,
#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.
</details>
<details><summary>Ablations (each negative pin)</summary>
`scripts/ablation-replace.mjs`, each anchor 1 to 0 on disk, each run
restored to the HEAD blob with `git diff HEAD` empty. The test imports
`./auth-plugin.js` and `./sign-in-method-floor.js` from `src`, and
`@objectstack/service-settings` is aliased to its `src`, so no dist leg
applies.
- D, `setMany` passes no `batch`: 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).
- E, the floor ignores `batch` (judges the asked key alone): 3 red (the
same three).
- F, a provider counts without its credential pair: 1 red (the
blank-secret GitHub).
- G, a key the save does not set reads as nothing instead of as stored:
2 red (the env-supplied secret case, the switch over a stored pair).
- A, the email and password refusal returns nothing: 6 red.
- B, the Google refusal returns nothing: 2 red (the last-provider case,
the both-keys case).
- C, the SSO switch counts without a provider row: 1 red.
</details>
<details><summary>Gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `0b89bdf1`: 73 derived families (the
first head's 72 plus `check:package-naming`, from the `service-settings`
path). Each run with its exit code recorded before any pipe, after a
full `turbo run build --filter='!@objectstack/docs'`; all 73 exit 0, and
`--ran` answers 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic:
`packages/spec` untouched.
- [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: n/a.
- [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 (the 维护者速读 block is
Chinese by the Communication rule).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT)_
<!-- signed: domain:server#2 · os-bill ·
session_01Utp6Cbvcmv8YWFhFJwzjKL -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent bc254be commit 870ebce
8 files changed
Lines changed: 910 additions & 2 deletions
File tree
- .changeset
- packages
- plugins/plugin-auth/src
- services/service-settings/src
- scripts
| 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 | |
|---|---|---|---|
| |||
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| 112 | + | |
112 | 113 | | |
113 | 114 | | |
114 | 115 | | |
| |||
554 | 555 | | |
555 | 556 | | |
556 | 557 | | |
| 558 | + | |
| 559 | + | |
557 | 560 | | |
558 | 561 | | |
559 | 562 | | |
| |||
751 | 754 | | |
752 | 755 | | |
753 | 756 | | |
| 757 | + | |
754 | 758 | | |
755 | 759 | | |
756 | 760 | | |
| |||
2079 | 2083 | | |
2080 | 2084 | | |
2081 | 2085 | | |
| 2086 | + | |
| 2087 | + | |
| 2088 | + | |
| 2089 | + | |
| 2090 | + | |
| 2091 | + | |
| 2092 | + | |
| 2093 | + | |
| 2094 | + | |
| 2095 | + | |
| 2096 | + | |
| 2097 | + | |
| 2098 | + | |
| 2099 | + | |
| 2100 | + | |
| 2101 | + | |
| 2102 | + | |
| 2103 | + | |
| 2104 | + | |
| 2105 | + | |
| 2106 | + | |
| 2107 | + | |
| 2108 | + | |
2082 | 2109 | | |
2083 | 2110 | | |
2084 | 2111 | | |
| |||
0 commit comments