diff --git a/.changeset/client-auth-family-wire-shape-binding.md b/.changeset/client-auth-family-wire-shape-binding.md new file mode 100644 index 0000000000..b7216f2cc2 --- /dev/null +++ b/.changeset/client-auth-family-wire-shape-binding.md @@ -0,0 +1,73 @@ +--- +"@objectstack/client": minor +--- + +fix(client)!: the `auth.*` family declares the wire shapes better-auth actually sends — thirteen published `Promise< any >` returns narrowed (#14313) + +**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. + + + +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. + +## 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 (35 entries before, 22 after): + +| method | resolved to (before) | resolves to (now) | +|:--|:--|:--| +| `client.auth.updateUser(data)` | `any` | `AuthStatusReceipt` | +| `client.auth.changePassword(req)` | `any` | `AuthPasswordChangeResult` | +| `client.auth.setInitialPassword(req)` | `any` | `AuthSetInitialPasswordResult` | +| `client.auth.changeEmail(req)` | `any` | `AuthStatusReceipt` | +| `client.auth.sendVerificationEmail(req)` | `any` | `AuthStatusReceipt` | +| `client.auth.verifyEmail(params)` | `any` | `AuthEmailVerificationResult` | +| `client.auth.sessions.revoke(token)` | `any` | `AuthStatusReceipt` | +| `client.auth.sessions.revokeOthers()` | `any` | `AuthStatusReceipt` | +| `client.auth.sessions.revokeAll()` | `any` | `AuthStatusReceipt` | +| `client.auth.twoFactor.verifyTotp(req)` | `any` | `AuthTwoFactorVerificationResult` | +| `client.auth.twoFactor.disable(req)` | `any` | `AuthStatusReceipt` | +| `client.auth.twoFactor.verifyBackupCode(req)` | `any` | `AuthTwoFactorVerificationResult` | +| `client.auth.accounts.unlink(req)` | `any` | `AuthStatusReceipt` | + +`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. + +## The exact reads that stop compiling + +Everything below compiled before only because `any` is assignable to, and indexable by, everything. + +```ts +const r = await client.auth.updateUser({ name: 'Ada' }); +r.user; // now TS2339 — the route answers `{ status: true }`, NOT the updated user +r.data; // now TS2339 — these routes carry NO envelope + +const cp = await client.auth.changePassword({ currentPassword, newPassword }); +cp.user.createdAt.getTime(); // now TS2339 — the wire sends an ISO-8601 STRING, not a Date +new Date(cp.user.createdAt); // the correct rewrite +cp.token.length; // now TS18047 — `token` is `string | null` (null unless other sessions were revoked) + +const v = await client.auth.verifyEmail({ token }); +v.user.email; // now TS18047 — `user` is `AuthWireUser | null` (null on a plain verification) + +const ok = await client.auth.setInitialPassword({ newPassword }); +ok.status; // now TS2339 — ObjectStack's mount answers `{ success: true }`, not `{ status }` + +const t = await client.auth.twoFactor.verifyTotp({ code }); +t.user.locale; // now TS2339 — ObjectStack's own sys_user columns are not on better-auth's wire user +``` + +A caller that only read `status`, `success`, `token` (guarding `null`) or the base user columns needs no change. + +## Timestamps: ISO-8601 `string`, never `Date` + +`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. + +## Where the vendor's own declarations were the wrong answer + +- `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. +- `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. +- 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`. + +## `auth.deleteUser` is deliberately NOT bound + +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. diff --git a/packages/client/exported-any-returns.json b/packages/client/exported-any-returns.json index 922f7df404..438f3b8c8d 100644 --- a/packages/client/exported-any-returns.json +++ b/packages/client/exported-any-returns.json @@ -22,19 +22,6 @@ "ObjectStackClient.organizations.teams.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", "ObjectStackClient.organizations.teams.addMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", "ObjectStackClient.organizations.teams.removeMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.updateUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.changePassword": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.setInitialPassword": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.changeEmail": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.sendVerificationEmail": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.verifyEmail": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.deleteUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.sessions.revoke": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.sessions.revokeOthers": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.sessions.revokeAll": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.twoFactor.verifyTotp": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.twoFactor.disable": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.twoFactor.verifyBackupCode": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.", - "ObjectStackClient.auth.accounts.unlink": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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." + "ObjectStackClient.auth.deleteUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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." } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6a411c67af..96ecd278e4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1014,6 +1014,156 @@ export interface OAuthConsentResult { url: string; } +/** + * The user object better-auth puts on the wire from the `auth.*` routes that + * echo one — `changePassword`, `verifyEmail` (on a change-email verification), + * `twoFactor.verifyTotp` and `twoFactor.verifyBackupCode`. Served BARE by + * better-auth (no `{ success, data }` envelope), camelCase, and exactly the + * columns better-auth's own user schema declares: the serialiser + * (`parseUserOutput`) walks that schema and nothing else, so ObjectStack's + * extra `sys_user` columns (`locale`, `must_change_password`, …) never appear + * here even though they sit on the same row. + * + * ⚠️ **Timestamps are ISO-8601 strings, never `Date`** (maintainer ruling on + * #12104). The adapter is declared `supportsDates: false`, better-auth revives + * the stored string into a `Date` server-side, and `JSON.stringify` puts an + * ISO string back on the wire — measured `"createdAt":"2026-09-07T07:02:20.593Z"` + * on a real SQL driver. There is no revival layer in this SDK; `new Date(x)` + * is the caller's own step. + * + * A nullable column arrives as `null` on the SQL drivers (measured on + * better-sqlite3: `"image":null`, `"banReason":null`) and as an ABSENT key on + * a store that does not materialise an unset column (measured on the + * in-memory engine) — hence `?: … | null` on every one of them. + * + * The plugin-conditional members below are on the wire only when the server + * enables the better-auth plugin that declares them; each was measured both + * with and without its plugin. No index signature: one would erase every + * precise member beside it. + */ +export interface AuthWireUser { + id: string; + name: string; + email: string; + emailVerified: boolean; + /** Avatar URL — `null` (SQL) or absent (document store) when unset. */ + image?: string | null; + /** ISO-8601. */ + createdAt: string; + /** ISO-8601. */ + updatedAt: string; + /** + * `twoFactor` plugin only. ⚠️ On the enrolment lane of `verifyTotp` this + * is echoed from the pre-flip snapshot — see + * {@link AuthTwoFactorVerificationResult}. + */ + twoFactorEnabled?: boolean; + /** + * `admin` plugin only. An open string: the vocabulary is the deployment's + * (`'user'` is the plugin's default), so no union is declared. + */ + role?: string | null; + /** `admin` plugin only; the plugin defaults it to `false`. */ + banned?: boolean | null; + /** `admin` plugin only. */ + banReason?: string | null; + /** `admin` plugin only. ISO-8601 when set. */ + banExpires?: string | null; + /** `phoneNumber` plugin only. */ + phoneNumber?: string | null; + /** `phoneNumber` plugin only; the plugin defaults it to `false`. */ + phoneNumberVerified?: boolean | null; +} + +/** + * `{ status: true }` — better-auth's receipt on the routes of the `auth.*` + * family that carry no payload: `updateUser`, `changeEmail`, + * `sendVerificationEmail`, `sessions.revoke` / `revokeOthers` / `revokeAll`, + * `twoFactor.disable` and `accounts.unlink`. Every one of those handlers ends + * `ctx.json({ status: true })` — read in the vendor's source and measured on + * all eight against a real server — so the literal IS the wire fact: the + * value never carries `false`. A refusal is a 4xx, which `this.fetch` raises + * as a throw before any receipt exists. + * + * ⚠️ `updateUser` does NOT echo the updated user, whatever its OpenAPI stub + * says: the handler answers this receipt and puts the new fields into the + * session cookie. Re-read `me()` for the new values. + */ +export interface AuthStatusReceipt { + status: true; +} + +/** + * What `POST /change-password` answers. + */ +export interface AuthPasswordChangeResult { + /** + * ⚠️ SECRET — an unsigned session token. When `revokeOtherSessions: true` + * made the server rotate the caller's session this is the NEW session's + * token (every other session is gone and the cookie the caller held is + * dead); `null` otherwise. A bearer-mode caller has to store it itself — + * this SDK does not. + */ + token: string | null; + /** The caller, as better-auth's session held it when the write ran. */ + user: AuthWireUser; +} + +/** + * What `GET /verify-email` answers when it answers JSON — i.e. when the call + * carries no `callbackURL`. With one, the route answers a 302 to that URL with + * an EMPTY body (measured), and what `res.json()` then parses is whatever the + * callback target serves — so a caller that wants this receipt omits + * `callbackURL`. + */ +export interface AuthEmailVerificationResult { + /** Always `true`; a bad or expired token is a 401 raised by `this.fetch`. */ + status: true; + /** + * The updated user when the token was minted by `changeEmail` (the address + * has changed and `emailVerified` is `true`); `null` when the token + * verified the CURRENT address — on the first verification and on every + * repeat of it alike. + */ + user: AuthWireUser | null; +} + +/** + * What `POST /two-factor/verify-totp` and `POST /two-factor/verify-backup-code` + * answer on success, on both lanes (a signed-in user confirming a factor, and + * a sign-in challenge being completed). + */ +export interface AuthTwoFactorVerificationResult { + /** + * ⚠️ SECRET — the unsigned token of the session the caller now holds, + * accepted as a bearer. On the enrolment lane the vendor rotates the + * session mid-request; the value here is the LIVE one (plugin-auth's + * `two-factor-rotated-token-echo` repairs the vendor's stale echo). + * Through this SDK `verifyBackupCode` cannot send `disableSession`, so + * the token is always present. + */ + token: string; + /** + * ⚠️ On the ENROLMENT lane of `verifyTotp` the vendor echoes the user from + * its pre-rotation snapshot, so `twoFactorEnabled` reads `false` here + * although the flag has just flipped server-side (measured on a real SQL + * driver). Re-read the session for the live value. + */ + user: AuthWireUser; +} + +/** + * What ObjectStack's own `POST /set-initial-password` mount answers on success + * — the platform envelope, not better-auth's `{ status }` receipt, because the + * route is an ObjectStack wrapper around the vendor's server-only + * `auth.api.setPassword`. Its refusals (`409 PASSWORD_ALREADY_SET`, `400`, + * `401`) carry `{ success: false, error: { code, message } }` and are raised + * by `this.fetch`, so `success` is never `false` here. + */ +export interface AuthSetInitialPasswordResult { + success: true; +} + export class ObjectStackClient { private baseUrl: string; private token?: string; @@ -3509,10 +3659,14 @@ export class ObjectStackClient { * Update the current user's profile. * * better-auth: POST /update-user — accepts `{ name?, image?, ... }` - * (any custom user fields configured on the server). Returns the - * updated user. + * (any custom user fields configured on the server). + * + * Answers `{ status: true }` and NOT the updated user — the handler puts + * the new fields into the session cookie and echoes only the receipt + * (measured; the vendor's OpenAPI stub, which promises `{ user }`, is + * wrong). Re-read `me()` for the new values. */ - updateUser: async (data: { name?: string; image?: string | null; [key: string]: unknown }) => { + updateUser: async (data: { name?: string; image?: string | null; [key: string]: unknown }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/update-user`, { method: 'POST', @@ -3526,13 +3680,15 @@ export class ObjectStackClient { * * better-auth: POST /change-password. * Set `revokeOtherSessions: true` to invalidate every other session - * after the change. + * after the change — the server then ROTATES the caller's session too and + * answers the new token in `token`; this SDK does not store it, so a + * bearer-mode caller must. */ changePassword: async (req: { currentPassword: string; newPassword: string; revokeOtherSessions?: boolean; - }) => { + }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/change-password`, { method: 'POST', @@ -3554,7 +3710,7 @@ export class ObjectStackClient { * * ObjectStack mount: POST /set-initial-password — `{ newPassword }`. */ - setInitialPassword: async (req: { newPassword: string }) => { + setInitialPassword: async (req: { newPassword: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/set-initial-password`, { method: 'POST', @@ -3570,7 +3726,7 @@ export class ObjectStackClient { * * better-auth: POST /change-email — `{ newEmail, callbackURL? }`. */ - changeEmail: async (req: { newEmail: string; callbackURL?: string }) => { + changeEmail: async (req: { newEmail: string; callbackURL?: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/change-email`, { method: 'POST', @@ -3583,7 +3739,7 @@ export class ObjectStackClient { * Re-send the email-verification link to the current user (or any * address when called as an admin). better-auth: POST /send-verification-email. */ - sendVerificationEmail: async (req: { email: string; callbackURL?: string }) => { + sendVerificationEmail: async (req: { email: string; callbackURL?: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/send-verification-email`, { method: 'POST', @@ -3596,8 +3752,13 @@ export class ObjectStackClient { * Verify an email-verification token (the link target). * * better-auth: GET /verify-email?token=…&callbackURL=… + * + * The declared result is what the route answers WITHOUT `callbackURL`. + * With one, the route answers a 302 to that URL with an empty body + * (measured); `fetch` follows it and `res.json()` then parses whatever + * the callback target serves. Omit `callbackURL` to receive the receipt. */ - verifyEmail: async (params: { token: string; callbackURL?: string }) => { + verifyEmail: async (params: { token: string; callbackURL?: string }): Promise => { const route = this.getRoute('auth'); const url = new URL(`${this.baseUrl}${route}/verify-email`); url.searchParams.set('token', params.token); @@ -3614,6 +3775,24 @@ export class ObjectStackClient { * typically following an out-of-band confirmation step. * * Server policy decides which is required; pass whichever you have. + * + * ⚠️ NOT BOUND, and deliberately so — the one member of the `auth.*` + * family #14313 left at `Promise`, with its + * `exported-any-returns.json` entry still open. + * + * The maintainer's ruling of 2026-08-12 on #7735 keeps better-auth's + * `user.deleteUser` deliberately unconfigured (self-service deletion in a + * B2B tenancy needs a design first), and `auth-route-ledger.ts` books the + * route `disabled`. Measured against a real server: the vendor's handler + * refuses with **HTTP 404 and a ZERO-BYTE body** (once the + * last-local-credential guard is satisfied; before it, 409 + * `LAST_LOCAL_CREDENTIAL`), so `this.fetch` throws before the + * `res.json()` below ever runs — this method has no success path a + * caller can observe. No declared return type can be honest for a value + * the runtime never delivers; the vendor's success shape + * (`{ success: true, message }`) becomes bindable the day the route is + * switched on, and binding it before then would declare a capability the + * runtime does not have. */ deleteUser: async (req: { password?: string; token?: string; callbackURL?: string }) => { const route = this.getRoute('auth'); @@ -3651,7 +3830,7 @@ export class ObjectStackClient { }, /** better-auth: POST /revoke-session — revoke a single session by token. */ - revoke: async (token: string) => { + revoke: async (token: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/revoke-session`, { method: 'POST', @@ -3661,7 +3840,7 @@ export class ObjectStackClient { }, /** better-auth: POST /revoke-other-sessions — keep current, kill the rest. */ - revokeOthers: async () => { + revokeOthers: async (): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/revoke-other-sessions`, { method: 'POST', @@ -3671,7 +3850,7 @@ export class ObjectStackClient { }, /** better-auth: POST /revoke-sessions — kill every session for this user. */ - revokeAll: async () => { + revokeAll: async (): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/revoke-sessions`, { method: 'POST', @@ -3709,8 +3888,12 @@ export class ObjectStackClient { * or to step up an existing 2FA-enabled session. `trustDevice` (when * supported by the server config) suppresses the 2FA challenge on * this browser for the configured trust period. + * + * On the enrolment lane the server rotates the session and answers the + * LIVE token in `token`; this SDK does not store it — a bearer-mode + * caller must, or its next call answers 401. */ - verifyTotp: async (req: { code: string; trustDevice?: boolean }) => { + verifyTotp: async (req: { code: string; trustDevice?: boolean }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/two-factor/verify-totp`, { method: 'POST', @@ -3719,8 +3902,15 @@ export class ObjectStackClient { return res.json(); }, - /** Disable 2FA for the current user. Requires the password again. */ - disable: async (req: { password: string }) => { + /** + * Disable 2FA for the current user. Requires the password again. + * + * ⚠️ The server ROTATES the caller's session on success and echoes only + * the receipt (the new token rides the `Set-Cookie` and the bearer + * plugin's `set-auth-token` header, neither of which this SDK reads), so + * a bearer-mode caller's stored token is dead after this call. + */ + disable: async (req: { password: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/two-factor/disable`, { method: 'POST', @@ -3747,7 +3937,7 @@ export class ObjectStackClient { * Verify a 2FA backup code in lieu of a TOTP. Useful as a recovery * affordance when the user has lost their authenticator app. */ - verifyBackupCode: async (req: { code: string }) => { + verifyBackupCode: async (req: { code: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/two-factor/verify-backup-code`, { method: 'POST', @@ -3792,7 +3982,7 @@ export class ObjectStackClient { * id at the provider. 1.7 narrowed the body from the old * `{ providerId, accountId? }` pair; the row id implies the provider. */ - unlink: async (req: { accountId: string }) => { + unlink: async (req: { accountId: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/unlink-account`, { method: 'POST', diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 132f2e128c..8a788744f1 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -40,6 +40,14 @@ import type { OAuthApplicationRegistration, OAuthConsentResult, } from './index'; +import type { + AuthEmailVerificationResult, + AuthPasswordChangeResult, + AuthSetInitialPasswordResult, + AuthStatusReceipt, + AuthTwoFactorVerificationResult, + AuthWireUser, +} from './index'; import type { SearchAllResponse } from '@objectstack/spec/api'; import type { AnalyticsMetadataResponse, @@ -668,6 +676,113 @@ export async function returnTypePrecisionPins15451(): Promise { void (await client.oauth.applications.delete('c_1')).client_id; } + +/** + * [#14313 — the `auth.*` family, card 2 of 3 of #12104] The fourteen + * better-auth-backed methods #12104 censused under `auth.*`. THIRTEEN are + * bound here; the fourteenth is named below and is still `Promise< any >` + * on purpose. + * + * ## These shapes were read off the WIRE, not off better-auth's `.d.ts` + * + * Every route was driven against a real server twice: the real `AuthPlugin` + * mounts over a real Hono app with a real `AuthManager` (better-auth 1.7.2) + * on the in-memory engine, and again over a real `SqlDriver` (better-sqlite3) + * driven THROUGH this very client with only the socket stood in for. Where + * the vendor's declaration and the wire disagreed, the wire won: + * + * 1. `updateUser`'s OpenAPI stub promises `{ user }`; its handler answers + * `{ status: true }` and puts the new fields in the cookie. + * 2. `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 — so `AuthWireUser | null`. + * 3. The `image` / `banReason` / `banExpires` columns arrive as `null` on a + * SQL driver and as an ABSENT key on a store that does not materialise + * unset columns — so `?: … | null`, both measured. + * + * ## The ruling's ISO-8601 clause HAS sites in this family + * + * `AuthWireUser.createdAt` / `updatedAt` (and `banExpires`) are the vendor's + * `Date` fields. On the wire they are ISO-8601 strings, and that is what the + * pins hold them to: no `Date` is declared and no revival layer exists. + * + * ## `auth.deleteUser` is NOT bound, and that is the finding + * + * Its route is switched OFF by maintainer ruling (2026-08-12 on #7735; + * `auth-route-ledger.ts` books it `disabled`). Measured: 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 — the method has no + * success path a caller can observe. No declared return type can be honest + * for a value the runtime never delivers, so its `exported-any-returns.json` + * entry stays open, which is what the shrink-only ledger is for. + * + * Type-level for the reason this file's header gives: only a compile-time + * assertion can observe a return-type change. + */ +export async function returnTypePrecisionPins14313(): Promise { + // ── the thirteen bindings ──────────────────────────────────────────── + expectTypeOf(await client.auth.updateUser({ name: 'n' })).toEqualTypeOf(); + expectTypeOf(await client.auth.changePassword({ currentPassword: 'a', newPassword: 'b' })) + .toEqualTypeOf(); + expectTypeOf(await client.auth.setInitialPassword({ newPassword: 'b' })) + .toEqualTypeOf(); + expectTypeOf(await client.auth.changeEmail({ newEmail: 'e@example.com' })).toEqualTypeOf(); + expectTypeOf(await client.auth.sendVerificationEmail({ email: 'e@example.com' })) + .toEqualTypeOf(); + expectTypeOf(await client.auth.verifyEmail({ token: 't' })).toEqualTypeOf(); + expectTypeOf(await client.auth.sessions.revoke('t')).toEqualTypeOf(); + expectTypeOf(await client.auth.sessions.revokeOthers()).toEqualTypeOf(); + expectTypeOf(await client.auth.sessions.revokeAll()).toEqualTypeOf(); + expectTypeOf(await client.auth.twoFactor.verifyTotp({ code: '000000' })) + .toEqualTypeOf(); + expectTypeOf(await client.auth.twoFactor.disable({ password: 'p' })).toEqualTypeOf(); + expectTypeOf(await client.auth.twoFactor.verifyBackupCode({ code: 'c' })) + .toEqualTypeOf(); + expectTypeOf(await client.auth.accounts.unlink({ accountId: 'a' })).toEqualTypeOf(); + + // ── the ruling, made mechanical ────────────────────────────────────── + // ISO-8601 STRINGS. These go red if a later sweep "improves" them to + // `Date` (which the ruling forbids outright) or to a number. + expectTypeOf((await client.auth.changePassword({ currentPassword: 'a', newPassword: 'b' })).user.createdAt) + .toEqualTypeOf(); + expectTypeOf((await client.auth.twoFactor.verifyTotp({ code: '0' })).user.updatedAt).toEqualTypeOf(); + expectTypeOf((await client.auth.twoFactor.verifyTotp({ code: '0' })).user.banExpires) + .toEqualTypeOf(); + // The receipts are the literal `true`: a refusal is a throw, never `false`. + expectTypeOf((await client.auth.sessions.revoke('t')).status).toEqualTypeOf(); + expectTypeOf((await client.auth.setInitialPassword({ newPassword: 'b' })).success).toEqualTypeOf(); + // `token` is nullable ONLY where the wire sends `null` (a change-password + // that rotated nothing); the 2FA lanes always mint one. + expectTypeOf((await client.auth.changePassword({ currentPassword: 'a', newPassword: 'b' })).token) + .toEqualTypeOf(); + expectTypeOf((await client.auth.twoFactor.verifyBackupCode({ code: 'c' })).token).toEqualTypeOf(); + expectTypeOf((await client.auth.verifyEmail({ token: 't' })).user).toEqualTypeOf(); + + // ── direction 2: WRONG shapes must now be refused ──────────────────── + // While these methods returned `any` every suppression below was unused + // (TS2578) and this file did not build — which is what makes them + // evidence of the narrowing rather than decoration. + // @ts-expect-error updateUser answers a receipt, not the updated user (its OpenAPI stub lies) + void (await client.auth.updateUser({ name: 'n' })).user; + // @ts-expect-error these routes are served BARE by better-auth — there is no `{ success, data }` envelope + void (await client.auth.sessions.revokeAll()).data; + // @ts-expect-error the wire sends an ISO string; `Date` methods do not exist on it + void (await client.auth.changePassword({ currentPassword: 'a', newPassword: 'b' })).user.createdAt.getTime(); + // @ts-expect-error ObjectStack's own sys_user columns never reach better-auth's wire user + void (await client.auth.twoFactor.verifyTotp({ code: '0' })).user.locale; + // @ts-expect-error set-initial-password is an ObjectStack mount — `{ success }`, not better-auth's `{ status }` + void (await client.auth.setInitialPassword({ newPassword: 'b' })).status; + // @ts-expect-error the verification receipt carries no session token + void (await client.auth.verifyEmail({ token: 't' })).token; + + // ── the method deliberately left open ──────────────────────────────── + // `deleteUser` still resolves to `any`, so `.anythingAtAll` compiles. + // Pinned as an EQUALITY rather than a suppression, exactly as #14312 did + // for `oauth.applications.delete`: when the ruling that keeps the route + // off is revisited, this line is the one that must be replaced. + expectTypeOf(await client.auth.deleteUser({ password: 'p' })).toEqualTypeOf(); +} + /** * ⚠️ GREEN IN BOTH STATES — regression guards, recorded as such rather than * counted as evidence that this card's change was needed. Each pins a @@ -828,6 +943,7 @@ describe('client SDK return-type precision (#8140)', () => { expect(typeof returnTypePrecisionPins12104).toBe('function'); expect(typeof returnTypePrecisionPins14312).toBe('function'); expect(typeof returnTypePrecisionPins15451).toBe('function'); + expect(typeof returnTypePrecisionPins14313).toBe('function'); expect(typeof returnTypePrecisionPins13023).toBe('function'); expect(typeof deleteDataResponseIsNotTheMetaResetShape).toBe('function'); expect(typeof metaResetResponseDeclaresTheWireReceipt).toBe('function');