Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedThe pull request is closed. 📝 WalkthroughWalkthroughAdds user-scoped team-invitation listing and a POST API to accept an invitation by ID; backend resolves invitations via verified emails, enriches responses with team_display_name; SDKs and template implementations gain sent/received invitation types, caches, and accept flows; e2e tests expanded. Changes
Sequence DiagramsequenceDiagram
actor User
participant Client
participant API as "POST /team-invitations/{id}/accept"
participant Tenancy as TenancyScopedPrisma
participant DB as Database
participant Billing as BillingCheck
User->>Client: accept(invitationId)
Client->>API: POST /team-invitations/{id}/accept (auth + user_id=me)
API->>Tenancy: resolve prisma & current user
API->>DB: lookup VerificationCode by invitation id
alt verification code missing
API-->>Client: Error (MISSING_VERIFICATION_CODE)
else
API->>Tenancy: query user's verified emails
alt email mismatch/unverified
API-->>Client: Error (CANNOT_USE_EMAIL_FOR_INVITATION)
else
opt dashboard_admins/payment constraint
API->>Billing: validate billing/customer constraints
alt constraint fails
API-->>Client: Error (PAYMENT_RESTRICTION)
end
end
API->>DB: begin transaction -> create/confirm membership
DB-->>API: membership created/confirmed
API->>DB: mark verification code used (usedAt)
DB-->>API: success
API-->>Client: 200 OK
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR adds the ability for users to list and accept team invitations sent to their verified email addresses. Users can now call Key changes:
Issues found:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User as User (Invitee)
participant Client as Client SDK
participant API as Accept Endpoint
participant DB as Database
participant TeamDB as Team Membership
Note over User,TeamDB: User lists invitations
User->>Client: user.listTeamInvitations()
Client->>API: GET /team-invitations?user_id=me
API->>DB: Query verified emails
API->>DB: Find invitations matching emails
DB-->>API: Return invitations
API-->>Client: Invitation list with team info
Client-->>User: ReceivedTeamInvitation[]
Note over User,TeamDB: User accepts invitation
User->>Client: invitation.accept()
Client->>API: POST /team-invitations/{id}/accept
API->>DB: Lookup invitation by ID
API->>DB: Verify user has matching verified email
API->>TeamDB: Check existing membership (in tx)
TeamDB-->>API: No existing membership
API->>TeamDB: Create team membership (in tx)
API->>DB: Mark invitation as used
DB-->>API: Success
API-->>Client: 200 OK
Client->>Client: Refresh invitations cache
Client->>Client: Refresh teams cache
Client-->>User: Success
Last reviewed commit: 8553203 |
There was a problem hiding this comment.
Pull request overview
Adds first-class support for viewing and accepting team invitations from the invited user’s perspective, exposed through both the client/server SDK user objects and new/extended backend API capabilities.
Changes:
- Introduces
ReceivedTeamInvitation(user-facing) and renames existing invitation type toSentTeamInvitation(team-facing) with a deprecated alias for compatibility. - Adds SDK methods/hooks:
user.listTeamInvitations()/user.useTeamInvitations()and implements invitation acceptance with cache refresh. - Extends
/team-invitationsAPI to supportuser_idlisting and adds a new/team-invitations/:id/acceptendpoint, plus E2E tests.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/template/src/lib/stack-app/users/index.ts | Adds user-level invitation listing/hook types and async store property. |
| packages/template/src/lib/stack-app/teams/index.ts | Splits invitation typing into sent vs received; deprecates old alias; updates team APIs to return sent invitations. |
| packages/template/src/lib/stack-app/index.ts | Re-exports new invitation types from the SDK entrypoint. |
| packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts | Implements server-side listTeamInvitations()/hook and received invitation mapping/acceptance. |
| packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts | Implements client-side listTeamInvitations()/hook and received invitation mapping/acceptance. |
| packages/stack-shared/src/interface/server-interface.ts | Adds server interface methods for listing/accepting user invitations (but currently doesn’t use userId on accept). |
| packages/stack-shared/src/interface/client-interface.ts | Adds client interface methods for listing/accepting current-user invitations. |
| packages/stack-shared/src/interface/crud/team-invitation.ts | Extends invitation list/read schema to include team_display_name. |
| apps/backend/src/app/api/latest/team-invitations/crud.tsx | Extends team-invitations CRUD to support user_id listing and includes team_display_name. |
| apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx | Adds “accept invitation by ID” endpoint. |
| apps/e2e/tests/js/team-invitations.test.ts | Adds SDK-level E2E coverage for listing/accepting invitations (client + server SDK). |
| apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts | Adds/updates endpoint-level E2E coverage for listing by user_id and acceptance by ID. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Fix all issues with AI agents
Before applying any fix, first verify the finding against the current code and
decide whether a code change is actually needed. If the finding is not valid or
no change is required, do not modify code for that item and briefly explain why
it was skipped.
In `@apps/backend/src/app/api/latest/team-invitations/`[id]/accept/route.tsx:
- Around line 72-76: The thrown StackAssertionError when matchingChannel is
missing is a user-facing error; replace it so the route throws a KnownError (or
an existing specific known error) instead of StackAssertionError. Update the
block that currently throws StackAssertionError to throw
KnownErrors.EmailMismatch (or new KnownError(KnownErrors.EmailMismatch) if the
API expects an instance) so the client receives the proper HTTP status and
message; ensure you import/use the KnownErrors/KnownError symbols consistently
with other routes.
- Around line 40-131: The verification code check and the final usedAt update
have a TOCTOU race: move the verificationCode update into the same transaction
that adds the team member (i.e., inside retryTransaction) so the read-and-mark
happen atomically, or at minimum change the standalone
globalPrismaClient.verificationCode.update call to include usedAt: null in its
where clause and handle the case where no rows are updated (throw
KnownErrors.VerificationCodeNotFound or similar); update the code that performs
membership creation (teamMembershipsCrudHandlers.adminCreate) and the
verificationCode update together inside the retryTransaction block that
currently contains the membership logic, using the same prisma transaction (tx)
and the same params.id / auth.tenancy identifiers.
- Around line 57-58: The code is unsafely casting database JSON with "const
invitationData = code.data as { team_id: string }" and "const invitationMethod =
code.method as { email: string }"; replace these blind casts with runtime
validation: define a zod (or yup) schema for the expected shapes (e.g.,
team_id:string and email:string) and run schema.parse()/validateSync() on
code.data and code.method inside the accept route handler (or, if you prefer not
to add a schema lib, explicitly check the presence and types of team_id and
email and throw a descriptive error if missing) so downstream logic using
invitationData and invitationMethod cannot proceed with undefined fields. Ensure
you reference the validated values (invitationData, invitationMethod) everywhere
they are used and remove the `as` casts.
In `@apps/backend/src/app/api/latest/team-invitations/crud.tsx`:
- Around line 72-82: The bare catch blocks around the
teamsCrudHandlers.adminRead loop (the loop over teamIds that calls
teamsCrudHandlers.adminRead and populates teamsMap with teamsMap.set) must not
swallow all errors; change the catch to capture the error (catch (err)) and only
handle the expected "team not found" case (e.g., check err.code/err.status or
err instanceof NotFoundError or use an isNotFound(err) helper) by skipping that
ID, otherwise rethrow or log and surface the error; apply the same change to the
second occurrence that also calls teamsCrudHandlers.adminRead so unexpected
network/permission/timeout errors are not silently ignored.
- Around line 29-34: The guard is throwing when auth.user is null for
server/admin cases; only enforce fetching currentUserId for client auth. Change
the block that handles query.user_id so it first checks auth.type === 'client'
and only then reads auth.user?.id (assign to currentUserId) and compares it to
query.user_id, throwing KnownErrors.CannotGetOwnUserWithoutUser() or the
mismatch error for client requests; for non-client (server/admin) allow the
provided query.user_id without attempting to access auth.user.
- Around line 66-82: The loop that builds teamsMap by calling
teamsCrudHandlers.adminRead for each teamId (teamIds, teamsMap,
teamsCrudHandlers.adminRead) produces an N+1 serial query pattern; replace it
with a single batch fetch that returns all teams for the set of teamIds (e.g.,
implement/use a teamsCrudHandlers.adminReadMany or teamsCrudHandlers.adminList
with a filter by team_ids) and then populate teamsMap from that single response,
ensuring you handle missing/deleted teams by skipping or omitting them.
- Around line 22-27: Replace the internal StackAssertionError uses in the
validation for team_id/user_id with an HTTP-friendly KnownErrors instance so
clients get a 400: in the branch that checks "if (query.team_id != null &&
query.user_id != null)" throw a KnownErrors.BadRequest (or
KnownErrors.InvalidRequest) with a clear message like "Cannot specify both
team_id and user_id", and in the branch that checks "if (query.team_id == null
&& query.user_id == null)" throw a KnownErrors.BadRequest (or
KnownErrors.InvalidRequest) with "Must specify either team_id or user_id";
reference the existing StackAssertionError, query.team_id, query.user_id, and
KnownErrors symbols when making the replacement so the validation returns an
HTTP 400 instead of an internal assertion.
In `@apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts`:
- Around line 182-188: The test "it(\"can't list invitations without team_id or
user_id\"" incorrectly expects a 500; change the assertion to expect a 4xx
client error (e.g., 400) or the specific schema error code returned by your
validation layer and verify the error payload shape instead of a 500. Update the
expect call on listInvitationsResponse.status to assert the appropriate 4xx
status and optionally assert that the response body indicates a SchemaError; if
the API currently returns 500, update the validation/handler for GET
`/api/v1/team-invitations` to validate presence of team_id or user_id and return
the correct 4xx status so the test passes.
In `@packages/stack-shared/src/interface/client-interface.ts`:
- Around line 1190-1199: The acceptTeamInvitationById method currently builds
the endpoint with template interpolation which bypasses URL encoding; update
acceptTeamInvitationById to construct the path using the existing urlString
helper (same pattern as other methods) when calling sendClientRequest so the
invitationId is encoded consistently; modify the call in
acceptTeamInvitationById to pass
urlString(`/team-invitations/${invitationId}/accept`) to sendClientRequest
instead of the raw template string.
In `@packages/stack-shared/src/interface/server-interface.ts`:
- Around line 444-453: The method acceptServerTeamInvitationById currently
ignores the userId parameter; update the client and server to pass user context
by adding user_id as a query parameter when calling sendServerRequest (i.e.,
include userId in the urlString for /team-invitations/${invitationId}/accept) so
the backend receives auth.user, and update the backend endpoint handling for
POST /team-invitations/{id}/accept to accept and validate a user_id query
parameter (add it to the endpoint's querySchema and use it to set auth.user when
running server-side logic).
In `@packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts`:
- Around line 819-833: The accept handler in
_serverReceivedTeamInvitationFromCrud currently refreshes
_serverUserTeamInvitationsCache and _serverTeamsCache but not the team
invitation list, leaving admins with stale data; update the accept async
function in _serverReceivedTeamInvitationFromCrud to also call and await
app._serverTeamInvitationsCache.refresh([...]) (use the relevant identifier,
e.g. await app._serverTeamInvitationsCache.refresh([crud.team_id]) so the team’s
invitation list is refreshed after accept).
🧹 Nitpick comments (3)
🤖 Fix all nitpicks with AI agents
Before applying any fix, first verify the finding against the current code and decide whether a code change is actually needed. If the finding is not valid or no change is required, do not modify code for that item and briefly explain why it was skipped. In `@apps/backend/src/app/api/latest/team-invitations/`[id]/accept/route.tsx: - Around line 57-58: The code is unsafely casting database JSON with "const invitationData = code.data as { team_id: string }" and "const invitationMethod = code.method as { email: string }"; replace these blind casts with runtime validation: define a zod (or yup) schema for the expected shapes (e.g., team_id:string and email:string) and run schema.parse()/validateSync() on code.data and code.method inside the accept route handler (or, if you prefer not to add a schema lib, explicitly check the presence and types of team_id and email and throw a descriptive error if missing) so downstream logic using invitationData and invitationMethod cannot proceed with undefined fields. Ensure you reference the validated values (invitationData, invitationMethod) everywhere they are used and remove the `as` casts. In `@apps/backend/src/app/api/latest/team-invitations/crud.tsx`: - Around line 66-82: The loop that builds teamsMap by calling teamsCrudHandlers.adminRead for each teamId (teamIds, teamsMap, teamsCrudHandlers.adminRead) produces an N+1 serial query pattern; replace it with a single batch fetch that returns all teams for the set of teamIds (e.g., implement/use a teamsCrudHandlers.adminReadMany or teamsCrudHandlers.adminList with a filter by team_ids) and then populate teamsMap from that single response, ensuring you handle missing/deleted teams by skipping or omitting them. - Around line 22-27: Replace the internal StackAssertionError uses in the validation for team_id/user_id with an HTTP-friendly KnownErrors instance so clients get a 400: in the branch that checks "if (query.team_id != null && query.user_id != null)" throw a KnownErrors.BadRequest (or KnownErrors.InvalidRequest) with a clear message like "Cannot specify both team_id and user_id", and in the branch that checks "if (query.team_id == null && query.user_id == null)" throw a KnownErrors.BadRequest (or KnownErrors.InvalidRequest) with "Must specify either team_id or user_id"; reference the existing StackAssertionError, query.team_id, query.user_id, and KnownErrors symbols when making the replacement so the validation returns an HTTP 400 instead of an internal assertion.apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx (1)
57-58: Unvalidatedascasts on JSON data from the database.Lines 57–58 cast
code.dataandcode.methodwithout runtime validation. If the shape doesn't match (e.g., a migration changed the format, or data is corrupted), the code will silently produceundefinedvalues and fail in confusing ways downstream. Consider using a schema validation (e.g., yup/zod.validateSync()) or at minimum a?? throwErr(...)on the accessed fields. As per coding guidelines, "Do NOT useas/any/type casts or anything like that to bypass the type system unless you specifically asked the user about it."🤖 Prompt for AI Agents
Before applying any fix, first verify the finding against the current code and decide whether a code change is actually needed. If the finding is not valid or no change is required, do not modify code for that item and briefly explain why it was skipped. In `@apps/backend/src/app/api/latest/team-invitations/`[id]/accept/route.tsx around lines 57 - 58, The code is unsafely casting database JSON with "const invitationData = code.data as { team_id: string }" and "const invitationMethod = code.method as { email: string }"; replace these blind casts with runtime validation: define a zod (or yup) schema for the expected shapes (e.g., team_id:string and email:string) and run schema.parse()/validateSync() on code.data and code.method inside the accept route handler (or, if you prefer not to add a schema lib, explicitly check the presence and types of team_id and email and throw a descriptive error if missing) so downstream logic using invitationData and invitationMethod cannot proceed with undefined fields. Ensure you reference the validated values (invitationData, invitationMethod) everywhere they are used and remove the `as` casts.apps/backend/src/app/api/latest/team-invitations/crud.tsx (2)
66-82: N+1 queries: sequentialadminReadfor each team ID.Each unique team ID triggers a separate
adminReadcall in a loop. If a user has many invitations across different teams, this becomes a serial N+1 pattern. Consider batch-fetching the teams in a single query.🤖 Prompt for AI Agents
Before applying any fix, first verify the finding against the current code and decide whether a code change is actually needed. If the finding is not valid or no change is required, do not modify code for that item and briefly explain why it was skipped. In `@apps/backend/src/app/api/latest/team-invitations/crud.tsx` around lines 66 - 82, The loop that builds teamsMap by calling teamsCrudHandlers.adminRead for each teamId (teamIds, teamsMap, teamsCrudHandlers.adminRead) produces an N+1 serial query pattern; replace it with a single batch fetch that returns all teams for the set of teamIds (e.g., implement/use a teamsCrudHandlers.adminReadMany or teamsCrudHandlers.adminList with a filter by team_ids) and then populate teamsMap from that single response, ensuring you handle missing/deleted teams by skipping or omitting them.
22-27: Validation errors should useKnownErrorsor HTTP-friendly errors, notStackAssertionError.
StackAssertionErroris for internal invariant violations. A client passing bothteam_idanduser_id(or neither) is a request validation error and should return a clear HTTP 400. Consider using a KnownError or validation-level error that produces a proper status code.🤖 Prompt for AI Agents
Before applying any fix, first verify the finding against the current code and decide whether a code change is actually needed. If the finding is not valid or no change is required, do not modify code for that item and briefly explain why it was skipped. In `@apps/backend/src/app/api/latest/team-invitations/crud.tsx` around lines 22 - 27, Replace the internal StackAssertionError uses in the validation for team_id/user_id with an HTTP-friendly KnownErrors instance so clients get a 400: in the branch that checks "if (query.team_id != null && query.user_id != null)" throw a KnownErrors.BadRequest (or KnownErrors.InvalidRequest) with a clear message like "Cannot specify both team_id and user_id", and in the branch that checks "if (query.team_id == null && query.user_id == null)" throw a KnownErrors.BadRequest (or KnownErrors.InvalidRequest) with "Must specify either team_id or user_id"; reference the existing StackAssertionError, query.team_id, query.user_id, and KnownErrors symbols when making the replacement so the validation returns an HTTP 400 instead of an internal assertion.
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md -->
### Context Some of our users' emails were getting stuck in sending. The long delays in processing the retries caused a vercel function timeout. ### Summary of Changes We refactor the low level email sending functions to remove the retry logic there. We kick it up to the email queue step. Additionally, we flag emails to be retried when they encounter issues but leave it for a future iteration to actually perform the retry. We perform an exponential backoff with a random component to decide when they have to be retried. We also make some small adjustments to the queuing function to not queue skipped emails. When an email fails to send during the sending function, we check to see if it is a retryable error or not. Some errors are transient and trying again may succeed while others indicate deeper issues. If it is retryable, and the max number of retry attempts hasn't been reached, we set `nextSendRetryAt` to a time determined by an exponential backoff calculation function. When the queuing function looks for emails to queue, it doesn't just pick up the `SCHEDULED`. emails whose `scheduledAt` time <= `NOW()`, but also those emails whose `nextSendRetryAt` time <= `NOW()`. What this means in practice is that one iteration of the `email-queue-step` will mark emails as retryable while another iteration will perform the retry. This should be cleaner and prevent long delays in the `email-queue-step` process due to retries. This also makes it easier to scale up the number of retries if need be.
https://www.loom.com/share/3b7c9288149e4f878693281778c9d7e0 ## Todos (future PRs) - Fix pre-login recording - Better session search (filters, cmd-k, etc) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Analytics → Replays: session recording & multi-tab replay with timeline, speed, seek, and playback settings; dashboard UI for listing and viewing replays. * **Admin APIs** * Admin endpoints to list recordings, list chunks, fetch chunk events, and retrieve all events (paginated). * **Client** * Client-side rrweb recording with batching, deduplication, upload API and a send-batch client method. * **Configuration** * New STACK_S3_PRIVATE_BUCKET for private session storage. * **Tests** * Extensive unit and end-to-end tests for replay logic, streams, playback, and APIs. * **Chores** * Removed an E2E API test GitHub Actions workflow. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added new session replay analytics columns to ClickHouse for enhanced tracking and reporting * **Refactor** * Renamed session recording segment identifier across APIs and data models from `tab_id` to `session_replay_segment_id` * Updated internal data structures and type definitions to align with new naming convention <!-- end of auto-generated comment: release notes by coderabbit.ai -->
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/stack-shared/src/interface/server-interface.ts`:
- Around line 434-442: The method listServerUserTeamInvitations casts the
response to TeamInvitationCrud['Client']['List'] and returns
TeamInvitationCrud['Client']['Read'][] which drops server-only fields; change
the response cast and return type to TeamInvitationCrud['Server']['List'] /
TeamInvitationCrud['Server']['Read'][] (matching listServerTeamInvitations) and
keep using sendServerRequest as-is so the server-level fields are preserved;
update the type assertion on the response.json() and the method signature
accordingly.
---
Duplicate comments:
In `@packages/stack-shared/src/interface/client-interface.ts`:
- Around line 1212-1221: The acceptTeamInvitationById method builds the request
path using a raw template literal which doesn't URL-encode the invitationId;
update the path construction to use the urlString template tag (or
encodeURIComponent) instead of ``/team-invitations/${invitationId}/accept`` so
the segment is encoded consistently with the codebase and then call
sendClientRequest with that encoded path; change the path in
acceptTeamInvitationById to use
urlString`/team-invitations/${invitationId}/accept` (keeping the same method,
body, and session parameters).
In `@packages/stack-shared/src/interface/server-interface.ts`:
- Around line 444-453: The acceptServerTeamInvitationById function is ignoring
the userId parameter so the POST to /team-invitations/{invitationId}/accept
carries no user context; update acceptServerTeamInvitationById to append user_id
as a query parameter (e.g.,
/team-invitations/${invitationId}/accept?user_id=${userId}) when calling
sendServerRequest so the backend receives user_id, and then update the backend
route handler for /team-invitations/[id]/accept to add a querySchema that
accepts user_id and uses it to resolve auth.user when no access token is present
(ensure code paths that currently read auth.user fall back to the supplied
user_id).
In `@packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts`:
- Around line 827-831: The accept handler currently calls
app._interface.acceptServerTeamInvitationById and refreshes
app._serverUserTeamInvitationsCache and app._serverTeamsCache but omits
refreshing the team-scoped caches; update the accept function to also refresh
app._serverTeamInvitationsCache and app._serverTeamMemberProfilesCache after
acceptance, using the appropriate cache keys (e.g., the team id and/or member id
from crud or the returned result) so that
team.listInvitations()/team.useInvitations() and
team.listUsers()/team.useUsers() see the new state immediately.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx (1)
3-4: Consolidate duplicate imports from@/prisma-client.
getPrismaClientForTenancy/retryTransactionandglobalPrismaClientcan be imported in a single statement.♻️ Proposed fix
-import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; -import { globalPrismaClient } from "@/prisma-client"; +import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client";🤖 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 3 - 4, The two separate import statements from "@/prisma-client" should be consolidated into one; replace the duplicate imports by importing getPrismaClientForTenancy, retryTransaction, and globalPrismaClient in a single import declaration (referencing the symbols getPrismaClientForTenancy, retryTransaction, globalPrismaClient) so there is only one import from "@/prisma-client".apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts (1)
852-852: Unusedinvitervariable in both new listing tests.
const { userId: inviter } = await Auth.fastSignUp()appears at lines 852 and 892 butinviteris never referenced afterwards. If the intent was only to establish a signed-in context, the destructuring can be dropped.♻️ Proposed fix (applies to both lines 852 and 892)
- const { userId: inviter } = await Auth.fastSignUp(); + await Auth.fastSignUp();Also applies to: 892-892
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts` at line 852, The variable `inviter` returned from `Auth.fastSignUp()` is unused; replace `const { userId: inviter } = await Auth.fastSignUp()` with a plain call to `await Auth.fastSignUp()` (or destructure into `_` if you want to indicate intentional ignore) in both test locations so the sign-in side-effect remains but the unused `inviter` binding is removed; update the two occurrences that call `Auth.fastSignUp()` accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/backend/src/app/api/latest/team-invitations/`[id]/accept/route.tsx:
- Around line 90-129: The current flow calls
teamMembershipsCrudHandlers.adminCreate outside the retryTransaction and updates
verificationCode.usedAt after the transaction, breaking atomicity; move the
verificationCode.update into the retryTransaction body and ensure membership
creation uses the transaction (either by passing tx into
teamMembershipsCrudHandlers.adminCreate if it supports a prisma instance, or
replace the call with tx.teamMember.create within the retryTransaction) so both
the membership creation and verificationCode.update (usedAt) occur under the
same tx in retryTransaction to avoid partial commits and retry duplication
issues.
- Around line 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.
In `@apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts`:
- Around line 995-1004: Update the two tests that assert a 500 on invalid
request parameters to expect a 4xx schema/validation error instead: in the tests
with descriptions "cannot specify both team_id and user_id" and "must specify
either team_id or user_id", change the assertion on listResponse.status from
.toBe(500) to assert a 4xx status (e.g. check listResponse.status >= 400 &&
listResponse.status < 500 or assert the specific validation error status your
backend returns such as 400 or SchemaError code). Keep the existing setup calls
(Auth.fastSignUp, Team.create) and the same niceBackendFetch request parameters,
only adjust the status expectation to validate client-side/schema validation
responses rather than a server error.
---
Duplicate comments:
In `@apps/backend/src/app/api/latest/team-invitations/`[id]/accept/route.tsx:
- Around line 84-88: Replace the internal StackAssertionError thrown when
matchingChannel is falsy with a user-facing KnownError so this user-driven email
mismatch returns a 4xx instead of a 500; specifically, in the accept invitation
handler (the block checking matchingChannel in
apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx), throw
new KnownError(...) with a clear message like "Cannot accept invitation: no
verified email matching the invitation's recipient email was found" (or reuse
the existing user-facing message) and preserve any existing error code/metadata
pattern used elsewhere for KnownError so callers receive a proper client error
response.
- Around line 52-143: The invitation can be consumed twice due to a TOCTOU: you
must move the verification code update into the same transaction that creates
the membership and make the update conditional on usedAt still being null to
ensure atomicity. Inside the retryTransaction callback (the function passed to
retryTransaction using tx from getPrismaClientForTenancy), perform a conditional
update on globalPrismaClient.verificationCode (or tx.verificationCode) using the
same composite key and include usedAt: null and expiresAt check in the WHERE,
then check the update result (rowsAffected or returned record) and throw
KnownErrors.VerificationCodeNotFound or a dedicated AlreadyUsed error if no row
was updated; only proceed with teamMembershipsCrudHandlers.adminCreate after the
conditional update succeeds (or do the update after ensuring oldMembership
absent but still within the same tx) so both membership creation and marking
used are in one atomic transaction using tx and the same verification code
constraints.
In `@apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts`:
- Around line 182-188: The test is asserting a 500 for a client validation
error; update the test "can't list invitations without team_id or user_id" to
expect a 4xx (e.g., 400 or 422) instead of 500 for requests to
`/api/v1/team-invitations` and adjust any server-side validation (the handler
that responds to GET /api/v1/team-invitations) to return the corresponding 4xx
when neither `team_id` nor `user_id` is provided; reference the test case name,
the niceBackendFetch call, and the endpoint `/api/v1/team-invitations` when
making the change so the assertion and server behavior align.
In `@packages/stack-shared/src/interface/client-interface.ts`:
- Around line 1212-1221: The acceptTeamInvitationById method interpolates
invitationId directly into the URL without encoding; change the path segment to
use the urlString helper (or encodeURIComponent) like the server-side
acceptServerTeamInvitationById does so that the invitationId is properly
URL-encoded before calling sendClientRequest; update the URL construction in
acceptTeamInvitationById to use urlString(invitationId) for the
`/team-invitations/{...}/accept` segment.
In `@packages/stack-shared/src/interface/server-interface.ts`:
- Around line 434-442: The return type of listServerUserTeamInvitations is
incorrect: change its declared Promise generic from
TeamInvitationCrud['Client']['Read'][] to TeamInvitationCrud['Server']['Read'][]
so server-only fields are preserved and consistent with
listServerTeamInvitations; locate the method listServerUserTeamInvitations
(which calls this.sendServerRequest and parses the JSON into
TeamInvitationCrud['Client']['List'] currently) and update both the method
signature and the parsed type to use TeamInvitationCrud['Server']['List'] /
TeamInvitationCrud['Server']['Read'][] as appropriate.
---
Nitpick comments:
In `@apps/backend/src/app/api/latest/team-invitations/`[id]/accept/route.tsx:
- Around line 3-4: The two separate import statements from "@/prisma-client"
should be consolidated into one; replace the duplicate imports by importing
getPrismaClientForTenancy, retryTransaction, and globalPrismaClient in a single
import declaration (referencing the symbols getPrismaClientForTenancy,
retryTransaction, globalPrismaClient) so there is only one import from
"@/prisma-client".
In `@apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts`:
- Line 852: The variable `inviter` returned from `Auth.fastSignUp()` is unused;
replace `const { userId: inviter } = await Auth.fastSignUp()` with a plain call
to `await Auth.fastSignUp()` (or destructure into `_` if you want to indicate
intentional ignore) in both test locations so the sign-in side-effect remains
but the unused `inviter` binding is removed; update the two occurrences that
call `Auth.fastSignUp()` accordingly.
| const invitationData = code.data as { team_id: string }; | ||
| const invitationMethod = code.method as { email: string }; |
There was a problem hiding this comment.
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.
| 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: {}, | ||
| }); | ||
| } | ||
| }); |
There was a problem hiding this comment.
teamMembershipsCrudHandlers.adminCreate is not part of the retryTransaction — breaks atomicity with the usedAt update.
adminCreate uses its own Prisma client (not tx), so it commits independently of the surrounding transaction. Combined with the usedAt update being completely outside the transaction (lines 132–143), this creates a window where:
retryTransactioncompletes andadminCreatehas committed the membership.- Any failure (process crash, unhandled error) between line 130 and line 143 leaves the user as a team member but the invitation still active and unconsumed.
- Additionally, if
retryTransactionneeds to retry its body (e.g., due to a serialization conflict),adminCreateis re-invoked even though its prior attempt already committed, risking a unique-constraint error that is not caught by the retry mechanism.
Prisma transactions guarantee that "all operations succeed or fail together"; an operation executing outside tx does not participate in that guarantee.
The membership creation and the usedAt stamp should be in the same atomic unit. The safest fix is to move the verificationCode.update inside retryTransaction and have it use tx, and either thread tx into adminCreate or replace it with a raw tx.teamMember.create.
🤖 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 90 - 129, The current flow calls
teamMembershipsCrudHandlers.adminCreate outside the retryTransaction and updates
verificationCode.usedAt after the transaction, breaking atomicity; move the
verificationCode.update into the retryTransaction body and ensure membership
creation uses the transaction (either by passing tx into
teamMembershipsCrudHandlers.adminCreate if it supports a prisma instance, or
replace the call with tx.teamMember.create within the retryTransaction) so both
the membership creation and verificationCode.update (usedAt) occur under the
same tx in retryTransaction to avoid partial commits and retry duplication
issues.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
| if (updated.count === 0) { | ||
| throw new KnownErrors.VerificationCodeNotFound(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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.


Note
Medium Risk
Introduces new invitation acceptance flow and expands invitation listing logic across teams, touching membership creation and verification-code consumption; mistakes could allow unintended team joins or incorrect invite visibility.
Overview
Adds a new
POST /team-invitations/:id/acceptendpoint to accept an invitation by ID, requiring the target user to have a verified email matching the invitation recipient and consuming the underlying verification code atomically.Extends
GET /team-invitationsto support listing invites byuser_id(server ormeon client), returningteam_display_nameand filtering out deleted teams; existing team-scoped listing now also includesteam_display_name, and delete now requiresteam_idexplicitly.Updates the SDK/types to distinguish sent vs received invitations, adds user-level
listTeamInvitations()/useTeamInvitations()plusaccept()on received invites, and adds broad e2e coverage for listing/accepting and edge cases (missing params, verified email requirement, consumption).Written by Cursor Bugbot for commit 03ee020. This will update automatically on new commits. Configure here.
Summary by CodeRabbit
New Features
Tests