Skip to content

fix(billing): route member creation through Better Auth hooks#1319

Merged
saddlepaddle merged 1 commit into
mainfrom
super-283
Feb 8, 2026
Merged

fix(billing): route member creation through Better Auth hooks#1319
saddlepaddle merged 1 commit into
mainfrom
super-283

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Feb 8, 2026

Summary

  • Two codepaths were creating org members via raw db.insert(members), bypassing beforeAddMember (free plan seat limit) and afterAddMember (Stripe seat quantity update + notification emails)
  • tRPC addMember mutation — replaced with ctx.auth.api.addMember(), matching the pattern already used by removeMember, leaveOrganization, and updateMemberRole
  • Invitation acceptance endpoint — replaced with auth.api.addMember() via dynamic import to avoid circular dependency (server.ts imports this file)

Seat quantities will self-correct on next member add/remove (the afterAddMember hook does a full member count, not an increment). Plan to email affected org owners ahead of time.

Test plan

  • bun run typecheck — 17/17 pass
  • bun run lint:fix — clean
  • bun test — 1205 pass, 0 fail

Summary by CodeRabbit

  • Refactor
    • Member creation now goes through a centralized API, improving consistency and reliability for inviting and adding organization members.
    • Role assignment during invites is standardized (defaults to member), reducing ambiguity in new member permissions.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 8, 2026

📝 Walkthrough

Walkthrough

Replaces direct member table inserts with calls to the centralized auth API (auth.api.addMember) in the invitation endpoint and the organization router; mutation signatures updated to pass context and headers where applicable.

Changes

