Repository navigation
fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 - #25109
objectstack-fleet[bot] wants to merge 11 commits into
Conversation
All sixteen pins move together, exact: the eleven family overrides in pnpm-workspace.yaml and the five exact ranges plugin-auth declares. The lockfile is regenerated by pnpm install. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
… publishes @better-auth/oauth-provider 1.7.12 registers POST /oauth2/client/rotate-keys, so the catch-all now publishes it. The handler refuses unless the plugin is given allowClientKeyRotation, which auth-manager does not set. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
… reservations on 1.7.12 Two vendor changes the lift brings, measured on a real ObjectQL + SQL stack: customSession lets a failed session lookup through as 500 FAILED_TO_GET_SESSION instead of a signed-out null, and the adapter keeps the forced reservation id so a replayed reservation is refused. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
… 1.7.12 Every live reading that named 1.7.3 was re-measured against the installed 1.7.12 dist or by the test that pins it, and restamped with the date and the card. Facts about the 1.7.3 release itself stay as written. The internal-field-readback census is scoped to its 1.7.3 trace and flagged instead of restamped: sso's verify-domain reads the verification identifier back off the row, which the census says no flow does. The pnpm-workspace.yaml family note states the new target and what the lift moved. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Breaking narrowing: Clause-② no (narrowing), ADR-0087 disposition not-required (no-migration-prescription), with FROM / TO for each measured vendor change and the operator notes. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 4 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 — 153 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 69b68c80e27f02f8944643d502810fb218cc7f7a && git checkout 69b68c80e27f02f8944643d502810fb218cc7f7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ae58b26f9c099ca44ac479d61df2727dea711d04 1a28ccb4384cb103a9dbe0b585a241e1e2f6929e && git checkout -B drift-repro ae58b26f9c099ca44ac479d61df2727dea711d04 && git merge --no-ff 1a28ccb4384cb103a9dbe0b585a241e1e2f6929e
node scripts/docs-audit/affected-docs.mjs --json ae58b26f9c099ca44ac479d61df2727dea711d04
|
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
… falsified The organization schema's updatedAt TSDoc keeps its 1.7.3 reading and appends the 1.7.12 re-measurement (the organization plugin schema is byte-identical; transformOutput still projects declared fields only). The os migrate account-issuer description names the restored key and no version, so no later lift can falsify it. spec and cli ride the existing changeset as patch, text only. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…rror, not a phantom bindings The never-built fixture required the bindings package relative to better-sqlite3 13, which does not depend on it; it resolved only through pnpm's hidden hoist of the copy better-sqlite3 12 brought in as better-auth 1.7.3's optional peer, and the 1.7.12 lift removed that copy. better-sqlite3 13 loads its addon with a plain require of build/Release/better_sqlite3.node, so the fixture now makes that require from the installed package against a module root that was never built, and the case asserts MODULE_NOT_FOUND. The two docblock sentences that named bindings' message now describe the 13.x error. No code moves. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Its resolveSqliteDriver docblock ships in dist/index.d.ts, so the corrected never-built sentence publishes. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Fixes #25096
Clause-②: no (narrowing)
What changed
75aaf74a1d). The 11 better-auth family overrides inpnpm-workspace.yamland the 5 exact ranges inpackages/plugins/plugin-auth/package.jsonmove from1.7.3to1.7.12, still exact. The selectors keep their bound at the 2.0.0 major boundary; the header's selector rule is untouched. The lockfile is regenerated bypnpm install(under the verify lock), never by hand.pnpm-workspace.yamlfamily note states the new target, why, and what moved with it.auth-route-ledger.ts: one new row,POST /api/v1/auth/oauth2/client/rotate-keys.@better-auth/oauth-provider1.7.12 registers it, so the catch-all publishes it. It is refused403 access_deniedbecauseauth-manager.tsdoes not setallowClientKeyRotation(measured below). This is the one test the lift turned red (auth-route-ledger.conformance.test.ts, the mounted-surface equality).session-read-failure-answer.test.ts: a session lookup the store cannot answer is a failure, never a signed-out caller.verification-reservation-single-use.test.ts: the ObjectQL adapter keeps the primary key better-auth forces on a single-use reservation, so a replay is refused.packages/plugins/plugin-auth/srcthat named1.7.3were each re-measured on 1.7.12 (table below). 19 restamped with the date and the card, 12 left as written (they are facts about the 1.7.3 release and still hold), and 1 not restamped but flagged and reported (its claim is false)..changeset/25096-better-auth-1-7-12.md:@objectstack/plugin-authminor, BREAKING, with theClause-②line, the ADR-0087 dispositionnot-required (no-migration-prescription), FROM / TO per measured change, Unchanged, and the operator notes.No behaviour of plugin-auth changes beyond the vendor's. No other dependency was moved on purpose;
packages/spec/**andpackages/objectql/**are untouched.Lockfile: what moved
Resolved package set, old vs new (
packages:keys ofpnpm-lock.yaml):1.7.3to1.7.12.better-call1.4.0 to 1.4.4,@better-auth/utils0.4.2 removed (0.5.0 was already resolved and is now the only copy; 1.7.12 peers^0.4.2 || ^0.5.0),@better-fetch/fetch1.3.1 to 1.3.2.better-sqlite3@12.11.1copy (better-auth dropped its optionalbetter-sqlite3 ^12peer) and the 20 packages only its native build used (prebuild-install,node-abi,tar-fs,bindingsand the rest);@cloudflare/workers-types(core dropped the peer);@opentelemetry/semantic-conventions(core dropped the dependency);@noble/ciphers@2.3.0andtldts/tldts-core@7.4.9(deduplicated onto the 2.4.0 and 7.4.16 copies already in the tree).vitestsnapshot lines of thepackages/apps/accountandpackages/apps/setupimporters exchanged which of the two existingvite@8.3.4peer variants (esbuild@0.28.1/0.28.2) they name. No version entered or left.Measured on 1.7.12, item by item (the card's list)
Real stack throughout:
ObjectQLover@objectstack/driver-sql(better-sqlite3:memory:), the objects from plugin-auth's manifest, a realAuthManager, requests throughAuthManager.handleRequest. Where a reading comes from a one-shot probe it is marked probe: a throwaway vitest file in the package, run once at347772618d, not committed.check:vendor-export-contractPASS, 3 edges (@better-auth/core/context,@better-auth/core/utils/ip,better-auth/adapters);check:override-consistencyPASS; plugin-authbuild(DTS) andtypecheckgreenimplicit-account-linking.tsmagic-link:TOKENwith value{"type":"magic-link",...}; an OAuth state row asauth-state:STATE. The unlink tombstone isaccount-unlinked:USER:PROVIDER; its exact read and its user-prefix read each return the tombstone alone, and the user sweep left both vendor rows in place. No collision, and plugin-auth reads neither prefix.implicit-account-linking.test.tsgreencustomSessionnow throwsGET /get-sessionanswers500 FAILED_TO_GET_SESSIONand keeps the session cookie; in processapi.getSessionrejects. Before (vendor ablation restoring the 1.7.7 catch):401 UNAUTHENTICATED"Sign in first" andnull. Right answer: yes, see belowsecondaryStorageHttpOnly; SameSite=Lax; Path=/, noSecureon plain HTTP, the same with or without a hostsecondaryStorage, so the RFC 6265bis forcing (SameSite=NoneandPartitionedimplySecure, inbetter-call1.4.4) never applies to plugin-auth's cookies. better-auth/better-auth#10591: a stale session cookie on/get-sessionis now cleared (Max-Age=0onsession_token,session_data,dont_remember) alongside the same401. better-auth/better-auth#11718: not armed heresession.create.beforestamp$activeOrgSignalmatcher to the session signals. No server path changed. The stamp's suites (first-session-membership-ordering,session-positions-security-axis,session-grants-resolved-once,principal-session-mint) green. The client that would pick it up is objectui's@object-ui/auth, which declaresbetter-auth ^1.7.2on its own lockfile; nothing here moves ithtuhtu= the public base URL is accepted (token_type: DPoP) even when the request reaches the server under another host (127.0.0.1:3000);htu= that request URL is refused400 invalid_dpop_proof"DPoP proof htu does not match the request URL". 1.7.3 compared against the request URL. Widening for host-rewriting proxies, in open questionscode_verifier:400 invalid_grant"code verification failed"; control with the right verifier:200. No pin of the old 401 exists (repo-wide grep for PKCE /code_verifierin tests: the success-path uses inauth-manager.mcp-oauth-resource.test.tsandauth-manager.oauth-consent-organization.test.ts, no status pin on a mismatch), so none was updatedgenerateId: user, session, account and verification rows carry better-auth's own 32-character base62 ids, kept as given. A reservation is stored under the forced base64url SHA-256 id, and its replay is refused. Theuuidpath the fix is about is not armed here/magic-link/verifyanswers302to?error=INVALID_TOKEN, sets no session cookie, and leaves the state row intact. Control: the real link answers302to the callback with a session cookie. The precondition set no longer yields the exposureIs the new
customSessionanswer right? Yes. A store outage is not a signed-out caller. Under 1.7.3 the browser was told "Sign in first" while its session still existed; now it is told the read failed and keeps its cookie. That follows AGENTS.md "Absence must be loud" and the degradation rule (a failure handed to the caller is not a silent degradation). The in-process callers that want "anonymous" on a failed read keep it on purpose:resolveRequestExecutionContext(core) andresolveSessionData(runtime) catch the throw themselves. One in-process path gets stricter: core's auth-gate re-read (decideAuthGate) already maps a throw toAuthzStoreUnavailableError; under 1.7.3 the swallowed failure reached it asnulland the gate read as not held. The raw/admin/*routes that callapi.getSessionanswer through their owncatch.Also measured, not on the card's list (both in the changeset): an over-long password at
/sign-in/emailnow answers400 PASSWORD_TOO_LONGbefore any lookup, and still counts as a failed attempt for the lockout (failed_login_count0 to 1, then 2 after a wrong short password); and the newrotate-keysroute answers403 access_denied"Client key rotation is disabled".Forks from the card
storeInDatabasewhenever a host suppliessecondaryStorage." Measured: it setsverification.storeInDatabase: true, notsession.storeSessionInDatabase. With a hostsecondaryStoragethe session of record lives in the cache only (0sys_sessionrows; the cache holds the token andactive-sessions-USER), and/list-sessionsanswers from that list. fix(session): fall back to database for bulk session lookups better-auth/better-auth#11718's database fallback (databaseSessionFallbackEnabled) requiresstoreSessionInDatabase === true, so it is never armed on this platform.patch; this shipsminor, BREAKING, per the claim'sClause-②: no (narrowing).Attestations: the 32 lines
R = restamped to 1.7.12 with the date and #25096 after the re-measure; K = kept, a fact about the 1.7.3 release that still holds on 1.7.12; F = flagged, not restamped.
account-identity-preflight.ts:11(providerId, accountId), noissuerin core's account tableaccount-invitation-marker.ts:14requestPasswordResetanswers{ status, message }; onlysendResetPasswordgets the linkpassword.mjsrequest leg unchangedaccount-invitation-marker.ts:68runInBackgroundOrAwaitcatches the send; answer stays{ status: true }create-context.mjsunchanged thereadmin-import-users-invite-delivery-failure.test.ts:9admin-user-endpoints.ts:69providerId+accountIdis the whole identity since 1.7.3auth-email-locale.test.ts:871/sign-in/magic-linkdoes no user lookup;/magic-link/verifycreates a user unlessdisableSignUpauth-email-locale.test.ts:1006findUserByEmaillowercasesauth-manager.test.ts:318auth-manager.ts:1802appNameread once; two otpauth readers; no cookie name derives from itauth-manager.ts:2062resetPasswordTokenExpiresInseconds, or 3600 when that is falsypassword.mjs:73auth-manager.ts:2084to-auth-endpoints.mjsbyte-identical;sendResetPassword(..., ctx.request)unchangedauth-manager.ts:4727databasebuilds a store keyed by schema KEY, read bymodelNameModel sys_user not foundauth-manager.ts:9701/sign-in/emailasksfindUserByEmail(email.toLowerCase())auth-schema-config.ts:104auth-schema-config.ts:956schemaoption; sixSCIMOptionsmembersauth-schema-config.ts:961implicit-account-linking.ts:45handleOAuthUserInfogate, thenassertValidUserInfowithlink-accountlinkOAuthAccount, same check, notedin-process-redispatch.ts:30disableRefreshis read from the query on both re-dispatchesin-process-session-renewal.pin.test.tsgreeninternal-field-readback.ts:197identifier: no flow reads it back off a row"@better-auth/sso's/sso/verify-domainreads it back (reported below)invite-role-not-found-code.ts:16ROLE_NOT_FOUND; update-member-role a coded onecrud-invites.mjsbyte-identical;crud-members.mjsstill codedlist-user-invitations-verification.ts:11listUserInvitationsnever asks the verification rulecrud-invites.mjsbyte-identicalobjectql-adapter.test.ts:95issuercolumnobjectql-adapter.ts:1051(provider_id, account_id)since 1.7.3organization-delete.ts:10POST /organization/deletedoes and leavescrud-org.mjsand the organization adapter byte-identicalsession-envelope-completion.ts:21createSessionawaits the write; line references259-332and333-370two-factor-reenrollment-verified-reset.test.ts:27, 29, 340, 345, 354, 461(6 lines)TOTP_ALREADY_ENABLEDgateplugins/two-factor/index.mjsbyte-identical 1.7.3 to 1.7.12two-factor-totp-issuer.test.ts:11appNamenode scripts/check-vendor-version-stamps.mjsis green on every plugin-auth line. It stays red on two sites outside this card's file surface, which the lift makes false and which I did not touch (claim: stop on breach):packages/spec/src/identity/organization.zod.ts:95: "Both halves measured against the installed better-auth 1.7.3, so fallback A applies".packages/spec/**is a hard stop, and the line is TSDoc on a schema property. Re-measured on 1.7.12: the organization plugin'sschema.mjsis byte-identical and the adapter factory'stransformOutputstill iterates the declared fields only, so the claim holds and a restamp is honest.packages/cli/src/commands/migrate/account-issuer.ts:71: the command description "...is ONE account under the key better-auth 1.7.3 restored". It is operator-facing text. The claim holds on 1.7.12.Both need the seat's word on the file surface before this lands; the report proposes the text.
Ablations
Both on committed code, each leg proven on disk, each restore proven by hash.
aa3112430a).scripts/ablation-replace.mjs(WRAP) replaced the adapter'sdataEngine.insert(objectName, row)with an insert that dropsid(an adapter that mints its own key): anchor 1 to 0, replacement 0 to 1, blob5ed0db896088tocbd1309c37a7. The test reaches the adapter through relativesrcimports, so no build sits between. Result:Tests 2 failed | 1 passed (3): "the engine kept the forced primary key: expected '3hj6lgd1y2K1GdHC' to be 'RhMSgVct...'" and "expected true to be false" (the replay won); the control stayed green. Restore: blob5ed0db896088equals HEAD,git diff HEADempty,git status --porcelainempty.aa3112430a). The behaviour is the vendor's, so the mutation is in the installedbetter-auth1.7.12dist/plugins/custom-session/index.mjs: the 1.7.7.catch(...)put back around the inner session read. That file is a hard link into the shared pnpm store, so the link was broken first (inode4096254to1565215, 1 link) and the store copy was never written. On disk: anchor 1 to 0, injected 0 to 1, sha256b2e60e53...to25e2b179.... Result:Tests 2 failed | 2 passed (4): over HTTP "expected 401 to be 500" with bodyUNAUTHENTICATED"Sign in first"; in process "promise resolved null instead of rejecting"; both controls green. Restore under a trap: sha256 back tob2e60e53..., and the store inode still hashesb2e60e53....Tests
75aaf74a1d):Test Files 1 failed | 176 passed (177),Tests 1 failed | 3375 passed | 10 skipped. The one red was the route ledger (rotate-keys), fixed by9ffd7eb619.session-read-failure-answer.test.tsTests 4 passed (4);verification-reservation-single-use.test.tsTests 3 passed (3).89b9affe00(this branch withorigin/main2e242bb0a9merged in; every reading below was taken after that merge):vitest run:Test Files 179 passed (179),Tests 3383 passed | 10 skipped (3393).typecheck: exit 0, withcheck:test-typecheck: OKand the debt ledger unchanged (88 errors, 23 pinned signatures).pnpm check:vendor-export-contract:VERDICT: PASS, 3 edges.pnpm check:override-consistency: all 9 published-manifest declarations resolve to their override targets.node scripts/check-vendor-version-stamps.mjs: exit 1, red only on the two out-of-surface sites above.node scripts/pm/dispatch-gates.mjs --commands, every family run and reconciled with--ran: 86 derived, 86 run, 0 NOT-MEASURED, every exit 0. On the first run, atbecfb99a89,check:dual-build-cjs-loadsexited 3 (no full build yet); after the dogfood closure build it was measured green.pnpm --filter @objectstack/dogfood test, no shard, no path, under the lock, afterpnpm --filter '@objectstack/dogfood^...' build):Test Files 286 passed | 3 skipped (289),Tests 2387 passed | 18 skipped (2405), lock verdict command-exit 0. The auth suites run in it.Acceptance notes
POST /api/v1/auth/admin/sso/request-domain-verificationanswersdnsRecordValueas_better-auth-token-PROVIDER=TOKEN.@better-auth/sso's/sso/verify-domainaccepts a TXT record equal toIDENTIFIER=VALUE, buildingIDENTIFIERfrom the verification row it read, or equal to the bareVALUE. On this storesys_verification.identifierisinternaland the read hands better-authundefinedthere, so it expectsundefined=TOKEN. Probe, vendor door, DNS answered by a double: the record as instructed answers502 DOMAIN_VERIFICATION_FAILED; the bare token answers204. Same vendor code on 1.7.3. Not changed here. The report asks the seat to file it.sendMagicLinkpassesexpiresInMinutes: 10toauth.magic_link, whose copy says "This link expires in 10 minutes";magicLink()is mounted withoutexpiresIn, so better-auth stores the row with a 300-second lifetime (probe:lifetimeSec: 300). Unchanged by the lift.secondaryStoragetogether with the embedded OAuth provider (on by default with the MCP surface) makes the auth instance throw at build ("OAuth Provider requiressession.storeSessionInDatabase: truewhen using secondaryStorage"), on 1.7.3 and 1.7.12 alike. The existingsecondaryStoragesuites setoidcProvider: false.objectql-adapter.ts, the SCIM: 停在 @better-auth/scim rc.1,等正式版再整体迁移 —— rc.2 换掉了整套模型 #3653 scoping note), so on this platform those three writes are sequential, in the vendor's order: consume, revoke, write. A refusal before the write (bad token, password length, unknown user) no longer spends the token.--force-with-leasepush pinned to that commit, with all five AGENTS.md §3 conditions holding (aclaude/issue-*branch this worktree created, one author, no PR yet, the lease on the sha I last pushed). It holds no secret.Patch round 1 (seat rulings
6315255713, claim amendment6315268175)packages/spec/src/identity/organization.zod.ts: theupdatedAtTSDoc keeps its 1.7.3 reading and appends the re-measurement: "(re-measured on 1.7.12 for deps(plugin-auth): lift the better-auth family from 1.7.3 to 1.7.12 — security fixes published after the 2026-10-01 deferral #25096: the organization plugin schema is byte-identical andtransformOutputstill projects declared fields only)". Nothing else inpackages/specmoves.pnpm --filter @objectstack/spec check:generatedafter a fresh spec build: all 14 generated artifacts up to date.packages/cli/src/commands/migrate/account-issuer.ts: thedescriptionnow reads "...is ONE account under the (provider_id, account_id) key better-auth restored...", with no version, so a later lift cannot falsify it. No test pins the sentence. cliunitlayer:Test Files 321 passed (321),Tests 4746 passed (4746); clitypecheckexit 0.@objectstack/specand@objectstack/cliride aspatch, text only and not breaking. plugin-auth stays the BREAKINGminor, and the DPoPhtuFROM / TO still names both halves (ruling Q2).node scripts/check-vendor-version-stamps.mjsexits 0 (check:vendor-version-stamps: OK — 10187 file(s) scanned).origin/mainwas merged atba244f1b48before the edits (merge28476848b5). Final head:8c4bf6489b.pnpm check:vendor-export-contractPASS (3 edges);pnpm check:override-consistency(9 declarations resolve to their targets); plugin-authTest Files 180 passed (180),Tests 3422 passed | 10 skipped (3432),typecheckexit 0;dispatch-gates.mjs --commands108 derived, 108 run, 0 NOT-MEASURED, every exit 0 (--ran); full dogfood battery (no shard, no path)Test Files 286 passed | 3 skipped (289),Tests 2387 passed | 18 skipped (2405), lock verdict command-exit 0.Generated by Claude Code