Skip to content

fix(auth): plugin-auth re-dispatch and vendor-call doors stop renewing a cookie session in-process (#22398) - #22461

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22398-plugin-auth-session-redispatch
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22398-plugin-auth-session-redispatch

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22398
Clause-②: yes (widening)

What this changes

Eight plugin-auth doors read the session through better-auth in-process by a route other than auth.api.getSession, so the cookie-conditional rule from #22258 (inProcessSessionReadInput, @objectstack/types) did not reach them. Each read renewed a session older than updateAge and staged the renewed cookie on a response the door threw away: the split session. The same rule now applies at every one of those reads, spelled for its call shape:

  • Handler re-dispatches (a /get-session lookup whose JSON is all the door keeps, or a bridge's forward to a better-auth route whose status and body are all it keeps): a new helper, in-process-redispatch.ts, adds disableRefresh=true to the re-dispatched URL when the caller's headers carry a session cookie. better-auth 1.7.3 reads it on both kinds of re-dispatch: /get-session declares it (getSessionQuerySchema, coerced), and getSessionFromCtx (used by sessionMiddleware and by AuthManager's own before-hooks) spreads the route's ctx.query into its read; none of the re-dispatched routes declares a query schema that would strip it.
  • In-process vendor endpoint calls (addMember, setPassword, createOAuthClient): each spreads inProcessSessionReadInput(request.headers) in place of headers: request.headers, so a cookie request hands query: { disableRefresh: true }, which getSessionFromCtx passes into the endpoint's session read. None of the three endpoints declares a query schema.

Cookie request: no in-process read renews, no cookie is set. Bearer-only request: unchanged, still renews to now + expiresIn, still no cookie. inProcessSessionReadInput's semantics are untouched (no packages/types change); carriesSessionCookie is reused for the URL helper.

Sites changed (line numbers at this head):

Site Door(s) Spelling before Now
register-sso-provider.ts:64 /admin/sso/register, /admin/sso/register-saml handle(new Request(sessionUrl, …)) (/get-session) inProcessRedispatchUrl(sessionUrl, h)
register-sso-provider.ts:210 /admin/sso/register inner /sso/register (OIDC) inProcessRedispatchUrl(innerUrl, headers)
register-sso-provider.ts:308 /admin/sso/register-saml inner /sso/register (SAML) same
register-sso-provider.ts:413 /admin/sso/request-domain-verification inner route inProcessRedispatchUrl(rw.innerUrl, headers)
register-sso-provider.ts:465 /admin/sso/verify-domain inner route same
send-verification-email.ts:66 /send-verification-email (no email in body) /get-session re-dispatch inProcessRedispatchUrl(sessionUrl, h)
send-verification-email.ts:135 /send-verification-email inner route inProcessRedispatchUrl(sendUrl, headers)
organization-add-member.ts:176 /organization/add-member authApi.addMember({ body, headers }) ...inProcessSessionReadInput(request.headers)
set-initial-password.ts:76 /set-initial-password authApi.setPassword({ body, headers }) same
auth-plugin.ts:3173 /sys-oauth-application/register authApi.createOAuthClient({ body, headers }) same

The four SSO bridges and the send-verification wrapper are the shared helpers the cloud auth proxy also mounts, so both mount points carry the rule. SetPasswordCapableApi.setPassword (exported) gains an optional query member; the AddMemberCapableApi shape (not exported from the entry) gains the same.

Also corrects PR #22367's H5 table, as the card says: the /admin/sso/register split was not gateAdmin's alone (its /get-session re-dispatch and the inner /sso/register read renewed too).

The packages/** enumeration

Census over every non-test source under packages/** (7,970 files; *.test.*, *.spec.*, __tests__/, test(s)/ and *-test-support.ts excluded; tests are not doors), by TypeScript AST at this head:

  • A — a method call on ANY receiver whose text contains api case-insensitively (authApi., api., auth.api., (authApi as any)., (await m.getApi()).) with an argument naming headers: 18 hits.
  • B — a call whose callee is named handle, handleRequest or handler on any receiver (the better-auth universal-handler re-dispatch): 57 hits, 19 of them better-auth.
  • C (control) — any call to a method named getSession, whatever the receiver and argument spelling: 40 hits.
  • Control for A's receiver filter — every call whose argument is an object literal with a headers member on a receiver NOT containing api: fetch/fetchImpl/resilientFetch (network clients), resolveAuthzContext (takes an injected getSession, rows below), zod object/strictObject schema builders, the verify harness's HTTP api( helper, and other non-auth helpers. No in-process better-auth call outside A.

Verdicts: converted (#22258) (already carries inProcessSessionReadInput, PR #22367 / PR #22396); fixed here (this PR); not renewing (with the reason); or not better-auth.

# Hit (file:line) Spelling Verdict
A1 plugin-auth/src/auth-plugin.ts:3173 authApi.createOAuthClient({ …headers }) fixed here
A2 plugin-auth/src/organization-add-member.ts:176 authApi.addMember({ …headers }) fixed here
A3 plugin-auth/src/set-initial-password.ts:76 authApi.setPassword({ …headers }) fixed here
A4–A7 plugin-auth/src/auth-plugin.ts:2465, :2528, :2595, :2913 authApi.getSession(inProcessSessionReadInput(…)) converted (#22258)
A8 plugin-auth/src/list-user-invitations-verification.ts:195 APIError.fromStatus('BAD_REQUEST', { message: '…headers…' }) not a better-auth call (receiver APIError, headers is inside a message string)
A9 cloud-connection/src/cloud-connection-plugin.ts:209 api.getSession(inProcessSessionReadInput(rawReq.headers)) converted (#22258)
A10 cloud-connection/src/marketplace-install-local-plugin.ts:2624 api.getSession(inProcessSessionReadInput(…)) converted (#22258)
A11 plugin-hono-server/src/current-user-endpoints.ts:412 same converted (#22258)
A12 plugin-webhooks/src/webhook-outbox-plugin.ts:483 same converted (#22258)
A13 rest/src/rest-server.ts:3224 same converted (#22258)
A14–A15 runtime/src/http-dispatcher.ts:1365, :1445 same converted (#22258)
A16 runtime/src/security/resolve-session-principal.ts:57 same converted (#22258)
A17 services/service-datasource/src/admin-routes.ts:212 same converted (#22258)
A18 services/service-storage/src/storage-service-plugin.ts:844 same converted (#22258)
B1 plugin-auth/src/register-sso-provider.ts:64 handle(new Request(…/get-session)) fixed here
B2–B3 plugin-auth/src/register-sso-provider.ts:216, :313 handle(innerReq) (inner /sso/register, OIDC and SAML) fixed here (the innerReq URL, :210 / :308)
B4–B5 plugin-auth/src/register-sso-provider.ts:413, :465 handle(new Request(rw.innerUrl, …)) fixed here
B6 plugin-auth/src/send-verification-email.ts:66 handle(new Request(…/get-session)) fixed here
B7 plugin-auth/src/send-verification-email.ts:141 handle(innerReq) fixed here (the innerReq URL, :135)
B8–B12 plugin-auth/src/auth-plugin.ts:2567, :3059, :3085, :3102, :3226 (req) => this.authManager!.handleRequest(req) passed to a bridge not renewing: the handler a bridge re-dispatches through; every request it receives is one of B1–B7
B13–B14 plugin-auth/src/auth-plugin.ts:2861 (/admin/remove-user), :2937 (/admin/has-permission, delegated) return await this.authManager!.handleRequest(c.req.raw) not renewing behind the cookie: the vendor's Response is returned verbatim, so a renewal's Set-Cookie reaches the browser
B15 plugin-auth/src/auth-plugin.ts:3265 catch-all handleRequest(c.req.raw) not renewing behind the cookie: the browser's own request, Response returned
B16 plugin-auth/src/auth-plugin.ts:3545 OIDC discovery-document handler(req) not renewing: no session read
B17 plugin-auth/src/auth-manager.ts:5957 auth.handler(request) the universal handler itself (AuthManager.handleRequest); whoever calls it owns the Response (rows above)
B18 adapters/hono/src/index.ts:640 authService.handleRequest(c.req.raw) not renewing behind the cookie: forwards the browser's own request and returns response.headers
B19 runtime/src/domains/auth.ts:138 authService.handleRequest(context.request) not renewing behind the cookie: the dispatcher's auth domain answers with that Response
B20–B57 38 hits in cli/bin, client, core (hook dispatch, memory job), mcp (transport), objectql (hooks), plugin-hono-server (adapter.ts:688, current-user-endpoints.ts:749), plugin-security, qa/http-conformance, runtime (route/liveness/domain handlers, artifact jobs, instrumentation), service-automation, service-cluster(-redis), service-job, service-queue, service-realtime, service-settings, trigger-api job / queue / hook / pubsub / route handlers not better-auth
C cloud-connection/…/marketplace-install-local-plugin.ts:2794, plugin-sharing/src/sharing-plugin.ts:941, rest/src/rest-server.ts:3037, runtime/src/security/resolve-execution-context.ts:165, services/service-settings/src/settings-service-plugin.ts:300 api.getSession(inProcessSessionReadInput(h)) (argument not spelled headers, so A does not see them) converted (#22258)
C core/src/security/resolve-authz-context.ts:401, services/service-storage/src/storage-service-plugin.ts:1058 input.getSession(headers) / getSession(headers) wrappers: the function they call is a converted (#22258) reader (A18, and the injected readers in the row above; mcp/src/plugin.ts:172 injects none)
C drivers/driver-mongodb/src/mongodb-driver.ts (13 hits), services/service-storage/src/metadata-store.ts:667, storage-routes.ts:958, :1066, :1106, :1211 this.getSession(options) / store.getSession(uploadId) not better-auth (MongoDB client sessions, upload sessions)

Related spelling, not a call with request headers: getSessionFromCtx(ctx) at plugin-auth/src/auth-manager.ts:2053 and :7111 (before-hooks) and list-user-invitations-verification.ts:180 (an endpoint) read inside the request's own better-auth pipeline, so a renewal's Set-Cookie merges into that pipeline's Response; on a re-dispatch from B1–B7 its ctx.query carries the rule (ablation L2 below runs exactly that hook read at :7111).

Other lanes: none. Every hit outside plugin-auth is either converted (#22258) or not better-auth, so no card is owed from this PR.

Pins and ablations

src/in-process-session-renewal.pin.test.ts (the #22258 real-better-auth harness: a real AuthManager on better-auth 1.7.3 over the shared in-memory engine, the real registerAuthRoutes on Hono, a session aged to now + expiresIn − updateAge − 60 s, sys_session read off the engine) gains 9 door shapes × 2 cases. The fixture now turns on SSO with domain verification, the OIDC provider and email verification, marks the admin's address verified and seeds one org-less SSO provider owned by the member; nothing reaches the network.

Each door's answer proves its last in-process read ran:

Door Answer (both cases) By cookie Bearer only Ablation leg (old call put back) Ablation result
/admin/sso/register 403 SSO_REGISTER_FAILED (the inner ADR-0135 D6 hook resolved the actor, then refused it) 0 s, no cookie +renewed to now + expiresIn, no cookie L1 /get-session re-dispatch; L2 inner OIDC /sso/register L1: this door and register-saml red; L2: this door red
/admin/sso/register-saml 403 SAML_REGISTER_FAILED 0 s, no cookie renewed, no cookie L1; L3 inner SAML /sso/register L3: this door red
/admin/sso/request-domain-verification 403 (checkProviderAccess, after sessionMiddleware) 0 s, no cookie renewed, no cookie L4 inner route this door red
/admin/sso/verify-domain 403 0 s, no cookie renewed, no cookie L5 inner route this door red
/send-verification-email {} 400 EMAIL_ALREADY_VERIFIED (needs the session's user; the email came from the /get-session re-dispatch) 0 s, no cookie renewed, no cookie L6 /get-session re-dispatch; L7 inner route L6: this door red; L7: both send-verification doors red
/send-verification-email { email } 400 EMAIL_ALREADY_VERIFIED 0 s, no cookie renewed, no cookie L7 red
/organization/add-member 400 ORGANIZATION_NOT_FOUND 0 s, no cookie renewed, no cookie L8 headers: request.headers red
/set-initial-password 409 PASSWORD_ALREADY_SET 0 s, no cookie renewed, no cookie L9 headers: request.headers red
/sys-oauth-application/register 200 (a client minted for the session's user) 0 s, no cookie renewed, no cookie L10 headers: c.req.raw.headers red

The get-session control (renews and re-issues with Max-Age = expiresIn) and the #22258 precondition (a bare in-process read renews) are unchanged in the same file.

Ablation, run at b9b04ef93 (source-identical to the head; feea8a863 adds only the changeset) through scripts/ablation-replace.mjs in WRAP mode (literal anchor, hit count 1 → 0, blob changed, then restored with git checkout HEAD and proven blob == HEAD with git diff HEAD empty), inside a driver whose own trap restored every touched file to HEAD by absolute path and re-proved it. Predicted direction: turn red on exactly the named door's cookie case(s), bearer controls green. Observed: exactly that, every leg.

  • L1: 2 failed | 37 passed (39) — POST /admin/sso/register …: the session renewed (+86460 s) but its cookie was not re-issued, and the same for register-saml.
  • L2, L3, L4, L5, L6, L8, L9, L10: 1 failed | 38 passed (39), the named door's by-cookie case, same message (+86460 s).
  • L7: 2 failed | 37 passed (39), both send-verification cookie cases.
  • Restored blobs (all == HEAD): register-sso-provider.ts 7e90a28d0efe, send-verification-email.ts abdc89488528, organization-add-member.ts 124215441d17, set-initial-password.ts 590471136670, auth-plugin.ts 3235e929a46b.

Every mutated file is imported by the pin through relative src/ imports, so no dist/ leg applies.

Pin file runtime (shared box, read as a ratio): before, 21 tests (11 own + 10 from impersonation-bearer-rotation.test.ts, which the file already imported for createMemoryEngine), tests 4.66s, Duration 19.87s; after, 39 tests, tests 4.72s, Duration 18.66s. No new sibling test file is imported.

Verification at feea8a863

Every reading below was taken at feea8a863 (git rev-parse --short HEAD), the head this PR opens with.

  • Build. turbo run build --filter='@objectstack/plugin-auth^...' --concurrency=2: 27/27; pnpm --filter @objectstack/plugin-auth build: exit 0 (2/2 declaration files); then the full turbo run build --filter='!@objectstack/docs' --concurrency=2: 72/72 (71 from the shared cache), for the gates that read every package's dist/.
  • Typecheck. pnpm --filter @objectstack/plugin-auth typecheck: exit 0 (tsc --noEmit, the examples config, and check:test-typecheck: "10 file(s) / 94 error(s) / 23 pinned signature(s) held", unchanged). tsc --listFilesOnly lists the pin file and register-sso-provider.test.ts under tsconfig.test.json, and in-process-redispatch.ts under tsconfig.json.
  • Tests. pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: Test Files 133 passed (133), Tests 2711 passed | 10 skipped (2721), 18 of them this PR's. The pin file alone: Tests 39 passed (39). Public surface: no new export from the package entry; SetPasswordCapableApi gains an optional member, so no import-side suite is owed.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 commands from this diff; each ran with its exit code recorded, and all 68 exit 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, no dist/ for 39 packages) and exits 0 after the full build; check:dts-closure (72 packages), check:sourcemap-no-sources-content (68), check:lean-entry-closure and check:published-files were re-run after it, all exit 0. --ran: "Run reconciliation — 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN."
  • Lint, narrowed and declared. The population, read from eslint's own config, is the 8 changed .ts files (the changeset answers "File ignored because no matching configuration was supplied"). eslint --no-inline-config --format json over them: 8 linted, 0 errors, 0 warnings. eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move a verdict on any untouched file. The repo-wide pnpm lint is CI's.

Acceptance notes

  • Census method: the triage's two spellings (authApi.* / api.* with headers, any receiver) are covered by one case-insensitive receiver test; a control pass over every call with a headers member on any other receiver found no in-process better-auth call. handler( re-dispatches are counted for every route, not only /get-session, because the bridges' inner forwards (card item 3) renew the same way.
  • The test register-sso-provider.test.ts pinned the SAML bridge's inner URL as …/sso/register for a request carrying a session cookie; it now expects …/sso/register?disableRefresh=true, the rule's URL.
  • organization-add-member.ts's AddMemberCapableApi and the exported SetPasswordCapableApi gain an optional query: { disableRefresh: true }; implementers that pass better-auth's own auth.api need no change (it honours the key, measured by L8/L9).
  • The branch is not merged with origin/main: the one commit since the base (3ca71b6e0, metadata-protocol) touches nothing in plugin-auth or types.
  • Observed while building the fixture, not investigated further: with email verification on and no email service wired, POST /api/v1/auth/send-verification-email for an unverified user answers 500 {"success":false}; the "no email service is configured" reason the AuthManager throws reaches the server log only (better-auth answers a thrown non-API error with an empty 500 body). A misconfiguration path; noted, not filed.
  • Out of scope, reported to the seat (not fixed here): with domain verification ON, POST /api/v1/auth/admin/sso/request-domain-verification and /admin/sso/verify-domain answer an unknown providerId with 400 DOMAIN_VERIFICATION_DISABLED ("not enabled … set OS_SSO_DOMAIN_VERIFICATION"). The vendor's answer is 404 {"message":"Provider not found"}, and the bridge treats any 404 without a code as "feature off" (feature off is a 404 with an empty body). Measured in this PR's harness before the pins were written.

Generated by Claude Code

claude added 3 commits October 9, 2026 10:04
…enewing a cookie session in-process

A /get-session re-dispatch, a bridge's re-dispatch to a better-auth route, and
an in-process vendor endpoint call each read the session through better-auth,
whose renewal stages its cookie on a response this plugin never sends. A
request carrying a session cookie now reads without renewal at each of them
(disableRefresh in the re-dispatched URL's query, or the getSession reader's
input spread into the vendor call); a bearer-only request renews as before.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…tter-auth

Nine door shapes (the SSO register and domain-verification bridges, the
send-verification-email wrapper with and without an email, add-member,
set-initial-password and the OAuth self-service register), each by cookie past
updateAge (expires_at unchanged, no cookie) and bearer-only (renews to now +
expiresIn, no cookie), on the existing real AuthManager + registerAuthRoutes
harness.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 15 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/tenancy-modes.mdx (via addMember (symbol, a method of interface AddMemberCapableApi))
  • content/docs/permissions/authentication.mdx (via addMember (symbol, a method of interface AddMemberCapableApi))
What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 60b4c8fd975c772831023ce98b941baa5e886111 — the merge of head 5816df41832b110a4cc3dc6ece0f8a4db8d6d7a8 into base 3ca71b6e05efbfc6ec5908c8c263fee6cceba389, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 60b4c8fd975c772831023ce98b941baa5e886111 && git checkout 60b4c8fd975c772831023ce98b941baa5e886111
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 5816df41832b110a4cc3dc6ece0f8a4db8d6d7a8 && git checkout -B drift-repro 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 && git merge --no-ff 5816df41832b110a4cc3dc6ece0f8a4db8d6d7a8

node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 9, 2026 10:45
…n optional query

The published SetPasswordCapableApi.setPassword options gain an optional
`query` key, an additive widening of the package's public surface, which
takes at least minor.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5816df41832b110a4cc3dc6ece0f8a4db8d6d7a8
Local-runs: none

Inputs: card #22398 (body and all five comments: triage 6073355679, unlock 6078438604, claim 6078472819, reports 6079286665 and 6079382421), PR #22461 (body, 9-file list, net diff against merge-base 3ca71b6e0, the docs-drift comment 6079251121), and the check-runs on the head. Read-only: the diff, git show at the head, the REST reads. Nothing built, run or re-run.

① Derived judgments

The published surface this diff is judged against. packages/plugins/plugin-auth/package.json exports at this head: . and ./rate-limit-storage only — no wildcard subpath. src/index.ts re-exports set-initial-password.js, register-sso-provider.js, send-verification-email.js, auth-plugin.js (among others) and does NOT re-export organization-add-member.js or in-process-redispatch.js.

Every surface change the diff makes, named right or wrong:

  1. SetPasswordCapableApi.setPassword opts (set-initial-password.ts:30): headers: Headers → headers: Headers; query?: { disableRefresh: true }. PUBLISHED (entry re-export). Additive and optional: a caller through the interface may now pass query; an implementer typed to the old opts still conforms (method-parameter bivariance). The one published widening — RIGHT, and it is the one the changeset names. Clause-② yes (widening) is therefore right; the first head's no was wrong and the seat corrected it on the PR and on the claim comment.
  2. AddMemberCapableApi.addMember opts (organization-add-member.ts:85) gain the same optional query. NOT published: the module is not re-exported by index.ts, and auth-plugin.ts reaches it only through a dynamic import() inside a handler body (:3030), so the type is reachable from no entry and addressable by no exports subpath. Leaving it out of the changeset is RIGHT (the PR body says exactly this).
  3. New module in-process-redispatch.ts (inProcessRedispatchUrl). Imported by register-sso-provider.ts:25 and send-verification-email.ts:33, re-exported by neither and not by index.ts. NOT published. The PR body's "no new export from the package entry" is RIGHT.
  4. The exported functions of register-sso-provider.ts (runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain, type AuthRequestHandler) and send-verification-email.ts (runResendVerificationEmail): signatures unchanged, behaviour only. No further widening. Count: exactly ONE published widening.

Accept-set judgments (what each door accepts and answers):

  • Inputs and answers of all eight doors are unchanged; the diff changes only whether an in-process session read renews a cookie-carrying session. RIGHT, and it is the card's "Done when".
  • Re-dispatch rule (six URLs: register-sso-provider.ts:64/210/308/413/465, send-verification-email.ts:66/135): inProcessRedispatchUrl sets disableRefresh=true only when carriesSessionCookie(headers); a bearer-only URL is returned unchanged. The headers tested are the forwarded ones (h, headers, forwardAuthHeaders(...)), which carry the caller's own cookie, so the test reads the cookie the re-dispatch will resolve. Every URL handed to it is absolute (${origin}${pathname} or url.href), so new URL(url) cannot throw at those sites. RIGHT.
  • Vendor-call rule (organization-add-member.ts:183, set-initial-password.ts:76, auth-plugin.ts:3179): ...inProcessSessionReadInput(request.headers) spreads InProcessSessionReadInput of Headers = { headers: Headers; query?: { disableRefresh: true } }, which matches setPassword's headers: Headers and addMember's headers?: Headers exactly. RIGHT.
  • Cookie-conditional semantics carried over as triage required; no packages/types file in the diff; inProcessSessionReadInput and carriesSessionCookie are read as on main. RIGHT.
  • The vendor mechanism (better-auth 1.7.3: /get-session coerces disableRefresh; getSessionFromCtx spreads ctx.query; none of the re-dispatched routes or the three endpoints declares a query schema) is not readable in this checkout (no node_modules), so it stands on the dev's reading plus the pin in-process-session-renewal.pin.test.ts (9 doors × 2 cases, both directions asserted: by cookie expires_at unchanged and no cookie; bearer-only renewed to now + expiresIn and no cookie) and the ten ablation legs on the PR, each red on exactly the named door. The pin's CI measurement is Test Core; shard 1/6 is not yet reported (below).
  • register-sso-provider.test.ts: the SAML case pinned the inner URL without the flag for a cookie-carrying request; it now expects ?disableRefresh=true. That is the rule's own URL, not a loosened expectation. RIGHT.
  • Census (triage widened it to all of packages/**): the PR body classifies A=18, B=57, C=40 with no unclassified row and no other-lane hit. Spot-checked read-only at this head: every handle( / handleRequest( / handler( call in plugin-auth non-test sources is one of the census's B1–B17 (17 of 17 match); the four authApi.* / api.* calls the census leaves out of class A (createUser twice, requestPasswordReset, signUpEmail) carry a body only and no headers, so they are not doors; no in-process better-auth call with request headers outside plugin-auth is spelled other than inProcessSessionReadInput(...). RIGHT: no other-lane card is owed from this PR.

The docs-drift rows (comment 6079251121: two pages via addMember), read at this head. content/docs/deployment/tenancy-modes.mdx:169-178 and content/docs/permissions/authentication.mdx:846-885 and :1141 state the route (POST /api/v1/auth/organization/add-member), that it wraps better-auth's server-only addMember, its body (userId; organizationId optional, falling back to the calling admin's active organization; teamId optional; the tier field; snake_case aliases) and its admit set (platform admin only; an org owner or admin is refused 403 PERMISSION_DENIED). The diff changes none of those: same URL, same body, same gate, same vendor wrapping; only the session-read input gains query. Neither page states anything about session renewal or cookies at that door, and authentication.mdx:1180's updateAge comment keeps its meaning. Not a finding: the rows are anchor hits on the interface member, not on a rule either page states. No docs edit is owed (and content/docs is ⛔ on this card anyway).

Check-runs on the head (latest per name, 33 names): 28 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 2 in progress and so not yet reported: Lint & Repo Gates, Test Core (1/6). Green: Build Core, Check Changeset (the level axis included), Type Check ×4 and TypeScript Type Check, Test Core 2/6–6/6, Dogfood Regression Gate 1/3–3/3 and its aggregate, Dogfood Verify CLI, Temporal Conformance, Governed Surface Queue Guard, Check PR Size, Flag docs affected by code changes, Check Documentation Links, the three card/branch guards, Auto Label, filter. Annotations on every completed run: the runner-image notice only (plus the link-check summary link). Their conclusions are the gate verdicts; nothing was re-run here.

② Semver level

  • .changeset/22398-plugin-auth-session-redispatch.md grades @objectstack/plugin-auth minor. AGENTS.md (Post-Task Checklist §3): a yes Clause-② takes at least minor. The published widening (①.1) is real and additive, so minor is the right level — not patch (the grade on feea8a863, which Check Changeset red-lit on the level axis) and not major (nothing removed, renamed or narrowed; no ADR-0087 marker owed). Check Changeset is green on this head.
  • Clause-②: line: PR body line 2 reads Clause-②: yes (widening); claim 6078472819 was amended in place to the same. Grammar is AGENTS.md's (yes plus one arm from the closed pair). RIGHT.
  • No model identifier in the changeset, the PR body or any added line of the diff.
  • Prose against the code at this head, sentence by sentence:
    • the title line (eight doors, re-dispatch or in-process endpoint call, no longer renew behind the cookie): true of the diff.
    • "A better-auth session read renews a session older than session.updateAge ... stages the renewed session cookie on that read's own response": the auth: server-side auth.api.getSession reads renew the session without forwarding the renewed cookie, so the browser cookie expires before the session (split session) #22258 rule as @objectstack/types states it; consistent.
    • "Eight doors read the session in-process by a route other than auth.api.getSession": eight doors (five re-dispatch, three vendor-call), ten call sites in the diff. True.
    • "each kept only the JSON, or the status and body, of the response the cookie was staged on": true of the re-dispatches (JSON at :64 / :66, status and body at the bridges); for the three vendor calls the call's return value only, which "the JSON" covers.
    • "Measured on better-auth 1.7.3 ... each moved expires_at by +86460 s on a cookie request and set no session cookie, before its own answer (a refusal included)": the dev's before-measurement (the card measured four of the eight the same way; the ten ablation legs reproduce "+86460 s" per door); the arithmetic matches updateAge 86400 + 60 s. Not re-measured here.
    • the two door bullets: the eight doors and their call shapes match the diff. The first bullet's label "through a /get-session re-dispatch and the bridge's forward" is a class label: the two domain-verification bridges have the forward only, and /send-verification-email's /get-session lookup runs only with no email in the body. Loose grouping, not false.
    • "A session cookie: the re-dispatched URL carries disableRefresh=true, and a vendor endpoint call takes inProcessSessionReadInput(headers) ..., whose query better-auth's session middleware passes into its read": true of the code; the vendor half stands on the pin (①).
    • "The session renews only through GET /api/v1/auth/get-session, which re-issues the cookie": as a bare sentence this over-reaches — a browser-facing better-auth route answered verbatim (/admin/remove-user, /admin/has-permission, the catch-all) also renews AND re-issues, which is fine. It is the auth: server-side auth.api.getSession reads renew the session without forwarding the renewed cookie, so the browser cookie expires before the session (split session) #22258 rule's own phrasing ("renews only where its cookie is re-issued — the /get-session route", types docblock) and it is true of every door this changeset names. Noted as an imprecision, not a finding; the seat may tighten it to "only where its cookie is re-issued" at its discretion. Nothing an upgrader must do turns on it.
    • "Measured after the change: each door leaves expires_at unchanged on a cookie request and sets no cookie": the pin's cookie case asserts exactly this.
    • "No session cookie: unchanged. Each door still renews the session to now + expiresIn and sets no cookie": the pin's bearer case asserts exactly this.
    • "Upgrading. Nothing to change.": true — the only published change is an optional member.
    • "The shared helpers the cloud auth proxy mounts (six names) carry the same rule": all six are real entry exports (register-sso-provider.ts:93/245/394/447, send-verification-email.ts:88, set-initial-password.ts:49) and each carries the rule in the diff; the cloud AuthProxyPlugin is the second mount point those files' own headers name. It lives outside this repo, so "mounts" is not verifiable here; nothing in this repo contradicts it. runOrganizationAddMember is rightly absent from the list (not an entry export).
    • "SetPasswordCapableApi.setPassword now also accepts an optional query: { disableRefresh: true }; better-auth's own auth.api.setPassword honours it (measured on 1.7.3)": the type is exactly that; "honours it" is ablation L9 plus the set-initial-password pin.

③ Boundary flags

File list against claim 6078472819's surface. Inside the enumeration: register-sso-provider.ts, send-verification-email.ts, organization-add-member.ts, set-initial-password.ts, auth-plugin.ts (the diff there is the createOAuthClient call and its comment at :3164-3180 only — the claim's "only" holds), in-process-redispatch.ts (the shared helper), in-process-session-renewal.pin.test.ts (the helper's pins — the existing #22258 pin file extended rather than a new one), .changeset/22398-plugin-auth-session-redispatch.md (minor, per the amended claim). Outside the enumeration: packages/plugins/plugin-auth/src/register-sso-provider.test.ts (+5/−2) — named here as the brief requires. Judged: a consequential edit the rule forces (the case pinned the pre-fix inner URL for a cookie-carrying request and goes red with the fix), inside the package's own tests, declared by the dev (report deviation 1). It breaches none of the card's ⛔ lines — no packages/types, no packages/spec, no content/docs, no other package's source. Accepted; no stop was owed.

Dev flags (reports 6079286665 and 6079382421), each answered:

  1. File surface (register-sso-provider.test.ts): answered above — accepted.
  2. The enumeration pin as a PR-body census rather than a committed test: ANSWERED, no. Triage 6073355679's own words are "List every hit on the PR", and the card's "Enumeration pin" is "a census ... every hit listed, none left unclassified" — a listing, not a committed scan. The PR-body census is the form asked; no new gate is decided here and none is owed from this card. Spot-check in ①.
  3. The new cases in the existing in-process-session-renewal.pin.test.ts: accepted — the auth: server-side auth.api.getSession reads renew the session without forwarding the renewed cookie, so the browser cookie expires before the session (split session) #22258 harness is the right fixture (same real AuthManager, same aging), and the auth: server-side auth.api.getSession reads renew the session without forwarding the renewed cookie, so the browser cookie expires before the session (split session) #22258 cases stay green in it.
  4. Branch not merged with origin/main (round 1): superseded — the head carries merge b5cd9e794 of 3ca71b6e0; merge-base is 3ca71b6e0; the net diff is the nine files.
  5. Ablation readings at b9b04ef93: verified — git diff --stat b9b04ef93 5816df418 touches no file under packages/plugins/plugin-auth or packages/types; the delta is the two changesets and 3ca71b6e0's metadata-protocol, rest, runtime and docs files. Source-identical for every ablated file, as reported.
  6. Attribution trailers: not a contract matter; no model identifier anywhere in the diff, changeset or PR body.
  7. The throwaway exploration test: not in the diff; nothing to judge.
  8. Round 2's two commits (merge + one-line changeset) and the recreated worktree: procedural; nothing on the contract.

open_questions: empty in both reports — nothing to answer.

out_of_scope_findings, each closed or escalated:

  • (a) class a, same lane: the domain-verification bridges map the vendor's 404 {"message":"Provider not found"} (no code) to 400 DOMAIN_VERIFICATION_DISABLED with "set OS_SSO_DOMAIN_VERIFICATION" copy while the feature is on (register-sso-provider.ts:417 and :480 at this head: resp.status === 404 && !parsed?.code). Not this card's defect class; rightly not fixed here. ESCALATED to the domain:services seat: file it as its own card with the dev's dedupe words. This PR's pin covers the unknown-owner path (403), not the unknown-provider one.
  • (b) carrier none: POST /send-verification-email for an unverified user with no email service answers 500 {"success":false}, the reason reaching the server log only. A diagnosability gap on a misconfiguration path, not this card's. ESCALATED as the seat's call: file or leave noted.
  • (c) census: no other-lane card owed — CONFIRMED by the spot-check in ①.

Not yet reported on the head: Lint & Repo Gates, Test Core (1/6). Their verdicts are theirs; this record judges the contract and names them so the landing condition ("every check green") is read off them, not off this verdict.

Checks read 2026-10-09T11:01Z.

Implemented-by: claude/issue-22398-plugin-auth-session-redispatch
Reviewed-by: session_01WYYhVJ78u7PhwFViWo1EmQ

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants