Skip to content

fix(client)!: bind the auth.* family to the wire shapes better-auth sends - #16537

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-14313-auth-family-wire-shape-binding
Sep 7, 2026
Merged

fix(client)!: bind the auth.* family to the wire shapes better-auth sends#16537
os-sales merged 1 commit into
mainfrom
claude/issue-14313-auth-family-wire-shape-binding

Conversation

@os-sales

@os-sales os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Part of #14313 — card 2 of 3 of the #12104 family. Merging this does not complete that card: thirteen of its fourteen methods are bound here, and the fourteenth (auth.deleteUser) is an open question stated below, so the card stays open for the PM seat to settle deliberately.

Clause-② is yes by the maintainer's #12104 ruling (this narrows published return types), so the PR is draft and carries needs:contract-review on both carriers. It waits for an at-tier contract reviewer; auto-merge is not armed and it is not enqueued.

What changed

Thirteen auth.* methods ended return res.json() with no return annotation, so lib.dom's Response.json(): Promise< any > was their published type. Each now declares the shape its route serves, and its exported-any-returns.json entry is deleted in the same change.

method before now
auth.updateUser any AuthStatusReceipt
auth.changePassword any AuthPasswordChangeResult
auth.setInitialPassword any AuthSetInitialPasswordResult
auth.changeEmail any AuthStatusReceipt
auth.sendVerificationEmail any AuthStatusReceipt
auth.verifyEmail any AuthEmailVerificationResult
auth.sessions.revoke / revokeOthers / revokeAll any AuthStatusReceipt
auth.twoFactor.verifyTotp any AuthTwoFactorVerificationResult
auth.twoFactor.disable any AuthStatusReceipt
auth.twoFactor.verifyBackupCode any AuthTwoFactorVerificationResult
auth.accounts.unlink any AuthStatusReceipt

Ledger: 35 entries before, 22 after — exactly these thirteen deleted, ObjectStackClient.auth.deleteUser deliberately kept, nothing else touched. Population re-derived from the ledger file itself at the merge base (a5eccf925): auth.* = 14, decomposing 7 direct / 3 sessions / 3 twoFactor / 1 accounts.unlink — it matched the card's 7/3/3/1. New exports: AuthWireUser, AuthStatusReceipt, AuthPasswordChangeResult, AuthEmailVerificationResult, AuthTwoFactorVerificationResult, AuthSetInitialPasswordResult. No method body changed; the only prose edits are JSDoc on the bound members.

The shapes were read off the WIRE — three arrangements, not the vendor's .d.ts

  1. Arrangement A — the real AuthPlugin route mounts (registerAuthRoutes, including ObjectStack's own set-initial-password and send-verification-email wrappers and the delete-user catch-all path) over a real Hono app with a real AuthManager (better-auth 1.7.2) on the in-memory engine, cookie-driven: all 14 routes plus their refusal variants, 56 exchanges recorded with status, content-type, byte count and raw body.
  2. Arrangement C — the same mounts over a real SqlDriver (better-sqlite3) with the plugin's own authIdentityObjects schema, driven through the real ObjectStackClient with only the socket stood in for (fetch: (u, init) => app.request(u, init), bearer auth): every member resolved or rejected exactly as the annotation now says.
  3. Arrangement D — real SQL driver with the phoneNumber, admin and twoFactor plugins on, to see which plugin members reach the wire user.

The receipts (8 routes), measured identically on all of them:

POST /update-user · /change-email · /send-verification-email · /revoke-session · /revoke-other-sessions · /revoke-sessions · /two-factor/disable · /unlink-account
  -> 200 application/json  {"status":true}
POST /set-initial-password (ObjectStack mount)   -> 200 {"success":true}   · 409 {"success":false,"error":{"code":"PASSWORD_ALREADY_SET",…}}

The payload routes:

POST /change-password                -> 200 {"token":null,"user":{…}}                          (revokeOtherSessions omitted)
POST /change-password                -> 200 {"token":"U4Z4…","user":{…}}                       (revokeOtherSessions:true — session rotated)
GET  /verify-email?token=…           -> 200 {"status":true,"user":null}                        (plain verification, first AND repeat)
GET  /verify-email?token=…           -> 200 {"status":true,"user":{…,"email":"probe-new@example.com","emailVerified":true,…}}   (change-email verification)
GET  /verify-email?token=…&callbackURL=/done -> 302 Location:/done, 0 bytes                    (documented; not the JSON path)
POST /two-factor/verify-totp         -> 200 {"token":"5yj5…","user":{…}}                       (both lanes)
POST /two-factor/verify-backup-code  -> 200 {"token":"5yj5…","user":{…}}                       (both lanes)

The wire user on the real SQL driver (admin + twoFactor + phoneNumber on):

{"name":"D","email":"d@example.com","emailVerified":false,"image":null,"createdAt":"2026-09-07T07:19:21.055Z","updatedAt":"2026-09-07T07:19:21.055Z","twoFactorEnabled":false,"role":"user","banned":false,"banReason":null,"banExpires":null,"phoneNumber":null,"phoneNumberVerified":false,"id":"3314…"}

Where the vendor's own declarations were the wrong answer

  • updateUser's OpenAPI stub promises { user } (and the SDK's JSDoc said "Returns the updated user"); the handler answers { status: true } and puts the new fields into the session cookie. The receipt is what is declared, and the JSDoc is corrected.
  • verifyEmail's stub declares user required; the handler answers user: null on a plain verification and the updated user only on a change-email verification — declared AuthWireUser | null.
  • A nullable column arrives as null on the SQL drivers ("image":null, "banReason":null) and as an ABSENT key on the in-memory engine (which does not materialise unset columns) — both measured, so each is ?: … | null. The plugin-conditional members (twoFactorEnabled; role / banned / banReason / banExpires; phoneNumber / phoneNumberVerified) were measured with and without their plugin and are optional. No index signature.