Cohort / File(s) Summary
Invitation Endpoint
packages/auth/src/lib/accept-invitation-endpoint.ts
Removed direct DB insert for new members; added dynamic import and call to auth.api.addMember when no existing member is found. Role value now explicitly typed as `"member"
Organization Router
packages/trpc/src/router/organization/organization.ts
addMember mutation signature changed to receive ctx; replaced direct members table insert with ctx.auth.api.addMember({ body: { organizationId, userId, role: "member" }, headers: ctx.headers }) and return API response.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant TRPC_Router as TRPC Router
  participant Auth_API as Auth API
  participant DB as Database

  Client->>TRPC_Router: addMember(input, headers)
  TRPC_Router->>Auth_API: auth.api.addMember(body{organizationId,userId,role}, headers)
  Auth_API->>DB: insert member record
  DB-->>Auth_API: inserted member
  Auth_API-->>TRPC_Router: API response (member)
  TRPC_Router-->>Client: return API response
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐇 I hopped through code with a tiny cheer,
Moved member-making to one place near,
No scattered writes, a tidy trail,
One API call now tells the tale,
🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: routing member creation through Better Auth hooks instead of direct database insertion. It is specific, concise, and clearly reflects the primary objective.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the problem, solution, impact analysis, and test results. It goes beyond the template by providing specific details about affected organizations and seat mismatches.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch super-283

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/auth/src/lib/accept-invitation-endpoint.ts (1)

121-145: ⚠️ Potential issue | 🟠 Major

Invitation marked "accepted" before member creation — failure leaves inconsistent state.

If auth.api.addMember throws (e.g. seat-limit exceeded from the beforeAddMember hook), the invitation is already marked "accepted" (line 122-125) but no member record exists. The user would see a stale "accepted" invitation with no actual membership and no way to re-accept.

Consider either:

  • Reordering: create the member first, then mark the invitation accepted, or
  • Wrapping both in a transaction with rollback on failure.

This was likely pre-existing with the old direct-insert path, but it's more likely to surface now that addMember enforces seat limits.

packages/trpc/src/router/organization/organization.ts (1)

316-333: ⚠️ Potential issue | 🔴 Critical

Missing authorization check — any authenticated user can add members to any organization.

Unlike removeMember (lines 342-402) and updateMemberRole (lines 463-543), the addMember mutation performs no permission verification before calling the API. Both sibling mutations explicitly verify the caller is an organization member, check their role, and validate permissions—then delegate to auth.api.removeMember or auth.api.updateMemberRole.

The beforeAddMember hook in packages/auth/src/server.ts receives only { organization } context, not caller information, so it cannot enforce who can add members.

Add an explicit authorization check to match the pattern:

  • Verify the caller is a member of the organization
  • Verify the caller has owner or admin role
  • Then call ctx.auth.api.addMember
🧹 Nitpick comments (1)
packages/auth/src/lib/accept-invitation-endpoint.ts (1)

142-143: Unsafe as cast on invitation.role — consider runtime validation.

The as "member" | "owner" | "admin" is a compile-time-only assertion; if invitation.role holds an unexpected value at runtime, it will be passed through unchecked. The ?? "member" fallback only triggers on null/undefined, not on an invalid string.

A quick guard would be safer:

Proposed fix
-							role:
-								(invitation.role as "member" | "owner" | "admin") ?? "member",
+							role: (["member", "owner", "admin"].includes(invitation.role)
+								? invitation.role
+								: "member") as "member" | "owner" | "admin",

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 8, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch
  • ✅ Electric Fly.io app

Thank you for your contribution! 🎉

Two codepaths were creating org members via raw db.insert(members),
bypassing beforeAddMember (free plan limit) and afterAddMember
(Stripe seat update + emails). Replace with auth.api.addMember()
in both the tRPC addMember mutation and invitation acceptance endpoint.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/auth/src/lib/accept-invitation-endpoint.ts (1)

121-148: ⚠️ Potential issue | 🔴 Critical

Invitation marked "accepted" before addMember — leaves inconsistent state on failure.

If auth.api.addMember throws (e.g., beforeAddMember rejects because the org hit the free-plan seat limit), the invitation is already set to "accepted" (line 122-125) but no member record exists. The user can't retry because the status !== "pending" guard on line 60 will reject them.

Move the invitation status update to after the addMember call so a failed add leaves the invitation retryable:

Proposed fix
-			// 5. Accept invitation by updating status and creating member
-			await db
-				.update(invitations)
-				.set({ status: "accepted" })
-				.where(eq(invitations.id, invitationId));
-
-			// Create member record (check if not already a member)
+			// 5. Create member record (check if not already a member)
 			const existingMember = await db.query.members.findFirst({
 				where: and(
 					eq(members.organizationId, invitation.organization.id),
 					eq(members.userId, user.id),
 				),
 			});

 			if (!existingMember) {
 				const { auth } = await import("../server");
 				await auth.api.addMember({
 					body: {
 						organizationId: invitation.organization.id,
 						userId: user.id,
 						role:
 							(invitation.role as "member" | "owner" | "admin") ?? "member",
 					},
 				});
 			}

+			// Mark invitation accepted only after member creation succeeds
+			await db
+				.update(invitations)
+				.set({ status: "accepted" })
+				.where(eq(invitations.id, invitationId));
packages/trpc/src/router/organization/organization.ts (2)

316-333: ⚠️ Potential issue | 🔴 Critical

Missing authorization — any authenticated user can add members to any organization.

removeMember (line 355-392) and updateMemberRole (line 484-492) both verify the caller is a member with a sufficient role before proceeding. addMember has no such check: any logged-in user can add any userId to any organizationId.

Even if auth.api.addMember performs some internal checks, the tRPC layer should enforce authorization consistently with the sibling mutations.

Proposed fix
 	.mutation(async ({ ctx, input }) => {
+		const actorMembership = await db.query.members.findFirst({
+			where: and(
+				eq(members.organizationId, input.organizationId),
+				eq(members.userId, ctx.session.user.id),
+			),
+		});
+
+		if (!actorMembership || actorMembership.role === "member") {
+			throw new TRPCError({
+				code: "FORBIDDEN",
+				message: "Only owners and admins can add members",
+			});
+		}
+
 		const member = await ctx.auth.api.addMember({
 			body: {
 				organizationId: input.organizationId,

116-122: ⚠️ Potential issue | 🟡 Minor

The founding owner will miss the welcome email since create bypasses afterAddMember hooks.

While bypassing the seat-limit check in beforeAddMember is intentional (new org has no constraints), the founding owner should still receive the welcome email sent by afterAddMember. Consider calling ctx.auth.api.addMember() like the addMember procedure does (line 316) to trigger the email, or explicitly send the welcome email for the founding owner.

🤖 Fix all issues with AI agents
In `@packages/auth/src/lib/accept-invitation-endpoint.ts`:
- Around line 136-148: The call to auth.api.addMember is unprotected, so if it
throws the user remains logged-in but not actually added to the organization;
wrap the await import("../server") / auth.api.addMember invocation in a
try/catch inside accept-invitation-endpoint.ts, catch errors from
auth.api.addMember, log contextual information (invitation.id,
invitation.organization.id, user.id) and then either rollback the session/cookie
you set earlier (clear session and delete the auth cookie) before returning a
handled error response or re-throw a wrapped error with that context so the
caller can present a meaningful message; ensure you reference auth.api.addMember
and the session/cookie teardown logic currently run earlier in this handler when
implementing the rollback.

Comment on lines +136 to 148
// Dynamic import: this plugin needs to call the organization plugin's
// addMember API to trigger billing hooks (beforeAddMember/afterAddMember).
// server.ts imports this file as a plugin, so a static import would be circular.
// The import resolves at request time when all modules are fully initialized.
const { auth } = await import("../server");
await auth.api.addMember({
body: {
organizationId: invitation.organization.id,
userId: user.id,
role:
(invitation.role as "member" | "owner" | "admin") ?? "member",
},
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No error handling around auth.api.addMember — unhandled throw leaves partial state.

If addMember fails, the session and cookie are already set (lines 92-119) and the user lands in a broken state: logged in, session points to the org, but they aren't a member. Consider wrapping the call and returning a meaningful error, or at minimum logging context before re-throwing.

🤖 Prompt for AI Agents
In `@packages/auth/src/lib/accept-invitation-endpoint.ts` around lines 136 - 148,
The call to auth.api.addMember is unprotected, so if it throws the user remains
logged-in but not actually added to the organization; wrap the await
import("../server") / auth.api.addMember invocation in a try/catch inside
accept-invitation-endpoint.ts, catch errors from auth.api.addMember, log
contextual information (invitation.id, invitation.organization.id, user.id) and
then either rollback the session/cookie you set earlier (clear session and
delete the auth cookie) before returning a handled error response or re-throw a
wrapped error with that context so the caller can present a meaningful message;
ensure you reference auth.api.addMember and the session/cookie teardown logic
currently run earlier in this handler when implementing the rollback.

@saddlepaddle saddlepaddle merged commit 8f5129d into main Feb 8, 2026
14 checks passed
@Kitenite Kitenite deleted the super-283 branch February 9, 2026 01:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant