diff --git a/.changeset/client-organizations-family-wire-shape-binding.md b/.changeset/client-organizations-family-wire-shape-binding.md new file mode 100644 index 0000000000..e9cb11b2ca --- /dev/null +++ b/.changeset/client-organizations-family-wire-shape-binding.md @@ -0,0 +1,88 @@ +--- +"@objectstack/client": minor +--- + +fix(client)!: the `organizations.*` family declares the wire shapes better-auth actually sends — nineteen published `Promise< any >` returns narrowed, twenty ledger entries closed (#14314) + +**BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. 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. + + + +Card 3 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, and every timestamp is declared as the ISO-8601 `string` the wire carries — no `Date`, no revival layer. + +## What changed + +Nineteen `organizations.*` methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change — together with the entry for `organizations.invitations.resend`, which has no annotation of its own and inherits `invite`'s (22 entries before, 2 after): + +| method | resolved to (before) | resolves to (now) | +|:--|:--|:--| +| `client.organizations.create(req)` | `any` | `OrganizationCreateResult` | +| `client.organizations.update(id, data)` | `any` | `OrganizationEchoWire` | +| `client.organizations.setActive(id)` | `any` | `OrganizationWire \| null` | +| `client.organizations.get(id)` | `any` | `OrganizationFullWire \| null` | +| `client.organizations.listMembers(id)` | `any` | `OrganizationMembersPage` | +| `client.organizations.invite(req)` | `any` | `OrganizationInvitationWire<'pending'>` | +| `client.organizations.leave(id)` | `any` | `OrganizationMemberWithUserWire` | +| `client.organizations.delete(id)` | `any` | `OrganizationWire` | +| `client.organizations.removeMember(id, params)` | `any` | `OrganizationRemoveMemberResult` | +| `client.organizations.updateMemberRole(id, params)` | `any` | `OrganizationMemberWire` | +| `client.organizations.getActiveMember(id)` | `any` | `OrganizationMemberWithUserWire` | +| `client.organizations.invitations.cancel(id)` | `any` | `OrganizationInvitationWire<'canceled'>` | +| `client.organizations.invitations.accept(id)` | `any` | `OrganizationInvitationAcceptResult` | +| `client.organizations.invitations.reject(id)` | `any` | `OrganizationInvitationRejectResult` | +| `client.organizations.invitations.resend(inv)` | `any` (inherited) | `OrganizationInvitationWire<'pending'>` (inherited from `invite`) | +| `client.organizations.teams.create(req)` | `any` | `OrganizationTeamWire` | +| `client.organizations.teams.update(params)` | `any` | `OrganizationTeamWire` | +| `client.organizations.teams.delete(params)` | `any` | `OrganizationTeamRemovedReceipt` | +| `client.organizations.teams.addMember(params)` | `any` | `OrganizationTeamMemberWire` | +| `client.organizations.teams.removeMember(params)` | `any` | `OrganizationTeamMemberRemovedReceipt` | + +`OrganizationWire`, `OrganizationEchoWire`, `OrganizationCreateResult`, `OrganizationFullWire`, `OrganizationMemberWire`, `OrganizationMemberUserWire`, `OrganizationMemberWithUserWire`, `OrganizationMembersPage`, `OrganizationRemoveMemberResult`, `OrganizationInvitationWire`, `OrganizationInvitationAcceptResult`, `OrganizationInvitationRejectResult`, `OrganizationTeamWire`, `OrganizationFullTeamWire`, `OrganizationTeamMemberWire`, `OrganizationTeamRemovedReceipt` and `OrganizationTeamMemberRemovedReceipt` are newly exported from `@objectstack/client`. Every one of these routes is served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap and none is introduced. `@objectstack/spec/identity`'s `Organization` / `Member` / `Invitation` are deliberately NOT relayed: each declares `updatedAt` required, and the wire never carries it (the adapter's output transform walks better-auth's own schema, which has no such column); `InvitationStatus` IS relayed, narrowed to the literal each handler pins. + +## The exact reads that stop compiling + +Everything below compiled before only because `any` is assignable to, and indexable by, everything. + +```ts +const org = await client.organizations.setActive(id); +org.id; // now TS18047 — `setActive` (and `get`) answer `null` for an empty id with no active organization +if (org?.metadata) JSON.parse(org.metadata); // fine — on the READ routes `metadata` is the stored JSON text, `null`/absent when unset +(await client.organizations.get(id))!.metadata.plan; // now TS2339 — it is a string here, not an object + +const echo = await client.organizations.update(id, { metadata: { plan: 'pro' } }); +JSON.parse(echo.metadata); // now TS2345 — the two WRITE routes (`create`, `update`) echo `metadata` already decoded + +const deleted = await client.organizations.delete(id); +deleted.length; // now TS2339 — the route answers the organization ROW, not the id string the vendor's OpenAPI stub declares +deleted.updatedAt; // now TS2339 — `sys_organization.updated_at` never reaches the wire +deleted.createdAt.getTime(); // now TS2339 — ISO-8601 STRING, not a Date; `new Date(deleted.createdAt)` is the rewrite + +const m = await client.organizations.updateMemberRole(id, { memberId, role: 'admin' }); +m.member.role; // now TS2339 — the row is answered BARE, not as `{ member }` (the vendor's stub is wrong) + +const removed = await client.organizations.removeMember(id, { memberIdOrEmail }); +removed.member.user.email; // now TS18048 — `user` is joined on ONLY when the member was addressed by email + +const inv = await client.organizations.invite({ email, organizationId: id }); +if (inv.status === 'accepted') { /* now TS2367 — `invite` answers the literal `'pending'` */ } + +(await client.organizations.listMembers(id)).data; // now TS2339 — no envelope on any route of this family +``` + +A caller that read `id`, `name`, `slug`, `role`, `email`, `members`, `total` or `message` off these values, or narrowed `null` where it can arrive, needs no change. + +## Timestamps: ISO-8601 `string`, never `Date` + +`createdAt` on every row type, `updatedAt` on teams and `expiresAt` on invitations are the vendor's `Date`-typed fields. The adapter is declared `supportsDates: false`, better-auth revives the stored string into a `Date` server-side, and `JSON.stringify` puts an ISO-8601 string back on the wire — measured `"createdAt":"2026-09-07T09:27:01.545Z"` on a real SQL driver. They are declared `string`, a type-level pin holds them there, and no revival layer exists in the SDK. + +## Where the vendor's own declarations were the wrong answer + +- `delete`'s OpenAPI stub declares the deleted id as a `string`; the handler answers the organization row. +- `updateMemberRole`'s stub declares `{ member }`; the handler answers the membership row bare, without `user`. +- `metadata` is one column with two wire forms: `create` and `update` decode it, every read route answers the stored JSON text (`setActive`, `get`, `delete`, `list`). +- `removeMember` joins `user` on only when the member was addressed by email; the by-id path strips it. +- Inside `get(...).teams` the vendor's `memberCount` is NOT stripped (it is on `teams.create` / `teams.update`). `teams.update` writes no timestamp of its own — `updatedAt` there comes from better-auth's team schema (`onUpdate` default, applied on every update) with the platform's `sys_team.updated_at` stamping behind it, measured on a real SQL driver; the default team minted at organization creation is written without `updatedAt` by the vendor and carries the platform's stamp, so `get(...).teams[].updatedAt` is declared optional as the safe direction. + +## Not a behaviour change + +`getActiveMember(organizationId)` keeps sending its query parameter; the measured fact that the server ignores it and answers the session's ACTIVE organization is recorded in the method's JSDoc and filed separately — a body change is outside this family's ruled narrowing scope. diff --git a/packages/client/exported-any-returns.json b/packages/client/exported-any-returns.json index 438f3b8c8d..506f1f161f 100644 --- a/packages/client/exported-any-returns.json +++ b/packages/client/exported-any-returns.json @@ -2,26 +2,6 @@ "$comment": "Exported callables of @objectstack/client whose AWAITED return type resolves to `any` (#11927). Judged against the BUILT dist by `pnpm --filter @objectstack/client check:exported-any-returns`, because the erasure is invisible in source text when a method carries no return annotation. SHRINK-ONLY and EXACT in both directions: a site here that no longer resolves to `any` is RED until its entry is deleted, and a site NOT here that resolves to `any` is RED — that unlisted case is the everyday one and the reason this file exists. There is deliberately NO --update flag: every entry is debt with a name on it, and a reason a tool wrote is a silencer rather than a worklist. SCOPE, and the one exclusion worth stating out loud: a return type that CONTAINS `any` (`{ packages: any[]; total: number }`, `Promise>`) is not listed, because it is not flagged — the gate asks whether the type IS `any`, the same line packages/spec's check:exported-any draws, and admitting the broader question costs the gate its zero-false-positive property. That is why 21 of #11925's 38 unannotated methods are absent here: they are `any`-CONTAINING, and they remain #11925's to close. Nothing is silently absorbed in either direction. A caller-supplied `` is likewise never listed: the record type and the action payload really are the caller's, and flagging them is the pressure that turns a correct generic into a wrong concrete type.", "entries": { "ObjectStackClient.meta.migrateStored": "#11925 — no return annotation; the published type comes from `this.unwrapResponse(res)`. Invisible to a `Promise<` grep because the text never appears in the method. Bind the contract the route actually answers, minding the envelope.", - "ObjectStackClient.organizations.create": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.update": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.setActive": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.get": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.listMembers": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.invite": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.leave": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.removeMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.updateMemberRole": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.getActiveMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.invitations.cancel": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.invitations.accept": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.invitations.reject": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.invitations.resend": "#12104 — no return annotation; delegates to `organizations.invite` and inherits ITS erasure rather than carrying one of its own. Binding `invite` closes this entry too, so do not annotate this site separately.", - "ObjectStackClient.organizations.teams.create": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.update": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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`. 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`. 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.deleteUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. 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." } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 96ecd278e4..0eaa96c32e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1164,6 +1164,241 @@ export interface AuthSetInitialPasswordResult { success: true; } +/** + * The columns every organization answer of the `organizations.*` family + * carries — exactly better-auth's organization schema (`id`, `name`, `slug`, + * `logo`, `metadata`, `createdAt`), served BARE (no `{ success, data }` + * envelope). The adapter's output transform walks that schema and nothing + * else, so `sys_organization`'s `updated_at` and every other ObjectStack column + * stay off the wire — measured against a real server on a real SQL driver. + * + * ⚠️ **`createdAt` is an ISO-8601 string, never `Date`** (maintainer ruling on + * #12104): the adapter is declared `supportsDates: false`, better-auth revives + * the stored string into a `Date` server-side, and `JSON.stringify` puts an + * ISO string back on the wire — measured `"createdAt":"2026-09-07T09:27:01.545Z"`. + * There is no revival layer in this SDK; `new Date(x)` is the caller's step. + * + * ⚠️ **`metadata` arrives as the stored JSON TEXT, not an object**, on every + * route that reads the row back (`setActive`, `get`, `delete`, `list`): better-auth + * stores it `JSON.stringify`-ed in a text column and only the two write routes + * decode it — see {@link OrganizationEchoWire}. `JSON.parse(metadata)` is the + * caller's step here. `null` (SQL) or absent (a store that does not + * materialise an unset column) when never set; same for `logo`. + * + * `@objectstack/spec/identity`'s `Organization` is NOT relayed: it declares + * `updatedAt` required and `metadata` as an object, and neither is what this + * wire carries. + */ +export interface OrganizationWire { + id: string; + name: string; + slug: string; + /** `null` (SQL) or absent (document store) when unset. */ + logo?: string | null; + /** ISO-8601. */ + createdAt: string; + /** The stored JSON text (`'{"plan":"pro"}'`), undecoded; `null`/absent when unset. */ + metadata?: string | null; +} + +/** + * The organization as the two WRITE routes echo it back — `create` and + * `update` — which are the only two that decode `metadata` before answering + * (`JSON.parse` in the create handler, `parseJSON` in the update adapter). + * An unset `metadata` is ABSENT here (the handlers fold it to `undefined`), + * never `null`. Every other column is {@link OrganizationWire}'s. + */ +export interface OrganizationEchoWire extends Omit { + /** Decoded object; absent when unset. */ + metadata?: Record; +} + +/** + * A membership row as better-auth serves it — its own member schema, nothing + * of ObjectStack's `sys_member` beyond it (no `updatedAt`). `role` is one of + * the closed ADR-0108 vocabulary (`owner` / `admin` / `delegated_admin` / + * `member`), typed `string` because the wire mirrors the vendor's column, not + * because the set is open; the platform refuses a multi-role + * (`'admin,member'`) at the door with `400 VALIDATION_FAILED`. + * + * `@objectstack/spec/identity`'s `Member` is not relayed: it declares + * `updatedAt` required and the wire never carries it. + */ +export interface OrganizationMemberWire { + id: string; + organizationId: string; + userId: string; + role: string; + /** ISO-8601. */ + createdAt: string; +} + +/** + * The four-column user projection better-auth hand-picks onto a member on the + * routes that join the user (`listMembers`, `get`, `getActiveMember`, `leave`, + * `removeMember` by email) — exactly these four, never the full user. + */ +export interface OrganizationMemberUserWire { + id: string; + name: string; + email: string; + /** `null` (SQL) or absent (document store) when unset. */ + image?: string | null; +} + +/** A membership row with its user joined on. */ +export interface OrganizationMemberWithUserWire extends OrganizationMemberWire { + user: OrganizationMemberUserWire; +} + +/** + * What `POST /organization/create` answers: the new organization (metadata + * decoded) plus `members`, which is ALWAYS exactly one row — the creator's + * `owner` membership (the handler answers the literal `[member]`). The default + * team the server also mints (teams are enabled on this platform) is NOT + * echoed; read it through `get`. + */ +export interface OrganizationCreateResult extends OrganizationEchoWire { + members: [OrganizationMemberWire]; +} + +/** + * The team row as better-auth serves it from `teams.create` / `teams.update`. + * `updatedAt` is on the wire from both, for two different reasons: the + * `create-team` handler writes `updatedAt: new Date()` itself, while the + * `update-team` handler writes NO timestamp of its own (its update is + * `{ name, ...additionalFields }`) — the value comes from better-auth's team + * schema, which declares `updatedAt` with an `onUpdate` default the adapter + * applies on every update of the model, with the platform's own audit stamping + * of `sys_team.updated_at` behind it. Measured: `update-team` on the default + * team (which the vendor creates without `updatedAt`) answered a fresh + * `updatedAt` on a real SQL driver and on an engine with no platform stamping + * in the loop at all. The vendor's `memberCount` column is stripped on these + * routes — but NOT inside `get`, see {@link OrganizationFullTeamWire}. + */ +export interface OrganizationTeamWire { + id: string; + name: string; + organizationId: string; + /** ISO-8601. */ + createdAt: string; + /** ISO-8601. */ + updatedAt: string; +} + +/** + * The team rows inside `get(...).teams`. Two differences from + * {@link OrganizationTeamWire}: the full-organization join does not strip the + * vendor's `memberCount` (measured), and the default team minted at + * organization creation is written without `updatedAt` by the vendor, so on + * this row the value is the platform's own `sys_team.updated_at` stamp rather + * than better-auth's (measured at rest on a real SQL driver before any + * update). `updatedAt` is optional here as the safe direction for a store + * without that stamping; the only place it was observed absent was a + * hand-rolled test fake, never a real driver. + */ +export interface OrganizationFullTeamWire extends Omit { + /** ISO-8601 when present. */ + updatedAt?: string; + memberCount: number; +} + +/** + * An invitation row as better-auth serves it: its invitation schema plus the + * two `additionalFields` ObjectStack declares on it (`businessUnitId`, + * `positions` — the ADR-0105 D8 placement intent), which arrive `null` on + * SQL and absent on a document store when unset. No `updatedAt`, so + * `@objectstack/spec/identity`'s `Invitation` is not relayed; its + * {@link InvitationStatus} vocabulary is (#7781), narrowed per route by the + * `Status` parameter where the handler pins it. + * + * `teamId` is the comma-joined list of team ids the invitee joins on accept, + * `null` when none (the handler writes the `null` explicitly). + */ +export interface OrganizationInvitationWire { + id: string; + organizationId: string; + email: string; + role: string; + status: Status; + teamId: string | null; + inviterId: string; + /** ISO-8601. */ + expiresAt: string; + /** ISO-8601. */ + createdAt: string; + /** ADR-0105 D8 placement: `null` (SQL) or absent when the invitation carries none. */ + businessUnitId?: string | null; + /** ADR-0105 D8 placement: `null` (SQL) or absent when the invitation carries none. */ + positions?: string[] | null; +} + +/** What `POST /organization/accept-invitation` answers. */ +export interface OrganizationInvitationAcceptResult { + invitation: OrganizationInvitationWire<'accepted'>; + /** The membership just created for the caller — bare, no `user` joined. */ + member: OrganizationMemberWire; +} + +/** + * What `POST /organization/reject-invitation` answers. `member` is the + * literal `null` — the vendor keeps the key for symmetry with accept. + */ +export interface OrganizationInvitationRejectResult { + invitation: OrganizationInvitationWire<'rejected'>; + member: null; +} + +/** What `GET /organization/list-members` answers. */ +export interface OrganizationMembersPage { + members: OrganizationMemberWithUserWire[]; + /** Total members in the organization, independent of the page. */ + total: number; +} + +/** + * What `POST /organization/remove-member` answers. ⚠️ `user` is on the wire + * ONLY when the member was addressed by EMAIL: that path answers the + * user-joined row, while the by-id path explicitly strips the join before + * answering (measured both ways). The vendor's OpenAPI stub omits `user` + * entirely. + */ +export interface OrganizationRemoveMemberResult { + member: OrganizationMemberWire & { user?: OrganizationMemberUserWire }; +} + +/** + * What `GET /organization/get-full-organization` answers: the row (metadata + * as stored JSON text, see {@link OrganizationWire}) plus every invitation of + * any status, every member with its user joined, and — because this platform + * mounts the organization plugin with `teams: { enabled: true }` + * unconditionally — the organization's teams. + */ +export interface OrganizationFullWire extends OrganizationWire { + invitations: OrganizationInvitationWire[]; + members: OrganizationMemberWithUserWire[]; + teams: OrganizationFullTeamWire[]; +} + +/** A team membership row as `teams.addMember` answers it (idempotent: re-adding answers the same row). */ +export interface OrganizationTeamMemberWire { + id: string; + teamId: string; + userId: string; + /** ISO-8601. */ + createdAt: string; +} + +/** The literal receipt `POST /organization/remove-team` answers; a refusal is a thrown 4xx. */ +export interface OrganizationTeamRemovedReceipt { + message: 'Team removed successfully.'; +} + +/** The literal receipt `POST /organization/remove-team-member` answers; a refusal is a thrown 4xx. */ +export interface OrganizationTeamMemberRemovedReceipt { + message: 'Team member removed successfully.'; +} + export class ObjectStackClient { private baseUrl: string; private token?: string; @@ -2915,8 +3150,12 @@ export class ObjectStackClient { /** * Create a new organization. * POST /api/v1/auth/organization/create + * + * Answers the new organization with `metadata` DECODED (one of the two + * routes that does) and `members` holding exactly the creator's `owner` + * row — measured; the vendor's OpenAPI stub names the bare Organization. */ - create: async (req: { name: string; slug?: string; logo?: string; metadata?: Record }) => { + create: async (req: { name: string; slug?: string; logo?: string; metadata?: Record }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/create`, { method: 'POST', @@ -2935,7 +3174,7 @@ export class ObjectStackClient { update: async ( organizationId: string, data: { name?: string; slug?: string; logo?: string; metadata?: Record }, - ) => { + ): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/update`, { method: 'POST', @@ -2950,8 +3189,13 @@ export class ObjectStackClient { * handlers (e.g. `EnvironmentProvisioningService`) consult. * * POST /api/v1/auth/organization/set-active + * + * Answers the organization row as STORED (`metadata` is the JSON text, + * see {@link OrganizationWire}). Answers `null` — measured, a 4-byte body + * — when `organizationId` is the empty string and the session has no + * active organization to fall back to; a non-member is a thrown 403. */ - setActive: async (organizationId: string) => { + setActive: async (organizationId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/set-active`, { method: 'POST', @@ -2963,8 +3207,12 @@ export class ObjectStackClient { /** * Get full organization detail (members, invitations, teams). * GET /api/v1/auth/organization/get-full-organization?organizationId=... + * + * `metadata` is the stored JSON text here (see {@link OrganizationWire}). + * Answers `null` (measured) when `organizationId` is the empty string and + * the session has no active organization; an unknown id is a thrown 400. */ - get: async (organizationId: string) => { + get: async (organizationId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch( `${this.baseUrl}${route}/organization/get-full-organization?organizationId=${encodeURIComponent(organizationId)}`, @@ -2975,7 +3223,7 @@ export class ObjectStackClient { /** * List members of an organization. */ - listMembers: async (organizationId: string) => { + listMembers: async (organizationId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch( `${this.baseUrl}${route}/organization/list-members?organizationId=${encodeURIComponent(organizationId)}`, @@ -2986,7 +3234,7 @@ export class ObjectStackClient { /** * Invite a user to the organization. */ - invite: async (req: { email: string; role?: string; organizationId?: string }) => { + 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', @@ -2998,7 +3246,7 @@ export class ObjectStackClient { /** * Leave the given organization. */ - leave: async (organizationId: string) => { + leave: async (organizationId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/leave`, { method: 'POST', @@ -3012,11 +3260,14 @@ export class ObjectStackClient { * * POST /api/v1/auth/organization/delete * + * Answers the deleted organization's row as it was stored (measured) — + * NOT the bare id string the vendor's OpenAPI stub declares. + * * better-auth removes the organization row, all members, and all * pending invitations. Project teardown (per-project DBs, etc.) is * handled server-side by hooks attached to the organization plugin. */ - delete: async (organizationId: string) => { + delete: async (organizationId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/delete`, { method: 'POST', @@ -3036,7 +3287,7 @@ export class ObjectStackClient { removeMember: async ( organizationId: string, params: { memberIdOrEmail: string }, - ) => { + ): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/remove-member`, { method: 'POST', @@ -3052,11 +3303,14 @@ export class ObjectStackClient { * Body: `{ memberId, role, organizationId? }`. The `memberId` is the * `member` table row id (not user id). `role` is one of the configured * organisation roles (default: `owner | admin | member`). + * + * Answers the updated membership row BARE (measured) — not wrapped in + * `{ member }` as the vendor's OpenAPI stub declares, and without `user`. */ updateMemberRole: async ( organizationId: string, params: { memberId: string; role: string }, - ) => { + ): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/update-member-role`, { method: 'POST', @@ -3066,13 +3320,20 @@ export class ObjectStackClient { }, /** - * Look up the calling user's membership row in the given organisation. + * Look up the calling user's membership row in the ACTIVE organisation. * Useful for permission checks on the client without having to scan the * full member list. * * better-auth: GET /organization/get-active-member?organizationId=… - */ - getActiveMember: async (organizationId: string) => { + * + * ⚠️ The server reads only the session's `activeOrganizationId` and + * ignores the `organizationId` query this method sends (measured: a query + * naming another organization answered the active one's row). Call + * `setActive` first if the organisation you mean is not the active one; + * with no active organisation the route is a thrown 400 + * `NO_ACTIVE_ORGANIZATION`. + */ + getActiveMember: async (organizationId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch( `${this.baseUrl}${route}/organization/get-active-member?organizationId=${encodeURIComponent(organizationId)}`, @@ -3144,7 +3405,7 @@ export class ObjectStackClient { }, /** better-auth: POST /organization/cancel-invitation */ - cancel: async (invitationId: string) => { + cancel: async (invitationId: string): Promise> => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/cancel-invitation`, { method: 'POST', @@ -3154,7 +3415,7 @@ export class ObjectStackClient { }, /** better-auth: POST /organization/accept-invitation */ - accept: async (invitationId: string) => { + accept: async (invitationId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/accept-invitation`, { method: 'POST', @@ -3164,7 +3425,7 @@ export class ObjectStackClient { }, /** better-auth: POST /organization/reject-invitation */ - reject: async (invitationId: string) => { + reject: async (invitationId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/reject-invitation`, { method: 'POST', @@ -3220,7 +3481,7 @@ export class ObjectStackClient { }, /** better-auth: POST /organization/create-team */ - create: async (req: { name: string; organizationId: string }) => { + create: async (req: { name: string; organizationId: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/create-team`, { method: 'POST', @@ -3230,7 +3491,7 @@ export class ObjectStackClient { }, /** better-auth: POST /organization/update-team */ - update: async (params: { teamId: string; data: { name?: string } }) => { + update: async (params: { teamId: string; data: { name?: string } }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/update-team`, { method: 'POST', @@ -3240,7 +3501,7 @@ export class ObjectStackClient { }, /** better-auth: POST /organization/remove-team */ - delete: async (params: { teamId: string; organizationId?: string }) => { + delete: async (params: { teamId: string; organizationId?: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/remove-team`, { method: 'POST', @@ -3261,7 +3522,7 @@ export class ObjectStackClient { }, /** better-auth: POST /organization/add-team-member */ - addMember: async (params: { teamId: string; userId: string }) => { + addMember: async (params: { teamId: string; userId: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/add-team-member`, { method: 'POST', @@ -3271,7 +3532,7 @@ export class ObjectStackClient { }, /** better-auth: POST /organization/remove-team-member */ - removeMember: async (params: { teamId: string; userId: string }) => { + removeMember: async (params: { teamId: string; userId: string }): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/organization/remove-team-member`, { method: 'POST', diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 8a788744f1..5435ceb6f6 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -48,6 +48,23 @@ import type { AuthTwoFactorVerificationResult, AuthWireUser, } from './index'; +import type { + OrganizationCreateResult, + OrganizationEchoWire, + OrganizationFullWire, + OrganizationInvitationAcceptResult, + OrganizationInvitationRejectResult, + OrganizationInvitationWire, + OrganizationMembersPage, + OrganizationMemberWire, + OrganizationMemberWithUserWire, + OrganizationRemoveMemberResult, + OrganizationTeamMemberRemovedReceipt, + OrganizationTeamMemberWire, + OrganizationTeamRemovedReceipt, + OrganizationTeamWire, + OrganizationWire, +} from './index'; import type { SearchAllResponse } from '@objectstack/spec/api'; import type { AnalyticsMetadataResponse, @@ -783,6 +800,136 @@ export async function returnTypePrecisionPins14313(): Promise { expectTypeOf(await client.auth.deleteUser({ password: 'p' })).toEqualTypeOf(); } + +/** + * [#14314 — the `organizations.*` family, card 3 of 3 of #12104] The twenty + * ledger entries of the family: NINETEEN unannotated `return res.json()` + * members (organizations 11 · invitations 3 · teams 5) bound here, plus + * `invitations.resend`, which carries no annotation of its own and inherits + * `invite`'s — pinned below as closing for free, with no edit to its site. + * + * ## These shapes were read off the WIRE, not off better-auth's `.d.ts` + * + * Every route was driven against a real `AuthManager` (better-auth 1.7.2, + * organization plugin with teams enabled) over a real `SqlDriver` + * (better-sqlite3) and again through this very client with only the socket + * stood in for, plus an in-memory-engine leg for the absent-vs-null + * question. Four times the vendor's declaration was the wrong answer: + * + * 1. `delete`'s OpenAPI stub declares the deleted id as a STRING; the handler + * answers the organization row. + * 2. `updateMemberRole`'s stub declares `{ member }`; the handler answers the + * membership row bare. + * 3. `metadata` is decoded on `create` / `update` only; `setActive`, `get` + * and `delete` answer the stored JSON TEXT — two types, not one. + * 4. `removeMember` joins `user` on only when the member was addressed by + * email; the by-id path strips it — so `user?`. + * + * ## The ruling's ISO-8601 clause has sites on every row type here + * + * `createdAt` / `updatedAt` / `expiresAt` are the vendor's `Date` fields. On + * the wire they are ISO-8601 strings, and the pins hold them there: no + * `Date` is declared and no revival layer exists. + * + * ## Nothing in this family answers with a zero-byte body + * + * `setActive` and `get` can answer the 4-byte JSON `null` (an empty + * `organizationId` with no active organization to fall back to), which + * `res.json()` resolves — declared `| null`, never invented away. + * + * Type-level for the reason this file's header gives: only a compile-time + * assertion can observe a return-type change. + */ +export async function returnTypePrecisionPins14314(): Promise { + // ── the nineteen bindings ──────────────────────────────────────────── + expectTypeOf(await client.organizations.create({ name: 'n' })).toEqualTypeOf(); + expectTypeOf(await client.organizations.update('o', { name: 'n' })).toEqualTypeOf(); + expectTypeOf(await client.organizations.setActive('o')).toEqualTypeOf(); + expectTypeOf(await client.organizations.get('o')).toEqualTypeOf(); + expectTypeOf(await client.organizations.listMembers('o')).toEqualTypeOf(); + expectTypeOf(await client.organizations.invite({ email: 'e@example.com' })) + .toEqualTypeOf>(); + expectTypeOf(await client.organizations.leave('o')).toEqualTypeOf(); + expectTypeOf(await client.organizations.delete('o')).toEqualTypeOf(); + expectTypeOf(await client.organizations.removeMember('o', { memberIdOrEmail: 'm' })) + .toEqualTypeOf(); + expectTypeOf(await client.organizations.updateMemberRole('o', { memberId: 'm', role: 'admin' })) + .toEqualTypeOf(); + expectTypeOf(await client.organizations.getActiveMember('o')).toEqualTypeOf(); + expectTypeOf(await client.organizations.invitations.cancel('i')) + .toEqualTypeOf>(); + expectTypeOf(await client.organizations.invitations.accept('i')).toEqualTypeOf(); + expectTypeOf(await client.organizations.invitations.reject('i')).toEqualTypeOf(); + expectTypeOf(await client.organizations.teams.create({ name: 't', organizationId: 'o' })) + .toEqualTypeOf(); + expectTypeOf(await client.organizations.teams.update({ teamId: 't', data: { name: 'n' } })) + .toEqualTypeOf(); + expectTypeOf(await client.organizations.teams.delete({ teamId: 't' })).toEqualTypeOf(); + expectTypeOf(await client.organizations.teams.addMember({ teamId: 't', userId: 'u' })) + .toEqualTypeOf(); + expectTypeOf(await client.organizations.teams.removeMember({ teamId: 't', userId: 'u' })) + .toEqualTypeOf(); + + // ── the twentieth entry closes for FREE ────────────────────────────── + // `resend` has no annotation of its own and delegates to `invite`; its + // ledger entry said binding `invite` closes it too. This equality is what + // makes that a measurement rather than a note: it holds only while the + // delegation is the return path and `invite` is bound. + expectTypeOf(await client.organizations.invitations.resend({ email: 'e@example.com', organizationId: 'o' })) + .toEqualTypeOf>(); + + // ── the ruling, made mechanical ────────────────────────────────────── + // ISO-8601 STRINGS on every row type of the family; red if a later sweep + // "improves" any of them to `Date` (forbidden outright) or to a number. + expectTypeOf((await client.organizations.delete('o')).createdAt).toEqualTypeOf(); + expectTypeOf((await client.organizations.invite({ email: 'e' })).expiresAt).toEqualTypeOf(); + expectTypeOf((await client.organizations.teams.update({ teamId: 't', data: {} })).updatedAt).toEqualTypeOf(); + expectTypeOf((await client.organizations.teams.addMember({ teamId: 't', userId: 'u' })).createdAt) + .toEqualTypeOf(); + expectTypeOf((await client.organizations.updateMemberRole('o', { memberId: 'm', role: 'r' })).createdAt) + .toEqualTypeOf(); + // The status literals the handlers pin, relayed from the spec vocabulary. + expectTypeOf((await client.organizations.invite({ email: 'e' })).status).toEqualTypeOf<'pending'>(); + expectTypeOf((await client.organizations.invitations.cancel('i')).status).toEqualTypeOf<'canceled'>(); + expectTypeOf((await client.organizations.invitations.accept('i')).invitation.status).toEqualTypeOf<'accepted'>(); + expectTypeOf((await client.organizations.invitations.reject('i')).invitation.status).toEqualTypeOf<'rejected'>(); + expectTypeOf((await client.organizations.invitations.reject('i')).member).toEqualTypeOf(); + // The two metadata shapes: decoded on the write echo, stored text on the read row. + expectTypeOf((await client.organizations.update('o', {})).metadata).toEqualTypeOf | undefined>(); + expectTypeOf((await client.organizations.delete('o')).metadata).toEqualTypeOf(); + // `create.members` is the literal one-element tuple the handler answers. + expectTypeOf((await client.organizations.create({ name: 'n' })).members).toEqualTypeOf<[OrganizationMemberWire]>(); + // `removeMember.member.user` is conditional on the by-email path. + expectTypeOf((await client.organizations.removeMember('o', { memberIdOrEmail: 'm' })).member.user) + .toEqualTypeOf(); + // The receipts are literals: a refusal is a throw, never another message. + expectTypeOf((await client.organizations.teams.delete({ teamId: 't' })).message) + .toEqualTypeOf<'Team removed successfully.'>(); + + // ── direction 2: WRONG shapes must now be refused ──────────────────── + // While these methods returned `any` every suppression below was unused + // (TS2578) and this file did not build — which is what makes them + // evidence of the narrowing rather than decoration. + // @ts-expect-error these routes are served BARE by better-auth — there is no `{ success, data }` envelope + void (await client.organizations.listMembers('o')).data; + // @ts-expect-error the wire sends an ISO string; `Date` methods do not exist on it + void (await client.organizations.leave('o')).createdAt.getTime(); + // @ts-expect-error `sys_organization.updated_at` never reaches the wire — the adapter walks the vendor schema only + void (await client.organizations.delete('o')).updatedAt; + // @ts-expect-error delete answers the organization ROW, not the id string the vendor's OpenAPI stub declares + void (await client.organizations.delete('o')).length; + // @ts-expect-error updateMemberRole answers the member BARE, not `{ member }` as the vendor's stub declares + void (await client.organizations.updateMemberRole('o', { memberId: 'm', role: 'r' })).member; + // @ts-expect-error setActive can answer `null` (empty id, no active organization) — narrow before reading + void (await client.organizations.setActive('o')).id; + // @ts-expect-error on the read routes `metadata` is the stored JSON TEXT, not an object + void (await client.organizations.get('o'))?.metadata?.plan; + // @ts-expect-error on the write echo `metadata` is already decoded — it is not a string to parse + void JSON.parse((await client.organizations.update('o', {})).metadata); + // @ts-expect-error updateMemberRole strips the user join; only the joined routes carry `user` + void (await client.organizations.updateMemberRole('o', { memberId: 'm', role: 'r' })).user; +} + /** * ⚠️ GREEN IN BOTH STATES — regression guards, recorded as such rather than * counted as evidence that this card's change was needed. Each pins a @@ -944,6 +1091,7 @@ describe('client SDK return-type precision (#8140)', () => { expect(typeof returnTypePrecisionPins14312).toBe('function'); expect(typeof returnTypePrecisionPins15451).toBe('function'); expect(typeof returnTypePrecisionPins14313).toBe('function'); + expect(typeof returnTypePrecisionPins14314).toBe('function'); expect(typeof returnTypePrecisionPins13023).toBe('function'); expect(typeof deleteDataResponseIsNotTheMetaResetShape).toBe('function'); expect(typeof metaResetResponseDeclaresTheWireReceipt).toBe('function');