fix(client): organizations.invitations.resend forwards teamId, so resending a team invitation keeps its team - #18429
os-support-ai wants to merge 2 commits into
Conversation
…wire
`resend` has declared `teamId?: string | null` since the organizations.*
family's first commit and never forwarded it: the re-invite carried
`email`, `role` and `organizationId` only, so resending a TEAM invitation
silently landed without its team. Nothing refused and nothing warned.
Direction established by driving the real endpoint (a real AuthManager,
better-auth 1.7.3, organization plugin with teams enabled, over a real
SqliteWasmDriver), not by reading the vendor's types:
invite-member { …, teamId: '<real team>' } -> 200 invitation.teamId === it
invite-member { …, teamId: 'team_nope' } -> 400 TEAM_NOT_FOUND
invite-member { …, teamId: null } -> 400 [body.teamId] Invalid input
invite-member { … } (omitted) -> 200 invitation.teamId === null
Row 1 settles the direction: the endpoint accepts a team on this call, so
forwarding is the repair and removing the member would have deleted a
capability the wire has.
Rows 3 and 4 are why the forward is not a bare spread. `null` is the SDK's
own spelling of "no team" — `invitations.list` answers `teamId: string |
null` and a caller hands that object straight back to `resend` — while the
vendor's spelling is ABSENCE. `invite` therefore lifts `teamId` out of the
spread and sends it only when it is a string, so a verbatim forward cannot
convert today's silent drop into a 400 for every round-tripping caller.
`invite` gains the same `teamId?: string | null` member, which is the only
route `resend` has to the wire. Purely additive on a published request
type. `resend` also drops its own redundant `role ?? 'member'` — the card
names this PR as the carrier for that tidy-up — and takes `invite`'s
default instead; byte-identical, pinned.
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
…wire Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 15 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 d5ee28203a121c39e69181341dbe5bd762baf04a && git checkout d5ee28203a121c39e69181341dbe5bd762baf04a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0c573f09f9f6acdee64a6075f73de88792b40fc7 15c1048a6713cf6fc0b5fffd8420f3d8ebc423b8 && git checkout -B drift-repro 0c573f09f9f6acdee64a6075f73de88792b40fc7 && git merge --no-ff 15c1048a6713cf6fc0b5fffd8420f3d8ebc423b8
node scripts/docs-audit/affected-docs.mjs --json 0c573f09f9f6acdee64a6075f73de88792b40fc7 |
Fixes #17274
Clause-②: yes (widening) —
organizations.invitegains ateamIdmember on its published request type. Nothing is narrowed: every existing call still compiles and still sends byte-identical requests.The defect
organizations.invitations.resendhas declaredteamId?: string | nullsince theorganizations.*family's first commit (371445c27, 2026-05-07) and has never forwarded it. The re-invite it issues carriedemail,roleandorganizationIdonly, so a caller resending a TEAM invitation passed the team, the compiler accepted it, the request succeeded — and the invitation landed with no team. Nothing refused, nothing warned, and the success path carried no trace of the loss.Which repair this is, and the measurement that decided it
The card left the direction open between forwarding the member and deleting it, and required the endpoint to be DRIVEN rather than read off the vendor's types. Driven here: a real
AuthManager(better-auth 1.7.3, organization plugin,teams: { enabled: true }— the postureauth-manager.ts:2921hard-wires) over a realObjectQLon a realSqliteWasmDriver, with the SDK's ownfetchhanding eachRequesttoAuthManager.handleRequest.POST /organization/invite-member{ …, teamId: A_REAL_TEAM_ID }200, and the invitation'steamIdis that team{ …, teamId: 'team_does_not_exist' }400Team not found(TEAM_NOT_FOUND){ …, teamId: null }400[body.teamId] Invalid input(VALIDATION_ERROR){ … }— noteamIdmember200, and the invitation'steamIdisnullresend({ email, organizationId, teamId })200, and the invitation'steamIdisnull— the defect, liveRow 1 settles the direction: FORWARD IT. The endpoint accepts a team on this call, the handler validates the id against the organisation's own teams, and the adapter stores the placement on the invitation row where
invitations.listreads it back. Removing the member would have deleted a capability the wire really has.Why it is not forwarded verbatim — rows 3 and 4
nullis this SDK's own spelling of "no team":invitations.listanswersteamId: string | null, and handing that object straight back intoresendis the ordinary way to resend. The vendor's spelling of the same fact is ABSENCE.A bare spread would therefore put
teamId: nullon the wire and convert today's SILENT drop into a LOUD400for every caller who ever read an invitation back out oflist()— a second defect wearing the fix's clothes. SoinviteliftsteamIdout of the spread and sends it only when it is a string;nulland an omitted member both send noteamIdat all.Nothing else is normalised. An unknown id keeps reaching the vendor, because
TEAM_NOT_FOUNDis exactly the loud refusal that replaces the silent drop.What changed
organizations.invitedeclaresteamId?: string | nulland lifts it out of the body spread. It is the only routeresendhas to the wire, so declaring the member is what lets the placement be typed rather than smuggled through an undeclared property.organizations.invitations.resendforwardsteamId.resendstops spelling its ownrole ?? 'member'and takesinvite's default instead. This is the behaviour-neutral tidy-up the card names, taken deliberately: one family, one substitution, no second copy to drift. An omitted or explicitly-undefinedrole still reaches the wire as'member', in the same byte position, and a caller-named role still survives — pinned by case ④d.#16582docblock sentence that assertedresendsubstitutes its own default is corrected in the same edit, because this change is what makes it false.Reverse verification — the pins fail on BOTH wrong answers
The fix was committed first, each leg mutated the file on disk, proved the mutation landed by counting its own anchors and comparing
git hash-objectagainst theHEADblob, ran, then restored and proved the restore by blob equality plus an emptygit diff HEAD. Nodistis in the resolution path: the suite imports./indexrelatively, inside its own package, so the bytes under test are the source bytes in this checkout.#16582)teamIdnever forwarded againteamIdverbatim)Leg B is the one the card asked for: a pin that fails on the wrong fix rather than merely passing on the right one. Case ③ throws
Error: [body.teamId] Invalid inputagainst the real vendor, and ④b readsEvery byte case is FULL-STRING equality, never a containment check: the drive proves the vendor accepts what we send and is structurally blind to a body carrying MORE than it should, which is exactly how a stray
nullgets through.The family question the card asked — answered, and the PR deliberately not widened
#14314did NOT miss this member, and the member was not added after it.teamIdonresendarrived in371445c27(2026-05-07), the commit that first created the wholeorganizations.*SDK surface.#14314landed four months later as7092d63e4(2026-09-07, PR#16576), and its axis was the RESPONSE: nineteenPromise of anyreturns bound to the shapes better-auth sends. Its own changeset says so in this repo's tree — "No request bytes, no URL and no response handling change" — and namesresendexplicitly as closing because it delegates toinvite, "not named here because its site is unchanged".route,family,source,disposition,client,requiresand an optionalresponseSchema. There is no request-member relation anywhere in it.So: REQUEST-member fidelity across the
organizations.*family has never had a binding artifact, which is why both#16582and this card had to be found by hand. That is a coverage observation, not a defect in this method, and per the dispatch this PR does not re-audit the other seventeen.One note for whoever follows it up: issue
#14314itself now answers404on this board through the REST API, while#16582answers200through the same token and endpoint — so the commit and its changeset in this tree, cited above, are the surviving record.Verification
pnpm --filter @objectstack/client test— 44 files, 534 passed.pnpm --filter @objectstack/client typecheck— clean, includingcheck:test-typecheckovertsconfig.test.json, which is what compiles case ⑤'s type-level pins.pnpm --filter "@objectstack/client^..." build— the dependency closure, exit 0.pnpm build(73 tasks) exit 0.pnpm lint— repo-wideeslint . --no-inline-config, exit 0. No narrowing was needed.node scripts/pm/dispatch-gates.mjs --commands, each run with its exit code written to disk before anything read it, then reconciled: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, all green except the one below.packages/clienthas no integration tier in this diff's reach:test:integrationis a separate script against a live server and is not part ofpnpm test; nothing here touches a spawn entry point.pnpm check:cross-package-test-inputsexits 1 on any tree wherepackages/spec/distis built. It namespackages/cli/test/init-created-files-summary.e2e.test.tsdescendingpackages/spec/dist/— neither path is in this diff. Already filed as#18353/#18348; no fourth card.Acceptance notes
Observed while measuring, deliberately NOT filed and NOT fixed here:
teamIdas a string ARRAY as well as a string, and stores the ids comma-joined. This SDK declares onlystring | null, which is what every shape in the family uses (invitations.listanswersteamId?: string | null) and all that is delivered here. Declaring the array form is a capability decision, not a defect.teamId: ''with a200and stores the empty string verbatim: its validation is guarded on truthiness, so an empty id skips theTEAM_NOT_FOUNDcheck entirely. The SDK forwards what the caller declared rather than inventing a meaning for it. Vendor-side, and a reading only.resend?: booleanflag oninvite-memberthat re-sends to an already-invited address instead of refusing it. This SDK implements resend as cancel-then-invite instead. Both work; the difference is deliberate and untouched.This PR parks, by declaration
The dispatching seat measured itself below
CONTRACT_REVIEW_TIERand therefore may not perform the clause-② review, which was declared up front on the card rather than discovered at the enqueue gate. This PR waits outside the merge queue until a tier-qualified seat clears it. That is a seat limitation, and no scope was cut for it.needs:contract-reviewis the seat's to apply or remove; this branch neither applied nor removed it, and its state at hand-off is reported in the delivery comment on#17274.Generated by Claude Code