Skip to content
Merged
56 changes: 56 additions & 0 deletions .changeset/client-oauth-delete-zero-byte-200.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/client": minor
---

fix(client)!: `oauth.applications.delete` resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete (#15451)

**BREAKING** on two independent axes, and it makes a published method usable for the first time. Before this change `client.oauth.applications.delete(id)` **rejected on every successful delete** — there was no success path a caller could observe. 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: registered client-oauth-applications-delete-void -->

The fifth and last method of the `oauth.*` family, and the one #14312 / PR #15445 deliberately could not close: its ruling fenced that card to *narrowing published return types*, and no declared return type could be true while the `res.json()` call stood.

## The defect, measured end to end

Real `betterAuth` + real `@better-auth/oauth-provider` over the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the **real** `ObjectStackClient` with only the socket stood in for:

```
POST /api/v1/auth/oauth2/delete-client -> 200 · 0 bytes
content-type: application/json
content-length: (absent)
through the client, BEFORE -> REJECTED: SyntaxError | Unexpected end of JSON input
the row, server-side -> ALREADY GONE (get-client answers 404 not_found)
through the client, AFTER -> RESOLVED | undefined
```

The handler returns nothing and the vendor declares the endpoint `void`. `res.json()` had nothing to parse, so the method rejected — *after* the delete had committed. A caller who did the obvious thing saw a failure, retried, and the retry failed **differently**, because the row no longer existed.

## What changes for a caller

| | before | now |
|:--|:--|:--|
| a successful delete | rejects `SyntaxError` | resolves |
| the resolved value | `any` (unreachable — the promise never resolved) | `void` |
| deleting a client that is not there | rejects `not_found` | rejects `not_found` — unchanged |
| a malformed non-empty body | rejects `SyntaxError` | rejects `SyntaxError` — unchanged |

⚠️ **The `catch` you wrote around this call stops firing on success.** Code shaped like

```ts
try { await client.oauth.applications.delete(id); }
catch { /* the delete probably worked anyway */ }
```

still compiles and still runs, but its catch block was executing on **every** successful delete and now executes only on a real failure. Any workaround that lived in there is now inert and can be deleted. And because the promise never used to resolve, a read off its resolved value — `(await …delete(id)).deleted` — was dead code that has never executed; it now stops compiling (TS2339), which is the compiler delivering the change at the call site.

## Why `void`, and not `{ deleted: boolean }`

"Deleted" and "was already gone" **are** distinguished by the route, but on the error channel: a missing client answers 404 `{ error: 'not_found' }`, which the client already raises as a throw. The 200 answer carries zero bytes and therefore zero information, so a synthesised `{ deleted: true }` would be a shape the wire never sends and strictly less informative than the 404 a caller already receives.

## Why the emptiness is detected by reading the body

Both shortcuts were measured against the real route and both are unusable: the status is **200**, not the `204` five other delete surfaces in this client key off, and the response carries **no `content-length` header at all** — so a header test would never fire and would leave the defect in place while looking like a fix. The body itself is the only thing that answers.

A non-empty body is still parsed and its failure still thrown, so **the only behaviour this change moves is the zero-byte case**: a malformed response stays loud, and the day this route grows a payload, surfacing it is a deliberate widening of the return type rather than a silent change of shape.

`packages/client/exported-any-returns.json` loses this method's entry in the same change — the ledger is shrink-only, so the entry goes **with** the binding. Its last `oauth.*` entry is now gone; 35 sites remain open.
1 change: 0 additions & 1 deletion packages/client/exported-any-returns.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"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.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.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
61 changes: 46 additions & 15 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3224,29 +3224,60 @@ 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.
* ## Why this method does not call `res.json()` (#15451)
*
* 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.
* Measured against a real server — real `betterAuth` + real
* `oauthProvider` over the real ObjectQL adapter, driven through this
* very client with only the socket stood in for:
*
* 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.
* POST /oauth2/delete-client -> 200 · 0 bytes
* content-type: application/json
* content-length: (absent)
*
* The handler returns nothing and the vendor declares the endpoint
* `void` (`StrictEndpoint<'/oauth2/delete-client', …, void>`). A
* `res.json()` on that body therefore rejected with `SyntaxError:
* Unexpected end of JSON input` on EVERY successful delete, while the
* row was already gone server-side — so the method had no success path
* a caller could observe, and the obvious recovery (retry) failed
* DIFFERENTLY, with the route's 404 `not_found`.
*
* ⛔ Emptiness is detected by READING the body, not from the status and
* not from `content-length`. Both were measured and both are unusable
* here: the status is `200`, not the `204` the `{ deleted: true }`
* shortcut elsewhere in this file keys off, and the response carries NO
* `content-length` header at all. Only the body itself answers.
*
* ⛔ The parse below is NOT decoration and must not be deleted as dead
* code on the grounds that nothing reads its value. It is what keeps
* this method LOUD on a malformed non-empty body: a body that is
* present but unparseable still rejects exactly as it did before, so
* the ONLY behaviour this method changed is the zero-byte case — the
* defect itself. Pinned by `oauth-applications-delete.test.ts`.
*
* ## Why `void`, and not `{ deleted: boolean }`
*
* "Deleted" and "was already gone" are distinguished by the route, but
* on the ERROR channel, not in the success value: a client that is not
* there answers 404 `{ error: 'not_found' }`, which `this.fetch` has
* already turned into a throw before this line runs. The 200 answer
* carries zero bytes and therefore zero information, so a synthesised
* `{ deleted: true }` would be a value the wire cannot support and
* strictly less informative than the 404 the caller already gets.
*/
delete: async (clientId: string) => {
delete: async (clientId: string): Promise<void> => {
const route = this.getRoute('auth');
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/delete-client`, {
method: 'POST',
body: JSON.stringify({ client_id: clientId }),
});
return res.json();
const body = await res.text();
if (body === '') return;
// Present but unread: validated so a malformed body still speaks, and
// discarded because the declared contract is `void`. The day this
// route starts answering a payload, widening the return type is a
// deliberate, reviewable edit here — never a silent change of shape.
JSON.parse(body);
},
},

Expand Down
131 changes: 131 additions & 0 deletions packages/client/src/oauth-applications-delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#15451] `oauth.applications.delete` must RESOLVE on the zero-byte 200 its
* route actually answers — and must still reject, loudly, on anything else.
*
* ## Why this file exists at all, when its sibling is type-level
*
* `return-type-precision.test.ts` says in its own header that a runtime test
* cannot observe a return-type narrowing: the value is identical either way.
* The reverse is true here and is the whole point. This card did not narrow a
* declaration — it changed what the method DOES. Before it, the method called
* `res.json()` on a body of zero bytes and REJECTED with `SyntaxError:
* Unexpected end of JSON input` on every successful delete; after it, the
* same call resolves. No compile-time assertion can see a reject/resolve
* flip, so the two files pin the two halves and neither is redundant.
*
* ## The wire fact these fixtures encode, measured not assumed
*
* Real `betterAuth` + real `@better-auth/oauth-provider` over the real
* ObjectQL adapter, driven through the real `ObjectStackClient` with only the
* socket stood in for:
*
* POST /api/v1/auth/oauth2/delete-client
* -> 200 · 0 bytes · content-type: application/json · NO content-length
*
* Both shortcuts a reader will reach for were measured and both are unusable,
* which is why the fix reads the body instead:
*
* - `res.status === 204` — the spelling five other delete surfaces in
* `index.ts` use. The status here is **200**, so it never fires.
* - `content-length === '0'` — the header is **absent**, not zero, so a
* header test never fires either and would leave the defect in place
* while looking like a fix.
*
* ## ⛔ The malformed-body case is load-bearing, not leftover
*
* The implementation still runs `JSON.parse` on a NON-EMPTY body and throws
* the result away. That reads like dead code and is not: it is what keeps a
* malformed response loud, so the ONLY behaviour the card changed is the
* zero-byte case — the defect itself. Delete the parse "because nothing reads
* it" and `expect(...).rejects` below goes red, by design.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackClient } from './index';

const BASE = 'http://localhost:3000';
const DELETE_URL = `${BASE}/api/v1/auth/oauth2/delete-client`;

/**
* A client whose transport answers with a REAL `Response`. Deliberately not a
* hand-rolled double with a stubbed `json()`: the defect lived in how a real
* `Response` behaves when its body is empty, and a double that answers
* `json: async () => undefined` cannot reproduce it — it would have been
* green against the broken client too.
*/
function clientAnswering(body: BodyInit | null, init?: ResponseInit) {
const fetchMock = vi.fn(async () => new Response(body, init));
const client = new ObjectStackClient({ baseUrl: BASE, fetch: fetchMock as never });
return { client, fetchMock };
}

/** The exact answer the route was measured to send on a successful delete. */
const ZERO_BYTE_200: [BodyInit | null, ResponseInit] = [
null,
{ status: 200, headers: { 'content-type': 'application/json' } },
];

describe('#15451 oauth.applications.delete — the zero-byte 200', () => {
it('RESOLVES on the 200 / zero-byte answer the route actually sends', async () => {
const { client } = clientAnswering(...ZERO_BYTE_200);
// ⚠️ RED BEFORE: this rejected with `SyntaxError: Unexpected end of JSON
// input`, on the successful path, every single time.
await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined();
});

it('resolves on an empty-STRING body too — the same zero bytes, spelled differently', async () => {
const { client } = clientAnswering('', { status: 200 });
await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined();
});

it('sends the same request bytes as before — only the RESPONSE handling moved', async () => {
const { client, fetchMock } = clientAnswering(...ZERO_BYTE_200);
await client.oauth.applications.delete('c_1');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(DELETE_URL);
expect(init.method).toBe('POST');
expect(init.body).toBe(JSON.stringify({ client_id: 'c_1' }));
});

it('⛔ still REJECTS on a malformed non-empty body — the parse is not decoration', async () => {
const { client } = clientAnswering('{ not json', { status: 200 });
// Green in BOTH states, and recorded as such: it is here to go RED if
// someone removes the `JSON.parse` as unused, which would trade this
// card's loud bug for a quiet one.
await expect(client.oauth.applications.delete('c_1')).rejects.toThrow(SyntaxError);
});

it('rejects on a whitespace-only body — the boundary is EXACTLY zero bytes', async () => {
const { client } = clientAnswering('\n', { status: 200 });
// Stated rather than left to drift: the tolerated case is the empty body
// the route sends, not "anything that looks blank". A body that is
// present but not JSON is a malformed response and says so.
await expect(client.oauth.applications.delete('c_1')).rejects.toThrow(SyntaxError);
});

it('resolves and DISCARDS a well-formed body, should the route ever grow one', async () => {
const { client } = clientAnswering(JSON.stringify({ deleted: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
// The declared contract is `void`. A payload arriving here is validated
// and dropped; surfacing it is a deliberate widening of the return type,
// never a silent change of shape under an unchanged declaration.
await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined();
});

it('"already gone" still arrives as a THROW, which is what makes `void` honest', async () => {
// The route distinguishes deleted from already-gone on the ERROR channel:
// a missing client answers 404 `{ error: 'not_found' }`. `this.fetch`
// raises that before any success value exists, so the success answer has
// no information left to carry and `{ deleted: true }` would be invented.
const { client } = clientAnswering(
JSON.stringify({ error_description: 'client not found', error: 'not_found' }),
{ status: 404, headers: { 'content-type': 'application/json' } },
);
await expect(client.oauth.applications.delete('gone')).rejects.toThrow(/not_found/);
});
});
Loading
Loading