Skip to content

fix(plugin-auth): a minted principal session is never refreshed by a read, so it boots the console - #23828

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23813-minted-session-console-boot
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23813-minted-session-console-boot

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23813

Clause-②: no

What was wrong

better-auth refreshed a session minted by mintPrincipalSession on every read. better-auth renews a session once expiresAt - expiresIn + updateAge has passed, and a 120-second session is past that from its first read. #23772's holdPrincipalSessionExpiry kept expiresAt where it was, but the refresh still ran. On each request it:

  • rewrote the row's updatedAt,
  • re-set the session cookie with a 7-day Max-Age,
  • caused bearer() to answer set-auth-token.

The console's auth client (objectui TokenStorage.subscribeRotation in AuthProvider) treats set-auth-token as a token rotation and runs loadSession again. The new user object then re-runs refreshOrganizations, and the organization reads refresh the session again. The console never leaves "Opening your console…".

Measured cause

Both of the card's hypotheses were tested on the same stack. The only variable toggled was the refresh skip; the minted activeOrganizationId stayed the same.

Stack (the e2e below, real console at .objectui-sha daeb77fc84, Chromium 141) admin, minted member, minted admin, normal sign-in
defect restored (ablation) never booted: 553 get-session in 30 s, 1238 set-auth-token responses, 65 more get-session in the 3 s after never booted: 651 / 1433 / 64 boots
this PR boots, 1 get-session, 0 set-auth-token, 0 after boots, same boots
  • Hypothesis 1 (the read-time renewal write) is the cause. Restoring only the refresh brings the loop back, and removing it ends the loop.
  • Hypothesis 2 (the active organization) is ruled out. The same activeOrganizationId boots once the refresh is gone.

At the wire, a scratch probe on the real better-auth (AuthManager.handleRequest) before the fix showed every minted get-session on both lanes answering set-auth-token plus a session Set-Cookie, with updated_at advancing on every read and expires_at held. After the fix: no set-auth-token, no Set-Cookie, and updated_at unchanged.

The fix (packages/plugins/plugin-auth)

  • principal-session-mint.ts: adds carriesPrincipalSession(ctx). It answers true when the request carries a minted session, either as the session cookie or as Authorization: Bearer (every spelling). It reads only the token brand and verifies nothing. That is deliberate: all it decides is "do not renew", which grants nothing. The module header gains section 3b.
  • auth-manager.ts: the global hooks.before is the first hook every endpoint passes, before bearer(). When the request carries a minted session, it calls better-auth's own per-request switch setShouldSkipSessionRefresh(true). better-auth's Next.js plugin sets the same switch for RSC reads. The switch is request-scoped, so it covers get-session and every endpoint that reads the session through better-auth's middleware (the organization reads included), on the cookie lane, the bearer lane and in-process.
  • holdPrincipalSessionExpiry stays as the backstop for any renewal that does not ask the switch.

No IAuthService contract change, nothing in packages/spec. The built declaration closure of @objectstack/plugin-auth (index.d.ts, index.d.mts, rate-limit-storage.d.ts, rate-limit-storage.d.mts) is byte-identical at base d7957199 and at head (sha256 compared after a build of each). Hence Clause-②: no.

Alternatives (four axes, in short)

  • A signed dont-remember cookie. This needs a second cookie in PrincipalSession, which is a packages/spec contract change. It would also hold only on the cookie lane: a bearer request carries no cookie. Rejected.
  • Returning false from session.update.before. updateSession would then answer null, and get-session deletes the cookie and throws 401. Rejected.
  • Changing the console's rotation handling. That works around the problem in the consumer. The producer (the server renewing a credential it promised never to extend) is where the defect lives. Rejected.

Pins

  • plugin-auth/src/principal-session-mint.no-refresh.pin.test.ts: a real better-auth through the real request seam, cookie and bearer lanes × /get-session and /list-sessions (which reads the session through sessionMiddleware, as the organization reads do). Each read must answer with no set-auth-token, no session Set-Cookie, and updated_at / expires_at unchanged. A control proves the same read does refresh an unbranded short session, so the pin cannot pass vacuously. Unit cases cover carriesPrincipalSession.
  • qa/dogfood/test/principal-session-console-boot.dogfood.test.ts: the end-to-end pin. The showcase is booted in-process, with the built console composed into its plugins by the CLI's own createConsoleStaticPlugin (what os serve mounts). Each browser request goes to the real Hono app from a real Chromium. The minted cookie is set exactly as handed out. The test pins boot complete, no get-session in the settle window, and no set-auth-token, for an administrator and a member. A normal sign-in is the control. It needs the built console and a Chromium, and says SKIPPED without them. playwright-core 1.63.0 (already in the lockfile) becomes a devDependency of the private @objectstack/dogfood.
  • auth(spec,plugin-auth): a short-lived principal-session mint, an optional IAuthService member with its AuthManager implementation, the seam #22269's print service consumes (S2 of #22269) #23772's pins (principal-session-mint.test.ts, .cookie-lane.pin, .bearer-lane.pin: TTL, no extension, revoke, refusals) are unchanged and green.

Ablation

The defect was put back with scripts/ablation-replace.mjs on the auth-manager.ts line, gating the switch on an env var that is never set: injected 1, removed 0. The marker was confirmed in dist/ by ablation-dist-preflight (exit 0). Results:

Restored with git checkout HEAD --. The HEAD-blob hash matched, git diff HEAD was empty, and after a rebuild the marker was absent from dist/ (exit 0 on the dist reading).

