Skip to content

fix(plugin-auth)!: deleting the last identity provider is refused, and a boot that serves no sign-in method says so (#30300) - #30449

Queued
objectstack-fleet[bot] wants to merge 4 commits into
mainfrom
claude/project-thread-xt31nw
Queued

objectstack-fleet[bot] wants to merge 4 commits into
mainfrom
claude/project-thread-xt31nw

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

Fixes #30300

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches deleting an SSO provider or the sign-in boot reports; content/docs/deployment/self-hosting.mdx gains "No sign-in method left".

维护者速读

  • 改了什么:邮箱密码登录关闭、只剩一个企业 SSO 身份提供方时,Setup 的「删除 SSO 提供方」被拒(409),不再删掉最后一条登录路;重部署后没有任何登录方式时,启动日志打一条 error,启动照常。
  • 为什么改:两扇门都在 main 上真实启动复现过:删完或重部署之后,全租户连同管理员都登录不了,而且没有任何提示。
  • 风险与回滚:只在「邮箱密码关、SSO 开、这是最后一个提供方、没有其他登录方式」时拒删,其余删除照旧;读不到提供方列表时返回 503 拒删。回滚即 revert 本 PR。
  • 先看:sign-in-method-floor.ts 的 ssoProviderDeleteRefusal 与 resolveNoSignInMethodReport;auth-manager-before-hook.ts 的 /sso/delete-provider 分支;auth-plugin.ts kernel:ready 新增的那一段。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts,预期 35 条全过。
  • 需要你做的:无,席位落地。

What changes

Two doors removed the last way to sign in without a word. Both are now judged by the sign-in method floor that #30298 added to the auth settings save (sign-in-method-floor.ts), so the three doors cannot drift apart.

Door 1: deleting the last identity provider. POST /api/v1/auth/sso/delete-provider (Setup's "Delete SSO Provider") is refused in plugin-auth's global better-auth before-hook, before the vendor deletes anything, when all of these hold:

  • email and password sign-in is off;
  • enterprise SSO is switched on;
  • no other way in counts: a social or OIDC provider with a client ID and secret, phone-number sign-in, or a host sign-in handoff;
  • the provider being deleted is the last sys_sso_provider row. One bounded read of two rows answers this.

The answer is 409 with code: DELETE_RESTRICTED, lastSignInMethod: enterprise_sso, and a message in the caller's language (en, zh-CN, ja-JP, es-ES). This is an existing standard code, the one an organization delete answers when it is refused for the same kind of reason. When the provider list cannot be read, the answer is 503 SERVICE_UNAVAILABLE, because a guard that could not be evaluated does not pass.

Nothing changes for an unauthenticated caller (still the vendor's 401), an unknown provider (still the vendor's 404), or the vendor's own provider-access check.

  • AuthManager.setSsoProviderDeleteFloor() (@internal): AuthPlugin hands the manager the judge, because the manager does not hold the host handoff declaration or the boot credential index.
  • AuthPlugin.signInMethodFloorDeps(): one builder of the floor's live facts, used by the settings save, the delete, and the boot report.

Door 2: a boot that serves no way in. At the kernel:ready site that already reports no_sign_in_account_at_boot, after the auth settings are applied, a deployment that serves no sign-in method now reports no_sign_in_method_at_boot at error. The line names the consequence and the recovery: OS_AUTH_EMAIL_PASSWORD_ENABLED=true and a restart.

  • It is a positive claim only: a provider table that cannot be read stays silent.
  • It is reported in every posture and never refuses the boot (seat ruling, option A). A platform-SSO tenant that signs in through a door this plugin cannot see, such as ssoOnlyMode, which the floor does not count, must keep starting.
  • Refusing such a boot is a later option, not done here. It fits only once every delegated path is counted.

Docs. content/docs/deployment/self-hosting.mdx gains "No sign-in method left", covering the three doors and the recovery.

Why

Both doors were measured on main bfca0ef on a real boot before any code (report 6652003986).

  • Door 1. With email and password off and one SAML provider as the only way in, the delete answered 200 {"success":true}. After sign-out, /sign-in/email answered 400 EMAIL_PASSWORD_DISABLED and /sign-in/sso answered 404.
  • Door 2. A redeploy without the GOOGLE_CLIENT_* pair booted to "Server is ready" with no sign-in line, and served no method.

Tests

Pins added to the floor's shared harness packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts, as top-level describes. There are 18 new cases, and the file has 35 in all.

  • Door 1, real composition. ObjectKernel + ObjectQLPlugin over in-memory SQLite, the real SettingsServicePlugin, the real AuthPlugin with the SSO plugin, and a real sign-up and session cookie. Email and password is turned off through settings.setMany, which the floor admits while a provider row exists. Then the real POST /api/v1/auth/sso/delete-provider:
    • the last provider gets 409 DELETE_RESTRICTED, the zh-CN copy under Accept-Language: zh-CN, then the en copy, and the row is still there;
    • CONTROL, two providers: the first delete goes through (200) and the one left is then refused;
    • CONTROL, email and password on: the delete goes through;
    • CONTROL: an unknown provider gets the vendor's 404, and no session gets the vendor's 401.
  • Door 2, the same composition.
    • emailAndPassword.enabled: false with no provider reports once at error and the boot completes;
    • the same with SSO on and no row;
    • CONTROL: GOOGLE_CLIENT_* set reports nothing;
    • recovery: OS_AUTH_EMAIL_PASSWORD_ENABLED=true turns email and password back on over the stack config, and nothing is reported.
  • The judge and the report, for faults the composition cannot make:
    • an unreadable provider list gets 503 on the delete and silence at boot;
    • seven pass cases;
    • the report goes to error, or to warn on a sink without error, and a throwing sink does not throw.
Ablations (each through scripts/ablation-replace.mjs, restored, worktree clean after)

Each mutation landed (anchor 1 → 0, blob changed), the run went red as below, and the file was restored to the HEAD blob (git status clean).

  • A1, door 1 call removed from the before-hook (await this.assertSsoProviderDeletable(ctx);): the last-provider refusal and the "one left is then refused" control go red, expected 200 to be 409. 2 failed, 33 passed. This reproduces the bug on the test stack.
  • A2, the boot report call removed from kernel:ready: both door 2 report cases go red. 2 failed, 33 passed.
  • A3, an unreadable provider list read as "another provider remains": the 503 case goes red. 1 failed, 17 passed (run before the pins moved into the shared file).
  • A4, the boot report speaks unless a provider row is present: the positive-claim case goes red. 1 failed, 17 passed (same).
  • A5, AuthPlugin does not hand the manager the judge: the two door 1 refusal cases go red, {"success":true}: expected 200 to be 409. 2 failed, 16 passed (same).

A1 and A2 were re-run against the final shared file.

Acceptance notes

Verification

All readings are on head 385ecdcc: this branch merged with origin/main 7d7babb1, which carries #30368 and #30376. Heavy runs were under scripts/pm/os-verify-lock.sh on a shared box.

Real boot re-measure of both doors (pnpm dev, SqlDriver better-sqlite3)

Door 1: fresh boot with OS_SSO_ENABLED=true, signed in as the dev admin. Two SAML providers were registered through /api/v1/auth/sso/register. PUT /api/settings/auth {email_password_enabled:false} answered 200; the floor admits it while SSO has rows.

  • POST /api/v1/auth/sso/delete-provider {providerId:"beta-saml"} → 200 {"success":true} (another provider remains).
  • POST /api/v1/auth/sso/delete-provider {providerId:"acme-saml"} → 409 {"code":"DELETE_RESTRICTED","message":"This is the last enterprise SSO identity provider, and with email and password off nothing else signs anyone in, …","lastSignInMethod":"enterprise_sso"}.
  • The same with Accept-Language: zh-CN → 409 with the zh-CN copy.
  • GET /api/v1/data/sys_sso_provider → acme-saml still there; /auth/config features.sso: true.
  • After sign-out, POST /api/v1/auth/sign-in/sso {email:"someone@acme.test"} → 200 with the IdP redirect URL.

Door 2: the database from the measurement round, with auth.email_password_enabled stored false and Google previously declared by env.

  • The redeploy without GOOGLE_CLIENT_* logged ERROR [auth] no_sign_in_method_at_boot: this deployment serves NO way to sign in, … Recover: set OS_AUTH_EMAIL_PASSWORD_ENABLED=true and restart … The boot continues., then ✓ Server is ready.
  • /sign-in/email → 400 EMAIL_PASSWORD_DISABLED.
  • The recovery boot with OS_AUTH_EMAIL_PASSWORD_ENABLED=true logged no such line, and /sign-in/email → 200.
Suites, typecheck, lint
  • pnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=3 → VERDICT command-exit 0, 119/119 tasks.
  • pnpm --filter @objectstack/plugin-auth test → VERDICT command-exit 0; Test Files 224 passed (224), Tests 3967 passed, 10 skipped (3977).
  • pnpm --filter @objectstack/plugin-auth typecheck → VERDICT command-exit 0 (check:test-typecheck: OK, debt ledger unchanged).
  • src/sign-in-method-floor.test.ts alone → 35/35.
  • pnpm lint → VERDICT command-exit 0.
Full dogfood battery (owed by the narrowing arm)

pnpm --filter @objectstack/dogfood test (no shard, no path), after the whole-workspace build above → VERDICT command-exit 0, held the lock 33m24s. Test Files 419 passed, 3 skipped (422). Tests 3492 passed, 36 skipped (3528). No failure.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 95 commands on 385ecdcc. Each was run with its exit code recorded before any pipe, and all 95 exited 0. --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.

On the pre-merge head e5f6926f, two commands were non-zero:

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No spec change.)
  • 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: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the maintainers' Chinese summary).
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CadeYZeJahkVWxXfYx

… say a boot without one

Deleting the last identity provider while email and password is off is
refused in the global before-hook (409 DELETE_RESTRICTED, 503 when the
provider list cannot be read), judged by the sign-in method floor. A boot
that serves no sign-in method says no_sign_in_method_at_boot at error and
still boots.

Claude-Session: https://claude.ai/code/session_01Utp6CadeYZeJahkVWxXfYx
Co-authored-by: Claude <noreply@anthropic.com>
…r's shared harness

Claude-Session: https://claude.ai/code/session_01Utp6CadeYZeJahkVWxXfYx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 32 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0892a94d8e190b7547408d1a37e49ba087ddd635.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 22 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 0892a94d8e190b7547408d1a37e49ba087ddd635 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fecb7c1d82be1586b19deba7fa4a10354815e7a9 — the merge of head 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9 into base 0892a94d8e190b7547408d1a37e49ba087ddd635, 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 fecb7c1d82be1586b19deba7fa4a10354815e7a9 && git checkout fecb7c1d82be1586b19deba7fa4a10354815e7a9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0892a94d8e190b7547408d1a37e49ba087ddd635 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9 && git checkout -B drift-repro 0892a94d8e190b7547408d1a37e49ba087ddd635 && git merge --no-ff 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9

node scripts/docs-audit/affected-docs.mjs --json 0892a94d8e190b7547408d1a37e49ba087ddd635

⚠️ 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 0892a94d8e190b7547408d1a37e49ba087ddd635 → 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-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9
Local-runs: none
Scope: packages/plugins/plugin-auth/src/sign-in-method-floor.ts, packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-manager-before-hook.ts, packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts, packages/plugins/plugin-auth/src/auth-plugin.ts + packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts, packages/plugins/plugin-auth/src/auth-manager-actor-guards.ts, packages/plugins/plugin-auth/src/organization-delete.ts, packages/plugins/plugin-auth/src/sso-client-secret.ts, packages/plugins/plugin-auth/src/index.ts, packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/tsup.config.ts, tsconfig.base.json, packages/spec/src/api/errors.zod.ts, .changeset/30300-keep-the-last-identity-provider.md and content/docs/deployment/self-hosting.mdx (prose faces, read for the Skills-docs: and docs-accuracy judgments only)

Face: narrowing. No path of the diff is a published schema or a governed path; the line-initial Clause-②: yes (narrowing) on the PR body and in the changeset puts the review on the narrowing face, so the scope floor is every diff path outside the prose faces, all six named above. PR user.login is objectstack-fleet[bot], so no author note is owed. No Contract-review-claim, hold or record on this head at read time (PR thread: the docs-drift bot comment only). Card thread read: 6650883232 (triage), 6651638125 (seat claim, Clause-②: no — the dev re-measures), 6652003986 (measurement report), 6657245276 (os-dev report). The merge base with origin/main is 7d7babb1; main has since moved auth-manager.ts only by #30107 (getPublicConfig().features.emailDelivery, L2790/L4701), nowhere near this PR's hunks.

① Derived judgments

  1. The narrowing itself (correct, as declared). sign-in-method-floor.ts#ssoProviderDeleteRefusal refuses POST /sso/delete-provider only when all four hold, in this order: publicConfig().emailPassword.enabled === false (anything else passes), features.sso === true (auth-manager.ts#isSsoWired is the switch alone, so with enterprise SSO off the rows sign nobody in and the delete passes), otherSignInMethods(deps, { withoutEnterpriseSso: true }) empty (a social or OIDC provider with a credential pair, phone-number sign-in, a host handoff each pass it), and probeOtherSsoProviders answering absent. The probe reads two rows of sys_sso_provider under isSystem (same find(object, { limit }, { context: { isSystem: true } }) shape as boot-sign-in-reachability.ts#probeSsoProvidersPresence): zero rows is not_found (pass, the vendor's 404), any row with another provider_id is present (pass), a read fault or no engine is unknown. The 409 is the exact accept-set change the card asks for; nothing outside it moves. Judged right.
  2. Where it runs: before the vendor's first write. auth-manager-auth-instance.ts L768-798 installs globalBeforeHook as better-auth's hooks.before, so auth-manager-before-hook.ts#globalBeforeHook L316-319 runs ahead of @better-auth/sso's handler, which deletes the provider row and its linked account rows. The branch sits after the ADR-0069 D3 held-caller refusal (same path, same fall through comment) and before the ADR-0135 D6 registration gate (/sso/register only). A refused delete writes nothing; the composition test asserts the row is still there after both the zh-CN and en refusal.
  3. What stays the vendor's answer. auth-manager.ts#assertSsoProviderDeletable returns before judging when no judge is set (a manager no plugin composed), when body.providerId is not a string (the vendor's 400), and when resolveActor finds no userId (the vendor's 401). An unknown providerId passes either way: with other rows the probe reads present, with none not_found, so the vendor's 404 stands (pinned, remove('nobody') → 404 with one okta row). The vendor's own provider-access check still runs after the pass. The 401/404 controls are pinned through the real endpoint.
  4. Can a valid or non-last delete be wrongly refused? Not within the floor's own vocabulary: with two rows the first delete passes and the one left is then refused (pinned); email and password on passes; SSO off passes; each of the three other methods passes (seven it.each cases at the judge). The one population that is refused while another door exists is the one fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in #30298 already does not count — ssoOnlyMode alone, magic link, passkeys (sign-in-method-floor.ts module docblock "Not counted") — so the delete door is consistent with the save door, and the refusal message names the recovery (turn email and password on first). Not a defect of this diff; the floor's counting is fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in #30298's landed contract.
  5. The 503 fail-closed path. unknown answers { status: 'SERVICE_UNAVAILABLE', code: 'SERVICE_UNAVAILABLE' }, never a pass; the judge test pins it on a throwing engine. Both status names are the ones auth-manager-actor-guards.ts already throws for the organization delete (new APIError('SERVICE_UNAVAILABLE', …) L98/L227, new APIError('CONFLICT', { code: ORGANIZATION_DELETE_REFUSAL_CODE, … }) L320), so the 409/503 mapping and the extra body field (lastSignInMethod, as dependents there) are a known-good shape. Both codes are existing StandardErrorCode members (packages/spec/src/api/errors.zod.ts L97 DELETE_RESTRICTED, L113 SERVICE_UNAVAILABLE): no new error code, so no ledger entry is owed. Note: the settings floor reads an unknown provider table as "no row" (does not count it), the delete door reads it as 503. The two directions are both fail-closed for their door (a save that cannot prove a way in is refused; a delete that cannot prove another way in is refused), so they agree in effect.
  6. Race between two concurrent deletes of the last two rows. Both probes can read two rows, both answer present, both pass, and the table ends empty. The probe and the vendor's delete are not one transaction, so the window is real. It does not change the verdict: it needs two administrators deleting within the same request window, the settings door of fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in #30298 has the identical window (a save judged while a delete is in flight), door 2 reports the result at the next boot, and the recovery (OS_AUTH_EMAIL_PASSWORD_ENABLED=true) is unchanged. A follow-up card is the right carrier if the seat wants it closed (the vendor's guardProviderMutation seam the dev read in 6652003986 would need adapter transactions).
  7. Door 2 (report only, as ruled). sign-in-method-floor.ts#resolveNoSignInMethodReport returns a message only when email and password is off, no other method counts, and (SSO off, or SSO on with the provider table READ and empty): an unknown probe with SSO on returns null. reportIfNoSignInMethodAtBoot catches a throwing resolver and a throwing sink, logs once at error (or warn on a sink without error, the boot-sign-in-reachability.ts convention), and returns. The kernel:ready site in auth-plugin.ts L1532-1541 awaits ensureAuthSettingsBound inside a try (that memoized call is already made unconditionally by the sibling hook at L1143, so no new binding trigger is introduced) and then the report; nothing in the path can reject the hook, so the boot is never refused. The composition test asserts the boot completes and one report line with the token and the recovery; the control (GOOGLE_CLIENT_* set) and the recovery (OS_AUTH_EMAIL_PASSWORD_ENABLED=true) assert silence. Holds the ruling.
  8. Public surface of @objectstack/plugin-auth: widened by one method and one reachable type. index.ts L34 publishes AuthManager by name through the "." entry (package.json exports, dist/index.d.ts); tsconfig.base.json sets declaration: true and no stripInternal, tsup.config.ts runs plain dts, and the repo has no api-extractor, so @internal is a doc tag only and setSsoProviderDeleteFloor(judge: (providerId, locale?) => Promise<SsoProviderDeleteRefusal | undefined>) is emitted on the published class, carrying SsoProviderDeleteRefusal with it through the parameter's return type (execution-duties 〈认领〉: public surface = what the built entry declarations reach, including types reachable through props, parameters and returns). sign-in-method-floor.ts itself is not re-exported from index.ts, so the new otherSignInMethods option, probeOtherSsoProviders, SSO_PROVIDER_DELETE_PATH, NO_SIGN_IN_METHOD_AT_BOOT and the two report functions are shipped bytes, not published. Hence Clause-②: yes (narrowing) is the right line: a widening (the setter and its type) plus a narrowing (the 409). no (narrowing) would be right only if the method were trimmed from the declarations or private, and neither is the case. Metadata-protocol: none holds: no key, value, type or default an author writes moves (sys_sso_provider keeps its columns; the endpoint keeps its request shape).
  9. i18n. SIGN_IN_FLOOR_COPY gains enterpriseSso and enterpriseSsoUnverified in all four locales (en, zh-CN, ja-JP, es-ES); SignInFloorCopy makes both required, so a missing locale would not compile. signInFloorCopy(locale) resolves exact, then language, then en; the hook hands it preferredLocaleFromHeader(acceptLanguageHeader(ctx)), the reader REST already uses. Pinned: zh-CN by header, en by default, ja-JP and es-ES at the judge.

