Skip to content

fix(client): a bearer-mode ObjectStackClient adopts the session token the three rotating auth routes hand it - #17182

Merged
os-project-manager merged 9 commits into
mainfrom
claude/issue-16534-bearer-token-rotation
Sep 9, 2026
Merged

fix(client): a bearer-mode ObjectStackClient adopts the session token the three rotating auth routes hand it#17182
os-project-manager merged 9 commits into
mainfrom
claude/issue-16534-bearer-token-rotation

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #16534

Clause-②: no

A bearer-mode ObjectStackClient was signed out by the three better-auth routes that
ROTATE the caller's session. It now adopts the credential the server hands it, on those
three routes and nowhere else.

The three routes are three different jobs

route where the new credential arrives what the SDK now does
auth.twoFactor.verifyTotp() (enrolment lane) body — token, and it is the LIVE one (plugin-auth's two-factor-rotated-token-echo repairs the vendor's stale echo) stores it, the way login() already stores the token it is handed
auth.changePassword({ revokeOtherSessions: true }) body — token same
auth.twoFactor.disable() response header only — the body is { status: true } reads the set-auth-token response header on that same response

disable is the only one whose credential is not in the body at all, and the only one a
"did the easy two" delivery drops. It is implemented as a header read on the response the
route already has — res.headers.get(...) — never as a second request.

Acceptance conditions, and how each one is discharged

Copied from triage's 验收口径 on the card, as triage asked.

① The card's probe runs to completion with the manual step DELETED.
packages/client/src/auth-rotated-session-token.test.ts drives
login → enable → verifyTotp → disable → deleteUser end to end against a real
AuthManager (better-auth 1.7.2, bearer() + twoFactor) over a real ObjectQL on a
real SqliteWasmDriver, with only the socket stood in for. The card's probe carried a
line reading (the probe re-set client.token by hand here to continue); there is no such
line and no hand-repaired credential anywhere in the file — its absence IS the criterion.
Deliberately no cookie jar: the defect is bearer-only, and a jar would hide it. The probe
opens on login the way the card's own probe opens on login, not on the registration
that had to precede it.

deleteUser is booked disposition: 'disabled' in auth-route-ledger.ts, so it refuses
either way; WHICH refusal it is, is the whole finding. The test asserts the refusal is not
401 and, stated directly rather than inferred from a status code, that the stored
credential still resolves to the same principal at the end of the sequence.

② One assertion per route, all three, separately. Four cases in block ②: the two
body-echo routes, changePassword WITHOUT revokeOtherSessions (which answers
token: null and must therefore store nothing), and disable. Each asserts the stored
credential moved, that it resolves to the same principal, and that the row behind the
value it replaced is genuinely gone — "the string changed" alone would not have been
enough.

③ The negative control — a non-rotating route leaves this.token byte-identical.
Two cases: ordinary traffic (auth.me(), auth.sessions.list(), auth.updateUser()) and
verifyBackupCode's already-logged-in lane. updateUser is the decisive leg — it stages a
session cookie to carry the updated user WITHOUT rotating, so bearer() emits a
set-auth-token for it too. In the verifyBackupCode leg the client deliberately holds
the SIGNED spelling, so a store there is different bytes rather than a coincidental no-op.

The control was ablated. Predictions were written before the first run:

ablation predicted observed
A — move the set-auth-token read into the shared fetch wrapper ③ leg 1 RED at the updateUser() assertion; ① and ② stay GREEN exactly that: updateUser() moved the stored credential: expected 'sUdwucweuiNbfoEJVkBhj7AVfT4PA423.nipw…' to be 'sUdwucweuiNbfoEJVkBhj7AVfT4PA423'; 6 passed, 1 failed. ③ leg 2 stayed green — recorded as observed, it was not predicted
B — verifyBackupCode adopts its echoed token ③ leg 2 RED; ①, ② and ③ leg 1 stay GREEN exactly that: verifyBackupCode moved the stored credential; 6 passed, 1 failed

Ablation A is the implementation triage warned about — it passes ① and ② and breaks the
client elsewhere. Each mutation was proved on disk (marker occurrence count 0 to 1, blob
hash moved off the HEAD blob 1be591b657…) and each restore was proved clean (blob back
to 1be591b657…, git diff HEAD empty), under a trap … EXIT INT TERM with absolute
paths. No dist is involved: the suite imports the subject as the relative specifier
./index, so vitest reads packages/client/src/index.ts itself.

④ The three TSDoc warnings are updated in the same landing. Re-located, since the
card's citations had rotted: changePassword (now index.ts:4226), verifyTotp
(:4439), twoFactor.disable (:4464), plus the two declared token members
(AuthPasswordChangeResult, AuthTwoFactorVerificationResult). git grep for
does not store it, this SDK does not and neither of which this SDK reads over
packages/client/src/ returns zero.

Why the two config hunks are here — measured, not asserted

packages/client/tsconfig.json and vitest.config.ts are build config on a published
package, so each was ablated to show the gate that demands it:

  • Drop the two vitest.config.ts aliases and pnpm check:test-source-alias goes to
    exit 1: @objectstack/client: NEW unaliased artifact import(s) since this entry was measured: @objectstack/platform-objects, @objectstack/plugin-auth — and it prints the
    two entries this branch added, verbatim, including the warning against collapsing the
    subpath into the prefix-matching object form, which this branch heeds.
  • Drop the two tsconfig.json paths and pnpm check:type-source-resolution goes to
    exit 1: NEW dist-resolved type import(s) since this entry was measured: @objectstack/platform-objects (via tsconfig.test.json), @objectstack/plugin-auth (via tsconfig.test.json).

Both are baseline exit 0 on this branch. Neither reaches the published artifact:
packages/client publishes files: ["dist", "README.md", "CHANGELOG.md"], and the two
workspace packages went into devDependencies, not dependenciesdependencies
is still exactly @objectstack/core and @objectstack/spec. pnpm check:published-files
is green.

No public surface moves

No new export, no new public option or flag, no new key on any declared request or
response type. SET_AUTH_TOKEN_HEADER is a module-level const, not exported;
adoptRotatedSessionToken is a private method, and the emitted dist/index.d.ts
carries it only as the uncallable line private adoptRotatedSessionToken; — the
export { … } list is unchanged. node scripts/pm/check-widening-tells.mjs --declaration no reads 8 changed files and reports no widening tell.

One consequence worth naming: after twoFactor.disable() the stored credential is the
SIGNED TOKEN.SIG spelling the bearer plugin emits, where the body-echo routes store the
UNSIGNED one. better-auth accepts both — the ① probe continues through deleteUser on
the signed value and resolves to the same principal — and both TSDoc and the changeset say
so. That asymmetry is also why the read is on three routes and never in the shared fetch
wrapper: set-auth-token rides every response that stages a session cookie, so a
wrapper-level read would churn the stored credential on ordinary writes.

The card's title is wrong, and this PR does not fix it

Triage measured "silently signed out" as false on origin/main: #16537 landed three
TSDoc warnings on the same day the card was filed, so nothing was silent any more. Triage
proposed a corrected title. Per the dispatch, the card title is left untouched and the
correction is stated here instead: the accurate reading is "a bearer-mode
ObjectStackClient does not store the rotated session token from twoFactor.disable() /
enrolment-lane verifyTotp() / changePassword({revokeOtherSessions:true}), so the
caller must re-authenticate itself".

Verification

