Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud";
import { getItemQuantityForCustomer } from "@/lib/payments";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { globalPrismaClient } from "@/prisma-client";
import { VerificationCodeType } from "@/generated/prisma/client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const POST = createSmartRouteHandler({
metadata: {
summary: "Accept a team invitation by ID",
description: "Accepts a team invitation for the specified user. The user must have a verified email matching the invitation's recipient email. This marks the invitation as used and adds the user to the team.",
tags: ["Teams"],
},
request: yupObject({
auth: yupObject({
type: clientOrHigherAuthTypeSchema,
tenancy: adaptSchema.defined(),
user: adaptSchema.optional(),
}).defined(),
Comment thread
N2D4 marked this conversation as resolved.
params: yupObject({
id: yupString().uuid().defined(),
}).defined(),
query: yupObject({
user_id: userIdOrMeSchema.defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({}).defined(),
}),
async handler({ auth, params, query }) {
const userId = query.user_id;

if (auth.type === 'client') {
if (!auth.user) {
throw new KnownErrors.CannotGetOwnUserWithoutUser();
}
if (userId !== auth.user.id) {
throw new KnownErrors.CannotGetOwnUserWithoutUser();
}

if (auth.user.restricted_reason) {
throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(auth.user.restricted_reason);
}
}

// Look up the invitation (verification code) by ID
const code = await globalPrismaClient.verificationCode.findUnique({
where: {
projectId_branchId_id: {
projectId: auth.tenancy.project.id,
branchId: auth.tenancy.branchId,
id: params.id,
},
type: VerificationCodeType.TEAM_INVITATION,
usedAt: null,
expiresAt: { gt: new Date() },
},
});

if (!code) {
throw new KnownErrors.VerificationCodeNotFound();
}

const invitationData = code.data as { team_id: string };
const invitationMethod = code.method as { email: string };
Comment on lines +69 to +70
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 | 🟡 Minor

Unvalidated type casts violate defensive coding guidelines.

code.data and code.method are Prisma Json fields; the casts to { team_id: string } and { email: string } provide no runtime guarantees. If the stored data is malformed or the schema evolves, subsequent code will silently operate on undefined values.

🛡️ Proposed fix
-    const invitationData = code.data as { team_id: string };
-    const invitationMethod = code.method as { email: string };
+    const invitationData = code.data as { team_id: string };
+    const teamId = invitationData?.team_id ?? throwErr("invitationData.team_id is undefined — the verification code data is malformed");
+    const invitationMethod = code.method as { email: string };
+    const recipientEmail = invitationMethod?.email ?? throwErr("invitationMethod.email is undefined — the verification code method is malformed");

Then replace all subsequent usages of invitationData.team_id with teamId and invitationMethod.email with recipientEmail.

As per coding guidelines: "Code defensively. Prefer ?? throwErr(...) over non-null assertions, with good error messages explicitly stating the assumption that must've been violated".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/app/api/latest/team-invitations/`[id]/accept/route.tsx
around lines 69 - 70, The current unchecked casts of code.data and code.method
to { team_id: string } and { email: string } are unsafe; instead parse and
validate these Json fields in the accept route so you explicitly throw on
missing/invalid values. Replace the direct casts/uses of invitationData.team_id
and invitationMethod.email by extracting teamId and recipientEmail from
code.data/code.method with runtime checks (e.g., if not a string then throw with
a clear message) and then use teamId and recipientEmail in the rest of the
function (referencing invitationData, invitationMethod, teamId, recipientEmail
in this file). Ensure every subsequent usage of invitationData.team_id becomes
teamId and invitationMethod.email becomes recipientEmail and include descriptive
error text per the defensive guideline.


// Verify that the target user has a verified email matching the invitation's recipient
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const matchingChannel = await prisma.contactChannel.findFirst({
where: {
tenancyId: auth.tenancy.id,
projectUserId: userId,
type: 'EMAIL',
isVerified: true,
value: invitationMethod.email,
},
});

if (!matchingChannel) {
throw new KnownErrors.VerificationCodeNotFound();
}
Comment thread
N2D4 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await retryTransaction(prisma, async (tx) => {
// Internal project payment checks (same as in the verification code handler)
if (auth.tenancy.project.id === "internal") {
const currentMemberCount = await tx.teamMember.count({
where: {
tenancyId: auth.tenancy.id,
teamId: invitationData.team_id,
},
});
const maxDashboardAdmins = await getItemQuantityForCustomer({
prisma: tx,
tenancy: auth.tenancy,
customerId: invitationData.team_id,
itemId: "dashboard_admins",
customerType: "team",
});
if (currentMemberCount + 1 > maxDashboardAdmins) {
throw new KnownErrors.ItemQuantityInsufficientAmount("dashboard_admins", invitationData.team_id, -1);
}
}

const oldMembership = await tx.teamMember.findUnique({
where: {
tenancyId_projectUserId_teamId: {
tenancyId: auth.tenancy.id,
projectUserId: userId,
teamId: invitationData.team_id,
},
},
});

if (!oldMembership) {
await teamMembershipsCrudHandlers.adminCreate({
tenancy: auth.tenancy,
team_id: invitationData.team_id,
user_id: userId,
data: {},
Comment thread
N2D4 marked this conversation as resolved.
});
}

// Mark the invitation as used inside the transaction to prevent race conditions
const updated = await globalPrismaClient.verificationCode.updateMany({
where: {
projectId: auth.tenancy.project.id,
branchId: auth.tenancy.branchId,
id: params.id,
usedAt: null,
},
data: {
usedAt: new Date(),
},
});

if (updated.count === 0) {
throw new KnownErrors.VerificationCodeNotFound();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-retryable operations inside retryTransaction cause failures

High Severity

The globalPrismaClient.verificationCode.updateMany call and teamMembershipsCrudHandlers.adminCreate are placed inside the retryTransaction callback but neither uses the tx transaction client — they commit independently and are not rolled back on retry. The retryTransaction infrastructure deliberately forces retries with 50% probability in dev/test to ensure all operations within are retryable. On retry, updateMany finds usedAt already set from the first attempt, returns count === 0, and throws VerificationCodeNotFound — even though the user was successfully added to the team. These non-retryable, non-idempotent operations need to be moved outside the retryTransaction callback.

Additional Locations (1)

Fix in Cursor Fix in Web


return {
statusCode: 200,
bodyType: "json",
body: {},
};
},
});
130 changes: 114 additions & 16 deletions apps/backend/src/app/api/latest/team-invitations/crud.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,156 @@
import { VerificationCodeType } from "@/generated/prisma/client";
import { ensureTeamExists, ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { teamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { teamsCrudHandlers } from "../teams/crud";
import { teamInvitationCodeHandler } from "./accept/verification-code-handler";

export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamInvitationCrud, {
querySchema: yupObject({
team_id: yupString().uuid().defined().meta({ openapiField: { onlyShowInOperations: ['List'] } }),
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: ['List', 'Delete'], description: 'The team ID to list invitations for. Required unless user_id is provided.' } }),
user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'List invitations sent to this user\'s verified emails. Must be "me" for client access. Cannot be combined with team_id.' } }),
}),
paramsSchema: yupObject({
id: yupString().uuid().defined(),
}),
onList: async ({ auth, query }) => {
if (query.team_id != null && query.user_id != null) {
throw new StatusError(StatusError.BadRequest, "Cannot specify both team_id and user_id");
}
if (query.team_id == null && query.user_id == null) {
throw new StatusError(StatusError.BadRequest, "Must specify either team_id or user_id");
}

Comment thread
N2D4 marked this conversation as resolved.
if (query.user_id != null) {
// List invitations sent to the user's verified emails
if (auth.type === 'client') {
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
if (query.user_id !== currentUserId) {
throw new KnownErrors.CannotGetOwnUserWithoutUser();
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

const targetUserId = query.user_id;

const prisma = await getPrismaClientForTenancy(auth.tenancy);
const verifiedEmails = await prisma.contactChannel.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: targetUserId,
type: 'EMAIL',
isVerified: true,
},
select: { value: true },
});

if (verifiedEmails.length === 0) {
return { items: [], is_paginated: false };
}

const codes = await globalPrismaClient.verificationCode.findMany({
where: {
projectId: auth.tenancy.project.id,
branchId: auth.tenancy.branchId,
type: VerificationCodeType.TEAM_INVITATION,
usedAt: null,
expiresAt: { gt: new Date() },
OR: verifiedEmails.map(({ value }) => ({
method: { path: ['email'], equals: value },
})),
},
});

const teamIds = [...new Set(codes.map(code => {
const data = code.data as { team_id: string };
return data.team_id;
}))];

const teamsMap = new Map<string, string>();
for (const teamId of teamIds) {
try {
const team = await teamsCrudHandlers.adminRead({
tenancy: auth.tenancy,
team_id: teamId,
allowedErrorTypes: [KnownErrors.TeamNotFound],
});
teamsMap.set(teamId, team.display_name);
} catch (e) {
if (KnownErrors.TeamNotFound.isInstance(e)) {
// Team may have been deleted since the invitation was created; skip these invitations
continue;
}
throw e;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
items: codes
.filter(code => {
const data = code.data as { team_id: string };
return teamsMap.has(data.team_id);
})
.map(code => {
const data = code.data as { team_id: string };
const method = code.method as { email: string };
return {
id: code.id,
team_id: data.team_id,
team_display_name: teamsMap.get(data.team_id) ?? throwErr("team_display_name should be available after filtering; this should never happen"),
expires_at_millis: code.expiresAt.getTime(),
recipient_email: method.email,
};
}),
is_paginated: false,
};
}

// List invitations for a specific team (existing behavior)
const teamId = query.team_id ?? throwErr("team_id is required when user_id is not provided; this should never happen because of the earlier validation");
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client') {
// Client can only:
// - list invitations in their own team if they have the $read_members AND $invite_members permissions
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());

await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId: query.team_id, userId: currentUserId });
await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId, userId: currentUserId });

for (const permissionId of ['$read_members', '$invite_members']) {
await ensureUserTeamPermissionExists(tx, {
tenancy: auth.tenancy,
teamId: query.team_id,
teamId,
userId: currentUserId,
permissionId,
errorType: 'required',
recursive: true,
});
}
} else {
await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId: query.team_id });
await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId });
}

const allCodes = await teamInvitationCodeHandler.listCodes({
tenancy: auth.tenancy,
dataFilter: {
path: ['team_id'],
equals: query.team_id,
equals: teamId,
},
});

const team = await teamsCrudHandlers.adminRead({
tenancy: auth.tenancy,
team_id: teamId,
});
const teamDisplayName = team.display_name;

return {
items: allCodes.map(code => ({
id: code.id,
team_id: code.data.team_id,
team_display_name: teamDisplayName,
expires_at_millis: code.expiresAt.getTime(),
recipient_email: code.method.email,
})),
Expand All @@ -59,26 +159,24 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
});
},
onDelete: async ({ auth, query, params }) => {
const teamId = query.team_id ?? throwErr(new StatusError(StatusError.BadRequest, "team_id is required for deleting a team invitation"));
const prisma = await getPrismaClientForTenancy(auth.tenancy);
await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client') {
// Client can only:
// - delete invitations in their own team if they have the $remove_members permissions

const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());

await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId: query.team_id, userId: currentUserId });
await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId, userId: currentUserId });

await ensureUserTeamPermissionExists(tx, {
tenancy: auth.tenancy,
teamId: query.team_id,
teamId,
userId: currentUserId,
permissionId: "$remove_members",
errorType: 'required',
recursive: true,
});
} else {
await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId: query.team_id });
await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId });
}
});

Expand Down
Loading
Loading