Skip to content

Commit f8fea00

Browse files
claude[bot]claude
andauthored
fix(client): default organizations.invite role to 'member' so the shorter call works (#17270)
* fix(client): default organizations.invite role to 'member' better-auth 1.7.2's body schema for POST /organization/invite-member makes `role` REQUIRED, while the SDK declared it optional and forwarded the caller's object verbatim. The shorter call the declaration advertises — `invite({ email, organizationId })` — was therefore refused with `400 [body.role] Invalid input` before it reached any ObjectStack code. `role` stays optional; omitting it now sends 'member', which is exactly what the sibling `invitations.resend` has always substituted over the same vendor endpoint. One family, one behaviour, and no published type moves. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com> * test(client): pin the explicitly-undefined role as the same call as omission A caller writing `invite({ email, role: maybeRole })` with an empty variable must reach the wire as `role: 'member'`, not as no role at all. This is the case that separates `{ ...req, role: req.role ?? 'member' }` from the `{ role: 'member', ...req }` spelling, which agrees with it on every status the driven cases can observe. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com> * chore(changeset): patch @objectstack/client for the invite role default Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ef8247 commit f8fea00

3 files changed

Lines changed: 377 additions & 1 deletion

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582)
6+
7+
`organizations.invite` declares `role?` as **optional** and forwarded the caller's object to better-auth verbatim. better-auth 1.7.2's body schema for `POST /organization/invite-member` makes `role` **required**, so the documented-looking minimal call was refused before it reached any ObjectStack code:
8+
9+
```
10+
client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR)
11+
```
12+
13+
Omitting `role` now sends `'member'`. **No published type moves**`role` stays optional, and a caller who names a role still gets exactly that role on the wire (including `role: undefined`, which is treated as omission rather than dropped).
14+
15+
The default is `'member'` because the sibling `organizations.invitations.resend` has always substituted exactly that over the **same** vendor endpoint. That asymmetry is why the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. It is also the least-privileged name in the closed membership vocabulary (ADR-0108 D1 — `orgRoleGrade` floors at `member` and rises only for `owner`/`admin`), and an invitation is a pending row the invitee must still accept, so the implicit choice cannot confer reach the caller did not ask for.
16+
17+
Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after:
18+
19+
```
20+
before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"}
21+
after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...}
22+
```
23+
24+
No caller had to change: the census found no in-repo or Console caller using the two-argument form, so this repairs a path that was declared and unreachable rather than one that was in use.

