Skip to content

Commit b1b978c

Browse files
os-salesclaude
andauthored
fix(client)!: bind the auth.* family to the wire shapes better-auth sends (#16537)
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. Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f48f3f1 commit b1b978c

4 files changed

Lines changed: 397 additions & 31 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client)!: the `auth.*` family declares the wire shapes better-auth actually sends — thirteen published `Promise< any >` returns narrowed (#14313)
6+
7+
**BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is.
8+
9+
<!-- adr-0087: not-required (type-surface-only packages/client/src/index.ts#auth.updateUser, packages/client/src/index.ts#auth.changePassword, packages/client/src/index.ts#auth.setInitialPassword, packages/client/src/index.ts#auth.changeEmail, packages/client/src/index.ts#auth.sendVerificationEmail, packages/client/src/index.ts#auth.verifyEmail, packages/client/src/index.ts#auth.sessions.revoke, packages/client/src/index.ts#auth.sessions.revokeOthers, packages/client/src/index.ts#auth.sessions.revokeAll, packages/client/src/index.ts#auth.twoFactor.verifyTotp, packages/client/src/index.ts#auth.twoFactor.disable, packages/client/src/index.ts#auth.twoFactor.verifyBackupCode, packages/client/src/index.ts#auth.accounts.unlink) A published TYPE-SURFACE narrowing. Each of the thirteen members was UNANNOTATED at the merge base, so lib.dom's `Response.json()` published it as an erased `any`; each now declares the shape its route already answered, read off the wire against a real server. No method body changed, so no request or response byte moves, and the diff touches no `packages/spec` path and no ADR-0087 shape surface. The affected party is a TypeScript consumer and the compiler delivers the break at their own call site; `objectstack migrate meta`, `spec-changes.json` and the upgrade guide have nothing to rewrite, so a ledger entry would be false data in the one ledger this gate keeps true. The fourteenth member, `auth.deleteUser`, is deliberately left unannotated and is not named here. -->
10+
11+
Card 2 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, and every timestamp is declared as the ISO-8601 `string` the wire carries — no `Date`, no revival layer.
12+
13+
## What changed
14+
15+
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 (35 entries before, 22 after):
16+
17+
| method | resolved to (before) | resolves to (now) |
18+
|:--|:--|:--|
19+
| `client.auth.updateUser(data)` | `any` | `AuthStatusReceipt` |
20+
| `client.auth.changePassword(req)` | `any` | `AuthPasswordChangeResult` |
21+
| `client.auth.setInitialPassword(req)` | `any` | `AuthSetInitialPasswordResult` |
22+
| `client.auth.changeEmail(req)` | `any` | `AuthStatusReceipt` |
23+
| `client.auth.sendVerificationEmail(req)` | `any` | `AuthStatusReceipt` |
24+
| `client.auth.verifyEmail(params)` | `any` | `AuthEmailVerificationResult` |
25+
| `client.auth.sessions.revoke(token)` | `any` | `AuthStatusReceipt` |
26+
| `client.auth.sessions.revokeOthers()` | `any` | `AuthStatusReceipt` |
27+
| `client.auth.sessions.revokeAll()` | `any` | `AuthStatusReceipt` |
28+
| `client.auth.twoFactor.verifyTotp(req)` | `any` | `AuthTwoFactorVerificationResult` |
29+
| `client.auth.twoFactor.disable(req)` | `any` | `AuthStatusReceipt` |
30+
| `client.auth.twoFactor.verifyBackupCode(req)` | `any` | `AuthTwoFactorVerificationResult` |
31+
| `client.auth.accounts.unlink(req)` | `any` | `AuthStatusReceipt` |
32+
33+
`AuthWireUser`, `AuthStatusReceipt`, `AuthPasswordChangeResult`, `AuthEmailVerificationResult`, `AuthTwoFactorVerificationResult` and `AuthSetInitialPasswordResult` are newly exported from `@objectstack/client`. Twelve of these routes are served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap and none is introduced; `setInitialPassword` is ObjectStack's own mount and answers the platform's `{ success: true }` envelope.
34+
35+
## The exact reads that stop compiling
36+
37+
Everything below compiled before only because `any` is assignable to, and indexable by, everything.
38+
39+
```ts
40+
const r = await client.auth.updateUser({ name: 'Ada' });
41+
r.user; // now TS2339 — the route answers `{ status: true }`, NOT the updated user
42+
r.data; // now TS2339 — these routes carry NO envelope
43+
44+
const cp = await client.auth.changePassword({ currentPassword, newPassword });
45+
cp.user.createdAt.getTime(); // now TS2339 — the wire sends an ISO-8601 STRING, not a Date
46+
new Date(cp.user.createdAt); // the correct rewrite
47+
cp.token.length; // now TS18047 — `token` is `string | null` (null unless other sessions were revoked)
48+
49+
const v = await client.auth.verifyEmail({ token });
50+
v.user.email; // now TS18047 — `user` is `AuthWireUser | null` (null on a plain verification)
51+
52+
const ok = await client.auth.setInitialPassword({ newPassword });
53+
ok.status; // now TS2339 — ObjectStack's mount answers `{ success: true }`, not `{ status }`
54+
55+
const t = await client.auth.twoFactor.verifyTotp({ code });
56+
t.user.locale; // now TS2339 — ObjectStack's own sys_user columns are not on better-auth's wire user
57+
```
58+
59+
A caller that only read `status`, `success`, `token` (guarding `null`) or the base user columns needs no change.
60+
61+
## Timestamps: ISO-8601 `string`, never `Date`
62+
63+
`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 `"createdAt":"2026-09-07T07:02:20.593Z"` on a real SQL driver. They are declared `string`, a type-level pin holds them there, and no revival layer exists in the SDK.
64+
65+
## Where the vendor's own declarations were the wrong answer
66+
67+
- `updateUser`'s OpenAPI stub promises `{ user }`; its handler answers `{ status: true }` and puts the new fields into the session cookie. The receipt is what is declared.
68+
- `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.
69+
- A nullable column (`image`, `banReason`, `banExpires`) arrives as `null` on the SQL drivers and as an ABSENT key on a store that does not materialise unset columns — both measured — so each is `?: … | null`.
70+
71+
## `auth.deleteUser` is deliberately NOT bound
72+
73+
The fourteenth method keeps its `Promise< any >` and its ledger entry. Its route is switched off by maintainer ruling (2026-08-12 on #7735; `auth-route-ledger.ts` books it `disabled`), and measured against a real server it answers HTTP 404 with a ZERO-BYTE body once the last-local-credential guard is satisfied — so `this.fetch` throws before `res.json()` ever runs and the method has no success path a caller can observe. No declared return type can be honest for a value the runtime never delivers. That the shrink-only ledger still carries exactly this one `auth.*` entry is the mechanism working.

packages/client/exported-any-returns.json

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,6 @@
2222
"ObjectStackClient.organizations.teams.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
2323
"ObjectStackClient.organizations.teams.addMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
2424
"ObjectStackClient.organizations.teams.removeMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
25-
"ObjectStackClient.auth.updateUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
26-
"ObjectStackClient.auth.changePassword": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
27-
"ObjectStackClient.auth.setInitialPassword": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
28-
"ObjectStackClient.auth.changeEmail": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
29-
"ObjectStackClient.auth.sendVerificationEmail": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
30-
"ObjectStackClient.auth.verifyEmail": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
31-
"ObjectStackClient.auth.deleteUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
32-
"ObjectStackClient.auth.sessions.revoke": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
33-
"ObjectStackClient.auth.sessions.revokeOthers": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
34-
"ObjectStackClient.auth.sessions.revokeAll": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
35-
"ObjectStackClient.auth.twoFactor.verifyTotp": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
36-
"ObjectStackClient.auth.twoFactor.disable": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
37-
"ObjectStackClient.auth.twoFactor.verifyBackupCode": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
38-
"ObjectStackClient.auth.accounts.unlink": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope."
25+
"ObjectStackClient.auth.deleteUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope."
3926
}
4027
}

0 commit comments

Comments
 (0)