From 2c90dad0d08df19b887c21cc1c437c340619b2af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:18:00 +0000 Subject: [PATCH 1/3] fix(client): default organizations.invite role to 'member' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/client/src/index.ts | 24 +- .../organization-invite-role-default.test.ts | 307 ++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 packages/client/src/organization-invite-role-default.test.ts diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ab4114f86c..264f459c6d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3508,12 +3508,34 @@ export class ObjectStackClient { /** * Invite a user to the organization. + * + * `role` is declared optional and STAYS optional — omitting it sends + * `'member'`. better-auth 1.7.2's body schema for + * `POST /organization/invite-member` makes `role` REQUIRED, so the shorter + * call the declaration advertises was refused before it reached any + * ObjectStack code. Measured against a real `AuthManager` (better-auth + * 1.7.2, organization plugin, `teams: { enabled: true }`) over a real + * `SqlDriver` (better-sqlite3): + * + * ``` + * invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR) + * invite({ email, role: 'member', organizationId }) -> 200 status: 'pending' + * ``` + * + * The default is `'member'` because {@link ObjectStackClient.organizations} + * `.invitations.resend` already substitutes exactly that over the same + * vendor endpoint: one family, one behaviour. It is also the least + * privileged name in the closed membership vocabulary (ADR-0108 D1 — + * `orgRoleGrade` floors at `member` and raises only for `owner`/`admin`), + * so the implicit choice cannot confer more reach than the caller asked + * for. Declaring `role` required instead would narrow a published request + * type to restate the vendor's requirement, and buy nothing. */ invite: async (req: { email: string; role?: string; organizationId?: string }): Promise> => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/invite-member`, { method: 'POST', - body: JSON.stringify(req), + body: JSON.stringify({ ...req, role: req.role ?? 'member' }), }); return res.json(); }, diff --git a/packages/client/src/organization-invite-role-default.test.ts b/packages/client/src/organization-invite-role-default.test.ts new file mode 100644 index 0000000000..a3dd614018 --- /dev/null +++ b/packages/client/src/organization-invite-role-default.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16582] `organizations.invite` declares `role?` optional, so the shorter + * call it advertises must actually work: omitting `role` sends `'member'`. + * + * ## The defect + * + * better-auth 1.7.2's body schema for `POST /organization/invite-member` makes + * `role` REQUIRED. The SDK declared it optional and forwarded the caller's + * object verbatim, so the documented-looking minimal call — + * `invite({ email, organizationId })` — was refused with + * `400 [body.role] Invalid input` (`VALIDATION_ERROR`) before it reached any + * ObjectStack code. Its sibling `invitations.resend` has always substituted + * `'member'` over the SAME vendor endpoint, which is exactly why the gap stayed + * invisible: one member of the family papered over the vendor's requirement and + * the other did not. + * + * ## Why this file boots the real server rather than asserting on a double + * + * The claim under test is "the vendor accepts what the SDK now sends". Only + * better-auth's own zod body schema can settle that — a hand-written stand-in + * would let this suite certify the SDK against a requirement this file + * invented, and a status-only mock would have been green before the fix and + * green after it. So the arrangement is the card's probe with only the socket + * stood in for: a real `AuthManager` (better-auth 1.7.2, organization plugin, + * `teams: { enabled: true }` — its defaults) over a real `ObjectQL` on a real + * `SqliteWasmDriver`, and an `ObjectStackClient` whose `fetch` hands the + * `Request` straight to `AuthManager.handleRequest`. + * + * Cases ① – ③ are RED on the defect: ① and ③ throw + * `[body.role] Invalid input`, and ② throws for the same reason before it can + * observe the role it sent. That is what makes them a pin on the DEFECT. + * + * ## Case ④ is a different mechanism and neither half can do the other's job + * + * The drive proves the vendor accepts the body; it cannot see a body that + * carries MORE than it should. The SDK serialises the caller's object straight + * into the request, so a later "helpful" translation layer could add or rename + * members without changing a type and without changing a status. ④ therefore + * holds the request bytes to FULL-STRING equality — never `toContain`, which a + * body carrying extra members would satisfy — and it is the guard that the + * default is applied by SUBSTITUTION, not by clobbering: a spread written the + * other way round (`{ role: 'member', ...req }`) passes ① and ③ and fails ② + * and ④. + * + * ## ⑤ pins the shape of the fix, not just its effect + * + * The other self-consistent repair — declaring `role` REQUIRED — is a + * narrowing of a published request type. It was weighed and rejected on this + * card: it restates the vendor's requirement at a real cost to every existing + * caller, while the default costs no type change at all. ⑤ is a compile-time + * assertion that `role` is still optional, so that route cannot be taken later + * by accident. + */ + +import { describe, it, expect, expectTypeOf, vi, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { AuthManager } from '@objectstack/plugin-auth'; +import * as identityObjects from '@objectstack/platform-objects/identity'; +import { ObjectStackClient } from './index'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const ORIGIN = 'http://localhost:3000'; +const PASSWORD = 'S3cure!Passw0rd-16582'; + +/** + * The identity objects this arrangement stands up, read out of + * `@objectstack/platform-objects/identity` BY SHAPE rather than transcribed: + * plugin-auth's own list is package-private, and a hand-copied one here would + * be a second declaration of the same set, drifting the day the plugin + * registers one more. Same derivation as `auth-rotated-session-token.test.ts`. + */ +const IDENTITY_OBJECTS = Object.values( + identityObjects as unknown as Record, +).filter( + (o): o is Record => + !!o && + typeof o === 'object' && + typeof (o as { name?: unknown }).name === 'string' && + typeof (o as { fields?: unknown }).fields === 'object', +); + +/** + * `beforeCreateOrganization` refuses to mint an organization unless a + * multi-organization posture is standing, and this suite needs one to invite + * INTO. Set for the whole file and restored after, so a sibling suite in the + * same worker is never handed a posture it did not ask for. + */ +const PRIOR_POSTURE = process.env.OS_TENANCY_POSTURE; + +beforeAll(() => { + process.env.OS_TENANCY_POSTURE = 'isolated'; +}); + +afterAll(() => { + if (PRIOR_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = PRIOR_POSTURE; +}); + +let seq = 0; +const nextEmail = (tag: string) => `os16582-${tag}-${++seq}-${Date.now()}@example.com`; + +interface Rig { + client: ObjectStackClient; + organizationId: string; +} + +/** + * A signed-in organization owner and the organization they own — every layer + * below the SDK is the real one. + */ +async function arrange(): Promise { + const engine = new ObjectQL(); + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); + await engine.init(); + for (const object of IDENTITY_OBJECTS) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + } as never); + + const client = new ObjectStackClient({ + baseUrl: ORIGIN, + fetch: (input: RequestInfo | URL, init?: RequestInit) => + manager.handleRequest(new Request(String(input), init)), + }); + + await client.auth.register({ + email: nextEmail('owner'), + password: PASSWORD, + name: 'Org Owner', + }); + + const organization = await client.organizations.create({ + name: 'Invite Default Org', + slug: `os16582-${seq}-${Date.now()}`, + }); + const organizationId = (organization as unknown as { id: string }).id; + expect(organizationId, 'no organization was minted — the premise of this suite is gone').toBeTruthy(); + await client.organizations.setActive(organizationId); + + return { client, organizationId }; +} + +// ───────────────────────────────────────────────────────────────────────── +// ① the card's call, against the real vendor schema +// ───────────────────────────────────────────────────────────────────────── + +describe('#16582 organizations.invite defaults role to member', () => { + it('① the two-argument form is accepted and lands a pending member invitation', async () => { + const { client, organizationId } = await arrange(); + + const invitation = await client.organizations.invite({ + email: nextEmail('invitee'), + organizationId, + }); + + // Before the fix this line was never reached: the call threw + // `[body.role] Invalid input` at 400. + expect(invitation.status).toBe('pending'); + expect(invitation.role).toBe('member'); + expect(invitation.organizationId).toBe(organizationId); + }); + + // ─────────────────────────────────────────────────────────────────────── + // ② a role the caller DID name is never overwritten + // ─────────────────────────────────────────────────────────────────────── + + it('② an explicit role survives — the default substitutes, it does not clobber', async () => { + const { client, organizationId } = await arrange(); + + const invitation = await client.organizations.invite({ + email: nextEmail('admin-invitee'), + role: 'admin', + organizationId, + }); + + expect(invitation.role).toBe('admin'); + expect(invitation.status).toBe('pending'); + }); + + // ─────────────────────────────────────────────────────────────────────── + // ③ the asymmetry the card is about is gone + // ─────────────────────────────────────────────────────────────────────── + + it('③ invite and its sibling resend agree on the shorter call', async () => { + const { client, organizationId } = await arrange(); + + const invited = await client.organizations.invite({ + email: nextEmail('family-invite'), + organizationId, + }); + const resent = await client.organizations.invitations.resend({ + email: nextEmail('family-resend'), + organizationId, + }); + + // One family, one behaviour — this is the equality the card asked for. + expect(invited.role).toBe(resent.role); + expect(invited.role).toBe('member'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// ④ the request BYTES — a mechanism the drive above structurally cannot see +// ───────────────────────────────────────────────────────────────────────── + +describe('#16582 the bytes organizations.invite puts on the wire', () => { + const INVITE_URL = `${ORIGIN}/api/v1/auth/organization/invite-member`; + + /** The 200 the route answers; identical before and after this card. */ + const PENDING = JSON.stringify({ + organizationId: 'org_probe', + email: 'probe@example.com', + role: 'member', + teamId: null, + status: 'pending', + expiresAt: '2026-09-12T00:16:41.261Z', + createdAt: '2026-09-10T00:16:41.261Z', + inviterId: 'usr_probe', + id: 'inv_probe', + }); + + function capturing() { + const fetchMock = vi.fn( + async () => + new Response(PENDING, { status: 200, headers: { 'content-type': 'application/json' } }), + ); + const client = new ObjectStackClient({ baseUrl: ORIGIN, fetch: fetchMock as never }); + return { client, fetchMock }; + } + + function soleBody(fetchMock: ReturnType['fetchMock']): string { + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(INVITE_URL); + return String(init.body); + } + + it('④a omitting role sends exactly the caller object plus role: "member"', async () => { + const { client, fetchMock } = capturing(); + + await client.organizations.invite({ email: 'probe@example.com', organizationId: 'org_probe' }); + + // FULL-STRING equality: a body carrying an extra member, a renamed one, or + // a second `role` fails here even though the vendor would still answer 200. + expect(soleBody(fetchMock)).toBe( + JSON.stringify({ email: 'probe@example.com', organizationId: 'org_probe', role: 'member' }), + ); + }); + + it('④b naming role sends that role, once', async () => { + const { client, fetchMock } = capturing(); + + await client.organizations.invite({ + email: 'probe@example.com', + role: 'admin', + organizationId: 'org_probe', + }); + + expect(soleBody(fetchMock)).toBe( + JSON.stringify({ email: 'probe@example.com', role: 'admin', organizationId: 'org_probe' }), + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// ⑤ the pin on the SHAPE of the fix — compile-time, never invoked +// ───────────────────────────────────────────────────────────────────────── + +declare const typedClient: ObjectStackClient; + +/** The declared request type of the method this card repairs. */ +type InviteRequest = Parameters[0]; + +/** + * Compiled by `packages/client/tsconfig.test.json` (which includes `src/**` and + * is reached by `package.json`'s `typecheck` script through + * `check:test-typecheck`), never invoked — every statement is an assertion tsc + * evaluates, and none of them may perform a request. Same arrangement as + * `oauth-applications-register-request-members.test.ts`'s pins. + * + * This is the guard against the route that was weighed and REJECTED on this + * card: declaring `role` required. Doing that makes the first statement red + * (the two-argument literal stops satisfying the parameter) and the + * `Exclude<…, undefined>` inequality red as well, so the narrowing cannot land + * quietly under a green suite. + */ +export async function inviteRoleStaysOptional16582(): Promise { + // The call the card is about must remain expressible. + await typedClient.organizations.invite({ email: 'e@example.com', organizationId: 'o' }); + // …and so must the bare one. + await typedClient.organizations.invite({ email: 'e@example.com' }); + + // `role` is optional: dropping `undefined` from it changes the type, which is + // only true while `undefined` is in it. + expectTypeOf().not.toEqualTypeOf< + Exclude + >(); +} From bd3bbf6eb28c6cfb9bf3f27b2af3386641564efe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:21:45 +0000 Subject: [PATCH 2/3] 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 --- .../organization-invite-role-default.test.ts | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/client/src/organization-invite-role-default.test.ts b/packages/client/src/organization-invite-role-default.test.ts index a3dd614018..2aa56a7fbc 100644 --- a/packages/client/src/organization-invite-role-default.test.ts +++ b/packages/client/src/organization-invite-role-default.test.ts @@ -39,10 +39,16 @@ * into the request, so a later "helpful" translation layer could add or rename * members without changing a type and without changing a status. ④ therefore * holds the request bytes to FULL-STRING equality — never `toContain`, which a - * body carrying extra members would satisfy — and it is the guard that the - * default is applied by SUBSTITUTION, not by clobbering: a spread written the - * other way round (`{ role: 'member', ...req }`) passes ① and ③ and fails ② - * and ④. + * body carrying extra members would satisfy. + * + * It is also the guard that the default is applied by SUBSTITUTION rather than + * by ordering. The tempting other spelling, `{ role: 'member', ...req }`, agrees + * with the shipped one on every status cases ① – ③ can observe, and differs in + * exactly two places ④ can: it re-orders the body, and — because a spread + * copies an explicitly-`undefined` member over the default while `??` does not + * — it puts a caller's `role: undefined` back on the wire as no `role` at all, + * restoring the 400 for the caller who wrote `invite({ email, role: maybe })`. + * ④c is that case. * * ## ⑤ pins the shape of the fix, not just its effect * @@ -269,6 +275,23 @@ describe('#16582 the bytes organizations.invite puts on the wire', () => { JSON.stringify({ email: 'probe@example.com', role: 'admin', organizationId: 'org_probe' }), ); }); + + it('④c an explicitly-undefined role is the same call as omitting it', async () => { + const { client, fetchMock } = capturing(); + + // What `invite({ email, role: maybeRole })` compiles to when the variable + // is empty — indistinguishable from omission to the caller, and it must be + // indistinguishable on the wire too. + await client.organizations.invite({ + email: 'probe@example.com', + role: undefined, + organizationId: 'org_probe', + }); + + expect(soleBody(fetchMock)).toBe( + JSON.stringify({ email: 'probe@example.com', role: 'member', organizationId: 'org_probe' }), + ); + }); }); // ───────────────────────────────────────────────────────────────────────── From b99a4b61267e8071b62c4c7cafca519d718cb968 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 01:20:29 +0000 Subject: [PATCH 3/3] chore(changeset): patch @objectstack/client for the invite role default Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude --- .../client-invite-role-default-member.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/client-invite-role-default-member.md diff --git a/.changeset/client-invite-role-default-member.md b/.changeset/client-invite-role-default-member.md new file mode 100644 index 0000000000..eb6d7ef46f --- /dev/null +++ b/.changeset/client-invite-role-default-member.md @@ -0,0 +1,24 @@ +--- +"@objectstack/client": patch +--- + +fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582) + +`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: + +``` +client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR) +``` + +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). + +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. + +Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after: + +``` +before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"} +after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...} +``` + +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.