Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .changeset/client-oauth-family-wire-shape-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
"@objectstack/client": minor
---

fix(client)!: the `oauth.*` family declares the wire shapes better-auth actually sends — four published `Promise< any >` returns narrowed (#14312)

**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.

<!-- 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. -->

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.

## What changed

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:

| method | resolved to (before) | resolves to (now) |
|:--|:--|:--|
| `client.oauth.applications.register(req)` | `any` | `OAuthApplicationRegistration` |
| `client.oauth.applications.get(id)` | `any` | `OAuthApplication` |
| `client.oauth.applications.getPublic(id)` | `any` | `OAuthApplicationPublic` |
| `client.oauth.consent(req)` | `any` | `OAuthConsentResult` |

`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.

## The exact reads that stop compiling

Everything below compiled before only because `any` is assignable to, and indexable by, everything.

```ts
const app = await client.oauth.applications.get('c_1');
app.data; // was fine; now TS2339 — these routes carry NO envelope
app.anythingAtAll; // was fine; now TS2339

const pub = await client.oauth.applications.getPublic('c_1');
pub.client_secret; // now TS2339 — the public projection hand-picks 7 columns
pub.grant_types; // now TS2339 — same reason
pub.disabled; // now TS2339 — same reason

const decision = await client.oauth.consent({ accept: true });
decision.client_id; // now TS2339 — consent answers `{ redirect, url }`

// Timestamps are RFC 7591 NUMBERS (Unix epoch seconds), so a caller that
// guessed `Date` or ISO `string` now fails:
new Date(app.client_id_issued_at!).toISOString(); // TS2769: number is not a Date arg
app.client_id_issued_at!.slice(0, 10); // TS2339: not a string
new Date(app.client_id_issued_at! * 1000); // the correct rewrite
```

A caller that only read `client_id`, `client_secret`, `redirect_uris` or `url` needs no change.

## Timestamps: `number`, not `Date` and not ISO-8601

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.

## Two places better-auth's own types were the wrong answer

Read off the wire against a real server, not off the vendor's `.d.ts`:

- `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.
- `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.

## `oauth.applications.delete` is deliberately NOT bound

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.
4 changes: 0 additions & 4 deletions packages/client/exported-any-returns.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
Expand Down
154 changes: 150 additions & 4 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,136 @@ function metaDeleteHeaders(options?: DeleteMetaItemOptions): Record<string, stri
return { 'If-Match': String(options.ifMatch) };
}

/**
* An OAuth client application exactly as `@better-auth/oauth-provider` puts it
* on the wire: RFC 7591 §2 client metadata, snake_case, served BARE — these
* routes carry no ObjectStack envelope (`auth-route-ledger.ts` records all six
* `oauth2/*` client routes as `source: 'better-auth'`).
*
* ⚠️ **The two timestamps are RFC 7591 numbers — Unix epoch SECONDS — not
* `Date` and not ISO-8601 strings.** The provider converts its stored `Date`
* to seconds before serialising, so a `Date` never reaches the wire and there
* is nothing here to revive. `new Date(client_id_issued_at * 1000)` is the
* caller's own step.
*
* Only `client_id` and `redirect_uris` are always present: the serialiser
* emits every other column as `undefined` when the row does not carry it, and
* `JSON.stringify` then drops the key. `redirect_uris` is unconditional
* because the serialiser defaults it to `[]`.
*
* ⚠️ A row that carries provider `metadata` spreads those keys at the TOP
* level alongside the members below. They are deliberately not declared: an
* index signature here would erase every precise member it sits beside.
*/
export interface OAuthApplication {
/** RFC 7591 `client_id`. Always present. */
client_id: string;
/**
* Returned by registration ONLY. `get` strips it, and it is never on the
* public projection — store it at creation time or lose it.
*/
client_secret?: string;
/**
* Unix epoch **seconds** at which the secret expires; `0` is RFC 7591's
* "never expires". Present whenever the client has a secret.
*/
client_secret_expires_at?: number;
/** Unix epoch **seconds** at which the client was registered. */
client_id_issued_at?: number;
/** Space-delimited scope list (RFC 7591 §2), not an array. */
scope?: string;
/**
* Owning user. Declared `string` and not `string | null`: the serialiser
* folds a null column to `undefined`, so `null` is not reachable here even
* though the provider's own type admits it.
*/
user_id?: string;
client_name?: string;
client_uri?: string;
logo_uri?: string;
contacts?: string[];
tos_uri?: string;
policy_uri?: string;
/** RFC 7517 JWK Set, already parsed out of its stored JSON text. */
jwks?: { keys: Record<string, unknown>[] };
jwks_uri?: string;
software_id?: string;
software_version?: string;
software_statement?: string;
/** Always present; the public projection answers an empty array. */
redirect_uris: string[];
post_logout_redirect_uris?: string[];
backchannel_logout_uri?: string;
backchannel_logout_session_required?: boolean;
/** Open union, as the provider declares it — the listed values autocomplete. */
token_endpoint_auth_method?: 'none' | 'client_secret_basic' | 'client_secret_post' | 'private_key_jwt' | (string & {});
/** Open union, as the provider declares it — the listed values autocomplete. */
grant_types?: ('authorization_code' | 'client_credentials' | 'refresh_token' | (string & {}))[];
response_types?: 'code'[];
/** Not `| null`: the serialiser folds a null column to `undefined`. */
application_type?: 'web' | 'native';
disabled?: boolean;
skip_consent?: boolean;
enable_end_session?: boolean;
require_pkce?: boolean;
dpop_bound_access_tokens?: boolean;
subject_type?: 'public' | 'pairwise';
reference_id?: string;
}

/**
* What dynamic registration answers (HTTP **201**): an {@link OAuthApplication}
* plus the server-owned resources linked to the new client, when there are any.
*
* This is the one shape that carries `client_secret`.
*/
export interface OAuthApplicationRegistration extends OAuthApplication {
/** Server-owned resources linked to the registered client, when present. */
resources?: string[];
}

/**
* The PUBLIC projection of an OAuth application — what the consent screen may
* read about a client before the user has agreed to anything.
*
* Deliberately derived from {@link OAuthApplication} rather than restated, so
* the two cannot drift, and deliberately NOT `OAuthApplication` itself: the
* provider hand-picks these seven columns, so every other member is provably
* absent rather than merely optional.
*
* ⚠️ `redirect_uris` is always `[]` here. The projection does not pass the
* stored redirect URIs through, and the serialiser defaults the missing value
* to an empty array — so this member carries no information on this route and
* must not be read as "the client has no redirect URIs".
*/
export type OAuthApplicationPublic = Pick<
OAuthApplication,
| 'client_id'
| 'client_name'
| 'client_uri'
| 'logo_uri'
| 'contacts'
| 'tos_uri'
| 'policy_uri'
| 'redirect_uris'
>;

/**
* What submitting a consent decision answers — for BOTH decisions.
*
* Accepting and denying return the same shape and the same HTTP 200; the
* outcome is carried inside `url`, which on a denial is the client's
* `redirect_uri` with RFC 6749 §4.1.2.1 `error=access_denied` appended. So
* `redirect` is `true` on every success and is not the accept/deny signal —
* read `url`.
*/
export interface OAuthConsentResult {
/** Always `true`; the provider's own type declares the literal. */
redirect: true;
/** Absolute URL the caller must navigate the user agent to. */
url: string;
}

export class ObjectStackClient {
private baseUrl: string;
private token?: string;
Expand Down Expand Up @@ -3033,7 +3163,7 @@ export class ObjectStackClient {
tos_uri?: string;
policy_uri?: string;
metadata?: Record<string, unknown>;
}) => {
}): Promise<OAuthApplicationRegistration> => {
const route = this.getRoute('auth');
// The new oauth-provider package exposes `/oauth2/create-client`
// (authenticated dynamic registration). The legacy `/oauth2/register`
Expand All @@ -3051,7 +3181,7 @@ export class ObjectStackClient {
* Get a single OAuth application by its `client_id`.
* GET /api/v1/auth/oauth2/get-client?client_id=...
*/
get: async (clientId: string) => {
get: async (clientId: string): Promise<OAuthApplication> => {
const route = this.getRoute('auth');
const res = await this.fetch(
`${this.baseUrl}${route}/oauth2/get-client?client_id=${encodeURIComponent(clientId)}`,
Expand All @@ -3064,7 +3194,7 @@ export class ObjectStackClient {
* once the user has signed in). Used by the consent screen.
* GET /api/v1/auth/oauth2/public-client?client_id=...
*/
getPublic: async (clientId: string) => {
getPublic: async (clientId: string): Promise<OAuthApplicationPublic> => {
const route = this.getRoute('auth');
const res = await this.fetch(
`${this.baseUrl}${route}/oauth2/public-client?client_id=${encodeURIComponent(clientId)}`,
Expand Down Expand Up @@ -3093,6 +3223,22 @@ export class ObjectStackClient {
*
* Tokens and consents referencing the client cascade-delete via the
* better-auth schema's `onDelete: cascade` foreign keys.
*
* ⚠️ NOT YET BOUND, and deliberately so — this is the one method of the
* `oauth.*` family that #14312 left at `Promise<any>`, with its
* `exported-any-returns.json` entry still open.
*
* Measured against a real server: the route answers **HTTP 200 with a
* ZERO-BYTE body** (its handler returns nothing; the provider declares
* it `void`) under a `content-type: application/json` header. So the
* `res.json()` below rejects with `SyntaxError: Unexpected end of JSON
* input` on every successful delete — the delete itself has already
* committed server-side by then.
*
* No declared return type can be honest while that call stands: any
* annotation here would promise a value this method never resolves.
* Binding it therefore needs a behaviour change, which is a decision
* beyond the type-narrowing this family was scoped to — see #14312.
*/
delete: async (clientId: string) => {
const route = this.getRoute('auth');
Expand All @@ -3113,7 +3259,7 @@ export class ObjectStackClient {
* carries the signed authorization request that the consent endpoint
* verifies before issuing the authorization code.
*/
consent: async (req: { accept: boolean; scope?: string; oauth_query?: string }) => {
consent: async (req: { accept: boolean; scope?: string; oauth_query?: string }): Promise<OAuthConsentResult> => {
const route = this.getRoute('auth');
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/consent`, {
method: 'POST',
Expand Down
Loading
Loading