Tests and gates (head d4839df3ad)

  • @objectstack/plugin-auth: pnpm --filter @objectstack/plugin-auth test → 150 files, 2942 passed, 10 skipped (pre-existing), exit 0. typecheck exit 0.
  • @objectstack/dogfood: test/principal-session-console-boot.dogfood.test.ts → 3/3 passed with the built console (daeb77fc84) and Chromium (/opt/pw-browsers/chromium). typecheck exit 0.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack gave 82 commands. Each was run with its exit code recorded before any pipe and stdin from /dev/null: 82 × exit 0. --ran: "82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED". One real red on the first pass was fixed: check:test-source-alias asked for @objectstack/cli/console to be aliased to source in dogfood's vitest config. Two first-pass exit-3 results were prerequisites, now met: the shallow clone was deepened (git fetch --unshallow origin main) for check-plugin-teardown-shape --self-test, and the 8 packages check:dual-build-cjs-loads needed were built.

Acceptance notes

🤖 Generated with Claude Code

https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA


Generated by Claude Code

…read

better-auth renewed a minted session on every read: the row's updatedAt
moved, the session cookie was re-set, and bearer() answered set-auth-token
on every request, which the console reads as a token rotation and
re-resolves identity on, looping its boot. A request that carries a minted
session now sets better-auth's own per-request skip-refresh switch from the
global before-hook, so no endpoint renews it on either lane.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA
Real Chromium, the built console composed into the showcase's plugins as
os serve mounts it, and a session cookie minted by mintPrincipalSession,
for an administrator and a member; a normal sign-in session is the
control. Skips, and says so, without a built console or a Chromium.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA
@github-actions github-actions Bot added size/m dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/qa/dogfood/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/qa/dogfood/vitest.config.ts) — pages documenting those are invisible to this run
  • 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 — 17 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 1ecb08a1ccb8f574ec4df8d4a65e1354ca55596c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 18671a904b7430e8a56b8a376729e28d79351de7 — the merge of head d4839df3ada8d8b73eaf404a54189047e998e8b6 into base 1ecb08a1ccb8f574ec4df8d4a65e1354ca55596c, 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 18671a904b7430e8a56b8a376729e28d79351de7 && git checkout 18671a904b7430e8a56b8a376729e28d79351de7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ecb08a1ccb8f574ec4df8d4a65e1354ca55596c d4839df3ada8d8b73eaf404a54189047e998e8b6 && git checkout -B drift-repro 1ecb08a1ccb8f574ec4df8d4a65e1354ca55596c && git merge --no-ff d4839df3ada8d8b73eaf404a54189047e998e8b6

node scripts/docs-audit/affected-docs.mjs --json 1ecb08a1ccb8f574ec4df8d4a65e1354ca55596c

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 2 · session_018VqJPGA1NDNSCZsnecsChP · 2026-10-16T07:20Z

Reviewed against GitHub at head d4839df3ad and against better-auth 1.7.3's own source, not against the report (6209767113 on #23813).

  • Form: draft, base main. The first line is Fixes #23813, then Clause-②: no; no other card's number sits beside a closing keyword. One patch changeset for @objectstack/plugin-auth. check-governed-merges.mjs --pr 23828: NOT governed, 511 changed lines.
  • File surface (8 files), against claim 6208138185:
  • The mechanism, read in better-auth's source:
    • setShouldSkipSessionRefresh is defineRequestState(() => false) (dist/api/state/should-session-refresh.mjs). Its value lives in a per-request store under an AsyncLocalStorage (@better-auth/core/dist/context/request-state.mjs).
    • toAuthEndpoints opens that scope around every endpoint call, HTTP and in-process (dist/api/to-auth-endpoints.mjs:51-52), and the before-hooks run inside it.
    • So the switch reaches only the request that carries the minted session. It can neither throw for want of a store nor leak into a concurrent user's renewal.
  • carriesPrincipalSession is safe unverified: its one effect is to skip a renewal, which grants nothing. A forged brand resolves no session, and the signature is still checked where the session is read. The hold from auth(spec,plugin-auth): a short-lived principal-session mint, an optional IAuthService member with its AuthManager implementation, the seam #22269's print service consumes (S2 of #22269) #23772 stays as the backstop.
  • Clause-②: no: plugin-auth's src/index.ts (94 lines) re-exports nothing from principal-session-mint.ts, so the new helper is package-internal. The dev measured the built declaration closure byte-identical. The mint's IAuthService contract, TTL, refusals and revoke are unchanged.
  • Evidence:
  • CI: 42 check-runs, latest per name: 37 success, 0 failure. 5 skipped, all on the roster: Console Pin Gate, Dogfood Regression Gate matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance.
  • The report's open questions:
    • Q1, where to stop the renewal: A (taken), right. It is the producer-side fix through better-auth's own switch, on every lane, with no contract change. B needs a spec change and holds on one lane; C breaks get-session; D is a consumer-side workaround.
    • Q2, CI provisioning for the end-to-end pin: A, no new CI step. A new gate defaults to no without the maintainer naming it. The wire pin runs everywhere and goes red on the same defect.
  • Carried, not a reason: plugin-auth's tsconfig excludes **/*.test.ts, so its typecheck covers no test file. It is noted in the PR's ## Acceptance notes; no card.
  • Downstream: print page ③ of #8346: a render service with one headless-Chromium driver renders a print page under the requesting user's principal and archives the PDF as a sys_file (M2) #22269 (PR feat(service-print): print a record's print page to a PDF in headless Chromium as the requesting user, and archive it on the record #23811) binds to the mint and owes its real render with a minted session (REWORK 6208156693, item 2). This seat pokes its dev when this merges.

ACCEPT. This act marks the PR ready and enables auto-merge through the fleet relay. It lands through the merge queue.


Generated by Claude Code

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

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: a session minted by mintPrincipalSession never boots the console — the console loops on boot, so #22269 cannot render under the requester

2 participants