fix(client)!: bind the organizations.* family to the wire shapes better-auth sends - #16576
Conversation
…er-auth sends
Nineteen methods of the organizations.* namespace 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 actually serves,
and its exported-any-returns.json entry is deleted in the same change — as is
the entry for organizations.invitations.resend, which carries no annotation of
its own and inherits invite's (22 entries before, 2 after):
organizations.create -> OrganizationCreateResult
organizations.update -> OrganizationEchoWire
organizations.setActive -> OrganizationWire | null
organizations.get -> OrganizationFullWire | null
organizations.listMembers -> OrganizationMembersPage
organizations.invite -> OrganizationInvitationWire<'pending'>
organizations.leave -> OrganizationMemberWithUserWire
organizations.delete -> OrganizationWire
organizations.removeMember -> OrganizationRemoveMemberResult
organizations.updateMemberRole -> OrganizationMemberWire
organizations.getActiveMember -> OrganizationMemberWithUserWire
organizations.invitations.cancel -> OrganizationInvitationWire<'canceled'>
organizations.invitations.accept -> OrganizationInvitationAcceptResult
organizations.invitations.reject -> OrganizationInvitationRejectResult
organizations.teams.create/update -> OrganizationTeamWire
organizations.teams.delete -> OrganizationTeamRemovedReceipt
organizations.teams.addMember -> OrganizationTeamMemberWire
organizations.teams.removeMember -> OrganizationTeamMemberRemovedReceipt
The shapes were read off the wire against a real server, not off
better-auth's own .d.ts: a real AuthManager (better-auth 1.7.2, organization
plugin, teams enabled) over a real SqlDriver (better-sqlite3), driven raw and
again through the real ObjectStackClient with only the socket stood in for,
plus an in-memory-engine leg for absent-vs-null. Four times the vendor's
declaration was the wrong answer: delete answers the organization row, not the
id string its OpenAPI stub declares; updateMemberRole answers the member bare,
not `{ member }`; metadata is decoded on create/update only and is the stored
JSON text on every read route; removeMember joins `user` only on the by-email
path.
Timestamps are ISO-8601 strings, never Date and never revived (maintainer
ruling on the family card): the adapter runs `supportsDates: false` and
JSON.stringify puts the ISO string back on the wire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…otted walker can resolve Five direct members of organizations (create, update, listMembers, delete, removeMember) share a name with a member of a nested literal, and the gate's walker counts nested same-named definitions inside the region, so those five have no resolvable spelling; they are stated in the marker's prose with the issue that records the walker limitation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
📓 Docs Drift CheckThis PR changes 1 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ba36b324d6ea01d13b509fd49a99ee9dcda7794b && git checkout ba36b324d6ea01d13b509fd49a99ee9dcda7794b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f2f6684cd5e93032ed5d82a1848784b6026a8a03 d472728128940a5e38cbfc19979b821b20450620 && git checkout -B drift-repro f2f6684cd5e93032ed5d82a1848784b6026a8a03 && git merge --no-ff d472728128940a5e38cbfc19979b821b20450620
node scripts/docs-audit/affected-docs.mjs --json f2f6684cd5e93032ed5d82a1848784b6026a8a03
|
…mpile-block line and the teams updatedAt guarantee The changeset's 'reads that stop compiling' block carried one line that does not compile: JSON.parse on the read-route metadata, whose declared type is string | null | undefined (TS2345). It now guards the value first, which is the honest spelling of 'the caller's step'. OrganizationTeamWire's JSDoc claimed teams.create and teams.update both set updatedAt explicitly. The vendor's update-team handler writes no timestamp; the value on the wire comes from better-auth's team schema, whose updatedAt declares an onUpdate default the adapter applies on every update of the model, with the platform's own sys_team.updated_at stamping behind it. Measured on a real SQL driver and on an engine with no platform stamping in the loop: update-team on the vendor-minted default team answered a fresh updatedAt on both. The type is unchanged; only the sentence moved. The sibling OrganizationFullTeamWire's JSDoc no longer presents a hand-rolled test fake's absent key as a measured store behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
Patch round —
|
Fixes #14314 — card 3 of 3 of the #12104 family. All nineteen members the card names are bound and all twenty of the family's ledger entries are closed (the twentieth,
organizations.invitations.resend, closes by delegation with no edit to its site — proven below), so a merge completes the card.Clause-② is yes by the maintainer's #12104 ruling (this narrows published return types), so the PR is draft and carries
needs:contract-reviewon both carriers. It waits for an at-tier contract reviewer; auto-merge is not armed and it is not enqueued.What changed
Nineteen
organizations.*methods endedreturn res.json()with no return annotation, solib.dom'sResponse.json(): Promise< any >was their published type. Each now declares the shape its route serves, andpackages/client/exported-any-returns.jsonloses exactly this family's twenty entries (22 before, 2 after —meta.migrateStoredandauth.deleteUserremain, neither is this card's).organizations.create(req)anyOrganizationCreateResultorganizations.update(id, data)anyOrganizationEchoWireorganizations.setActive(id)anyOrganizationWireornullorganizations.get(id)anyOrganizationFullWireornullorganizations.listMembers(id)anyOrganizationMembersPageorganizations.invite(req)anyOrganizationInvitationWirewithstatus: 'pending'organizations.leave(id)anyOrganizationMemberWithUserWireorganizations.delete(id)anyOrganizationWireorganizations.removeMember(id, params)anyOrganizationRemoveMemberResultorganizations.updateMemberRole(id, params)anyOrganizationMemberWireorganizations.getActiveMember(id)anyOrganizationMemberWithUserWireorganizations.invitations.cancel(id)anyOrganizationInvitationWirewithstatus: 'canceled'organizations.invitations.accept(id)anyOrganizationInvitationAcceptResultorganizations.invitations.reject(id)anyOrganizationInvitationRejectResultorganizations.invitations.resend(inv)any(inherited)invite— site untouchedorganizations.teams.create(req)/.update(params)anyOrganizationTeamWireorganizations.teams.delete(params)anyOrganizationTeamRemovedReceiptorganizations.teams.addMember(params)anyOrganizationTeamMemberWireorganizations.teams.removeMember(params)anyOrganizationTeamMemberRemovedReceiptSeventeen
Organization*interfaces are newly exported from@objectstack/client. Every one of these routes is served BARE by better-auth (auth-route-ledger.tsrecords themsource: 'better-auth'); no envelope is unwrapped and none is introduced.@objectstack/spec/identity'sOrganization/Member/Invitationare deliberately NOT relayed — each declaresupdatedAtrequired and the wire never carries it (the adapter's output transform walks better-auth's own schema, which has no such column);InvitationStatusIS relayed, narrowed to the literal each handler pins.The shapes were read off the WIRE, not off better-auth's
.d.tsEvery route was driven three ways against a real
AuthManager(better-auth 1.7.2, organization plugin,teams: { enabled: true }as plugin-auth mounts it, isolated tenancy in force):SqlDriver(better-sqlite3), 46 exchanges covering the 19 members plus the edge cases;ObjectStackClientwith only the socket stood in for (fetchhanded toAuthManager.handleRequest) — 19 members plusresend;nullfrom absent on every nullable column.Four times the vendor's own declaration was the wrong answer, and the wire won:
delete's OpenAPI stub declares the deleted id as astring; the handler answers the organization row (return ctx.json(org)).updateMemberRole's stub declares{ member }; the handler answers the membership row bare, and withoutuser.metadatais one column with two wire forms:createandupdatedecode it to an object, every read route (setActive,get,delete,list) answers the stored JSON TEXT — measured"metadata":"{\"plan\":\"enterprise\"}"onset-activetwo calls after"metadata":{"plan":"enterprise"}onupdate. Hence two types,OrganizationEchoWireandOrganizationWire.removeMemberjoinsuseron ONLY when the member was addressed by email; the by-id path strips it (measured both ways) — souser?.Also measured and declared:
create.membersis always the literal one-element tuple (the creator'sownerrow);get(...).teams[]keeps the vendor'smemberCount(stripped onteams.create/teams.update) and the default team minted at org creation has noupdatedAton the in-memory engine;setActive('')andget('')answer the 4-byte JSONnullwhen the session has no active organization — declared| null, not invented away. No member of this family answers with a zero-byte body (the class both prior cards hit), so nothing is left unbound.Timestamps:
createdAton every row,updatedAton teams andexpiresAton invitations are the vendor'sDatefields. The adapter issupportsDates: false, better-auth revives server-side,JSON.stringifyputs an ISO-8601 string back on the wire (measured"createdAt":"2026-09-07T09:27:01.545Z"). They are declaredstringand pinned there; noDate, no revival layer — the maintainer's ruling, quoted on the card.Population, and the twentieth entry
Re-derived from the ledger on my tree at merge base
9407e1865, not inherited: 22 entries total;organizations.*20 = direct 11 · teams 5 · invitations 4;auth.*1;meta.*1; a control family that cannot exist answers 0. The card's 19 is the count of members annotated; 20 is the count of entries closed. The reconciling entry isorganizations.invitations.resend, whose ledger note said bindinginvitecloses it too. It did: the gate is exact in both directions, and at HEAD it reports2 ledgered site(s) still openwith no unledgered callable — soresendno longer resolves toanywith zero edits to its site. The pin file holds the same fact as a type equality, and the ablation below measures its other direction.Ablation (direction predicted before running)
Removing
invite's annotation only — nothing else — must redden the gate naming TWO callables:organizations.inviteandorganizations.invitations.resend(the delegation), and redden the pin file. Measured, both directions, with the implementation committed (da6f4b2a4b) so the restore leg had a real reference.invite's annotation was replaced by a marker comment — on-disk proof before anything was read: removed-text 1 → 0, injected 0 → 1, source blobd3cc8367…→5522b5fb…— then rebuilt, andscripts/ablation-dist-preflight.mjs packages/client MARKER --absentread the marker absent from all 6 built files. Red 1:check:exported-any-returnsexit 1, its own line❌ 2 exported callable(s) of @objectstack/client resolve to any and are not ledgered: ObjectStackClient.organizations.invite · ObjectStackClient.organizations.invitations.resend— the delegation closure, measured in the red direction. Red 2:check:test-typecheckexit 1,4 type error(s)inreturn-type-precision.test.ts. Restore:git checkout HEAD -- ABSOLUTE PATHunder an EXIT/INT/TERM trap, proven bygit diff HEADempty,git status --porcelainempty and the source blob equal to the HEAD blob (d3cc8367…), never by an exit code; rebuilt; the preflight read the marker present in 2 built files; the gate is green again at2 ledgered site(s) still open. Recorded, not hidden: the first attempt's dist anchor used the source's single-quoted literal, which the emitted.d.tsspells with double quotes, so that attempt's dist-side reading was vacuous (its tree-side reading and both reds were real); the second attempt used the dist spelling and is the one quoted.Verification
Baseline first, on the unmodified merge base: the gate answered
22 ledgered site(s) still open; positive control in the builtdist/index.d.ts:auth.sessions.revokeAllresolves toPromise< AuthStatusReceipt >andoauth.applications.gettoPromise< OAuthApplication >, both absent from the ledger, whileorganizations.setActivestill readPromise< any >in the same dist — the instrument sees this world.At HEAD (
b66530f33cfor the code,da6f4b2a4badds only the changeset marker fix): client build exit 0 (check-dts-emitted: 1/1);check:exported-any-returnsexit 0 —✅ no NEW exported callable of @objectstack/client resolves to any: 317 callables reached (52 caller-supplied generics, not counted as erasure), 2 ledgered site(s) still open.withLedger is exact in both directions.;pnpm --filter @objectstack/client typecheckexit 0 (tsc clean;check:test-typecheck: OK — 0 file(s) / 0 error(s)), the pin file proven inside that program withtsc -p tsconfig.test.json --listFiles;pnpm --filter @objectstack/client testexit 0 —Test Files 34 passed (34) · Tests 444 passed (444).check:adr-0087-registration --base origin/mainexit 0 (14 refs verified, eachunannotated -> Promise< X >);check-changeset-no-majorexit 0 (minor; the level axis is NOT MEASURED locally — no clause-② declaration is readable outside CI);check-empty-changesetexit 0;check:nul-bytesexit 0 (8145 files);check-partof-closing-keywordexit 0 on this body plus the 2 commits (no card-relation trailer). Gate union derived at HEAD withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths, change set from the merge base):Run reconciliation — 56 derived, 56 run, 0 NOT-MEASURED, 0 UNRUNand✓ dispatch-gates --ran: 56 derived famil(ies) accounted foratda6f4b2a4b; 53 exit 0, the 3 prerequisite refusals named under Deviations recorded as NOT MEASURED rather than passes. The derivation also flagged a STALE-TREE note (origin/mainmoved 4 commits past my merge base during the run; the one derived-from file that changed isscripts/pm/check-skill-line-ratchet.mjs, askills/**family no path of this diff touches). Artifact rosters run separately as their own block (31 commands): 27 exit 0; 4 not verdicts —check-partof-closing-keywordandcheck-single-claim-pathsbare-run NOT WIRED without a PR (the former was then run wired, above),check:react-declaration-parityneeds a browser dump,check:published-readme-exportsexit 3 prerequisite (whole-workspace dist). The four rosters whose baseline sits under my paths —check-changeset-fixed(70 packages in sync),check:authz-resolver,check:error-code-casing,check:filter-alias-parity— all exit 0.Deviations, declared
pnpm lintnarrowed to the two changed.tsfiles: eslint--no-inline-config --format jsonover them answered 2 files / 0 errors / 0 warnings; the population iseslint.config.mjs'spackages/**/*.{ts,tsx,mts,cts}blocks (both files are members); the config is not type-aware (noparserOptions.project, stated in the config's own comment), so this diff cannot move any untouched file's verdict. CI runs the repo-wide lint.check:skill-examples(exit 1, wantspackages/client-react/dist),check:dual-build-cjs-loads(exit 3, "NOT a pass: nothing was measured"),check:type-check-debt(exit 3, same class). CI builds the farm and runs all three; none reads a file this diff touches.git grepfinds ZERO in-repo call sites of the nineteen members outsidepackages/client(the only other references areauth-route-ledger.tsstring rows and docs), and the change adds exports without removing or renaming any, so no consumer in the 8-package prefix closure (...@objectstack/client) can be moved by it; the examples' typecheck (which needs the whole workspace built) is CI's.organizations.create/update/listMembers/delete/removeMemberas AMBIGUOUS because a same-named member sits in the nestedteams/invitationsliteral and the walker counts nested definitions inside the region — a limitation of the gate, filed ascheck-adr-0087-registration's dottedtype-surface-onlywalker cannot name a DIRECT member of an object literal whose name recurs in a nested literal —organizations.createis refused as AMBIGUOUS withorganizations.teams.create, and no deeper path exists for the direct one #16571; the five are stated in the marker's prose and are judged bycheck:exported-any-returnson the built dist like the other fourteen.AuthManager+ realSqlDriver, through the real client), not apnpm devprocess — the same harness class as cards 1 and 2.Findings filed, not fixed here
organizations.getActiveMember(organizationId)sends anorganizationIdthe server ignores — it answers the session's ACTIVE organization, whatever id the caller names #16568 —organizations.getActiveMember(organizationId)sends anorganizationIdthe vendor route ignores: it answers the session's ACTIVE organization (measured: a query naming org2 answered org1's row). The JSDoc is corrected in this PR to say so; the parameter and request bytes are untouched because a body change is outside this family's ruled scope.organizations.invitations.listMine()(GET /organization/list-user-invitations) answers 403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION for every unverified user, although plugin-auth setsrequireEmailVerificationOnInvitation: falsefor exactly that deployment shape #16569 —organizations.invitations.listMine()answers 403EMAIL_VERIFICATION_REQUIRED_FOR_INVITATIONfor every unverified user although plugin-auth setsrequireEmailVerificationOnInvitation: falsefor exactly the no-mailer deployment;accepthonours the option, the inbox listing does not (vendor).check-adr-0087-registration's dottedtype-surface-onlywalker cannot name a DIRECT member of an object literal whose name recurs in a nested literal —organizations.createis refused as AMBIGUOUS withorganizations.teams.create, and no deeper path exists for the direct one #16571 — the ADR-0087 dotted walker cannot address a direct member whose name recurs in a nested literal (above).update-member-role(role: ['admin','member']) is refused400 VALIDATION_FAILEDby the ADR-0108 closed vocabulary — expected, and the SDK only offersrole: string.Scope
Only the nineteen methods this card names on
packages/client/src/index.ts, plus the ledger, the pin file and the changeset.resend's site is untouched. The #13080 BREAKING-token gate is not addressed here — the ruling says that card is independent.🤖 Generated with Claude Code
Generated by Claude Code