packages/client/src/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3508,12 +3508,34 @@ export class ObjectStackClient {
35083508

35093509
/**
35103510
* Invite a user to the organization.
3511+
*
3512+
* `role` is declared optional and STAYS optional — omitting it sends
3513+
* `'member'`. better-auth 1.7.2's body schema for
3514+
* `POST /organization/invite-member` makes `role` REQUIRED, so the shorter
3515+
* call the declaration advertises was refused before it reached any
3516+
* ObjectStack code. Measured against a real `AuthManager` (better-auth
3517+
* 1.7.2, organization plugin, `teams: { enabled: true }`) over a real
3518+
* `SqlDriver` (better-sqlite3):
3519+
*
3520+
* ```
3521+
* invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR)
3522+
* invite({ email, role: 'member', organizationId }) -> 200 status: 'pending'
3523+
* ```
3524+
*
3525+
* The default is `'member'` because {@link ObjectStackClient.organizations}
3526+
* `.invitations.resend` already substitutes exactly that over the same
3527+
* vendor endpoint: one family, one behaviour. It is also the least
3528+
* privileged name in the closed membership vocabulary (ADR-0108 D1 —
3529+
* `orgRoleGrade` floors at `member` and raises only for `owner`/`admin`),
3530+
* so the implicit choice cannot confer more reach than the caller asked
3531+
* for. Declaring `role` required instead would narrow a published request
3532+
* type to restate the vendor's requirement, and buy nothing.
35113533
*/
35123534
invite: async (req: { email: string; role?: string; organizationId?: string }): Promise<OrganizationInvitationWire<'pending'>> => {
35133535
const route = this.getRoute('auth');
35143536
const res = await this.fetch(`${this.baseUrl}${route}/organization/invite-member`, {
35153537
method: 'POST',
3516-
body: JSON.stringify(req),
3538+
body: JSON.stringify({ ...req, role: req.role ?? 'member' }),
35173539
});
35183540
return res.json();
35193541
},
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#16582] `organizations.invite` declares `role?` optional, so the shorter
5+
* call it advertises must actually work: omitting `role` sends `'member'`.
6+
*
7+
* ## The defect
8+
*
9+
* better-auth 1.7.2's body schema for `POST /organization/invite-member` makes
10+
* `role` REQUIRED. The SDK declared it optional and forwarded the caller's
11+
* object verbatim, so the documented-looking minimal call —
12+
* `invite({ email, organizationId })` — was refused with
13+
* `400 [body.role] Invalid input` (`VALIDATION_ERROR`) before it reached any
14+
* ObjectStack code. Its sibling `invitations.resend` has always substituted
15+
* `'member'` over the SAME vendor endpoint, which is exactly why the gap stayed
16+
* invisible: one member of the family papered over the vendor's requirement and
17+
* the other did not.
18+
*
19+
* ## Why this file boots the real server rather than asserting on a double
20+
*
21+
* The claim under test is "the vendor accepts what the SDK now sends". Only
22+
* better-auth's own zod body schema can settle that — a hand-written stand-in
23+
* would let this suite certify the SDK against a requirement this file
24+
* invented, and a status-only mock would have been green before the fix and
25+
* green after it. So the arrangement is the card's probe with only the socket
26+
* stood in for: a real `AuthManager` (better-auth 1.7.2, organization plugin,
27+
* `teams: { enabled: true }` — its defaults) over a real `ObjectQL` on a real
28+
* `SqliteWasmDriver`, and an `ObjectStackClient` whose `fetch` hands the
29+
* `Request` straight to `AuthManager.handleRequest`.
30+
*
31+
* Cases ① – ③ are RED on the defect: ① and ③ throw
32+
* `[body.role] Invalid input`, and ② throws for the same reason before it can
33+
* observe the role it sent. That is what makes them a pin on the DEFECT.
34+
*
35+
* ## Case ④ is a different mechanism and neither half can do the other's job
36+
*
37+
* The drive proves the vendor accepts the body; it cannot see a body that
38+
* carries MORE than it should. The SDK serialises the caller's object straight
39+
* into the request, so a later "helpful" translation layer could add or rename
40+
* members without changing a type and without changing a status. ④ therefore
41+
* holds the request bytes to FULL-STRING equality — never `toContain`, which a
42+
* body carrying extra members would satisfy.
43+
*
44+
* It is also the guard that the default is applied by SUBSTITUTION rather than
45+
* by ordering. The tempting other spelling, `{ role: 'member', ...req }`, agrees
46+
* with the shipped one on every status cases ① – ③ can observe, and differs in
47+
* exactly two places ④ can: it re-orders the body, and — because a spread
48+
* copies an explicitly-`undefined` member over the default while `??` does not
49+
* — it puts a caller's `role: undefined` back on the wire as no `role` at all,
50+
* restoring the 400 for the caller who wrote `invite({ email, role: maybe })`.
51+
* ④c is that case.
52+
*
53+
* ## ⑤ pins the shape of the fix, not just its effect
54+
*
55+
* The other self-consistent repair — declaring `role` REQUIRED — is a
56+
* narrowing of a published request type. It was weighed and rejected on this
57+
* card: it restates the vendor's requirement at a real cost to every existing
58+
* caller, while the default costs no type change at all. ⑤ is a compile-time
59+
* assertion that `role` is still optional, so that route cannot be taken later
60+
* by accident.
61+
*/
62+
63+
import { describe, it, expect, expectTypeOf, vi, beforeAll, afterAll } from 'vitest';
64+
import { ObjectQL } from '@objectstack/objectql';
65+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
66+
import { AuthManager } from '@objectstack/plugin-auth';
67+
import * as identityObjects from '@objectstack/platform-objects/identity';
68+
import { ObjectStackClient } from './index';
69+
70+
const SECRET = 'test-secret-at-least-32-chars-long!!';
71+
const ORIGIN = 'http://localhost:3000';
72+
const PASSWORD = 'S3cure!Passw0rd-16582';
73+
74+
/**
75+
* The identity objects this arrangement stands up, read out of
76+
* `@objectstack/platform-objects/identity` BY SHAPE rather than transcribed:
77+
* plugin-auth's own list is package-private, and a hand-copied one here would
78+
* be a second declaration of the same set, drifting the day the plugin
79+
* registers one more. Same derivation as `auth-rotated-session-token.test.ts`.
80+
*/
81+
const IDENTITY_OBJECTS = Object.values(
82+
identityObjects as unknown as Record<string, unknown>,
83+
).filter(
84+
(o): o is Record<string, unknown> =>
85+
!!o &&
86+
typeof o === 'object' &&
87+
typeof (o as { name?: unknown }).name === 'string' &&
88+
typeof (o as { fields?: unknown }).fields === 'object',
89+
);
90+
91+
/**
92+
* `beforeCreateOrganization` refuses to mint an organization unless a
93+
* multi-organization posture is standing, and this suite needs one to invite
94+
* INTO. Set for the whole file and restored after, so a sibling suite in the
95+
* same worker is never handed a posture it did not ask for.
96+
*/
97+
const PRIOR_POSTURE = process.env.OS_TENANCY_POSTURE;
98+
99+
beforeAll(() => {
100+
process.env.OS_TENANCY_POSTURE = 'isolated';
101+
});
102+
103+
afterAll(() => {
104+
if (PRIOR_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
105+
else process.env.OS_TENANCY_POSTURE = PRIOR_POSTURE;
106+
});
107+
108+
let seq = 0;
109+
const nextEmail = (tag: string) => `os16582-${tag}-${++seq}-${Date.now()}@example.com`;
110+
111+
interface Rig {
112+
client: ObjectStackClient;
113+
organizationId: string;
114+
}
115+
116+
/**
117+
* A signed-in organization owner and the organization they own — every layer
118+
* below the SDK is the real one.
119+
*/
120+
async function arrange(): Promise<Rig> {
121+
const engine = new ObjectQL();
122+
engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true);
123+
await engine.init();
124+
for (const object of IDENTITY_OBJECTS) {
125+
engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
126+
}
127+
await engine.syncSchemas();
128+
129+
const manager = new AuthManager({
130+
secret: SECRET,
131+
baseUrl: ORIGIN,
132+
dataEngine: engine,
133+
} as never);
134+
135+
const client = new ObjectStackClient({
136+
baseUrl: ORIGIN,
137+
fetch: (input: RequestInfo | URL, init?: RequestInit) =>
138+
manager.handleRequest(new Request(String(input), init)),
139+
});
140+
141+
await client.auth.register({
142+
email: nextEmail('owner'),
143+
password: PASSWORD,
144+
name: 'Org Owner',
145+
});
146+
147+
const organization = await client.organizations.create({
148+
name: 'Invite Default Org',
149+
slug: `os16582-${seq}-${Date.now()}`,
150+
});
151+
const organizationId = (organization as unknown as { id: string }).id;
152+
expect(organizationId, 'no organization was minted — the premise of this suite is gone').toBeTruthy();
153+
await client.organizations.setActive(organizationId);
154+
155+
return { client, organizationId };
156+
}
157+
158+
// ─────────────────────────────────────────────────────────────────────────
159+
// ① the card's call, against the real vendor schema
160+
// ─────────────────────────────────────────────────────────────────────────
161+
162+
describe('#16582 organizations.invite defaults role to member', () => {
163+
it('① the two-argument form is accepted and lands a pending member invitation', async () => {
164+
const { client, organizationId } = await arrange();
165+
166+
const invitation = await client.organizations.invite({
167+
email: nextEmail('invitee'),
168+
organizationId,
169+
});
170+
171+
// Before the fix this line was never reached: the call threw
172+
// `[body.role] Invalid input` at 400.
173+
expect(invitation.status).toBe('pending');
174+
expect(invitation.role).toBe('member');
175+
expect(invitation.organizationId).toBe(organizationId);
176+
});
177+
178+
// ───────────────────────────────────────────────────────────────────────
179+
// ② a role the caller DID name is never overwritten
180+
// ───────────────────────────────────────────────────────────────────────
181+
182+
it('② an explicit role survives — the default substitutes, it does not clobber', async () => {
183+
const { client, organizationId } = await arrange();
184+
185+
const invitation = await client.organizations.invite({
186+
email: nextEmail('admin-invitee'),
187+
role: 'admin',
188+
organizationId,
189+
});
190+
191+
expect(invitation.role).toBe('admin');
192+
expect(invitation.status).toBe('pending');
193+
});
194+
195+
// ───────────────────────────────────────────────────────────────────────
196+
// ③ the asymmetry the card is about is gone
197+
// ───────────────────────────────────────────────────────────────────────
198+
199+
it('③ invite and its sibling resend agree on the shorter call', async () => {
200+
const { client, organizationId } = await arrange();
201+
202+
const invited = await client.organizations.invite({
203+
email: nextEmail('family-invite'),
204+
organizationId,
205+
});
206+
const resent = await client.organizations.invitations.resend({
207+
email: nextEmail('family-resend'),
208+
organizationId,
209+
});
210+
211+
// One family, one behaviour — this is the equality the card asked for.
212+
expect(invited.role).toBe(resent.role);
213+
expect(invited.role).toBe('member');
214+
});
215+
});
216+
217+
// ─────────────────────────────────────────────────────────────────────────
218+
// ④ the request BYTES — a mechanism the drive above structurally cannot see
219+
// ─────────────────────────────────────────────────────────────────────────
220+
221+
describe('#16582 the bytes organizations.invite puts on the wire', () => {
222+
const INVITE_URL = `${ORIGIN}/api/v1/auth/organization/invite-member`;
223+
224+
/** The 200 the route answers; identical before and after this card. */
225+
const PENDING = JSON.stringify({
226+
organizationId: 'org_probe',
227+
email: 'probe@example.com',
228+
role: 'member',
229+
teamId: null,
230+
status: 'pending',
231+
expiresAt: '2026-09-12T00:16:41.261Z',
232+
createdAt: '2026-09-10T00:16:41.261Z',
233+
inviterId: 'usr_probe',
234+
id: 'inv_probe',
235+
});
236+
237+
function capturing() {
238+
const fetchMock = vi.fn(
239+
async () =>
240+
new Response(PENDING, { status: 200, headers: { 'content-type': 'application/json' } }),
241+
);
242+
const client = new ObjectStackClient({ baseUrl: ORIGIN, fetch: fetchMock as never });
243+
return { client, fetchMock };
244+
}
245+
246+
function soleBody(fetchMock: ReturnType<typeof capturing>['fetchMock']): string {
247+
expect(fetchMock).toHaveBeenCalledTimes(1);
248+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
249+
expect(url).toBe(INVITE_URL);
250+
return String(init.body);
251+
}
252+
253+
it('④a omitting role sends exactly the caller object plus role: "member"', async () => {
254+
const { client, fetchMock } = capturing();
255+
256+
await client.organizations.invite({ email: 'probe@example.com', organizationId: 'org_probe' });
257+
258+
// FULL-STRING equality: a body carrying an extra member, a renamed one, or
259+
// a second `role` fails here even though the vendor would still answer 200.
260+
expect(soleBody(fetchMock)).toBe(
261+
JSON.stringify({ email: 'probe@example.com', organizationId: 'org_probe', role: 'member' }),
262+
);
263+
});
264+
265+
it('④b naming role sends that role, once', async () => {
266+
const { client, fetchMock } = capturing();
267+
268+
await client.organizations.invite({
269+
email: 'probe@example.com',
270+
role: 'admin',
271+
organizationId: 'org_probe',
272+
});
273+
274+
expect(soleBody(fetchMock)).toBe(
275+
JSON.stringify({ email: 'probe@example.com', role: 'admin', organizationId: 'org_probe' }),
276+
);
277+
});
278+
279+
it('④c an explicitly-undefined role is the same call as omitting it', async () => {
280+
const { client, fetchMock } = capturing();
281+
282+
// What `invite({ email, role: maybeRole })` compiles to when the variable
283+
// is empty — indistinguishable from omission to the caller, and it must be
284+
// indistinguishable on the wire too.
285+
await client.organizations.invite({
286+
email: 'probe@example.com',
287+
role: undefined,
288+
organizationId: 'org_probe',
289+
});
290+
291+
expect(soleBody(fetchMock)).toBe(
292+
JSON.stringify({ email: 'probe@example.com', role: 'member', organizationId: 'org_probe' }),
293+
);
294+
});
295+
});
296+
297+
// ─────────────────────────────────────────────────────────────────────────
298+
// ⑤ the pin on the SHAPE of the fix — compile-time, never invoked
299+
// ─────────────────────────────────────────────────────────────────────────
300+
301+
declare const typedClient: ObjectStackClient;
302+
303+
/** The declared request type of the method this card repairs. */
304+
type InviteRequest = Parameters<ObjectStackClient['organizations']['invite']>[0];
305+
306+
/**
307+
* Compiled by `packages/client/tsconfig.test.json` (which includes `src/**` and
308+
* is reached by `package.json`'s `typecheck` script through
309+
* `check:test-typecheck`), never invoked — every statement is an assertion tsc
310+
* evaluates, and none of them may perform a request. Same arrangement as
311+
* `oauth-applications-register-request-members.test.ts`'s pins.
312+
*
313+
* This is the guard against the route that was weighed and REJECTED on this
314+
* card: declaring `role` required. Doing that makes the first statement red
315+
* (the two-argument literal stops satisfying the parameter) and the
316+
* `Exclude<…, undefined>` inequality red as well, so the narrowing cannot land
317+
* quietly under a green suite.
318+
*/
319+
export async function inviteRoleStaysOptional16582(): Promise<void> {
320+
// The call the card is about must remain expressible.
321+
await typedClient.organizations.invite({ email: 'e@example.com', organizationId: 'o' });
322+
// …and so must the bare one.
323+
await typedClient.organizations.invite({ email: 'e@example.com' });
324+
325+
// `role` is optional: dropping `undefined` from it changes the type, which is
326+
// only true while `undefined` is in it.
327+
expectTypeOf<InviteRequest['role']>().not.toEqualTypeOf<
328+
Exclude<InviteRequest['role'], undefined>
329+
>();
330+
}

0 commit comments

Comments
 (0)