Skip to content
Draft
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
70 changes: 70 additions & 0 deletions .changeset/client-oauth-delete-zero-byte-200.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
"@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 disposition: BLOCKED ON A DECISION, and deliberately not claimed. This is
NOT a disposition marker and must not be read as one; the gate is expected to red on
this changeset until a maintainer settles which category applies. Measured, both legs:
(1) `type-surface-only packages/client/src/index.ts#delete` is REFUSED at predicate 4
-- a reference is a bare identifier resolved to the FIRST same-named definition, and
this file declares TEN members named `delete`; the first (line 2397) is unannotated at
both revs, so the gate reads "still UNANNOTATED" about a member this diff never
touched. That is issue #15627, filed off PR #15445 where the same ambiguity cost the
`get` member its place in the marker -- here it blocks the ONLY member there is.
(2) `no-migration-prescription` is mechanically ACCEPTED (the detector finds no
prescription in this body) and is not claimed for that reason: ADR-0087's own D7
records that #8277 held this exemption on a detector MISS rather than a positive
finding, and names that as the pattern the sixth category was created to stop.
Claiming it here, with the measurement in hand, would repeat it knowingly.
Neighbouring open card: #14502. Full analysis in this PR's body. -->

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