fix(plugin-auth): build one better-auth instance per boot, so the RFC 8707 resource row is seeded once - #17525
Conversation
`getOrCreateAuth()` assigned `this.auth` only after `createAuthInstance()` resolved, so every caller arriving inside that window started its own build. Overlapping boot callers therefore constructed one better-auth instance each, and each instance re-ran `@better-auth/oauth-provider`'s `init` — whose check-then-insert seed of the RFC 8707 `sys_oauth_resource` row all miss together on a fresh database, leaving the unique index to refuse all but the first. Hold the in-flight build so concurrent callers share it. The new slot is also read by `setRuntimeBaseUrl()` (a build in flight counts as created) and cleared by `applyConfigPatch()` (a build composed from the pre-patch config is discarded rather than adopted). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
…uth-resource-seed
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 13 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 c880425ccbc8d1afb51a9199b166da8e98112645 && git checkout c880425ccbc8d1afb51a9199b166da8e98112645
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f8e5790593ed6da5aecb600477a704a3c1db7951 311b4eec7354312d7e8660ad61cd172c504e54ec && git checkout -B drift-repro f8e5790593ed6da5aecb600477a704a3c1db7951 && git merge --no-ff 311b4eec7354312d7e8660ad61cd172c504e54ec
node scripts/docs-audit/affected-docs.mjs --json f8e5790593ed6da5aecb600477a704a3c1db7951
|
docs-drift-check: all three rows answered, plus the emitter-blind halfTree. Answered on Row 1 —
|
|
ACCEPT — 2026-09-10T19:22Z, head Landing pre-checks: ① no in-seat contract review owed ( The one question this seat held open, answered from the diffThe changeset is graded setRuntimeBaseUrl(url: string): void {
- if (this.auth) {
+ if (this.auth || this.authBuild) {
console.warn('[AuthManager] setRuntimeBaseUrl() called after the auth instance was already created — ignoring. …⇒ Neither change moves a succeeding path. Both take a case that previously produced a silently wrong or silently ineffective result and make it correct or explicitly reported:
⇒ What the delivery established, beyond fixing it
Flipped ready and armed. ⛔ Not approved and ⛔ not merged by this seat. Generated by Claude Code |
Fixes #17176
Clause-②: no
Both title limbs were falsified first. One of them turned out to name a real defect on OUR side.
This is the re-cut of the card, not the card as filed. Every reading below is on
origin/main, and every zero carries a control that fires.Limb 2 — "its UNIQUE refusal is now logged at ERROR" — FALSIFIED
All three write doors in
packages/objectql/src/engine.tslog atwarn, unchanged since0780e8848(#17211, closing #17052)::11069this.logger.warn('Insert operation failed', …):12624this.logger.warn('Update operation failed', …):14081this.logger.warn('Delete operation failed', …)Control (so the
warn-only reading is not a dead grep):logger.erroroccurs 5 times in the same file —:7725'Failed to connect driver',:8532'Error disconnecting driver',:9814'Find operation failed',:13202,:14827. The probe findserrorwhereerrorexists and findswarnat the three write doors because that is what is there.⇒ The "an idempotent step reporting as a failure" framing is gone. ⛔ Nothing in
packages/objectqlis touched here; #17052's ruling stands.Limb 1 — "the bootstrap row is re-inserted on every boot" — FALSIFIED of the vendor
@better-auth/oauth-provider1.7.2 does not re-insert. ItsseedResources()— called from the plugin's owninit— is already check-then-insert:findOnebyidentifier, thencreateonly on a miss, with the UNIQUE refusal caught and treated as a no-op that its docblock declares by name ("one wins, the other catches the constraint error").resourceSeedModedefaults to"insertOnly", documented as "only inserts rows whoseidentifieris not already present".Measured, not read:
seedsOncein the new test boots the REAL provider from the REAL optionsAuthManagerproduces, three times over one shared store. Insert attempts: 1, 1, 1 — the second and third inits attempt none, and zero refusals reach the unique index.Control: blinding the vendor's existence check (
findOneon the resource model forced to answernull) takes that count from 1 to 3 and reddens the pin. The green is a reading of the vendor's check, not an assertion that cannot fail.What IS ours, and it is the whole of this card
AuthManager.getOrCreateAuth()was an async lazy initializer with no single-flight guard:this.authis assigned only aftercreateAuthInstance()resolves, and that function awaits a dynamicimport('better-auth'),buildPluginList(),resolvePasswordHasher()and finally better-auth's own$context. Every caller arriving inside that window readsnulland starts its own build.Measured on the unmodified tree, with no mocks at all: three concurrent
getAuthInstance()calls returned three DISTINCT better-auth instances. Sequential calls returned one.The boot has such callers.
auth-plugin.tsdispatchesregisterOidcDiscoveryRoutes()withvoidfrom its route-mountingkernel:readyhook — that hook returns while the call is still pending (:3096, callinggetAuthInstance()at:3111) — and a laterkernel:readyhook reads the instantiated social providers off the instance for the account-issuer backfill (:1106into:374).Each duplicate instance re-runs every better-auth plugin's
init, so each one runs the resource seed. On a warm database all of them find the row and insert nothing. On a fresh one they all miss onfindOnetogether, all insert, and the unique index refuses all but the first.⇒ That is the reported line, and it is first-boot-on-a-fresh-database only — exactly the shape the
repo:hotcrmseat measured downstream (boot 1: one occurrence; boots 2–4: none, each paired with aServer is readycontrol) and exactly whatpackages/objectql/src/engine.ts:493records as the cost #17052 accepted.Measured, as a differential against
seedsOnce: same store, same options, one variable — two overlapping inits on a fresh store attempt 2 inserts and the unique index refuses 1, while the row ends up present exactly once. Nothing breaks; only the log line shows it.The change
packages/plugins/plugin-auth/src/auth-manager.tsonly, plus one new test file and apatchchangeset.getOrCreateAuth()now holds the in-flight build in a new private slot so concurrent callers share it. A rejected build is not cached — the slot clears and the next caller retries, which is what the un-serialised form did.Two consequences of the new slot, both handled in the same edit so it leaves no half-state:
setRuntimeBaseUrl()reports "already created" for a build in flight. It has read (or is about to read) the standing config and is now the instance every later caller receives, so reporting only on the materialised memo would let the call silently no-op.applyConfigPatch()discards an in-flight build alongside a materialised one. It was composed from the pre-patch config;getOrCreateAuth()checks its own identity before installing, so a disowned build resolves to its callers and installs nothing.⛔ Not done, deliberately: no log level is changed anywhere, and no upsert is introduced. The triage note asked for a genuinely idempotent step over a quieter log — the step is already check-then-insert inside the vendor, so the cure is not doing it twice. That also answers the driver question: this needs no upsert support from any driver, because there is no second insert to reconcile on any of them.
Clause-② working
Limb A — does this widen the accept set? No. Nothing that was refused becomes legal. It is a pure narrowing in every direction: fewer instances per manager, and two invalidation paths that now discard a build they previously adopted.
Limb B — does it add or change a public surface? No. The new slot is
private. No export is added, removed or renamed;getAuthInstance(),getApi(),getAuthContext(),ownsRoute(),setRuntimeBaseUrl()andapplyConfigPatch()keep their signatures; no key moves on any published payload.pnpm check:api-surfaceterritory (packages/spec) is untouched — this card never reached it.⇒
Clause-②: no, and the measurement agrees with the claim-time reading.Verification
RED before GREEN, by mutating the committed state. The mutation restored the pre-fix
if (!this.auth)shape.git hash-objectbefore36154af3191bf549e9a999a5489a58627aba67a8(equal to the HEAD blob), after0a247a91c61a524d6fa2e3e20b6608567368eeea; injected-marker occurrence count 0 → 1. Under the mutation:singleFlightfails with "expected 3 to be 1" and theapplyConfigPatchpin fails; the two vendor-characterisation checks stay green, which is the predicted direction — they do not depend on this fix. Restored by state:git diff HEADempty,git status --porcelainempty, file hash back to the HEAD blob byte-for-byte.pnpm --filter @objectstack/plugin-auth test— 107 files / 2274 tests passed; lockVERDICT command-exit 0.pnpm --filter @objectstack/plugin-auth build && … typecheck— all three legs pass (tsc --noEmit, the examples project,check:test-typecheck); lockVERDICT command-exit 0. An earlier run exited 2 on a missingdist/— a prerequisite, not a finding; building the package first cleared it.pnpm exec eslint . --no-inline-config --format json— repo-wide, 6581 files, 0 errors, 0 warnings, exit 0. No narrowing claimed: this is the whole population as eslint's own config resolves it.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, all 61 run, reconciled with--rancarrying every exit code: 59 exit 0, 2 exit 3 = PREREQUISITE NOT MET = NOT MEASURED —check:dual-build-cjs-loadsandcheck:type-check-debt, both refusing because packages across the repo have nodist/. ⛔ Neither is reported as a pass. CI builds the farm and measures them there.Acceptance notes — noted, not filed
getOrCreateAuth()is not the only async lazy initializer of this shape in the package, but no other one was measured to construct anything whoseinitperforms I/O, so none is a defect on today's evidence. Successor: whoever next touches an in-flight-memo seam in this file. Noted, not filed.void this.registerOidcDiscoveryRoutes(...)inauth-plugin.tsis what makes the overlap reachable at boot. It is a legitimate fire-and-forget with its owncatch, and with the single-flight fix in place it costs nothing; it is also inside PR feat(auth)!: adopt better-auth's account-issuer rollback — drop sys_account.issuer, retire the backfill, lift the family to 1.7.3 #17454's held file set, so it is not touched here. Noted, not filed.Fence
⛔ Nothing in PR #17454's held set is touched:
auth-schema-config.ts,auth-plugin.ts,index.ts,objectql-adapter.ts,admin-user-endpoints.ts,package.json,auth-manager.test.tsandpackages/spec/src/migrations/registry.tsare all unmodified — the new pins are in a new file of their own.packages/specis untouched entirely.Generated by Claude Code