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
31 changes: 31 additions & 0 deletions .changeset/oauth-register-declares-only-honoured-members.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/client": minor
---

fix(client): `oauth.applications.register` declares only the members `/oauth2/create-client` accepts — `name`, `scopes` and `metadata` are removed (#15447)

**BREAKING** — three members leave a published request type. A caller who sets one compiles today and gets a type error after this release. That is the point: the route never honoured any of them, so what the compiler now refuses is code that was already having its value thrown away.

## What a caller passing these members should do instead

| you were passing | pass instead | why |
|---|---|---|
| `name: 'My App'` | `client_name: 'My App'` | same `string`, and `client_name` is the member the route reads |
| `scopes: ['openid', 'profile']` | `scope: ['openid', 'profile'].join(' ')` | ⚠️ **not** a rename — `scope` is one space-delimited string; posting an array is refused with `400 [body.scope] Invalid input: expected string, received array` |
| `metadata: { tenant: 'acme' }` | nothing — delete the member | no door this SDK can reach accepts it (see below) |

## ⚠️ These were the vendor's RECORD vocabulary, not typos

`client_name` writes the DB column literally named **`name`**; `scope` writes the DB column literally named **`scopes`**, as a JSON array. The removed members were the *column* names offered next to the *wire* names in the same declared type — an author picking the adjacent one of two got a success receipt and no value. Treating them as misspellings would be the wrong reading of what they were; the prescription above is still the wire member either way.

## Why they had to go rather than be honoured here

`POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider@1.7.2`. Its body schema declares 21 members and sets no `catchall`, so it is zod's default **strip**: an unknown key is dropped, not refused, and the caller gets **HTTP 201 and a client that quietly does not have the value**. Driven end to end against a real `betterAuth` + `oauthProvider` over a real ObjectQL engine on a real socket, through this client: each of the three came back absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, and `null` in the `sys_oauth_application` row.

A second, independent barrier stands behind that strip — the handler funnels the parsed remainder into the opaque-metadata envelope, and all three names sit in `OPAQUE_METADATA_RESERVED_FIELDS` — so no amount of loosening on the SDK side could ever have made them arrive. `metadata` in particular is honoured only by `PATCH /admin/oauth2/update-client`, which is `SERVER_ONLY` and therefore not an HTTP route at all: over the wire it answers 404 with a zero-byte body.

Nothing else on the method moves. The two members the route does honour, `client_name` and `scope`, are declared exactly as before and still reach the server byte for byte; the method's return type, its URL and its request-building step are unchanged.

Graded `minor` rather than `patch` because a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch #35) that such a change takes at least `minor`; the banner above carries the breaking-ness the level cannot.

<!-- adr-0087: not-required (no-migration-prescription) Claimed on a POSITIVE argument rather than on the detector finding nothing. Stated plainly: the table above IS a prescription, and it is addressed to a TYPESCRIPT CONSUMER at their own call site, delivered by the compiler — the audience ADR-0087's D8 addendum says the ledger explicitly does not serve. Nothing authorable moves: no spec key, no Zod schema, no object definition, no config field and no stored representation changes spelling or shape, so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. Minting a ledger id here would put a prescription in the one ledger this gate keeps true that none of its three consumers can project. The other four categories are closed on facts: `@objectstack/client` publishes to npm (not `unpublished`); no id pre-dates the base (not `already-registered`); no named symbol is a non-metadata runtime interface whose members moved (not `runtime-interface-only`); and `type-surface-only` fails its predicate 4 (`narrowed-from-erased`), because the request type was concretely declared at the merge base rather than `any` — this narrowing removes members from a concrete type instead of replacing an erased one. ⚠️ Residual declared rather than hidden: the vocabulary still has no category for a source-author prescription the ledger must not carry, which is D8's blind spot reached from a third direction; raised for the maintainer in the PR report rather than resolved by dropping the BREAKING banner. -->
41 changes: 38 additions & 3 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3842,22 +3842,57 @@ export class ObjectStackClient {
*
* Returns the freshly-issued `client_id` and `client_secret`.
* The secret is only returned at creation time — store it securely.
*
* ## Why `name`, `scopes` and `metadata` are NOT declared here (#15447)
*
* They used to be, and the route silently dropped all three. Its body
* schema is `@better-auth/oauth-provider@1.7.2`'s, a zod object with no
* `catchall` — so zod's default `strip` — and none of the three is among
* its 21 members. A caller who set one got **HTTP 201 and a client that
* quietly did not have it**: no error, no receipt, nothing to notice.
* Driven end to end (real `betterAuth` + real `oauthProvider` over the
* real ObjectQL engine on a real socket, through this very client): each
* member came back absent from the response, absent from
* `applications.get`, absent from `applications.list`, and `null` in the
* `sys_oauth_application` row.
*
* A second, independent barrier stands behind the strip, so widening the
* SDK alone could never have made them arrive: the handler funnels the
* rest of the parsed body into the opaque-metadata envelope, and all
* three names are in `OPAQUE_METADATA_RESERVED_FIELDS`.
*
* ## ⚠️ They were the vendor's RECORD vocabulary, not typos
*
* The two near-misses look like misspellings of `client_name` and
* `scope` and are not — they are the names of the DB columns those two
* wire members write. Measured: `client_name: 'CTRL-…'` lands in the
* column literally named **`name`**, and `scope: 'openid profile email'`
* lands in the column literally named **`scopes`**, as a JSON array. So
* this type used to offer the record spelling and the wire spelling side
* by side, and only the wire one worked. The right prescription is the
* wire member, and for `scopes` it is not a rename: `scope` is a single
* space-delimited `string`, and posting an array is refused —
* `400 [body.scope] Invalid input: expected string, received array`.
*
* `metadata` has no reachable door at all: only the SERVER_ONLY
* `PATCH /admin/oauth2/update-client` honours it, and `better-call`'s
* router skips SERVER_ONLY endpoints, so over HTTP it answers 404 with a
* zero-byte body.
*
* Pinned by `oauth-applications-register-request-members.test.ts`.
*/
register: async (req: {
client_name?: string;
name?: string;
redirect_uris: string[];
token_endpoint_auth_method?: 'none' | 'client_secret_basic' | 'client_secret_post';
grant_types?: string[];
response_types?: string[];
client_uri?: string;
logo_uri?: string;
scope?: string;
scopes?: string[];
contacts?: string[];
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`
Expand Down
Loading
Loading