feat(auth): migrate @better-auth/scim to stable 1.7.1 — seven-model provision, app-owned bearer verification (#3653) - #12726
Conversation
…e line (measurement base, #3653) Part of #3653. NOT a proposed landing: leg 2' stopped on a scope fork before the provisioning half could be written, so this branch carries the pin move alone as the reproducible base every measurement in the report was taken on. A pin-move-only tree is red by construction (7 failed | 21 passed on the parity gate) and must never be landed on its own. - packages/plugins/plugin-auth/package.json 1.7.0-rc.1 -> ^1.7.1 - pnpm-workspace.yaml '@better-auth/scim@<1.7.0-rc.1': '1.7.0-rc.1' -> '@better-auth/scim@<2.0.0': '^1.7.1' (major-boundary bound, matching the rest of the family per this file's own doctrine; the old bound sat AT the rc precisely so a <2.0.0 bound could not rewrite the stable release down onto it.) Measured consequences, all reproducible from this commit: - '^1.7.1' resolves to 1.7.2, not 1.7.1. Every prior measurement on this epic was taken against 1.7.1. Re-measured on 1.7.2: same seven models, same three-way constructor predicate, same absent scimProvider. - scim lands on 1.7.2 while the rest of the family stays 1.7.1. 1.7.2 peers '@better-auth/core': '^1.7.2' and 'better-auth': '^1.7.2', which 1.7.1 does not satisfy; this repo's existing overrides rewrite those peer ranges down to '^1.7.1' in the lockfile, so nothing reports the mismatch. One copy each of better-auth and @better-auth/core remain in the store. - check:prerelease-pins retires itself: "No prerelease pin left in pnpm-workspace.yaml overrides - nothing to watch", exit 0. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
…m-stable-pin-move
… 1.7.1 — provision the seven models, app-owned bearer verification (#3653) Part of #3653 (leg 2' of epic #11632). The pin resolves to 1.7.1 EXACTLY (not ^1.7.1, which resolves to 1.7.2 whose better-auth/core ^1.7.2 peers only the workspace overrides' silencing would 'satisfy'; floating is its own follow-up card). - pnpm-workspace.yaml + plugin-auth: exact 1.7.1 pin, stale rc-hold comment blocks rewritten to the landed state. - packages/platform-objects/src/identity/: the seven stable models provisioned as platform objects (scimConnectionBinding, scimGroup, scimGroupMember, scimIdentityTombstone, scimProjectionGrant, scimSubject, scimUser) plus sys_scim_connection_credential, the ObjectStack-owned credential store for the app-owned verifyBearerToken route (stable upstream stores no credential at all). Translation bundles regenerated with pnpm i18n:extract only. - packages/spec platform-object-names.ts: eight additive registry names (the measured-unsplittable spec edit; sys_scim_provider stays — its retirement is #11757's). - plugin-security BETTER_AUTH_MANAGED_OBJECTS: the seven library-managed names, forced additively by the bidirectional drift pin (default-permission-sets.test.ts) the moment the objects declare managedBy: 'better-auth'. - plugin-auth scim-connection-service.ts: mint + digest + verify owned outright; at rest only HMAC-SHA-256(secret, domain-separated bearer), base64url — at parity or better than the rc.1 unsalted SHA-256. - auth-manager.ts: stable constructor shape — connections: [] plus authentication.verifyBearerToken resolving the connection from a sys_scim_connection_credential row at request time; auth secret resolved once (memoized) so digests and better-auth share key material. - objectql-adapter.ts: seven AUTH_MODEL_TO_PROTOCOL bridge entries, and NATIVE adapter transactions over engine.transaction({ require: true }) — stable scim refuses the sequential fallback by assertion. - better-auth-schema-parity.test.ts: KNOWN_UNMAPPED_MODELS shrinks to the empty set (exact-set assertion kept, both directions). - credential-at-rest-posture.test.ts: restructured honestly — the upstream-cleartext control arm is no longer measurable (upstream stores nothing); its successor pins that vacating fact plus the keyed digest relationship, negatives for the unkeyed/wrong-key/undomained shapes, and live 200/401 rejection paths incl. revocation and expiry. Co-authored-by: Claude <noreply@anthropic.com>
…l suppression with the pin; adapter transaction degrades declared (#3653) Epic item 7, same stroke as the pin move: stable 1.7.1 peers better-call@1.4.0 exactly, so the rc-era allowedVersions key retires in its two declared places (SCAFFOLD_ALLOWED_PEER_VERSIONS and the blank template) and both presence ratchets flip to absence pins. The better-auth>better-sqlite3 and four @better-auth/utils entries stay — re-derived from the tree, their conditions are separate and unmet; the separation pin now guards that they survived the retirement. Adapter transactions: better-auth routes its own multi-writes (sign-up included) through adapter.transaction — measured: a fail-closed require:true 500'd every sign-up on the memory engine — so the implementation keeps the two declared degrades those flows always had (no-transaction-API engines run the callback directly; drivers without beginTransaction follow the engine's ADR-0119 D1 warn-once degrade). SQL drivers get real atomic transactions. Also: changeset (minor across the six touched published packages, level argued in the body), and two stale-prose corrections where files described the retired rc.1 storeSCIMToken wiring as current. Co-authored-by: Claude <noreply@anthropic.com>
…test with the pin (#3653) The watch script promised to retire itself when the last prerelease pin went stable, and its CLI does (empty watch list is the success state) — but its --self-test kept an anti-vacuity assertion that the repo's own pnpm-workspace.yaml parses to a NON-empty watch list, so the 'Stable release watch for prerelease pins' job went red on the very commit that redeemed the pin. Flip the pair to the retired state without opening the hole the old check guarded: the overrides block must still parse non-empty (a parser break cannot masquerade as retirement), and the derived watch list must be empty, with the failure text instructing how to flip back if a prerelease pin ever reappears. Reproduced the CI failure locally before the fix (self-test exit 1, same failing line) and measured exit 0 after; check-self-test-workflow-commands green (140 scripts scanned). Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 6 package(s): 76 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 139 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 c8829449e4302636ac0f7ab81cfd20bc0dcaae27 && git checkout c8829449e4302636ac0f7ab81cfd20bc0dcaae27
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 dba7c1d21892ef3d87f9fe25ccbec125804d32c0 && git checkout -B drift-repro d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 && git merge --no-ff dba7c1d21892ef3d87f9fe25ccbec125804d32c0
node scripts/docs-audit/affected-docs.mjs --json d29e42f8b1b44b92ea58c1d05e619f27c027c3a9
|
…equests (#3653) Measured twice on the unscoped variant: better-auth wraps whole request flows in adapter.transaction (runWithTransaction), so opening a real driver transaction around every sign-in/sign-up starved the single-connection sqlite pools — the dogfood showcase boot deadlocked on 'Acquire connection error' until the 180s hook timeout, in CI and reproduced locally on this branch, with 337 sibling dogfood tests green. The scim verifier now marks its request's async chain (AsyncLocalStorage enterWith), and config.transaction opens a real engine.transaction only inside that scope — exactly where assertNativeSCIMTransactions demands atomicity. Every other better-auth flow keeps the sequential behaviour it has always had under the factory's as-is fallback, so nothing existing weakens. Re-measured after the fix: the deadlocked dogfood file passes in 19s (13/13), the credential-at-rest suite (which drives a real SCIM 2.0 request through the scoped transaction over better-sqlite3) stays green, and the full plugin-auth suite is 81 files / 1660 tests green. Co-authored-by: Claude <noreply@anthropic.com>
Remove the rc.1-era @better-auth/scim connection-row object and every in-repo reader, plus its registry line in packages/spec/src/system/constants/platform-object-names.ts (one PR, both halves — the registry conformance test asserts the set bidirectionally). Stable @better-auth/scim 1.7.x derives no scimProvider model (#3653 landed in PR #12726), so the object backed nothing. No data migration by ruling. The ADR-0066 D3 capability-gate pin moves from the retired object to the surviving sibling SysSsoProvider so the gate cannot be dropped silently. Translation bundles regenerated (pnpm i18n:extract). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4
Fixes #3653
Leg 2′ of epic #11632 — the
@better-auth/scimstable-1.7.x migration, landed atomically as the parity gate's exact-set assertion and the registry's bidirectional conformance test force (neither half can land alone; maintainer rulings 2026-08-24 「os#3653 立项迁移」 and 2026-08-25 「#11693 + #11757 SCIM 目前没有真实客户在用,不需要考虑旧数据,只要现有系统能平滑升级到新版本就可以。」 govern; quoted verbatim, untranslated, per repo rule).Branch note (forked window): this branch continues the measurement base
c87d731f6(pin move alone, deliberately not a landing) and mergesorigin/main(15bf9e8) in — merge commit, no rebase of the shared base.What lands
@better-auth/scimresolves1.7.1exactly (workspace override target + plugin-auth declaration both exact).^1.7.1resolves to 1.7.2, whosebetter-auth/@better-auth/corepeers at^1.7.2would be silenced (not satisfied) by the family overrides — epic-owner pin ruling, adopted at the ACCEPT (comment 5437430514). Floating to 1.7.2 is its own follow-up card. Stale rc-hold comment blocks inpnpm-workspace.yamlrewritten to the landed state.packages/platform-objects/src/identity/:sys_scim_connection_binding,sys_scim_group,sys_scim_group_member,sys_scim_identity_tombstone,sys_scim_projection_grant,sys_scim_subject,sys_scim_user— columns mirror the installed 1.7.1plugin.schema(dumped, not assumed), uniques mirrored as indexes,managedBy: 'better-auth', generic API read-only.sys_scim_connection_credential— the ObjectStack-owned credential store theverifyBearerTokenroute requires (stable upstream stores NO credential:storeSCIMToken/generate-token/scimProviderare 0 occurrences; no stable model declares a token/secret column). Deliberately not asys_scim_providerresurrection: one row per bearer credential (staged rotation),managedBy: 'engine-owned', capability-gated, digest-only at rest.packages/spec/src/system/constants/platform-object-names.ts): eight additive names in theplatform-objectsgroup — the measured-unsplittable spec edit.sys_scim_providerstays (retirement is Retiresys_scim_provider— consumer sweep first, no data migration (leg 1a of #11632, ruled on #11693) #11757's,Blocked-by:this card).system-names.tsuntouched, as measured.AUTH_MODEL_TO_PROTOCOLentries; parity gate green withKNOWN_UNMAPPED_MODELSshrunk to the empty set (exact-set assertion kept, both directions — the ruled direction; nothing widened).auth-manager.ts: the stalescim({ storeSCIMToken: 'hashed' })(a TS2353 compile error on stable, measured) replaced with the stable shape —connections: []+ application-ownedauthentication.verifyBearerTokenresolving the connection from a credential row at request time. Connections stay runtime data;managedConnectionsNOT adopted (ruling). Auth secret now resolved once (memoized) so better-auth and the credential digests share key material —generateSecret()'s dev fallback is per-call.scim-connection-service.ts(new): mint + digest + verify. At rest: HMAC-SHA-256 keyed by the deployment auth secret over a domain-separated bearer, base64url — parity or better vs rc.1's unsalted SHA-256 (keyed ⇒ offline-table-resistant), deterministic on purpose (the digest is the unique lookup key). Keying consequence stated in the file header: secret rotation invalidates stored SCIM credentials ⇒ IdP token reissue, which is already the epic's migration-day posture on every path.objectql-adapter.ts+scimRequestScopeinscim-connection-service.ts): stable scim refuses to mount on the factory's sequential-fallback transaction (assertNativeSCIMTransactionsdemands a function — found by running the real manager, not by reading release notes). Implemented overengine.transaction()(ambient handle, ADR-0034), opened ONLY inside an authenticated SCIM request — the verifier marks its request's async chain viaAsyncLocalStorage.enterWith. The scoping is load-bearing and was measured twice, not hedged: better-auth wraps its OWN whole request flows inadapter.transaction(runWithTransaction), so (a) a fail-closedrequire: truevariant 500'd every sign-up on the memory engine (275 plugin-auth tests red), and (b) an unconditional real-transaction variant starved the single-connection sqlite pools — the dogfood showcase boot deadlocked onAcquire connection errorto the 180s hook timeout, in CI (Dogfood Regression Gate 3/3 onda00d48) and reproduced locally; with the scoping the same dogfood file passes in 19s (13/13). Core flows keep the sequential behaviour they have always had under the factory's as-is default; SCIM provisioning gets the genuine atomicity upstream demands (the credential-at-rest suite drives a real SCIM 2.0 request through the scoped transaction over better-sqlite3).credential-at-rest-posture.test.tsrestructured honestly, never relaxed: the upstream-cleartext control arm is no longer measurable (upstream stores nothing), so its successor pins that vacating fact from the installed artifact (no credential-shaped column on any stable model +generate-token404 through the real manager) plus the keyed digest relationship recomputed independently withnode:crypto, three wrong-shape negatives (unkeyed SHA-256 = the rc.1 shape, wrong key, missing domain separation), and live rejection paths: forged bearer → 401 with the SCIM error envelope (urn:ietf:params:scim:api:messages:2.0:Error,status: '401'), revoked → 401, expired → 401.managed-extension-fields.test.ts): scim constructed with stable-legal options; sevenUNMAPPED_MANAGED_OBJECTSentries (same bridge reason assys_sso_provider);sys_scim_providerentry re-grounded (no model derives on stable).allowedVersionskey@better-auth/scim>better-callremoved in its two declared places (packages/cli/src/commands/init.tsSCAFFOLD_ALLOWED_PEER_VERSIONS,packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml) — stable 1.7.1 peersbetter-call@1.4.0exactly. Both presence ratchets flipped to absence pins (packages/cli/test/init.test.ts,template-consistency.test.ts). Re-derived from the tree:better-auth>better-sqlite3and the four@better-auth/utilsentries STAY (separate, unmet retirement conditions), and the separation pin now guards that they survived the better-call retirement.pnpm i18n:extractonly (extract config + bundle-ownership list extended — the two lists that config file instructs be kept in sync).minoracross the six touched published packages, with the semver level argued in the changeset body (default-off feature, zero real customers by ruling, smooth-upgrade constraint satisfied: every table the installed library can write exists at this commit).Declared deviation from the claim surface (falsifies one PM mechanism assumption)
packages/plugins/plugin-security/src/objects/default-permission-sets.ts— 7 additive names inBETTER_AUTH_MANAGED_OBJECTS. Its drift pin (default-permission-sets.test.ts#3325) asserts bidirectionally that every platform-objects export withmanagedBy: 'better-auth'is in that list, so declaring the objects honestly forces this edit exactly the way the registry's conformance test forces the spec edit. The alternative — omittingmanagedByto stay inside the administrative boundary — would ship dishonest metadata to dodge a gate (wrong on the AI-error-proofing axis) and would drop the write-deny posture on tables the library owns. The edit is mechanical (content dictated by the pin test), additive, and verified by that package's own suite below. Also outside the letter of the surface:packages/platform-objects/scripts/i18n-extract.config.tsandsrc/apps/translations/bundle-ownership.test.ts(the enumerated-input and in-sync-list halves of the "generated translation bundles" surface item), two stale-prose touch-ups where files named the retiredstoreSCIMTokenwiring as current (sys-scim-provider.object.tsheader,sso-client-secret.tsheader), andscripts/check-prerelease-pin-watch.mjs— the self-retirement leg its own header promised, forced red on this PR's CI by the pin move itself (see Verification record).Not in this PR (deliberately)
sys_scim_providerremoval — Retiresys_scim_provider— consumer sweep first, no data migration (leg 1a of #11632, ruled on #11693) #11757 carries it,Blocked-by:this card. The object, its registry entry, itsAUTH_MODEL_TO_PROTOCOLentry and its D7/permission-set entries stay.sys_scim_connection_credential(mint/revoke UI) — a separate epic leg; mint/verify are service-level here and fully test-driven.OS_SCIM_ENABLED).Verification record
All heavy runs via
scripts/pm/os-verify-lock.sh; exit codes captured before any pipe; verdicts below quote each gate's own line. Full suites on the changed packages:@objectstack/specplatform-object-names.test.ts:Test Files 1 passed (1)/Tests 7 passed (7)(bidirectional registry conformance with the 8 new objects).@objectstack/platform-objectsbuild + full suite:Test Files 33 passed (33)/Tests 523 passed (523).@objectstack/plugin-authbuild green (check-dts-emitted 2/2); parity + D7 gates:Test Files 2 passed (2)/Tests 55 passed (55);credential-at-rest-posture.test.ts:Tests 5 passed (5); full suite:Test Files 81 passed (81)/Tests 1660 passed (1660)(a first run failed 275 tests under the fail-closed transaction variant — one of the two measurements that produced the scoped design above).@objectstack/clifull suite:Test Files 197 passed (197)/Tests 2237 passed (2237).create-objectstackfull suite:Test Files 14 passed (14)/Tests 177 passed (177)(after one honest fix: the first template-comment draft cited an issue number, whichstarter-comments-self-contained.test.tscorrectly refused).pnpm check:i18ngreen after CLI build (first run wasPREREQUISITE NOT MET— not measured — rerun afterturbo run build --filter=@objectstack/cli).token_digest: token); proofinjected-marker=1, original-line=0, blob8bca1cf3 → 601d0c56; predicted direction 2 failed | 3 passed (at-rest + authenticate red, rejections/oauth/upstream-arm green); measuredTests 2 failed | 3 passed (5)— predicted and measured agree; restore blob8bca1cf3= HEAD blob, marker absent. No dist leg needed: the mutation target resolves via relative src imports under vitest (not through packageexports).Test Files 81 passed (81)/Tests 1660 passed (1660); plugin-security full suite86 passed/1561 passed; spec full suite435 passed/11541 passed; typechecks green for all six changed packages (their tsconfigs exclude**/*.test.ts— pre-existing posture; the edited test files are exercised by the vitest runs above).node scripts/pm/dispatch-gates.mjs(no paths passed, changed set read from the merge base) at headdba7c1d: 44/44 families green — includingcheck:override-consistency,check:nul-bytes,check:i18n,check:i18n-coverage,check:type-check-debt(--re-measure OK, none above its recorded number),check-prerelease-pin-watch(retired-state self-test), the changeset trio, andcheck-osv-exemptions. Three of the first-pass reds were unbuilt-workspacePREREQUISITE NOT MET-class readings, re-run green afterturbo run build --filter='./packages/*' --filter='./packages/*/*'. Deliberately excluded as CI-context runs: the docs-audit pair (its bot comment on this PR is the evidence it ran) andrelease-rehearsal-clone(cut-rc/release workflow, not PR CI).--self-testasserted the repo still HAS a prerelease pin — the retirement leg its header promised was missing. Reproduced locally (exit 1, same line), fixed inda00d48by flipping the repo-anchored pair to the retired state (overrides must still parse non-empty so a parser break cannot masquerade as retirement), re-run green.dba7c1d), root-caused by local reproduction, not waved off as a flake.Operational notes
mintScimConnectionCredential(plaintext shown once); rc.1 tokens are not portable on any path.Generated by Claude Code