② Semver level

Changeset .changeset/30300-keep-the-last-identity-provider.md: '@objectstack/plugin-auth': patch on the one released package the diff moves (package.json has no private field); title fix(plugin-auth)!:; **BREAKING** banner; Clause-②: yes (narrowing) — …, Metadata-protocol: none and Skills-docs: none — … line-initial in both the changeset and the PR body (Clause-② is read as the first token after the colon, arm in parentheses, scripts/pm/clause2-line.mjs); exactly one <!-- adr-0087: not-required (no-migration-prescription) … --> marker, the grammar scripts/check-adr-0087-registration.mjs L62-63 reads. Against AGENTS.md Post-Task Checklist step 3: a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition — patch ✓, banner ✓, disposition ✓; nothing an author writes is removed or renamed, so no FROM → TO line or retiredKey() is owed. Same spelling and level as #30298's .changeset/30202-keep-a-sign-in-method.md (patch, yes (narrowing), not-required (no-migration-prescription)), the precedent for this family. Check Changeset, PR-body guards, Governed Surface Queue Guard and Dogfood Regression Gate are success on this head.

③ Boundary flags

  1. Skills-docs: line — present, well-spelled, true. Skills-docs: none — no file under \skills/**` teaches deleting an SSO provider or the sign-in boot reports; `content/docs/deployment/self-hosting.mdx` gains "No sign-in method left".is thenone — REASONarm (pm-dispatch SKILL.md L313), owed because the arm is(narrowing). The docs page it names is in the diff: a three-row table (settings save → 400 SETTINGS_VALIDATIONat the field, which is whatservice-settings' route tests pin for a write-predicate refusal; the delete → 409 DELETE_RESTRICTED/503 SERVICE_UNAVAILABLE; the redeploy → boots, errorunderno_sign_in_method_at_boot`), the "not refused" sentence, and the recovery with the env override's precedence. Each sentence agrees with the code read in ①. Accurate.
  2. Dev open_questions — Clause-② re-declared yes (narrowing) against the seat's no: option A is right. The seat's claim line reasoned that a refused delete "narrows nothing an author writes", which is the Metadata-protocol question, not the Clause-② one (〈认领〉: "本卡放宽接受集或扩大公开面吗"). Measured: 200 before, 409 after at a published endpoint (the narrowing arm), and a published class gains a method (the yes). ①.8 carries the reading. The seat's claim comment stands as written (it said the dev re-measures); nothing to edit there.
  3. Refusal tests assert code and status? The 409 does, through the real endpoint: res.status 409 and toMatchObject({ code: 'DELETE_RESTRICTED', message, lastSignInMethod }), twice (zh-CN, en), plus the two-row control's second delete. The 503 is asserted at the judge (status: 'SERVICE_UNAVAILABLE', code: 'SERVICE_UNAVAILABLE'), not over HTTP, because the composition cannot make the fault; the one APIError(refusal.status, { code: refusal.code, … }) line is shared with the HTTP-pinned 409, and the same status word is the organization delete's 503. Acceptable; the seat may note it in ACCEPT, no REWORK.
  4. Door 2 holds ruling A. Report only, positive claim only, never refuses (①.7). The refuse-by-posture option is named in the PR body and the module docblock as a later option, not done, with the reason (ssoOnlyMode tenants).
  5. ! and (narrowing) battery. dogfood_battery in 6657245276 is pinned to 385ecdcc, full (pnpm --filter @objectstack/dogfood test, 419/422 files, 3492 passed, 36 skipped, no failure). The seat verifies the check-runs, not this record.
  6. Check-runs at read time (2026-11-05 ~12:00Z): Lint · ESLint, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Lint · scoped · query-options erasure, Type Check · source gates, Type Check · debt ledger, Spec property liveness, Governed Surface Queue Guard, Check Changeset, PR-body guards, Dogfood Regression Gate, Flag docs affected by code changes, Check PR Size, Check Documentation Links, Auto Label are success; Type Check · workspace, Type Check · consumer gates, Test Core (1-8/8) still in_progress. The queue entry waits for all-green (contract-review.md ②); this record does not stand in for it.
  7. Collision note (seat 2 in-flight work; branches not fetched, hunk headers read off the REST file list). PR fix(plugin-auth)!: clearing an authentication setting, or resetting the namespace, takes effect at once (#30312) #30447 (Authentication settings: clearing a key (or resetting the namespace) answers saved, but the running value keeps the last stored one until a restart (lockout threshold, max sessions per user and siblings) #30312, bindAuthSettings/applySettings) touches auth-plugin.ts at main L794-806 (the init() lines where this PR inserts setSsoProviderDeleteFloor right after this.effectiveSecondaryStorage = …), L2103-2134 (the registerSignInMethodFloor(settings, {…}, ctx.logger) block this PR collapses into registerSignInMethodFloor(settings, this.signInMethodFloorDeps(ctx, this.authManager), ctx.logger), and the applySettings opening), and sign-in-method-floor.ts at main L57-66 (the module docblock tail, where this PR appends the "two doors that are not a settings save" section): three textual overlaps, each a short hunk, whichever lands second rebases. fix(plugin-auth)!: clearing an authentication setting, or resetting the namespace, takes effect at once (#30312) #30447's other hunks (auth-plugin.ts L547, L2328-2560; sign-in-method-floor.ts L97-100 SignInMethodFloorDeps, L335-344 signInMethodFloorPredicate) do not meet this PR. Organization require MFA: the save refusal is English only, the grace period is unreadable by the console, and the field help does not say two-factor must be on #30411 (organization require-MFA refusal copy) has no open PR yet; it will touch auth-manager.ts, where this PR's hunks are the import block (L82-85), the setter after setOrganizationDeleteStandingJudge (L1588-1605), the before-hook host map (L1761), and assertSsoProviderDeletable after assertOrganizationDeletable (L5055-5083): overlap only if Organization require MFA: the save refusal is English only, the grace period is unreadable by the console, and the field help does not say two-factor must be on #30411 edits those four spots. auth-manager-auth-instance.ts and auth-manager-before-hook.ts have no in-flight sibling named.
  8. Dev's out-of-scope finding (Setup's Register SAML Provider bridge 400) is already plugin-auth: Setup's Register SAML Provider answers 400 SAML_REGISTER_FAILED for every complete form (the bridge sends no idpMetadata.entityID) #30352 by the seat; nothing new to file.

④ Design judgment

  • sign-in-method-floor.ts — the delete door reuses the floor rather than copying it, as the card asks. ssoProviderDeleteRefusal asks otherSignInMethods with the new withoutEnterpriseSso option and judges the provider rows itself with a bounded two-row read, so "another way in" is one list for the save, the delete and the boot, and the delete's own question ("is this the last row?") is answered without a count. 409 DELETE_RESTRICTED is the standard code for a delete refused while something depends on the row (the organization delete's), and 503 for a guard that could not be evaluated is the same module's precedent (fix(plugin-auth): an organization-delete door lookup that throws hands the delete to better-auth uncounted, so a driver without transactions keeps a partial delete #25059). resolveNoSignInMethodReport is a positive claim in the exact shape of no_sign_in_account_at_boot beside it. The module docblock records both measurements (main bfca0efb) and why door 2 does not refuse. Holds against the code it cites and the two measured scenarios.
  • auth-manager-before-hook.ts / auth-manager.ts / auth-manager-auth-instance.ts — the right seam. The before-hook is the one place the vendor's delete can be stopped before its write (its beforeDelete would run inside the vendor's transaction-less handler); the host-map pattern (BeforeHookHost, AuthInstanceHost) is the file's own convention for every other guard. The judge is handed to the manager by AuthPlugin because the manager does not hold the host handoff or the boot credential index; setSsoProviderDeleteFloor mirrors setOrganizationDeleteStandingJudge. The @internal tag is honest about intent, but ①.8 stands: it does not trim the declaration, so the PR's yes is the right word.
  • auth-plugin.ts — one builder, three doors. signInMethodFloorDeps replaces the inline deps object with a method used by the save, the delete and the boot report, so the three cannot drift (the card's "applied at every door"). The kernel:ready placement, after the account report and after the settings are bound, is where the live manager reflects the stored email_password_enabled; the test for the stored-false case (settings.setMany then a redeploy) exercises it on the real composition.
  • sign-in-method-floor.test.ts — the pins carry the scenario. Two new top-level describes in the floor's shared harness (ruling 6583487846): the real kernel composition with the real endpoint and controls for each arm, and the judge/report against faults the composition cannot make. The dev's ablations (A1 hook call removed → expected 200 to be 409; A2 report call removed; A3 unknown read as present; A4 report speaks unless present; A5 judge not wired) each go red on the case that owns the behaviour; A1 reproduces the card's bug on the test stack.
  • Must-fix defects on the contract faces: none. The two correct declaration lines are Clause-②: yes (narrowing) and Metadata-protocol: none, as the PR carries them. The concurrent-delete window (①.6) is a noted gap for a follow-up card, not a defect of this PR's contract.

Implemented-by: claude/project-thread-xt31nw
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

席位核对

ACCEPT · head 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9 · card #30300 (claim 6651638125, amended 6657502177) · 2026-11-05T12:17Z

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 12:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 12:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Any commits made after this event will not be merged.
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

维护者速读(席位终稿)

  • 改了什么:邮箱密码登录关闭、只剩一个企业 SSO 身份提供方、也没有其他登录方式时,Setup 的「删除 SSO 提供方」答 409 DELETE_RESTRICTED 拒删(读不到提供方列表答 503);重部署后一种登录方式都没有时,启动日志打一条 error no_sign_in_method_at_boot,启动照常。
  • 为什么改:两扇门都在 main 上真实启动复现过:删完或重部署后,全租户连同管理员都登录不了,且没有任何提示(p1 Sign-in lockout through doors other than the auth settings save: deleting the last SSO provider row, or a redeploy that drops the last provider, while email and password is off #30300)。
  • 风险与回滚:BREAKING 收窄(Clause-②: yes (narrowing)):原来答 200 的「删最后一个提供方」现在答 409;其余删除、401/404 照旧。回滚即 revert 本 PR。
  • 先看:sign-in-method-floor.ts 的 ssoProviderDeleteRefusal 与 resolveNoSignInMethodReport;auth-manager-before-hook.ts 的 /sso/delete-provider 分支;auth-plugin.ts kernel:ready 段。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts,35 条全过。
  • 需要你做的:无。维护者已于 2026-11-05 12:28Z 在席位线程同意落地;合同级复核 PASS 记录 6657487124(Head-sha 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9)。

席位意见:同意落地。p1 锁死漏洞,拒删条件窄且与 #30298 设置保存门共用同一判定;两条已知余项不阻塞:并发删最后两条的竞态(与 #30298 同窗,下次启动会报),503 只在判定层钉住。

席位核对

Supplements ACCEPT 6657794372 on the same head 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9 · 2026-11-05T12:29Z. ⚠️ Posted after pr_ready + automerge_enable (12:28Z): the seat's record should have carried the six lines above and the governed-surface pre-check before readying; both are recorded here, and neither changes the verdict.

This branch has not been deployed

No deployments
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/l tests tooling

Projects

None yet

1 participant