Repository navigation
refactor(plugin-auth): move the better-auth global after-hook into auth-manager-after-hook.ts (#25224 cut 12) - #25536
Conversation
…th-manager-after-hook.ts The body of the arrow createAuthInstance passes to createAuthMiddleware for hooks.after moves unedited into globalAfterHook, re-indented four levels. hooks.after keeps its place in the config literal; its arrow now builds one host of the eight AuthManager methods the body reads, as late-bound closures, and returns globalAfterHook.call(host, ctx). The async moved from the arrow to the function, so the middleware awaits the same promise the body settles. The arrow read no local of createAuthInstance, and the body uses no arguments, new.target or super. Seven imports leave auth-manager.ts with it; SIGN_IN_EMAIL_PATH is imported by both files. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr
📓 Docs Drift Check19 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 7d673bb0fbe091422ccba9b780885e52e6278a10 && git checkout 7d673bb0fbe091422ccba9b780885e52e6278a10
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 74496c64b0637103f87cceec3f37932a48813e65 d79f55f402986e8577c4586466fc094071ea73b0 && git checkout -B drift-repro 74496c64b0637103f87cceec3f37932a48813e65 && git merge --no-ff d79f55f402986e8577c4586466fc094071ea73b0
node scripts/docs-audit/affected-docs.mjs --json 74496c64b0637103f87cceec3f37932a48813e65 |
席位核对 · ACCEPT · 2026-10-23T21:10Z
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. 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 fourth cut it applies to.Cut 12 of the
auth-manager.tssplit: the global after-hook of the better-auth instance. It is the first 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
6374301142, which amends6372234293on the file cut 11 left (#25524, landed note6373433471). The shape is cuts 1 to 11's, which is #23050's, applied to a closure instead of a method. The seat ruled that in scope at 19:56Z, the same call as #25223'sstart()R3, with the bars listed below.Branch:
claude/project-thread-lg3wrj, the harness-pinned branch for this session, restarted fromorigin/main1ff43b67eaafter #25524 merged. It names no issue, so the card relation is this body's first line.auth-manager.tsgoes from 8,536 to 8,362 lines. The cut is +274 / −193 over 2 files: 467 changed lines, under the 3,000-line line. It is not a governed surface (check-governed-merges.mjs --branch HEAD: NOT governed). One commit:d79f55f402refactor(plugin-auth): move the better-auth global after-hook into auth-manager-after-hook.ts.The move
hooks.afterarrow body increateAuthInstance:2834–:3016The lines are base
1ff43b67ea. The 185 proof lines are the body with its braces (:2833's{to:3017's}).What it is:
createAuthInstance(:1699, private, annotatedPromise<Auth<any>>) builds one config literal for better-auth. Itshooks.afteriscreateAuthMiddleware(async (ctx: any) => {BODY}): the after-hook every better-auth endpoint's response passes through.What BODY does, in order (all of it moves, with its comments): the bearer rotation on impersonation, the echo of the installed session token, the session envelope completion, the two-factor re-enrolment reset, the sign-in outcome count with the refusal audit, the concurrent-session cap and the login stamp, the password-change stamp and history commit, the old-address notice on an e-mail change, the directory doors narrowed for an external caller, the invite-role refusal code, and the sign-up
disableSignUprestore.Where it goes: a new module,
packages/plugins/plugin-auth/src/auth-manager-after-hook.ts(255 lines). BODY becomes the body ofexport async function globalAfterHook(this: AfterHookHost, ctx: any), re-indented eight spaces (four levels) and otherwise unedited. The module header gives the reasoning, in the form the eleven earlierauth-manager-*.tsmodules use.What stays:
createAuthInstancekeeps its place, docblock and signature, andhooks.afterkeeps its place in the literal. Its arrow is now:Nothing else in
createAuthInstancechanges.The seat's bars
Body.
globalAfterHook: 185 lines, 610 tokens. Every line is the base line minus exactly eight spaces, and the parameter list (ctx: any) is the same text. Neither side has a return annotation.asyncmoves from the arrow to the function. The head arrow is notasync, and it returnsglobalAfterHook.call(host, ctx), the function's own promise, unchanged. better-auth 1.7.12'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 host literal and the.callcannot throw before that promise exists.createAuthInstancedeclares before the literal (betterAuth,createAuthMiddleware,setShouldSkipSessionRefresh,plugins,passwordHasher) is read in BODY. The binding proof below finds no identifier in BODY bound anywhere else in the method either.arguments,new.targetorsuper(0 of each), where an arrow and a function differ. There is no barethis: BODY's onlythisuses are 9 member reads,this.X.Receiver.
thiswas theAuthManagerinstance, the arrow's lexical receiver. It is now the host.AfterHookHostnames exactly the 8 members BODY reads, no more, each typed as the class member (AuthManager['x']):auditRefusedSignIn,enforceConcurrentCap,externalDirectoryDoorDeps,recordPasswordHistory,recordSignInOutcome,sendChangeEmailNotice,stampLastLoginandstampPasswordChangedAt.(...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.resolveKernelService.Host lifetime. The host is built inside the arrow, once per run of the hook: each time better-auth runs
hooks.afteron an endpoint's response. The seat allowed once percreateAuthInstancecall; this is more often, and it is what every method stub in this series does. Nothing BODY reads is captured early, because each closure looks its method up on the manager at call time.Readers. The decision letters are read at both ends below: 4 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.
Imports and evaluation order
Seven import lines leave
auth-manager.ts, because BODY was their last reader there:narrowDirectoryForExternalCaller(the line stays asimport { type ExternalDirectoryDoorDeps } from './external-directory-door.js');rotateCallerBearerOnImpersonation(a three-line import block),echoInstalledSessionToken,resetVerifiedOnTwoFactorReenrollment,codeInviteRoleNotFound,attachSessionToCredentialResponseandreadRequestClientAddress.SIGN_IN_EMAIL_PATHhas other readers inauth-manager.ts, so both files import it. One import line is added after cut 11's, with a comment in the same form:import { globalAfterHook, type AfterHookHost } from './auth-manager-after-hook.js'. The new module imports theAuthManagertype only.esbuild drops a type-only import, so a module whose last value import leaves
auth-manager.tsis now first evaluated later, when the new module is reached. I read each top level:two-factor-rotated-token-echo,two-factor-reenrollment-verified-reset,invite-role-not-found-code,session-envelope-completionandexternal-directory-doorare now reached only through the new module. None of the five imports anything. Their top levels are literals, interfaces and function declarations (plus two arrow consts inexternal-directory-door), so there is no side effect to reorder.impersonation-bearer-rotationis still reached earlier, throughadmin-impersonate-endpoint.ts:137.request-client-addressis now first reached throughauth-manager-request-routing.ts, before the new module. Its top level is onedefineRequestState(() => undefined)from@better-auth/core/context, which creates a frozen ref object over the per-request store and registers nothing globally, so the order cannot be observed.auth-manager.tsis type-only, so there is no runtime cycle.The decision-letter layer: −4, as expected
check-adr-anchors, measured at both ends. The gate exits 0 at both.1ff43b67ead79f55f402:2882, the account lockout counter), D7 (:2904, the login audit stamp) and D1 twice (:2918and:2995, the password-changed stamps). 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). Neither describeshooks.after, and no other entry names it. So, per the seat's bar, no entry is added for the new module.Path readers
No path reader needs migrating: none reads a moved line.
this.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 174 lines earlier at head, the cut's net), and 0 are in the new module. OnlyscannedSources(743 → 744) andmeasuredAtdiffer, so neither census document is edited.check-dispatcher-error-vocabulary.mjsandapi-error-code.pin.test.ts'sauthManagerFilesfloor: BODY has nonew APIError(. The gate reads 56 sites found and 56 classified at both; only its scanned-file count moves (3,420 → 3,421).check-auth-mount-ledger.mjs: no base-path literal or declaration is in BODY.better-auth-schema-parity.test.tsandmanaged-extension-fields.test.tsmatchbetter-auth/plugins…and@better-auth/…dynamic imports. BODY has no dynamic import, and 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. None of their symbols occurs as a token on a line of BODY.Proof
Builds and suites ran through
os-verify-lock, and every exit code was captured before any pipe. Base1ff43b67ea. 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): 185 lines and 610 tokens, each line the base line minus eight spaces.
The head arrow's shape, checked by the same run: not
async, the same parameters, one host literal whose keys are exactlyAfterHookHost's and whose every value is(...a) => this.<key>(...a), thenreturn globalAfterHook.call(host, ctx), and nothing else.Binding identity: a second proof walks all 181 identifiers of BODY at base and in the module, in order, and resolves each with the TypeScript checker. All 181 resolve the same way at both ends:
ctxparameter;AuthManagerat base,AfterHookHostat head);Errorandundefined, and property names on untyped values.None resolves to a
createAuthInstancelocal or to a top-level declaration of either file.Negative controls: both proofs read the module text, and each control changes it in memory only; the file is untouched.
await rotateCallerBearerOnImpersonation(ctx);changed tovoid …fails the token and line checks, and the run exits 1.readRequestClientAddressimport pointed at another specifier gives 1 difference, and the run exits 1.An independent reading:
git diff --color-moved=plain --color-moved-ws=allow-indentation-change 1ff43b67ea d79f55f402over the source files marks 188 of the 193 deleted lines and 194 of the 274 added lines as moved.external-directory-doorimport line (now type-only), the three-linerotateCallerBearerOnImpersonationimport block (now one line in the module), and theafter: createAuthMiddleware(async (ctx: any) => {line (now withoutasync).return, and the two new import comments and lines.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).globalAfterHookandAfterHookHostare not among them, anddist/index.d.tsnames neither.dist/at both, the same list. 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.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 2 source 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.
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, and the quiet rows are identical. Only the scanned file count moves (3,470 → 3,471, the new module).tryStatements(3,639),droppedNotAwaited(239) and every other statistic are the same: BODY's 4tryblocks move whole.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 54 commands for the 2 paths. All 54 ran at head after the full build, and all 54 exited 0.--ranover the record with each exit code: 54 derived, 54 run, 0 NOT-MEASURED, a derived zero.--tier: no path-derived mandate.check-governed-merges.mjs --branch HEAD: NOT governed, 467 changed lines.node scripts/docs-audit/affected-docs.mjs --json 1ff43b67ea, exit 0): 0 pages name something this change touched (19 anchors acrossplugin-auth), and 0 release-owned pages. So no doc edit is owed.auth-manager.ts, the new module's path, or a reader named above. The Version Packages PR chore: version packages #24876 touchesplugin-auth'spackage.json, itsCHANGELOG.mdand changesets only. refactor(plugin-security): move the security service registration and the seed-ownership claim out of security-plugin.ts (#25223 cut 11) #25530 adds onescripts/adr-anchors/entry for aplugin-securityfile, which movescheck-adr-anchors' totals for whichever lands second, but not this cut's four letters.origin/mainhas moved 2 commits past this base, to74496c64b0(fix(plugin-approvals): the approval request Timeline shows each step by its label, not the node id #25526plugin-approvals, fix(service-settings): translate the AI "Reset to environment defaults" button and cover action_button labels #25527service-settings). Neither touchesplugin-authorscripts/.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