feat(spec,plugin-auth): declare plugins.admin tri-state; refuse explicit admin:false beside effective SCIM at construction (ADR-0071 conflict) - #14246
Conversation
…cit admin:false beside effective SCIM (ADR-0071 conflict) Part of #13816 — WIP before artifact regeneration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1
…in tri-state; declare the default move; add changeset Part of #13816. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1
…im-admin-tristate-conflict
…doc-authoring gate); keep it in comments Part of #13816. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1
…im-admin-tristate-conflict
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 132 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 0890a6356b60fef6264cd255676fb8db1860ca0a && git checkout 0890a6356b60fef6264cd255676fb8db1860ca0a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b360cc7d5ca2e9cdb60a12018af20cbc3470cafa 9238222d3b638497b8f1990964025b9691c3ebdf && git checkout -B drift-repro b360cc7d5ca2e9cdb60a12018af20cbc3470cafa && git merge --no-ff 9238222d3b638497b8f1990964025b9691c3ebdf
node scripts/docs-audit/affected-docs.mjs --json b360cc7d5ca2e9cdb60a12018af20cbc3470cafa
|
Fixes #13816
Executes the 2026-09-01 director-batch-#21 ruling (issue comment 5491056008): Shape 2, immediate — declare
plugins.admintri-state on the auth-config surface, and refuse loudly at construction time when SCIM is effective whileadminis explicitlyfalse. The measurement half (better-auth action-granularity + upgrade evaluation) is #14150 and is deliberately not touched here; #14150 remains open.Clause-②: yes— this PR changes declared-surface parse output (a.default(false)removal) and adds a new construction-time refusal behavior.What changed
1. Declaration —
packages/spec/src/system/auth-config.zod.tsAuthPluginConfigSchema.adminmoves fromz.boolean().default(false)to tri-statez.boolean().optional(), the same shape #13439 → PR #14066 gavescim/sso/ssoDomainVerificationin the same file. Docblock and.describe()now state the tri-state semantics: unset ⇒ effective SCIM decides (ADR-0071 forces the admin plugin on, because SCIM'sactive:falsedeprovisioning runs through admin ban/unban); explicittrue⇒ mounted unconditionally; explicitfalse⇒ declined — and beside effective SCIM, a construction-time conflict. Thescimdocblock's ADR-0071-coupling sentence was amended to match (its #14066 precedence prose is untouched — see boundary flags).2. Conflict refusal —
packages/plugins/plugin-auth/src/auth-manager.tsresolveScimEnabled(pluginConfig)— the single decision point for "is SCIM effective" (explicit config wins overOS_SCIM_ENABLED, per [finding]plugins.scim/plugins.ssoare undeclared config keys that read as configuration but cannot ever change the outcome — the env var always outranks them #13439), now shared by the plugin mount (buildPluginList), the advertised/auth/configfeatures flag, and the new assert, so the three can never disagree.assertScimAdminCoherence(pluginConfig)— throws a documented conflict whenpluginConfig.admin === falseand SCIM is effective. The message names both keys, the ADR-0071 coupling, and the two ways out (accept the admin plugin, or disable SCIM). PlainError, no wire code — same class as the existing boot-entry asserts (assertAudienceConfig,assertOtpCooldownSeconds); it never reaches an HTTP envelope, so no ADR-0112 ledger entry is due (check:dispatcher-error-vocabularygreen confirms).AuthManagerconstructor (boot refusal, the established pattern),applyConfigPatchon the merged result (no smuggling path; the standing config keeps ruling), andbuildPluginList(where the env var is actually read — anOS_SCIM_ENABLEDappearing between construction and the lazy better-auth build is still refused).?? scimEffectivefallbacks are kept: absentadminunder effective SCIM still gets the admin plugin forced on (ADR-0071-backed current behavior); this PR only adds the loud exit for the explicit decline.3. Generated artifacts + ledger
packages/spec/scripts/lib/default-changes.ts: the default movesystem/AuthPluginConfig:admin false → (none)is declared under major 17 with a full reason (the spec 门禁盲区:可作者化 key 的「默认值 / 约束」变更不被任何 gate、tombstone 或 conversion 记录(#4650 / #4659 同族) #4666 ratchet accepted it; the build prints the reason).packages/spec/authorable-defaults/system.json(row removed) andcontent/docs/references/system/auth-config.mdxregenerated bycheck:generated --fix; everything else reproduced byte-identical.@objectstack/specminor +@objectstack/plugin-authminor (launch-window MINOR rule; a declared-surface change plus a new refusal behavior, no removal/rename — ADR-0087 marker: not-required, no-migration-prescription, since a conflicted config needs a human decision between accepting admin and disabling SCIM, nothing mechanical to rewrite).Derived judgments (Clause-②, itemised)
.default(false)removal — census of every parse consumer ofadmin. Runtime parse consumers ofAuthConfigSchema/AuthPluginConfigSchema: none (grep census; the only non-spec reference is a comment in auth-manager.ts — the runtime receives plain objects, exactly as the audience docblock records). The two live read points ofplugins.adminare the??chains in auth-manager.ts (buildPluginListand the/auth/configfeatures mirror), both fed raw config, both verified byte-identical in absent-key behavior. Gates on messages namingauth.plugins.admin(admin-import-users, auth-plugin 501 paths) gate on the mounted API (typeof authApi.createUser), not on the config value — unaffected.AuthPluginConfigParsedhas zero external consumers (declaration-only). The z.input type is unchanged (adminwas already optional on the input side); only z.infer moves frombooleantoboolean | undefined, and spec typecheck plus the full spec suite (448 files / 12035 tests) pass with no expectation on the materialized default anywhere.falseonto a silent document. Before this change, any authoring path that parsed through the spec schema would hand the runtime an explicit-lookingfalsethat silently defeated the ADR-0071 forced coupling; the tri-state makes parse output faithful to the runtime's raw-config contract.admin === falseAND effective SCIM. Coherent shapes proven accepted by tests:scim: false, admin: falsebeside an ambientOS_SCIM_ENABLED(explicit config wins — declining both is legal);admin: falsewith SCIM off everywhere;admin: trueanywhere; absentadminunder SCIM (forced on, unchanged). Trigger entry points: constructor, config patch (merged result), lazy build (late env). Deployment-visible consequence, named: a deployment already running the contradiction booted with silently broken SCIM deprovisioning (everyactive:falseban attempt failing at runtime); it now refuses to construct, with the remedy in the message — that loud stop is the ruled behavior.Verification
Union runs at the final merged head
9238222d3(all commands re-run there after the lastgit merge origin/main):pnpm --filter @objectstack/plugin-auth test— 87 files / 1803 passed (includes 7 new pins: 4 refusal + 3 acceptance).pnpm --filter @objectstack/spec test— 449 files / 12052 passed.pnpm --filter @objectstack/plugin-auth typecheckandpnpm --filter @objectstack/spec typecheck— both exit 0.assertScimAdminCoherenceneutralized (mutation proved on disk, marker grep 0 → 1), exactly the 4 refusal tests go red (4 failed / 263 passed in auth-manager.test.ts) and the acceptance tests stay green; restore proven by blob identity against HEAD (git hash-objectequals the HEAD blob,git diff HEADempty). The test imports in-package src, so no dist-resolution leg applies.scripts/pm/dispatch-gates.mjs, no-paths derivation — re-derived at the merged head, list unchanged) run green locally;check:test-completenessis NOT MEASURED locally by its own contract (it grades a saved CI test log; left to CI).check:generatedreproduces all 15 artifacts byte-identical on the merged tree.check:type-check-debtre-measured on the merged tree — no entry above its recorded number (ledger untouched).tsconfig.jsonexcludes test files fromtypecheckand carries no sibling test-typecheck config, so the edited test file's type health is exercised by vitest only — the pre-existing repo-wide gap already tracked by [finding] No plugin package has a tsc program that compiles its tests — plugin-approvals alone hides 345 type errors, and 0 of 14 are in the test-typecheck ledger #14062 (no new filing).Boundary flags (not re-judged here)
scim/sso/ssoDomainVerificationkeys and their feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env #14066 explicit-config-wins precedence chain: untouched except the one ADR-0071-coupling sentence in thescimdocblock/describe, amended because its old wording ("unless admin is set") became inaccurate under the refusal — flagged here rather than silently reworded.Generated by Claude Code
Generated by Claude Code