Repository navigation
refactor(plugin-auth): move the better-auth global before-hook into auth-manager-before-hook.ts (#25224 cut 13) - #25548
Conversation
…uth-manager-before-hook.ts The body of the arrow createAuthInstance passes to createAuthMiddleware for hooks.before moves unedited into globalBeforeHook, re-indented four levels. hooks.before keeps its place in the config literal; its arrow now builds one host of the nineteen AuthManager members the body reads (eighteen methods as late-bound closures, config as a getter) and returns globalBeforeHook.call(host, ctx, setShouldSkipSessionRefresh). The async moved from the arrow to the function. The one createAuthInstance local the arrow read, setShouldSkipSessionRefresh, is the function's second parameter under the same name; the body's one static read, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD, is a value import of the class, read at call time. Nine imports leave auth-manager.ts with it. Two readers outside the move follow it: the api-error-code pin counts the new module among the auth manager's files, and the vocabulary gate's APIError argument census names it as the live member's file. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr
📓 Docs Drift CheckThis PR changes 1 package(s): 2 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 — 21 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 41c146f866c5d673df6bee2bdc9ffff4fecb7d1d && git checkout 41c146f866c5d673df6bee2bdc9ffff4fecb7d1d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4df174bcc48d694e654f90105ebd9e68d3545136 3ec293d86af8f1fc76bdc9dfa14908bc14dd6383 && git checkout -B drift-repro 4df174bcc48d694e654f90105ebd9e68d3545136 && git merge --no-ff 3ec293d86af8f1fc76bdc9dfa14908bc14dd6383
node scripts/docs-audit/affected-docs.mjs --json 4df174bcc48d694e654f90105ebd9e68d3545136
|
席位核对 · ACCEPT · 2026-10-23T23:17Z
Generated by Claude Code |
Requested by Sales · project thread
Part of #25224
Clause-②: no
Metadata-protocol: none
Ruling-ref: 6348891160, 6364006498
The basis for the
Clause-②: noline: this is a move plus two path-reader migrations. No accept set, route, refusal, code or answer changes, and the published surface is measured unchanged:@objectstack/plugin-auth'sdist/index.d.tsanddist/index.d.mtsare raw byte-identical to base (0 diff lines, so the union-order normalisation has nothing to do). The positive control is the one ruling6348891160asks for.The
.d.tsbar is ruling6348891160item 1 (on #25222, covering this card). Ruling6364006498is Sales's ruling A on this card. This is the fifth cut it applies to.Cut 13 of the
auth-manager.tssplit: the global before-hook of the better-auth instance. It is the second of the threecreateAuthInstanceslices the seat set (hooks.after, thenhooks.before, then the rest of the method). #25224 stays open for the later cuts.This follows the amended claim
6376168919, which amends6374301142on the file cut 12 left (#25536, landed note6375260860). The shape is cut 12's: #23050's, applied to a closure. The seat ruled that in scope at 19:56Z, with the bars listed below.Branch:
claude/project-thread-lg3wrj, the harness-pinned branch for this session, restarted fromorigin/main93a6ba4136after #25536 merged. It names no issue, so the card relation is this body's first line.auth-manager.tsgoes from 8,362 to 7,800 lines. The cut is +734 / −597 over 4 files: 1,331 changed lines, under the 3,000-line line. Two of the four files are path readers outside the move, socheck-governed-merges.mjs --testran on the final file list before the claim: NOT governed (0 of 4 paths hit the register), and--branch HEADagrees. One commit:3ec293d86arefactor(plugin-auth): move the better-auth global before-hook into auth-manager-before-hook.ts.The move
hooks.beforearrow body increateAuthInstance:2246–:2827The lines are base
93a6ba4136. The 584 proof lines are the body with its braces (:2245's{to:2828's}).What it is:
createAuthInstance(:1695, private, annotatedPromise<Auth<any>>) builds one config literal for better-auth. Itshooks.beforeiscreateAuthMiddleware(async (ctx: any) => {BODY}): the before-hook every better-auth endpoint's request passes through before the endpoint runs.What BODY does, in order (all of it moves, with its comments): the actor resolver handed to the attribution scope, the refresh skip for a minted principal session, the per-number OTP send guard, the password complexity and reuse checks, the change-email capture, the remove-member, organization-delete and no-record revoke refusals, the held-caller refusal on the admin plugin's role-scalar actions, the no-record admin revoke refusal, the held-caller refusal on rewriting or deleting an identity provider, the identity-provider registration gate, the cloud-as-IdP authorization gate, the break-glass last-local-credential refusal, the audience posture and uniqueness refusal on password sign-up, the cloud-managed invite-member refusal, the account lockout gate, and the first-user
disableSignUpflip.Where it goes: a new module,
packages/plugins/plugin-auth/src/auth-manager-before-hook.ts(696 lines). BODY becomes the body ofexport async function globalBeforeHook(this: BeforeHookHost, ctx: any, setShouldSkipSessionRefresh: typeof import('better-auth/api').setShouldSkipSessionRefresh), re-indented eight spaces (four levels) and otherwise unedited. The module header gives the reasoning, in the form the twelve earlierauth-manager-*.tsmodules use.What stays:
createAuthInstancekeeps its place, docblock and signature, andhooks.beforekeeps its place in the literal. Its arrow is now:Nothing else in
createAuthInstancechanges.The seat's bars
Body.
globalBeforeHook: 584 lines, 1,326 tokens. Every line is the base line minus exactly eight spaces. The first parameter (ctx: any) is the same text; the second is the captured local, below. Neither side has a return annotation.asyncmoves from the arrow to the function. The head arrow is notasync, and it returnsglobalBeforeHook.call(host, ctx, setShouldSkipSessionRefresh), the function's own promise, unchanged. better-auth'screateAuthMiddlewareis better-call 1.4.4'screateMiddleware, whose handler wrapper runsconst response = await _handler(internalContext);inside itstry(dist/middleware.mjs:15). So it awaits the same promise BODY settles, with the same value or rejection, and itscatchsees the same error. The reader closure, the host literal and the.callcannot throw before that promise exists.arguments,new.targetorsuper(0 of each), where an arrow and a function differ. There is no barethis: BODY's onlythisuses are 28 member reads,this.X.One captured local, now a parameter. Unlike cut 12's body, this one read a
createAuthInstancelocal:setShouldSkipSessionRefresh, once (:2273,if (carriesPrincipalSession(ctx)) await setShouldSkipSessionRefresh(true);).const { createAuthMiddleware, setShouldSkipSessionRefresh } = await import('better-auth/api');at:1697, before it builds the literal, and never reassigns it.typeof import('better-auth/api').setShouldSkipSessionRefresh. The head arrow passes the same binding on every call. The base arrow read thatconstwhen the hook ran, and the head arrow reads it at the same moment and hands it on, so BODY calls the same function object.betterAuth,createAuthMiddleware,plugins,passwordHasher) is read in BODY. The binding proof below finds no other identifier in BODY bound in the method.Receiver.
thiswas theAuthManagerinstance, the arrow's lexical receiver. It is now the host.BeforeHookHostnames exactly the 19 members BODY reads, no more, each typed as the class member (AuthManager['x']).(...a) => this.x(...a). The method runs on the manager and is looked up when BODY calls it, so a spy or double set on the manager is still the one BODY reaches:assertAccountNotLocked,assertActorNotHeld,assertAdminRevokeUserSessionIdentifiesRecord,assertOrganizationDeletable,assertPasswordComplexity,assertPasswordNotReused,assertPhoneOtpSendAllowed,assertRemoveMemberPermitted,assertRevokeSessionIdentifiesRecord,existingAddressSignUpAnswersSuccess,hasExistingUserFor,isAuthGateActive,isBootstrapCreation,isOperatorProvisioning,isPlatformAdminUserId,resolveActor,resolvePasswordChangeUserIdandvalidateAudienceAdmission.config. The class reassigns it after construction (setRuntimeBaseUrlat:4549,applyConfigPatchat:4630), so it goes as a getter over a reader closure (const readConfig = () => this.config;andget config() { return readConfig(); }), the shape cut 6's stubs use. BODY reads the manager's current object each time. It isreadonlyon the interface, and BODY never writes it.resolveKernelService): BODY callsthis.config.oidcAuthorizeGate({…})(:2546, after the presence check at:2536), an optional function on the config object (AuthManagerOptions,:789). Its receiver isthis.config, which the getter returns as the manager's current object, so the gate is looked up and called on the same object, at the same moment, with the samethis, as before.hasExistingUserForandisBootstrapCreationread private statics. They stay in the class, per ruling A, and BODY reaches them as members.One static read, a value import. BODY reads
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELDonce (base:2820, module:688), a publicstatic readonlystring (:4844). The module imports the class by value from./auth-manager.js, as cut 8'sauth-manager-membership-admission.tsdoes. The one read is inside the function, at call time, so the cycle is never read during evaluation (below).Host lifetime. The host is built inside the arrow, once per run of the hook: each time better-auth runs
hooks.beforeon a request. This is what every method stub in this series does. Nothing BODY reads is captured early: the 18 closures look each method up on the manager at call time, and the getter readsconfigwhen BODY reads it.Readers. The decision letters are read on the merge base and on the head below: 10 drop, as expected. No anchor entry is owed. Tenant census and symbol-anchor output are byte-identical at both ends, and the full plugin-auth suite passes at both ends. The two path readers that read the moved lines are migrated in this commit.
Imports and evaluation order
Nine names leave
auth-manager.ts, because BODY was their last reader there:setAuthActorResolver(its import line goes);SIGN_IN_EMAIL_PATH(cut 12 left BODY its last reader here; the block keepstype AuthEventAuditSurface);VENDOR_ROLE_SCALAR_ADMIN_PATHS,carriesPrincipalSessionandORGANIZATION_DELETE_PATH(each block keeps its other names);last-local-credential.jsblock:isLastLocalCredentialHolder,LAST_LOCAL_CREDENTIAL_CODEandLAST_LOCAL_CREDENTIAL_MESSAGE.isCloudManaged,CLOUD_MANAGED_REFUSAL_CODEandCLOUD_MANAGED_REFUSAL_MESSAGEShave other readers inauth-manager.ts, so both files import them. One import line is added after cut 12's, with a comment in the same form:import { globalBeforeHook, type BeforeHookHost } from './auth-manager-before-hook.js'.esbuild drops a type-only import, so a module whose last value import leaves
auth-manager.tscan now be evaluated later. The order was measured rather than argued:dist/index.mjsloaded, at base and at head. 171 modules evaluate at both, in the same order, except one:@better-auth/core/dist/utils/ip.mjsnow runs just after@objectstack/platform-objects'identityentry instead of just before it. Its own dependencies (env/env-impl.mjs,zod) run much earlier at both, unchanged. Its top level is twozodschema objects (z.ipv4(),z.ipv6()), constants and function declarations, and it registers nothing.Module._loadlog fordist/index.jsgives 127 first loads at both, the same apart from that one module, which now loads last.init_*calls finds two that run later:auth-session-audit.ts, whose one value import inauth-manager.tswasSIGN_IN_EMAIL_PATH, andlast-local-credential.ts. Their top levels are string and regex constants and function declarations, with no side effect.auth-session-audit.ts's one import isgetIPfrom thatipmodule, which is why theipmodule moves with it. No importer reads either module's bindings at load time: all 24 references in the 5 importing files are inside functions, andindex.tsre-exports both.auth-actor-attribution.ts, whosesetAuthActorResolverleaves, is an__esm-wrapped module that another importer initialises earlier, at both, so it does not move. The other modules keep a value import inauth-manager.ts.auth-manager.ts, for the static, andauth-manager.ts's import of it form a cycle with nothing read at load time.The decision-letter layer: −10, as expected
check-adr-anchors, measured on the merge base and on the head. The gate exits 0 at both.93a6ba41363ec293d86a:2258), ADR-0069 D1 (:2292,:2308), ADR-0069 D3 (:2406,:2445,:2492), ADR-0135 D6 and ADR-0068 D4 (:2463), ADR-0068 D4 (:2481) and ADR-0069 D2 (:2787). They leaveauth-manager.ts, which is anchored, for a module that is not. Their citations still resolve, so the citation count does not move.auth-manager.tsentry has two paragraphs: ADR-0108 (better-auth's organization roles map) and ADR-0093 D2 (settleMembershipand its two seams,user.create.afterandsession.create.before, which are database hooks, nothooks.before). Neither describes this hook, and no other entry names it. So, per the seat's bar, no entry is added for the new module.Path readers
Two readers outside the move read the moved lines by path, and both follow BODY in this commit:
api-error-code.pin.test.ts:authManagerFilesgains'auth-manager-before-hook.ts'. BODY carries 5 of the auth manager'snew APIError(sites (:2505,:2552,:2651,:2691,:2781). Without the entry the floor would still pass, silently, at 25 against≥ 24. With it the count stays 30, as at base.scripts/check-dispatcher-error-vocabulary.mjs:APIERROR_ARGUMENT_POSITION_CENSUS.liveMemberFilenow namespackages/plugins/plugin-auth/src/auth-manager-before-hook.ts, whereBASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL(APIError.from, base:2752) now lives, with a two-line comment that the counts stay the reading atmeasuredOn. The three comments that name the file the idiom lives in say the new module too. No count, pattern or verdict changes. The gate exits 0 at both: 56 sites found and 56 classified, and only its scanned-file count moves (3,424 → 3,425).--self-testpasses (11 shapes, 396 assertions).Read, and not edited:
adapter.findOneis in a comment,:2802), nothis.X.delete()and no elevation read.check-tenant-audit-censusexits 0 at both with no--write, and its output is byte-identical. The--jsondocument has 238 sites at both and 0 per-site differences with line numbers ignored. 2 sites are inauth-manager.tsat both (each 562 lines earlier at head, the cut's net), and 0 are in the new module. OnlyscannedSources(747 → 748) andmeasuredAtdiffer, so neither census document is edited.scripts/measure-durability-swallow-family.mjs(a measurement, not a gate): 77 members and 84 quiet rows at both. The members are the same with line numbers ignored (one member's write line, inobserveFirstMemberships, which stays, moves 5,739 → 5,177), and the quiet rows are identical. Only the scanned file count moves (3,474 → 3,475, the new module).tryStatements(3,640),droppedNotAwaited(235) and every other statistic are the same: BODY'stryblocks move whole.check-auth-mount-ledger.mjsreads only thebasePathdeclaration inauth-manager.ts, which stays.auth-manager-base-path.test.tsreadsauth-manager.tsfor code lines carrying'/api/v1/auth'; BODY has none.better-auth-schema-parity.test.tsandmanaged-extension-fields.test.tsmatchconst { X } = await import('better-auth/plugins…' | '@better-auth/…'). BODY's dynamic imports arebetter-auth/apiand@better-auth/core/error, and@better-auth/coreis excluded, so none is a factory. Both suites pass at head.rate-limit-storage-isolation.test.tswalks the import graph fromrate-limit-storage.ts, which reaches neither file. It passes at head.auth-manager.ts#symbolanchor in the tracked tree outside changelogs was mapped withsymbolSegmentResolutionfromscripts/symbol-anchors.mjs. There are 29, and all 29 aredeclaration-class at base, at base with BODY blanked, and at head. Two of their symbols occur as a token on a line of BODY:AuthManager(ADR-0135:104; BODY:2820, the static read) andisBootstrapCreation(the identity-auth checklist area:1810; BODY:2823, a member call). Each resolves to its declaration, which stays, at all three readings.Proof
Builds and suites ran through
os-verify-lock, and every exit code was captured before any pipe. Base93a6ba4136. A fullturbo run buildran first. Thenplugin-authwas built and its suite run at base, and built and run again at head, on the same dependency dists.1. The moved text.
Token identity: the base arrow body and the module function body are the same TypeScript token sequence (scanner tokens, kind and text, trivia skipped, each template literal re-scanned at its substitutions as the parser does): 584 lines and 1,326 tokens, each line the base line minus eight spaces.
The head arrow's shape, checked by the same run: not
async, the same first parameter, the reader closure forconfig, one host literal whose keys are exactlyBeforeHookHost's, whose 18 method values are each(...a) => this.<key>(...a)and whoseconfigis the getter over the reader, thenreturn globalBeforeHook.call(host, ctx, setShouldSkipSessionRefresh), and nothing else.Binding identity: a second proof walks all 356 identifiers of BODY at base and in the module, in order, and resolves each with the TypeScript checker (the module's
./auth-manager.jsresolved to the base file). All 356 resolve the same way at both ends:ctx, and the captured local, which the proof is told about;AuthManagerat base,BeforeHookHostat head) and the one static;oidcAuthorizeGateonAuthManagerOptions, twice;None resolves to any other
createAuthInstancelocal or to an unexported top-level declaration of either file.Negative controls: each proof reads the module text, and each control changes it in memory only; the file is untouched.
setShouldSkipSessionRefresh(true)changed to(false)fails the token and line checks, and the run exits 1.last-local-credentialimport pointed at another specifier gives 3 differences, and the run exits 1. Run without being told about the captured local, it reports that local, and exits 1.An independent reading:
git diff --color-moved=plain --color-moved-ws=allow-indentation-change 93a6ba4136 3ec293d86aover the two source files marks 588 of the 593 deleted lines and 596 of the 727 added lines as moved.SIGN_IN_EMAIL_PATH,,VENDOR_ROLE_SCALAR_ADMIN_PATHS,,carriesPrincipalSession,andORGANIZATION_DELETE_PATH,, each now on its own line in the module) and thebefore: createAuthMiddleware(async (ctx: any) => {line (now withoutasync).return, and the new import comment and line.2.
.d.tsand exports (ruling6348891160items 1 and 2).dist/index.d.tsraw1b3a48357d137155de33104277667a922aab62df73b418b03894c56faaacd0badist/index.d.tsnormalisedcee7303ccdfe72794efd588c85b628419d4253d730b5a0c5a3f1cdb8dddb4443dist/index.d.mtsrawb5e8dc982b634f0a5108b1b533626a68767f2da5a56f72dba801059b149e2872dist/index.d.mtsnormalised562d0a4ce656f97aeb937541482cdf531b61d7c75a005918784bf0a52d032dfcnode -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum.dist/index.d.tswith one private member made public (private createAuthInstance;→createAuthInstance;, line 1712, a one-line change) hashes raw35b18682171cd394898c9e56bc8d5272ccf20aeff0d1966fc568cb5e566a08dfand normalisede69c1b0157b45e48bbc16bd64eb939abf9be33ccb31553485df93ef05aac6fc3. Both differ from the readings above.dist/index.mjsand CJSdist/index.jseach have 159 names at base and at head, the same list (the sha256 of the sorted names is3abb126f5babb701…on all four).globalBeforeHookandBeforeHookHostare not among them, anddist/index.d.tsnames neither.dist/at both, plus the two.build-input-hashstamps the build writes from its inputs. Onlyindex.js,index.mjsand their two.mapfiles differ, as a move of code inside the bundle must.tsupbundles the new module into the root entry (splitting: false).3. Tests and lint.
pnpm exec vitest run --maxWorkers=3inpackages/plugins/plugin-auth: baseTest Files 191 passed (191),Tests 3607 passed | 10 skipped (3617), exit 0. Head: the same counts, exit 0. The pin test'sauthManagerFilessum is 30 at both.pnpm --filter @objectstack/plugin-auth typecheck(bothtscprojects andcheck:test-typecheck): exit 0.pnpm --filter @objectstack/runtime typecheck: exit 0.eslint --no-inline-config(the rootlintscript's binary and flags) over the 4 files: exit 0.4. The censuses, the measurement and the anchors, at base and at head.
The tenant-audit census, as above: byte-identical gate output, 238 sites at both, 0 per-site differences line-blind, 0 unresolved and 0 unledgered at both.
The durability measurement, as above.
These checkers were run by hand at base and at head, because
dispatch-gatesdoes not derive the ADR one. All six exit 0 at both. Five print byte-identical output at both (cmpclean):check-adr-symbol-anchors: 2,384 anchors across 142 records, 466 symbol (440 declaration, 26 literal);check-scripts-symbol-anchors: 4,070 anchors;check-spec-docblock-symbol-anchors: 5,076 anchors;check-system-context-census: 149 elevation read sites;check-platform-checklist: 16 areas, 329 items.The sixth,
check-adr-anchors, differs only in the file count and the decision letters, as in the table above.5. Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 80 commands for the 4 paths. All 80 ran at head after the full build, and all 80 exited 0.--ranover the record with each exit code: 80 derived, 80 run, 0 NOT-MEASURED, a derived zero.--tier: no path-derived mandate.check-governed-merges.mjs:--teston the final file list before the claim and--branch HEADafter the commit both read NOT governed (0 of 4 paths hit the register), 1,331 changed lines.node scripts/docs-audit/affected-docs.mjs --json 93a6ba4136, exit 0): 2 pages, both name matches only.content/docs/kernel/contracts/auth-service.mdx:39is the auth service contract'sisAuthGateActive?(), which the class implements unchanged;BeforeHookHostonly names the member.content/docs/permissions/system-context.mdx:172citesplugin-approvals'approval-service.ts#resolveActor, another symbol. 0 release-owned pages, so no doc edit is owed.auth-manager.ts: a comment inside thehooks.afterbody, which cut 12 already moved out, and one property inexternalDirectoryDoorDeps, which stays. Neither is in BODY or in an import line this cut edits, and it touches no reader named above. The Version Packages PR chore: version packages #24876 touchesplugin-auth'spackage.json, itsCHANGELOG.mdand changesets only.origin/mainhas moved 4 merges past this base, to4df174bcc4(fix(create-objectstack, cli): scaffolded projects pin every @objectstack/* dependency to the exact scaffolder version #25539, refactor(metadata-protocol): move the query validation out of protocol.ts into protocol-query-validation.ts (#25467 cut 3) #25538, feat(plugin-security): a declared membership key is staged per request, beside the registered resolver #25528, fix(app-showcase): showcase money reads one way — roll-up scale, USD project amounts, one total-budget format #25543); none touchesplugin-auth,scripts/adr-anchors/or a reader named above.Changeset
None:
skip-changeset, per ruling6348891160item 4. The published declarations and export lists are unchanged, and the bundle's bytes move only because code moved inside it.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr