Skip to content

fix(client): organizations.invitations.resend forwards teamId, so resending a team invitation keeps its team - #18429

Draft
os-support-ai wants to merge 2 commits into
mainfrom
claude/issue-17274-invitations-resend-teamid
Draft

os-support-ai wants to merge 2 commits into
mainfrom
claude/issue-17274-invitations-resend-teamid

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #17274

Clause-②: yes (widening) — organizations.invite gains a teamId member on its published request type. Nothing is narrowed: every existing call still compiles and still sends byte-identical requests.

The defect

organizations.invitations.resend has declared teamId?: string | null since the organizations.* family's first commit (371445c27, 2026-05-07) and has never forwarded it. The re-invite it issues carried email, role and organizationId only, 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 posture auth-manager.ts:2921 hard-wires) over a real ObjectQL on a real SqliteWasmDriver, with the SDK's own fetch handing each Request to AuthManager.handleRequest.

body sent to POST /organization/invite-member answer
{ …, teamId: A_REAL_TEAM_ID } 200, and the invitation's teamId is that team
{ …, teamId: 'team_does_not_exist' } 400 Team not found (TEAM_NOT_FOUND)
{ …, teamId: null } 400 [body.teamId] Invalid input (VALIDATION_ERROR)
{ … } — no teamId member 200, and the invitation's teamId is null
today's resend({ email, organizationId, teamId }) 200, and the invitation's teamId is null — the defect, live

Row 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.list reads it back. Removing the member would have deleted a capability the wire really has.

Why it is not forwarded verbatim — rows 3 and 4

null is this SDK's own spelling of "no team": invitations.list answers teamId: string | null, and handing that object straight back into resend is the ordinary way to resend. The vendor's spelling of the same fact is ABSENCE.

A bare spread would therefore put teamId: null on the wire and convert today's SILENT drop into a LOUD 400 for every caller who ever read an invitation back out of list() — a second defect wearing the fix's clothes. So invite lifts teamId out of the spread and sends it only when it is a string; null and an omitted member both send no teamId at all.

Nothing else is normalised. An unknown id keeps reaching the vendor, because TEAM_NOT_FOUND is exactly the loud refusal that replaces the silent drop.

What changed

  • organizations.invite declares teamId?: string | null and lifts it out of the body spread. It is the only route resend has to the wire, so declaring the member is what lets the placement be typed rather than smuggled through an undeclared property.
  • organizations.invitations.resend forwards teamId.
  • resend stops spelling its own role ?? 'member' and takes invite'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-undefined role still reaches the wire as 'member', in the same byte position, and a caller-named role still survives — pinned by case ④d.
  • The sibling #16582 docblock sentence that asserted resend substitutes 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-object against the HEAD blob, ran, then restored and proved the restore by blob equality plus an empty git diff HEAD. No dist is in the resolution path: the suite imports ./index relatively, inside its own package, so the bytes under test are the source bytes in this checkout.

leg what it does result
green the fix as it stands 17 passed (11 new + 6 sibling #16582)
A — revert the fix to the merge base teamId never forwarded again 8 failed / 9 passed — ①, ①b, ②, ②b, ④a, ④d, ④e, ④f red
B — the NAIVE forward (bare spread, teamId verbatim) the wrong fix 4 failed / 13 passed — ③, ④b, ④e, ④f red

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 input against the real vendor, and ④b reads

Expected: {"email":"probe@example.com","role":"member","organizationId":"org_probe"}
Received: {"email":"probe@example.com","role":"member","organizationId":"org_probe","teamId":null}

Every 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 null gets through.

The family question the card asked — answered, and the PR deliberately not widened

#14314 did NOT miss this member, and the member was not added after it.

  • teamId on resend arrived in 371445c27 (2026-05-07), the commit that first created the whole organizations.* SDK surface.
  • #14314 landed four months later as 7092d63e4 (2026-09-07, PR #16576), and its axis was the RESPONSE: nineteen Promise of any returns 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 names resend explicitly as closing because it delegates to invite, "not named here because its site is unchanged".
  • The route ledger, the other binding artifact, carries route, family, source, disposition, client, requires and an optional responseSchema. 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 #16582 and 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 #14314 itself now answers 404 on this board through the REST API, while #16582 answers 200 through 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, including check:test-typecheck over tsconfig.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-wide eslint . --no-inline-config, exit 0. No narrowing was needed.
  • Gate families derived from the ACTUAL changed paths with 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/client has no integration tier in this diff's reach: test:integration is a separate script against a live server and is not part of pnpm test; nothing here touches a spawn entry point.

⚠️ One red, pre-existing and not this PR's: pnpm check:cross-package-test-inputs exits 1 on any tree where packages/spec/dist is built. It names packages/cli/test/init-created-files-summary.e2e.test.ts descending packages/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:

  • The vendor accepts teamId as a string ARRAY as well as a string, and stores the ids comma-joined. This SDK declares only string | null, which is what every shape in the family uses (invitations.list answers teamId?: string | null) and all that is delivered here. Declaring the array form is a capability decision, not a defect.
  • The vendor accepts teamId: '' with a 200 and stores the empty string verbatim: its validation is guarded on truthiness, so an empty id skips the TEAM_NOT_FOUND check entirely. The SDK forwards what the caller declared rather than inventing a meaning for it. Vendor-side, and a reading only.
  • better-auth's own body schema carries a resend?: boolean flag on invite-member that 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_TIER and 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-review is 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

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0c573f09f9f6acdee64a6075f73de88792b40fc7packageMentionDocs.

Which tree this was computed on

This run read content/docs from d5ee28203a121c39e69181341dbe5bd762baf04a — the merge of head 15c1048a6713cf6fc0b5fffd8420f3d8ebc423b8 into base 0c573f09f9f6acdee64a6075f73de88792b40fc7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] organizations.invitations.resend declares teamId and never forwards it — resending a team invitation silently drops the team placement

2 participants