Run at 2acf1a1dce, the final commit.

  • pnpm --filter '@objectstack/client^...' build — exit 0 (dependency closure)
  • pnpm --filter @objectstack/client build — exit 0
  • pnpm --filter @objectstack/client test — exit 0, 38 files / 475 tests passed
  • pnpm --filter @objectstack/client typecheck — exit 0
  • the seven acceptance cases, verbose: Test Files 1 passed (1) · Tests 7 passed (7)
  • 69 of 69 derived gate families run, every one exit 0.
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran … reports
    69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. Exit codes captured by redirect-then-$?,
    never through a pipe.
  • pnpm lint (eslint . --no-inline-config) run over the whole repo, not narrowed —
    exit 0, 6436 files linted, 0 findings. No narrowing to justify.
  • control-character self-scan over the eight changed files
    (grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'): no matches. pnpm check:nul-bytes
    exit 0.

pnpm --filter @objectstack/spec run check:skill-examples refused on its first run —
packages/client/dist was older than src, a PREREQUISITE NOT MET, which is NOT
MEASURED and not a pass. Building packages/client and re-running gives exit 0.

验收备注


Generated by Claude Code

…th routes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…auth pipeline

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…be opens

The card's end-to-end probe is `login -> enable -> verifyTotp -> disable ->
deleteUser`. The suite opened on the registration that had to precede that
login, which is the same credential state by construction but not the same
line. Spell the first step as the card spells it: a real second sign-in whose
echoed token the SDK stores, asserted against the stored credential before
the sequence continues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added the size/l label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 5 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/client/tsconfig.json, packages/client/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.

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

  • content/docs/api/client-sdk.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/api/environment-routing.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/api/wire-format.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/kernel/runtime-services/data-service.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/kernel/runtime-services/storage-service.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via ObjectStackClient (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/releases/v17/17-2.mdx (via ObjectStackClient (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/client/tsconfig.json, packages/client/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 14 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 fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3

⚠️ 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 fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Docs Drift Check — discharged

First: which tree, and is the bot's list closed

The bot warns its checkout carried uncommitted changes, so its sha does not fully
identify what it read. Settled by comparing tree objects, not shas:

git fetch origin refs/pull/17182/merge:refs/os-dev-16534/prmerge   # a ref I own, not FETCH_HEAD
git rev-parse refs/os-dev-16534/prmerge                            -> 1eb738dc9ab32f7a640e3e67e87fc2c39e23e95b
git rev-parse refs/os-dev-16534/prmerge:content/docs               -> 150e82d6e9b93a86ddbc37147acda8c2cdfe34a6
git rev-parse HEAD:content/docs                                    -> 150e82d6e9b93a86ddbc37147acda8c2cdfe34a6
git diff refs/os-dev-16534/prmerge HEAD -- content/docs            -> empty

Identical tree object, so reading in this worktree IS reading the bot's docs corpus.
Re-deriving on it,
node scripts/docs-audit/affected-docs.mjs --json fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3
returns exactly the same 8 hand-written plus 2 release-owned rows. The uncommitted
changes did not move the docs population — measured, not assumed.

The claim classes actually at risk

Only three claims on this diff can be false: (a) a page asserting the SDK does not
store a rotated token; (b) a page telling a bearer-mode client what it must do after
twoFactor.disable() / verifyTotp() / changePassword({revokeOtherSessions:true});
(c) a page stating the credential's spelling, which matters because after disable()
the stored value is the SIGNED TOKEN.SIG form while the body-echo routes store the
UNSIGNED one.

Corpus-wide searches for (c) — session token is, token is a JWT/opaque/signed,
opaque token, token format, signed token, TOKEN.SIG, dot-separated, JWT over
content/ and skills/ — return zero claims about the ObjectStack bearer
credential's shape. Every hit is something else: Apple's client-secret JWT in sso.mdx,
the OpenAPI bearerFormat field description in generated reference tables, removed
query cursor rows, storage resumeToken, and a plugin-security schema enum. So
class (c) has no carrier anywhere in the docs, and the signed/unsigned asymmetry
under-completes nothing.

The 8 hand-written pages, one reading each

page why it is still true
api/client-sdk.mdx Documents ClientConfig.token as an input (token?: string) and lists auth.login / register / me / logout / refreshToken. It never names twoFactor.* or changePassword, never says the SDK does not store a rotated token, and states no spelling. ClientConfig is byte-unchanged by this diff.
api/environment-routing.mdx Its three ObjectStackClient mentions are constructor examples for environment scoping (environmentId, scoped URL). No credential-lifecycle claim.
api/wire-format.mdx Both mentions are about error-envelope unwrapping (body.error.code vs body.code). Its one credential line — Authorization: Bearer TOKEN on all requests — stays exactly true: the SDK still sends that header; only which value it holds changed, and the server accepts both spellings, proved by the ① probe running through deleteUser on the signed value and resolving to the same principal.
kernel/runtime-services/data-service.mdx Names packages/client/src/index.ts as canonical for ObjectStackClient['data']. This diff touches no data method. Its typed excerpt is not eyeballed — it is in the corpus check:skill-examples type-checks, which is exit 0 on this branch (258 blocks, 3 surfaces).
kernel/runtime-services/storage-service.mdx One mention: ObjectStackClient.storage is the browser/HTTP client. No storage method is touched.
plugins/packages.mdx The row most at risk, because it makes an export-list claim: "Exports: ObjectStackClient, query builders, error classes". Unfalsified, and that is the same finding as the Clause-② answer — no new export, adoptRotatedSessionToken is private, SET_AUTH_TOKEN_HEADER unexported, and the emitted dist/index.d.ts export { … } list is unchanged. Its plugin-auth feature line ("session management, bearer-token auth") also stays true.
protocol/kernel/realtime-protocol.mdx A name collision, reportable as such: its class ObjectStackClient at line 560 is a hand-written illustrative WebSocket client with a positional constructor (new ObjectStackClient('wss://…', token)), not @objectstack/client's class. The diff cannot reach it.
permissions/authentication.mdx The one page that documents the 2FA lifecycle — and it documents it through raw fetch against the endpoints, for a custom account UI, not through the SDK. It never mentions twoFactor.disable() at all (its endpoint list carries /two-factor/enable and /two-factor/verify-totp only), never says the SDK does or does not store anything, and states no spelling. A raw-fetch caller still handles its own credential, exactly as written — this change moves ObjectStackClient only, never the wire.

The input-vs-emitter blind spot

The bot names this hole and this diff is emitter-side, so I searched the prose class the
bot structurally cannot list — a page saying "you must sign in again after X" that names
none of my symbols. Over content/ and skills/, excluding release-owned:
sign in again, log in again, re-login, re-authenticate, must authenticate,
session is lost/invalid. Seven hits, all read:

  • protocol/kernel/error-handling.mdx:123,169 and api/error-catalog.mdx:260,270
    generic remedies for UNAUTHENTICATED / EXPIRED_TOKEN / SESSION_EXPIRED, i.e. for a
    session that genuinely ended. Still true. This PR does not create a case where they are
    wrong; it removes a case where a live session was misread as expired, which these
    pages never described.
  • realtime-protocol.mdx:1007 — the WebSocket 10-second authenticate window. Unrelated.
  • deployment/cli.mdx:1525,1616--force re-authentication on a CLI login. Unrelated.

No page in the corpus tells a reader to sign in again after disabling two-factor. Nothing
to correct.

The coverage hole the bot declared, read by hand

The bot could not anchor packages/client/tsconfig.json or packages/client/vitest.config.ts
and said so. Searched, and what was found:

  • any doc naming either filegrep -rn 'packages/client/(tsconfig|vitest\.config)' over
    content/, skills/, docs/, root *.md and packages/client/*.md: zero hits.
  • any doc naming the mechanismsKNOWN_UNALIASED_TEST_IMPORTS,
    check:test-source-alias, check:type-source-resolution, "resolve … from source",
    "tsconfig paths" over content/, skills/, docs/: zero files.
  • the package's own READMEpackages/client/README.md has a ## Testing section
    (lines 293-312), and it documents only the commands, pnpm test and
    pnpm test:integration. No aliases, no tsconfig paths, no dependency-resolution prose.
    Both commands still work unchanged (pnpm --filter @objectstack/client test exit 0).
  • packages/client/CLIENT_SPEC_COMPLIANCE.md (26 lines) — zero hits on any of those terms.
  • the nearest contributor-facing page, content/docs/plugins/development.mdx — it tells a
    third-party plugin author to create their own tsconfig.json and run vitest; it says
    nothing about how packages/client resolves workspace deps.

So: no page documents this build config, and the hole is empty rather than unexamined. The
rules those two hunks satisfy live in AGENTS.md and in the two gate scripts' own headers,
which are the authority on them and which this branch does not change.

The 2 release-owned pages — read-only, and neither is wrong

⛔ Not edited, per the guardrail.

  • releases/v17/17-0.mdx:903 — "SDK callers need no change. ObjectStackClient already
    normalised this" is about the ADR-0112 error envelope (err.code semantic,
    err.httpStatus numeric). This diff does not touch error normalisation; in fact the ①
    probe asserts on httpStatus and code off an SDK error, so that contract is exercised
    and still holds. Its only other hit, line 192's "rotation", is MCP-bridge key rotation.
  • releases/v17/17-2.mdx:176 — about datasources.external.* requiring a capability.
    Untouched by this diff.

Nothing to file on either.

One observation, noted and not filed

protocol/kernel/error-handling.mdx:120 advises "Include valid JWT token in
Authorization header". ObjectStack session tokens are better-auth session tokens, not
JWTs, so that phrasing was already loose before this PR and is no more or less accurate
after it — this diff neither introduces nor repairs it, and the page is not in scope here.
Recording it rather than burying it; it is not a defect this card may fix.

No doc edit is needed, so no code is pushed for this round. The PR stays a draft.


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/l tests tooling

Projects

None yet

2 participants