Skip to content

Commit e944fdb

Browse files
os-litantclaude
andauthored
fix(client)!: bind the oauth.* family to the wire shapes better-auth sends (#15445)
* fix(client)!: bind the oauth.* family to the wire shapes better-auth sends Four methods of the `oauth.*` 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: oauth.applications.register -> OAuthApplicationRegistration oauth.applications.get -> OAuthApplication oauth.applications.getPublic -> OAuthApplicationPublic oauth.consent -> OAuthConsentResult The shapes were read off the wire against a real server, not off better-auth's own `.d.ts` — twice the vendor's declaration was the wider, wrong answer: `getPublic` is declared as the full client row but its handler hand-picks seven columns, and `user_id` / `application_type` are declared nullable while the serialiser folds a null column to `undefined`. Timestamps are RFC 7591 numbers (Unix epoch seconds), not `Date` and not ISO-8601 strings: the provider converts its stored `Date` to a number before serialising, so no `Date` reaches the wire and no revival layer exists. `oauth.applications.delete` is deliberately NOT bound and keeps its ledger entry: its route answers HTTP 200 with a zero-byte body, so its `res.json()` rejects with a SyntaxError on every successful delete. No annotation can be honest while that call stands, and binding it needs a behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore(changeset): state the ADR-0087 disposition for the oauth.* narrowing The changeset declares `**BREAKING**` (and a title bang) and carried no `adr-0087:` disposition, so `check-adr-0087-registration.mjs` exited 1 and Check Changeset has been red on this PR since 16:01Z. This adds the one disposition line the gate requires. Category: `type-surface-only` (#13080). All four of its predicates were checked against this diff rather than assumed: 1 published @objectstack/client has no `private` field in packages/client/package.json. 2 no-spec-diff the four changed paths carry no `packages/spec/` prefix. 3 no-metadata-surface-diff `metadataSurfaceKind` is null for all four. 4 narrowed-from-erased read at both revs with the gate's own `readDeclaredTypeSurface`: `register`, `getPublic` and `consent` are unannotated at the merge base and concrete at HEAD. Predicate 4 is NOT claimed for the fourth narrowed member. The reference `packages/client/src/index.ts#get` does not address `oauth.applications.get`: the file declares 13 members named `get` and the predicate reads the FIRST (line 1928), which is an unrelated member, unannotated at both revs. Naming it would assert a verified fact about the wrong member, so it is named in the disposition's prose instead and the ambiguity is filed as its own card. No annotation, exported type, method body or ledger row is touched: the contract settled by the at-tier review is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d30ccb9 commit e944fdb

4 files changed

Lines changed: 308 additions & 8 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client)!: the `oauth.*` family declares the wire shapes better-auth actually sends — four published `Promise< any >` returns narrowed (#14312)
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#register, packages/client/src/index.ts#getPublic, packages/client/src/index.ts#consent) A published TYPE-SURFACE narrowing. Each member 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. 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. DISCLOSURE, not an omission: the fourth narrowed member of this changeset is `oauth.applications.get` (index.ts line 3184; unannotated at base, `Promise` of `OAuthApplication` at HEAD). It satisfies this same predicate on a direct reading, but it is deliberately NOT named above, because the reference `packages/client/src/index.ts#get` does not address it: this file declares 13 members named `get`, predicate 4 reads the FIRST one (line 1928), and that member is unrelated and unannotated at both revs. Naming it would assert a verified fact about the wrong member; the ambiguity is filed as its own card. -->
10+
11+
Card 1 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, and better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, not the wire fact.
12+
13+
## What changed
14+
15+
Four 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:
16+
17+
| method | resolved to (before) | resolves to (now) |
18+
|:--|:--|:--|
19+
| `client.oauth.applications.register(req)` | `any` | `OAuthApplicationRegistration` |
20+
| `client.oauth.applications.get(id)` | `any` | `OAuthApplication` |
21+
| `client.oauth.applications.getPublic(id)` | `any` | `OAuthApplicationPublic` |
22+
| `client.oauth.consent(req)` | `any` | `OAuthConsentResult` |
23+
24+
`OAuthApplication`, `OAuthApplicationRegistration`, `OAuthApplicationPublic` and `OAuthConsentResult` are newly exported from `@objectstack/client`. These four 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.
25+
26+
## The exact reads that stop compiling
27+
28+
Everything below compiled before only because `any` is assignable to, and indexable by, everything.
29+
30+
```ts
31+
const app = await client.oauth.applications.get('c_1');
32+
app.data; // was fine; now TS2339 — these routes carry NO envelope
33+
app.anythingAtAll; // was fine; now TS2339
34+
35+
const pub = await client.oauth.applications.getPublic('c_1');
36+
pub.client_secret; // now TS2339 — the public projection hand-picks 7 columns
37+
pub.grant_types; // now TS2339 — same reason
38+
pub.disabled; // now TS2339 — same reason
39+
40+
const decision = await client.oauth.consent({ accept: true });
41+
decision.client_id; // now TS2339 — consent answers `{ redirect, url }`
42+
43+
// Timestamps are RFC 7591 NUMBERS (Unix epoch seconds), so a caller that
44+
// guessed `Date` or ISO `string` now fails:
45+
new Date(app.client_id_issued_at!).toISOString(); // TS2769: number is not a Date arg
46+
app.client_id_issued_at!.slice(0, 10); // TS2339: not a string
47+
new Date(app.client_id_issued_at! * 1000); // the correct rewrite
48+
```
49+
50+
A caller that only read `client_id`, `client_secret`, `redirect_uris` or `url` needs no change.
51+
52+
## Timestamps: `number`, not `Date` and not ISO-8601
53+
54+
The ruling ordered every `Date`-typed field declared as an ISO `string` and forbade both a `Date` declaration and a runtime revival layer. **This family has no `Date` field to convert.** RFC 7591 carries `client_id_issued_at` and `client_secret_expires_at` as Unix-epoch SECONDS, and the provider converts its stored `Date` to a number before serialising, so the wire sends neither a `Date` nor an ISO string. Both are declared `number`, and a type-level pin holds them there. The ruling's prohibitions are satisfied: nothing declares a `Date`, and no revival layer exists.
55+
56+
## Two places better-auth's own types were the wrong answer
57+
58+
Read off the wire against a real server, not off the vendor's `.d.ts`:
59+
60+
- `getPublic` is declared `OAuthClient` — the full row — but its handler hand-picks seven columns. `OAuthApplicationPublic` is that projection, derived with `Pick` so it cannot drift from its parent. Its `redirect_uris` is always `[]` on this route and carries no information.
61+
- `user_id` and `application_type` are declared nullable by the vendor, but the serialiser folds a null column to `undefined`, so `null` is unreachable and is not declared.
62+
63+
## `oauth.applications.delete` is deliberately NOT bound
64+
65+
The fifth method of the family keeps its `Promise< any >` and its ledger entry. Its route answers HTTP 200 with a zero-byte body, so its `res.json()` rejects with a `SyntaxError` on every successful delete. No annotation can be honest while that call stands, and binding it needs a behaviour change — a decision beyond this card's type-narrowing scope. That the shrink-only ledger still carries exactly this one entry is the mechanism working.

‎packages/client/exported-any-returns.json‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,7 @@
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.oauth.applications.register": "#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.oauth.applications.get": "#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.oauth.applications.getPublic": "#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.",
2825
"ObjectStackClient.oauth.applications.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.",
29-
"ObjectStackClient.oauth.consent": "#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.",
3026
"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.",
3127
"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.",
3228
"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.",

‎packages/client/src/index.ts‎

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,136 @@ function metaDeleteHeaders(options?: DeleteMetaItemOptions): Record<string, stri
884884
return { 'If-Match': String(options.ifMatch) };
885885
}
886886

887+
/**
888+
* An OAuth client application exactly as `@better-auth/oauth-provider` puts it
889+
* on the wire: RFC 7591 §2 client metadata, snake_case, served BARE — these
890+
* routes carry no ObjectStack envelope (`auth-route-ledger.ts` records all six
891+
* `oauth2/*` client routes as `source: 'better-auth'`).
892+
*
893+
* ⚠️ **The two timestamps are RFC 7591 numbers — Unix epoch SECONDS — not
894+
* `Date` and not ISO-8601 strings.** The provider converts its stored `Date`
895+
* to seconds before serialising, so a `Date` never reaches the wire and there
896+
* is nothing here to revive. `new Date(client_id_issued_at * 1000)` is the
897+
* caller's own step.
898+
*
899+
* Only `client_id` and `redirect_uris` are always present: the serialiser
900+
* emits every other column as `undefined` when the row does not carry it, and
901+
* `JSON.stringify` then drops the key. `redirect_uris` is unconditional
902+
* because the serialiser defaults it to `[]`.
903+
*
904+
* ⚠️ A row that carries provider `metadata` spreads those keys at the TOP
905+
* level alongside the members below. They are deliberately not declared: an
906+
* index signature here would erase every precise member it sits beside.
907+
*/
908+
export interface OAuthApplication {
909+
/** RFC 7591 `client_id`. Always present. */
910+
client_id: string;
911+
/**
912+
* Returned by registration ONLY. `get` strips it, and it is never on the
913+
* public projection — store it at creation time or lose it.
914+
*/
915+
client_secret?: string;
916+
/**
917+
* Unix epoch **seconds** at which the secret expires; `0` is RFC 7591's
918+
* "never expires". Present whenever the client has a secret.
919+
*/
920+
client_secret_expires_at?: number;
921+
/** Unix epoch **seconds** at which the client was registered. */
922+
client_id_issued_at?: number;
923+
/** Space-delimited scope list (RFC 7591 §2), not an array. */
924+
scope?: string;
925+
/**
926+
* Owning user. Declared `string` and not `string | null`: the serialiser
927+
* folds a null column to `undefined`, so `null` is not reachable here even
928+
* though the provider's own type admits it.
929+
*/
930+
user_id?: string;
931+
client_name?: string;
932+
client_uri?: string;
933+
logo_uri?: string;
934+
contacts?: string[];
935+
tos_uri?: string;
936+
policy_uri?: string;
937+
/** RFC 7517 JWK Set, already parsed out of its stored JSON text. */
938+
jwks?: { keys: Record<string, unknown>[] };
939+
jwks_uri?: string;
940+
software_id?: string;
941+
software_version?: string;
942+
software_statement?: string;
943+
/** Always present; the public projection answers an empty array. */
944+
redirect_uris: string[];
945+
post_logout_redirect_uris?: string[];
946+
backchannel_logout_uri?: string;
947+
backchannel_logout_session_required?: boolean;
948+
/** Open union, as the provider declares it — the listed values autocomplete. */
949+
token_endpoint_auth_method?: 'none' | 'client_secret_basic' | 'client_secret_post' | 'private_key_jwt' | (string & {});
950+
/** Open union, as the provider declares it — the listed values autocomplete. */
951+
grant_types?: ('authorization_code' | 'client_credentials' | 'refresh_token' | (string & {}))[];
952+
response_types?: 'code'[];
953+
/** Not `| null`: the serialiser folds a null column to `undefined`. */
954+
application_type?: 'web' | 'native';
955+
disabled?: boolean;
956+
skip_consent?: boolean;
957+
enable_end_session?: boolean;
958+
require_pkce?: boolean;
959+
dpop_bound_access_tokens?: boolean;
960+
subject_type?: 'public' | 'pairwise';
961+
reference_id?: string;
962+
}
963+
964+
/**
965+
* What dynamic registration answers (HTTP **201**): an {@link OAuthApplication}
966+
* plus the server-owned resources linked to the new client, when there are any.
967+
*
968+
* This is the one shape that carries `client_secret`.
969+
*/
970+
export interface OAuthApplicationRegistration extends OAuthApplication {
971+
/** Server-owned resources linked to the registered client, when present. */
972+
resources?: string[];
973+
}
974+
975+
/**
976+
* The PUBLIC projection of an OAuth application — what the consent screen may
977+
* read about a client before the user has agreed to anything.
978+
*
979+
* Deliberately derived from {@link OAuthApplication} rather than restated, so
980+
* the two cannot drift, and deliberately NOT `OAuthApplication` itself: the
981+
* provider hand-picks these seven columns, so every other member is provably
982+
* absent rather than merely optional.
983+
*
984+
* ⚠️ `redirect_uris` is always `[]` here. The projection does not pass the
985+
* stored redirect URIs through, and the serialiser defaults the missing value
986+
* to an empty array — so this member carries no information on this route and
987+
* must not be read as "the client has no redirect URIs".
988+
*/
989+
export type OAuthApplicationPublic = Pick<
990+
OAuthApplication,
991+
| 'client_id'
992+
| 'client_name'
993+
| 'client_uri'
994+
| 'logo_uri'
995+
| 'contacts'
996+
| 'tos_uri'
997+
| 'policy_uri'
998+
| 'redirect_uris'
999+
>;
1000+
1001+
/**
1002+
* What submitting a consent decision answers — for BOTH decisions.
1003+
*
1004+
* Accepting and denying return the same shape and the same HTTP 200; the
1005+
* outcome is carried inside `url`, which on a denial is the client's
1006+
* `redirect_uri` with RFC 6749 §4.1.2.1 `error=access_denied` appended. So
1007+
* `redirect` is `true` on every success and is not the accept/deny signal —
1008+
* read `url`.
1009+
*/
1010+
export interface OAuthConsentResult {
1011+
/** Always `true`; the provider's own type declares the literal. */
1012+
redirect: true;
1013+
/** Absolute URL the caller must navigate the user agent to. */
1014+
url: string;
1015+
}
1016+
8871017
export class ObjectStackClient {
8881018
private baseUrl: string;
8891019
private token?: string;
@@ -3033,7 +3163,7 @@ export class ObjectStackClient {
30333163
tos_uri?: string;
30343164
policy_uri?: string;
30353165
metadata?: Record<string, unknown>;
3036-
}) => {
3166+
}): Promise<OAuthApplicationRegistration> => {
30373167
const route = this.getRoute('auth');
30383168
// The new oauth-provider package exposes `/oauth2/create-client`
30393169
// (authenticated dynamic registration). The legacy `/oauth2/register`
@@ -3051,7 +3181,7 @@ export class ObjectStackClient {
30513181
* Get a single OAuth application by its `client_id`.
30523182
* GET /api/v1/auth/oauth2/get-client?client_id=...
30533183
*/
3054-
get: async (clientId: string) => {
3184+
get: async (clientId: string): Promise<OAuthApplication> => {
30553185
const route = this.getRoute('auth');
30563186
const res = await this.fetch(
30573187
`${this.baseUrl}${route}/oauth2/get-client?client_id=${encodeURIComponent(clientId)}`,
@@ -3064,7 +3194,7 @@ export class ObjectStackClient {
30643194
* once the user has signed in). Used by the consent screen.
30653195
* GET /api/v1/auth/oauth2/public-client?client_id=...
30663196
*/
3067-
getPublic: async (clientId: string) => {
3197+
getPublic: async (clientId: string): Promise<OAuthApplicationPublic> => {
30683198
const route = this.getRoute('auth');
30693199
const res = await this.fetch(
30703200
`${this.baseUrl}${route}/oauth2/public-client?client_id=${encodeURIComponent(clientId)}`,
@@ -3093,6 +3223,22 @@ export class ObjectStackClient {
30933223
*
30943224
* Tokens and consents referencing the client cascade-delete via the
30953225
* better-auth schema's `onDelete: cascade` foreign keys.
3226+
*
3227+
* ⚠️ NOT YET BOUND, and deliberately so — this is the one method of the
3228+
* `oauth.*` family that #14312 left at `Promise<any>`, with its
3229+
* `exported-any-returns.json` entry still open.
3230+
*
3231+
* Measured against a real server: the route answers **HTTP 200 with a
3232+
* ZERO-BYTE body** (its handler returns nothing; the provider declares
3233+
* it `void`) under a `content-type: application/json` header. So the
3234+
* `res.json()` below rejects with `SyntaxError: Unexpected end of JSON
3235+
* input` on every successful delete — the delete itself has already
3236+
* committed server-side by then.
3237+
*
3238+
* No declared return type can be honest while that call stands: any
3239+
* annotation here would promise a value this method never resolves.
3240+
* Binding it therefore needs a behaviour change, which is a decision
3241+
* beyond the type-narrowing this family was scoped to — see #14312.
30963242
*/
30973243
delete: async (clientId: string) => {
30983244
const route = this.getRoute('auth');
@@ -3113,7 +3259,7 @@ export class ObjectStackClient {
31133259
* carries the signed authorization request that the consent endpoint
31143260
* verifies before issuing the authorization code.
31153261
*/
3116-
consent: async (req: { accept: boolean; scope?: string; oauth_query?: string }) => {
3262+
consent: async (req: { accept: boolean; scope?: string; oauth_query?: string }): Promise<OAuthConsentResult> => {
31173263
const route = this.getRoute('auth');
31183264
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/consent`, {
31193265
method: 'POST',

0 commit comments

Comments
 (0)