fix(client): a bearer-mode ObjectStackClient adopts the session token the three rotating auth routes hand it - #17182
Conversation
…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
…eads Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…sc too Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…ce sweep Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…arer-token-rotation
…arer-token-rotation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 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 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
|
Docs Drift Check — dischargedFirst: which tree, and is the bot's list closedThe bot warns its checkout carried uncommitted changes, so its sha does not fully Identical tree object, so reading in this worktree IS reading the bot's docs corpus. The claim classes actually at riskOnly three claims on this diff can be false: (a) a page asserting the SDK does not Corpus-wide searches for (c) — The 8 hand-written pages, one reading each
The input-vs-emitter blind spotThe bot names this hole and this diff is emitter-side, so I searched the prose class the
No page in the corpus tells a reader to sign in again after disabling two-factor. Nothing The coverage hole the bot declared, read by handThe bot could not anchor
So: no page documents this build config, and the hole is empty rather than unexamined. The The 2 release-owned pages — read-only, and neither is wrong⛔ Not edited, per the guardrail.
Nothing to file on either. One observation, noted and not filed
No doc edit is needed, so no code is pushed for this round. The PR stays a draft. Generated by Claude Code |
Fixes #16534
Clause-②: no
A bearer-mode
ObjectStackClientwas signed out by the three better-auth routes thatROTATE 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
auth.twoFactor.verifyTotp()(enrolment lane)token, and it is the LIVE one (plugin-auth'stwo-factor-rotated-token-echorepairs the vendor's stale echo)login()already stores the token it is handedauth.changePassword({ revokeOtherSessions: true })tokenauth.twoFactor.disable(){ status: true }set-auth-tokenresponse header on that same responsedisableis 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.tsdriveslogin → enable → verifyTotp → disable → deleteUserend to end against a realAuthManager(better-auth 1.7.2,bearer()+twoFactor) over a realObjectQLon areal
SqliteWasmDriver, with only the socket stood in for. The card's probe carried aline reading
(the probe re-set client.token by hand here to continue); there is no suchline 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
loginthe way the card's own probe opens onlogin, not on the registrationthat had to precede it.
deleteUseris bookeddisposition: 'disabled'inauth-route-ledger.ts, so it refuseseither way; WHICH refusal it is, is the whole finding. The test asserts the refusal is not
401and, stated directly rather than inferred from a status code, that the storedcredential 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,
changePasswordWITHOUTrevokeOtherSessions(which answerstoken: nulland must therefore store nothing), anddisable. Each asserts the storedcredential 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.tokenbyte-identical.Two cases: ordinary traffic (
auth.me(),auth.sessions.list(),auth.updateUser()) andverifyBackupCode's already-logged-in lane.updateUseris the decisive leg — it stages asession cookie to carry the updated user WITHOUT rotating, so
bearer()emits aset-auth-tokenfor it too. In theverifyBackupCodeleg the client deliberately holdsthe 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:
set-auth-tokenread into the sharedfetchwrapperupdateUser()assertion; ① and ② stay GREENupdateUser() moved the stored credential: expected 'sUdwucweuiNbfoEJVkBhj7AVfT4PA423.nipw…' to be 'sUdwucweuiNbfoEJVkBhj7AVfT4PA423'; 6 passed, 1 failed. ③ leg 2 stayed green — recorded as observed, it was not predictedverifyBackupCodeadopts its echoed tokenverifyBackupCode moved the stored credential; 6 passed, 1 failedAblation 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 backto
1be591b657…,git diff HEADempty), under atrap … EXIT INT TERMwith absolutepaths. No dist is involved: the suite imports the subject as the relative specifier
./index, so vitest readspackages/client/src/index.tsitself.④ The three TSDoc warnings are updated in the same landing. Re-located, since the
card's citations had rotted:
changePassword(nowindex.ts:4226),verifyTotp(
:4439),twoFactor.disable(:4464), plus the two declaredtokenmembers(
AuthPasswordChangeResult,AuthTwoFactorVerificationResult).git grepfordoes not store it,this SDK does notandneither of which this SDK readsoverpackages/client/src/returns zero.Why the two config hunks are here — measured, not asserted
packages/client/tsconfig.jsonandvitest.config.tsare build config on a publishedpackage, so each was ablated to show the gate that demands it:
vitest.config.tsaliases andpnpm check:test-source-aliasgoes toexit 1:
@objectstack/client: NEW unaliased artifact import(s) since this entry was measured: @objectstack/platform-objects, @objectstack/plugin-auth— and it prints thetwo entries this branch added, verbatim, including the warning against collapsing the
subpath into the prefix-matching object form, which this branch heeds.
tsconfig.jsonpathsandpnpm check:type-source-resolutiongoes toexit 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/clientpublishesfiles: ["dist", "README.md", "CHANGELOG.md"], and the twoworkspace packages went into
devDependencies, notdependencies—dependenciesis still exactly
@objectstack/coreand@objectstack/spec.pnpm check:published-filesis 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_HEADERis a module-level const, not exported;adoptRotatedSessionTokenis a private method, and the emitteddist/index.d.tscarries it only as the uncallable line
private adoptRotatedSessionToken;— theexport { … }list is unchanged.node scripts/pm/check-widening-tells.mjs --declaration noreads 8 changed files and reports no widening tell.One consequence worth naming: after
twoFactor.disable()the stored credential is theSIGNED
TOKEN.SIGspelling the bearer plugin emits, where the body-echo routes store theUNSIGNED one. better-auth accepts both — the ① probe continues through
deleteUseronthe 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
fetchwrapper:
set-auth-tokenrides every response that stages a session cookie, so awrapper-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 threeTSDoc 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
ObjectStackClientdoes not store the rotated session token fromtwoFactor.disable()/enrolment-lane
verifyTotp()/changePassword({revokeOtherSessions:true}), so thecaller 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 0pnpm --filter @objectstack/client test— exit 0, 38 files / 475 tests passedpnpm --filter @objectstack/client typecheck— exit 0Test Files 1 passed (1) · Tests 7 passed (7)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran …reports69 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.
(
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'): no matches.pnpm check:nul-bytesexit 0.
pnpm --filter @objectstack/spec run check:skill-examplesrefused on its first run —packages/client/distwas older thansrc, a PREREQUISITE NOT MET, which is NOTMEASURED and not a pass. Building
packages/clientand re-running gives exit 0.验收备注
19 open PRs, each one's file list taken from
GET /pulls/{n}/files, which is thethree-dot diff against that PR's own merge base.
packages/client/src/index.tsisheld by none of them. Positive controls that fire on the same instrument:
packages/services/service-analytics/src/plugin.tsis held by 3 open PRs, and withinthis card's own file set
packages/client/package.jsonis held by chore: version packages #17076 (the standingbot-authored Version Packages PR, which moves the
versionfield, not thedevDependenciesblock) andpnpm-lock.yamlby fix(triggers,spec,service-automation): a time-triggered flow declares its acting organization and runs as it (#16659) #17126. PR fix(client): the scoped SDK reads metadata.prefix off the advertised routes instead of restating /meta #17122 — the previous holderof the hot file — is merged and its squash
032452a545is an ancestor of this branch(
git merge-base --is-ancestorexit 0, alongside a control leg that also answers 0).set-auth-tokeniscontent/docs/protocol/kernel/http-protocol.mdx, and it documents the server's CORSexpose-header surface ("
set-auth-tokendelivers a rotated session token"), which thischange does not touch — it is what the SDK now finally reads. Nothing in
content/docs/claims the SDK does not store the token.
content/docs/releases/is untouched; thisPR's only input to release notes is its changeset.
packages/plugins/plugin-auth/src/auth-route-ledger.tsbooksPOST /api/v1/auth/delete-userasdisabled(better-auth publishes it, butuser.deleteUseris deliberately unconfigured, so it answers 404) whileObjectStackClientstill publishesauth.deleteUser. That is a declared, deliberatestate with a maintainer ruling behind it, not a defect, and this card's probe depends on
the refusal being something other than 401. Named here because the shape of the ① test
only makes sense if a reader knows it. 承接者: whoever revisits the auth-method-matrix:
change-emailanddelete-userare booked as mounted inauth-route-ledger.tsbut plugin-auth never configures better-auth'suser.changeEmail/user.deleteUser— no switch to enable them #7735 B2Bself-service-deletion design.
Generated by Claude Code