Secrets in the bound shapes (as the card asks)

  • AuthPasswordChangeResult.token and AuthTwoFactorVerificationResult.token are unsigned session tokens (bearer credentials). Both were already on the wire; the types name them and their JSDoc marks them SECRET. Nothing is widened.
  • AuthWireUser carries no secret: no password hash, no backup codes (backupCodes stay only on twoFactor.enable / generateBackupCodes, already typed before this card, untouched here).
  • AuthStatusReceipt, AuthEmailVerificationResult, AuthSetInitialPasswordResult carry nothing sensitive.

Timestamps: ISO-8601 string, never Date — the ruling HAS sites here

AuthWireUser.createdAt / updatedAt (and banExpires) are the vendor's Date-typed fields. The adapter is declared supportsDates: false, better-auth revives the stored string into a Date server-side, and JSON.stringify puts an ISO-8601 string back on the wire (measured above). They are declared string, JSDoc says ISO-8601, a type-level pin holds them there (toEqualTypeOf< string >), and the reverse pin refuses .getTime() on them. No revival layer exists in the SDK.

⚠️ auth.deleteUser is NOT bound — an open question for the reviewer

Its route is switched OFF by maintainer ruling (2026-08-12 on #7735; auth-route-ledger.ts books it disabled). Measured against a real server, through the real client:

client.auth.deleteUser({ password })   holder of the LAST local credential   -> REJECTED 409 LAST_LOCAL_CREDENTIAL   (plugin-auth's break-glass guard, first)
client.auth.deleteUser({ password })   with a second break-glass credential  -> REJECTED 404, ZERO-BYTE body          (the vendor's disabled-route refusal)
client.auth.deleteUser({ token })      same                                  -> REJECTED 404, ZERO-BYTE body

this.fetch throws on every non-2xx before res.json() runs, so the method has no success path a caller can observe. No declared return type can be honest for a value the runtime never delivers, and binding the vendor's success shape ({ success: true, message: 'User deleted' | 'Verification email sent' }) today would declare a capability the runtime does not have — the first-commandment shape the ruling names. Its ledger entry stays open, the JSDoc says why, and the pin file holds it as an EQUALITY (toEqualTypeOf< any >) so the line that must change is named. The three readings and what each costs are in the dev report on #14313; I did not pick one.

Verification

Final commit f293df639, clean tree (git status --porcelain empty).

  • Instrument first, then the change. Baseline at the merge base: pnpm --filter @objectstack/client check:exported-any-returns✅ … 35 ledgered site(s) still open, with the positive control read off the built dist/index.d.ts: oauth.applications.get resolves to Promise< OAuthApplication > and automation.trigger to Promise< AutomationResult >, both absent from the ledger, while auth.sessions.revokeOthers still read Promise< any >. At HEAD: ✅ no NEW exported callable of @objectstack/client resolves to any: 317 callables reached (52 caller-supplied generics, not counted as erasure), 22 ledgered site(s) still open. Its --self-test reports Ledger is exact in both directions.
  • pnpm --filter @objectstack/client typechecktsc --noEmit clean, then check:test-typecheck: OK — … 0 file(s) / 0 error(s).
  • pnpm --filter @objectstack/client test34 files, 444 tests, all passing.
  • Ablation — direction predicted first, then measured. Predicted: removing setInitialPassword's annotation gives two independent reds. The mutation was proved on disk (removed-text count 1→0, injected marker 0→1, source blob moved), rebuilt, and proved to have reached dist/ with ablation-dist-preflight.mjs --absent (marker absent from all 6 built files). Then: check:exported-any-returns exit 1 — ❌ 1 exported callable(s) … not ledgered: ObjectStackClient.auth.setInitialPassword resolves to Promise< any >; check:test-typecheck exit 1 — 3 type error(s) in return-type-precision.test.ts (two equality pins and the now-unused @ts-expect-error, TS2578). Restored with git checkout HEAD -- naming the absolute path under a trap, proved by git diff HEAD empty, git status --porcelain empty and the blob hash equal to HEAD's (96ecd278…), rebuilt, marker proved present again, gate green at 22.
  • Gate union re-derived at final HEAD with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths; the change set from the merge base): Reconciliation total 56; --ran✓ 56 derived famil(ies) accounted for — 56 run, 0 NOT-MEASURED. 53 exit 0. The other three are prerequisite refusals that need a full-workspace build, not measured and reported as such: check:skill-examples (wants packages/client-react/dist), check:dual-build-cjs-loads (exit 3, "This is NOT a pass: nothing was measured"), check:type-check-debt (exit 3, same class). Declared narrowing: a full-workspace build is lock-governed on a shared box that spent the shift building another lane's closure; CI builds the farm and runs all three regardless, and none of them reads a file this diff touches beyond the packages/client closure already built and typechecked here.
  • Artifact rosters (run separately, as the derivation says they are not in the total): the four whose roster sits under my paths all pass — check-changeset-fixed (70 packages in sync), check:authz-resolver, check:error-code-casing (5750 files, no unlisted code), check:filter-alias-parity.
  • Changeset gates on the committed diff: check:adr-0087-registration exit 0 — the type-surface-only disposition verified for all thirteen dotted member references (unannotated → Promise< X > each); check-changeset-no-major exit 0 (minor); check:nul-bytes exit 0; check-partof-closing-keyword run against this body and the branch's commits (no commit carries a card trailer).
  • Lint, narrowed and declared: eslint --no-inline-config --format json over the two changed .ts files — 2 files linted, 0 errors, 0 warnings. Population evidence: eslint.config.mjs scopes packages/**/*.{ts,tsx,mts,cts} blocks and states it uses no parserOptions.project (not type-aware), so this diff cannot move the verdict of any untouched file; the repo-wide pnpm lint is CI's.
  • No in-repo consumer breaks: git grep finds zero call sites of the thirteen bound methods outside packages/client (the other hits are CHANGELOG/docs prose). ../objectui is not checked out in this container; it consumes @objectstack/client from npm at a released version.

Acceptance notes · 验收备注

Scope

Only the fourteen methods this card names, on packages/client/src/index.ts, plus the ledger, the pin file and the changeset. The organizations.* card (#14314) is serialized behind this one on the same hot file and is untouched. The #13080 BREAKING-token gate is not addressed here — the ruling says that card is independent.

🤖 Generated with Claude Code


Generated by Claude Code

…ends

Thirteen methods of the auth.* namespace ended `return res.json()` with no
return annotation, so lib.dom's `Response.json(): Promise<any>` was their
published type. Each now declares the shape its route actually serves, and
its exported-any-returns.json entry is deleted in the same change:

  auth.updateUser                  -> AuthStatusReceipt
  auth.changePassword              -> AuthPasswordChangeResult
  auth.setInitialPassword          -> AuthSetInitialPasswordResult
  auth.changeEmail                 -> AuthStatusReceipt
  auth.sendVerificationEmail       -> AuthStatusReceipt
  auth.verifyEmail                 -> AuthEmailVerificationResult
  auth.sessions.revoke/Others/All  -> AuthStatusReceipt
  auth.twoFactor.verifyTotp        -> AuthTwoFactorVerificationResult
  auth.twoFactor.disable           -> AuthStatusReceipt
  auth.twoFactor.verifyBackupCode  -> AuthTwoFactorVerificationResult
  auth.accounts.unlink             -> AuthStatusReceipt

The shapes were read off the wire against a real server, not off
better-auth's own .d.ts: the real AuthPlugin mounts over a real Hono app with
a real AuthManager (better-auth 1.7.2), once on the in-memory engine and once
over a real SqlDriver driven through the real ObjectStackClient. Twice the
vendor's declaration was the wrong answer: updateUser's stub promises the
updated user but the handler answers `{ status: true }`; verifyEmail's stub
declares `user` required but the handler answers `null` on a plain
verification.

Timestamps are ISO-8601 strings, never Date and never revived (maintainer
ruling on the family card): the adapter runs `supportsDates: false` and
JSON.stringify puts the ISO string back on the wire.

auth.deleteUser is deliberately NOT bound and keeps its ledger entry: its
route is switched off by maintainer ruling and answers HTTP 404 with a
zero-byte body, so `this.fetch` throws before `res.json()` runs and no
declared return type can be honest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 19 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/import-mappings.mdx (via /:object/import/jobs (route, bridged from symbol createdAt — its route source's handler names it))
  • content/docs/permissions/authentication.mdx (via emailVerified (symbol, a field of interface AuthWireUser), phoneNumber (symbol, a field of interface AuthWireUser))
  • content/docs/protocol/objectql/state-machine.mdx (via /:object/import/jobs (route, bridged from symbol createdAt — its route source's handler names it))
  • content/docs/protocol/objectui/actions.mdx (via phoneNumber (symbol, a field of interface AuthWireUser))

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

  • content/docs/releases/v15.mdx (via phoneNumber (symbol, a field of interface AuthWireUser))

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
  • 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 46e626513940806af0e8da5dbe1eeec12eae1c45packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 46e626513940806af0e8da5dbe1eeec12eae1c45

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

@os-sales
os-sales marked this pull request as ready for review September 7, 2026 08:16
@os-sales
os-sales enabled auto-merge September 7, 2026 08:16
@os-sales
os-sales added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit b1b978c Sep 7, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14313-auth-family-wire-shape-binding branch September 7, 2026 08:57
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

Development

Successfully merging this pull request may close these issues.

2 participants