Skip to content

Commit bccf311

Browse files
claude[bot]claude
andauthored
fix(client)!: oauth.applications.register declares only the members /oauth2/create-client accepts (#17209)
* fix(client)!: oauth.applications.register declares only members /oauth2/create-client accepts `name`, `scopes` and `metadata` were never honoured by the route this method posts to. The vendor body schema (`@better-auth/oauth-provider@1.7.2`) has no `catchall`, so zod strips them: the caller got HTTP 201 and a client that quietly did not have the value. Driven on the card — absent from the response, from `applications.get`, from `applications.list`, and `null` in the `sys_oauth_application` row. The two near-misses were the vendor's RECORD vocabulary, not typos: `client_name` writes the DB column named `name`, and `scope` writes the column named `scopes`. `scopes` -> `scope` is therefore not a rename — the array form is refused (`400 [body.scope] Invalid input: expected string, received array`) and only a space-joined string passes. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com> * docs(changeset): BREAKING banner and the FROM -> TO prescription for #15447 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com> * test(client): the array-form scope case sends the body that produced the recorded 400 The third case in the #15447 pin paired a VALID request (the joined string the route accepts with 201) with the error the INVALID request produces, against a double that answers 400 unconditionally — so its title asserted two things the case could not see. It now sends the array form through a suppression, which is the body that actually drew the recorded refusal, and the title and comment say what it pins: the SDK's handling of that refusal, never the route's verdict. The suppression is load-bearing: after this card's narrowing the array form is no longer expressible through the declared type, and widening `scope` to accept one leaves the directive unused and this case red. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e45ee8 commit bccf311

3 files changed

Lines changed: 322 additions & 3 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client): `oauth.applications.register` declares only the members `/oauth2/create-client` accepts — `name`, `scopes` and `metadata` are removed (#15447)
6+
7+
**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.
8+
9+
## What a caller passing these members should do instead
10+
11+
| you were passing | pass instead | why |
12+
|---|---|---|
13+
| `name: 'My App'` | `client_name: 'My App'` | same `string`, and `client_name` is the member the route reads |
14+
| `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` |
15+
| `metadata: { tenant: 'acme' }` | nothing — delete the member | no door this SDK can reach accepts it (see below) |
16+
17+
## ⚠️ These were the vendor's RECORD vocabulary, not typos
18+
19+
`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.
20+
21+
## Why they had to go rather than be honoured here
22+
23+
`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.
24+
25+
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.
26+
27+
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.
28+
29+
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.
30+
31+
<!-- 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. -->

packages/client/src/index.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3842,22 +3842,57 @@ export class ObjectStackClient {
38423842
*
38433843
* Returns the freshly-issued `client_id` and `client_secret`.
38443844
* The secret is only returned at creation time — store it securely.
3845+
*
3846+
* ## Why `name`, `scopes` and `metadata` are NOT declared here (#15447)
3847+
*
3848+
* They used to be, and the route silently dropped all three. Its body
3849+
* schema is `@better-auth/oauth-provider@1.7.2`'s, a zod object with no
3850+
* `catchall` — so zod's default `strip` — and none of the three is among
3851+
* its 21 members. A caller who set one got **HTTP 201 and a client that
3852+
* quietly did not have it**: no error, no receipt, nothing to notice.
3853+
* Driven end to end (real `betterAuth` + real `oauthProvider` over the
3854+
* real ObjectQL engine on a real socket, through this very client): each
3855+
* member came back absent from the response, absent from
3856+
* `applications.get`, absent from `applications.list`, and `null` in the
3857+
* `sys_oauth_application` row.
3858+
*
3859+
* A second, independent barrier stands behind the strip, so widening the
3860+
* SDK alone could never have made them arrive: the handler funnels the
3861+
* rest of the parsed body into the opaque-metadata envelope, and all
3862+
* three names are in `OPAQUE_METADATA_RESERVED_FIELDS`.
3863+
*
3864+
* ## ⚠️ They were the vendor's RECORD vocabulary, not typos
3865+
*
3866+
* The two near-misses look like misspellings of `client_name` and
3867+
* `scope` and are not — they are the names of the DB columns those two
3868+
* wire members write. Measured: `client_name: 'CTRL-…'` lands in the
3869+
* column literally named **`name`**, and `scope: 'openid profile email'`
3870+
* lands in the column literally named **`scopes`**, as a JSON array. So
3871+
* this type used to offer the record spelling and the wire spelling side
3872+
* by side, and only the wire one worked. The right prescription is the
3873+
* wire member, and for `scopes` it is not a rename: `scope` is a single
3874+
* space-delimited `string`, and posting an array is refused —
3875+
* `400 [body.scope] Invalid input: expected string, received array`.
3876+
*
3877+
* `metadata` has no reachable door at all: only the SERVER_ONLY
3878+
* `PATCH /admin/oauth2/update-client` honours it, and `better-call`'s
3879+
* router skips SERVER_ONLY endpoints, so over HTTP it answers 404 with a
3880+
* zero-byte body.
3881+
*
3882+
* Pinned by `oauth-applications-register-request-members.test.ts`.
38453883
*/
38463884
register: async (req: {
38473885
client_name?: string;
3848-
name?: string;
38493886
redirect_uris: string[];
38503887
token_endpoint_auth_method?: 'none' | 'client_secret_basic' | 'client_secret_post';
38513888
grant_types?: string[];
38523889
response_types?: string[];
38533890
client_uri?: string;
38543891
logo_uri?: string;
38553892
scope?: string;
3856-
scopes?: string[];
38573893
contacts?: string[];
38583894
tos_uri?: string;
38593895
policy_uri?: string;
3860-
metadata?: Record<string, unknown>;
38613896
}): Promise<OAuthApplicationRegistration> => {
38623897
const route = this.getRoute('auth');
38633898
// The new oauth-provider package exposes `/oauth2/create-client`

0 commit comments

Comments
 (0)