Skip to content

Commit 053af37

Browse files
committed
Fixes
1 parent 07d93e8 commit 053af37

6 files changed

Lines changed: 51 additions & 43 deletions

File tree

apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { VerificationCodeType } from "@/generated/prisma/client";
66
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
77
import { KnownErrors } from "@stackframe/stack-shared";
88
import { adaptSchema, clientOrHigherAuthTypeSchema, userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
9-
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
109

1110
export const POST = createSmartRouteHandler({
1211
metadata: {
@@ -82,9 +81,7 @@ export const POST = createSmartRouteHandler({
8281
});
8382

8483
if (!matchingChannel) {
85-
throw new StackAssertionError(
86-
"Cannot accept this invitation: no verified email matching the invitation's recipient email was found on the target user",
87-
);
84+
throw new KnownErrors.VerificationCodeNotFound();
8885
}
8986

9087
await retryTransaction(prisma, async (tx) => {
@@ -126,20 +123,23 @@ export const POST = createSmartRouteHandler({
126123
data: {},
127124
});
128125
}
129-
});
130126

131-
// Mark the invitation as used
132-
await globalPrismaClient.verificationCode.update({
133-
where: {
134-
projectId_branchId_id: {
127+
// Mark the invitation as used inside the transaction to prevent race conditions
128+
const updated = await globalPrismaClient.verificationCode.updateMany({
129+
where: {
135130
projectId: auth.tenancy.project.id,
136131
branchId: auth.tenancy.branchId,
137132
id: params.id,
133+
usedAt: null,
138134
},
139-
},
140-
data: {
141-
usedAt: new Date(),
142-
},
135+
data: {
136+
usedAt: new Date(),
137+
},
138+
});
139+
140+
if (updated.count === 0) {
141+
throw new KnownErrors.VerificationCodeNotFound();
142+
}
143143
});
144144

145145
return {

apps/backend/src/app/api/latest/team-invitations/crud.tsx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import { VerificationCodeType } from "@/generated/prisma/client";
12
import { ensureTeamExists, ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks";
2-
import { globalPrismaClient, getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
3+
import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client";
34
import { createCrudHandlers } from "@/route-handlers/crud-handler";
4-
import { VerificationCodeType } from "@/generated/prisma/client";
55
import { KnownErrors } from "@stackframe/stack-shared";
66
import { teamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation";
77
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8-
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
8+
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
99
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
1010
import { teamsCrudHandlers } from "../teams/crud";
1111
import { teamInvitationCodeHandler } from "./accept/verification-code-handler";
@@ -20,17 +20,19 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
2020
}),
2121
onList: async ({ auth, query }) => {
2222
if (query.team_id != null && query.user_id != null) {
23-
throw new StackAssertionError("Cannot specify both team_id and user_id");
23+
throw new StatusError(StatusError.BadRequest, "Cannot specify both team_id and user_id");
2424
}
2525
if (query.team_id == null && query.user_id == null) {
26-
throw new StackAssertionError("Must specify either team_id or user_id");
26+
throw new StatusError(StatusError.BadRequest, "Must specify either team_id or user_id");
2727
}
2828

2929
if (query.user_id != null) {
30-
// List invitations sent to the current user's verified emails
31-
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
32-
if (auth.type === 'client' && query.user_id !== currentUserId) {
33-
throw new KnownErrors.CannotGetOwnUserWithoutUser();
30+
// List invitations sent to the user's verified emails
31+
if (auth.type === 'client') {
32+
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
33+
if (query.user_id !== currentUserId) {
34+
throw new KnownErrors.CannotGetOwnUserWithoutUser();
35+
}
3436
}
3537

3638
const targetUserId = query.user_id;
@@ -74,10 +76,15 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
7476
const team = await teamsCrudHandlers.adminRead({
7577
tenancy: auth.tenancy,
7678
team_id: teamId,
79+
allowedErrorTypes: [KnownErrors.TeamNotFound],
7780
});
7881
teamsMap.set(teamId, team.display_name);
79-
} catch {
80-
// Team may have been deleted since the invitation was created; skip these invitations
82+
} catch (e) {
83+
if (KnownErrors.TeamNotFound.isInstance(e)) {
84+
// Team may have been deleted since the invitation was created; skip these invitations
85+
continue;
86+
}
87+
throw e;
8188
}
8289
}
8390

@@ -133,16 +140,11 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
133140
},
134141
});
135142

136-
let teamDisplayName: string;
137-
try {
138-
const team = await teamsCrudHandlers.adminRead({
139-
tenancy: auth.tenancy,
140-
team_id: teamId,
141-
});
142-
teamDisplayName = team.display_name;
143-
} catch {
144-
teamDisplayName = "";
145-
}
143+
const team = await teamsCrudHandlers.adminRead({
144+
tenancy: auth.tenancy,
145+
team_id: teamId,
146+
});
147+
const teamDisplayName = team.display_name;
146148

147149
return {
148150
items: allCodes.map(code => ({
@@ -157,7 +159,7 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
157159
});
158160
},
159161
onDelete: async ({ auth, query, params }) => {
160-
const teamId = query.team_id ?? throwErr(new StackAssertionError("team_id is required for deleting a team invitation"));
162+
const teamId = query.team_id ?? throwErr(new StatusError(StatusError.BadRequest, "team_id is required for deleting a team invitation"));
161163
const prisma = await getPrismaClientForTenancy(auth.tenancy);
162164
await retryTransaction(prisma, async (tx) => {
163165
if (auth.type === 'client') {

apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ it("can't list invitations without team_id or user_id", async ({ expect }) => {
184184
accessType: "server",
185185
method: "GET",
186186
});
187-
expect(listInvitationsResponse.status).toBe(500);
187+
expect(listInvitationsResponse.status).toBe(400);
188188
});
189189

190190

@@ -1000,7 +1000,7 @@ it("cannot specify both team_id and user_id", async ({ expect }) => {
10001000
accessType: "server",
10011001
method: "GET",
10021002
});
1003-
expect(listResponse.status).toBe(500);
1003+
expect(listResponse.status).toBe(400);
10041004
});
10051005

10061006

@@ -1011,7 +1011,7 @@ it("must specify either team_id or user_id", async ({ expect }) => {
10111011
accessType: "server",
10121012
method: "GET",
10131013
});
1014-
expect(listResponse.status).toBe(500);
1014+
expect(listResponse.status).toBe(400);
10151015
});
10161016

10171017

packages/stack-shared/src/interface/client-interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1214,7 +1214,7 @@ export class StackClientInterface {
12141214
session: InternalSession,
12151215
) {
12161216
await this.sendClientRequest(
1217-
`/team-invitations/${invitationId}/accept?` + new URLSearchParams({ user_id: 'me' }),
1217+
urlString`/team-invitations/${invitationId}/accept?` + new URLSearchParams({ user_id: 'me' }),
12181218
{ method: "POST" },
12191219
session,
12201220
);

packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,8 +1007,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
10071007
expiresAt: new Date(crud.expires_at_millis),
10081008
accept: async () => {
10091009
await app._interface.acceptTeamInvitationById(crud.id, session);
1010-
await app._currentUserTeamInvitationsCache.refresh([session]);
1011-
await app._currentUserTeamsCache.refresh([session]);
1010+
await Promise.all([
1011+
app._currentUserTeamInvitationsCache.refresh([session]),
1012+
app._currentUserTeamsCache.refresh([session]),
1013+
app._teamInvitationsCache.refresh([session, crud.team_id]),
1014+
]);
10121015
},
10131016
};
10141017
}

packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -826,8 +826,11 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
826826
expiresAt: new Date(crud.expires_at_millis),
827827
accept: async () => {
828828
await app._interface.acceptServerTeamInvitationById(crud.id, userId);
829-
await app._serverUserTeamInvitationsCache.refresh([userId]);
830-
await app._serverTeamsCache.refresh([userId]);
829+
await Promise.all([
830+
app._serverUserTeamInvitationsCache.refresh([userId]),
831+
app._serverTeamsCache.refresh([userId]),
832+
app._serverTeamInvitationsCache.refresh([crud.team_id]),
833+
]);
831834
},
832835
};
833836
}

0 commit comments

Comments
 (0)