Skip to content

Team invitations on user#1200

Merged
N2D4 merged 18 commits intodevfrom
team-invitations-on-user
Feb 18, 2026
Merged

Team invitations on user#1200
N2D4 merged 18 commits intodevfrom
team-invitations-on-user

Conversation

@N2D4
Copy link
Copy Markdown
Contributor

@N2D4 N2D4 commented Feb 16, 2026

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/accept endpoint 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-invitations to support listing invites by user_id (server or me on client), returning team_display_name and filtering out deleted teams; existing team-scoped listing now also includes team_display_name, and delete now requires team_id explicitly.

Updates the SDK/types to distinguish sent vs received invitations, adds user-level listTeamInvitations()/useTeamInvitations() plus accept() 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

    • View pending team invitations for the current user (received invites) with a team display name.
    • Accept invitations by ID.
    • SDK/type updates: distinct sent vs. received invitation types; client/server methods and hooks to list/accept invitations; reactive stores and caching for invites.
  • Tests

    • Expanded end-to-end coverage for listing and accepting invitations, permission/verification checks, pagination, and edge cases.

Copilot AI review requested due to automatic review settings February 16, 2026 19:59
@vercel
Copy link
Copy Markdown

vercel bot commented Feb 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-backend Ready Ready Preview, Comment Feb 18, 2026 0:25am
stack-dashboard Ready Ready Preview, Comment Feb 18, 2026 0:25am
stack-demo Ready Ready Preview, Comment Feb 18, 2026 0:25am
stack-docs Ready Ready Preview, Comment Feb 18, 2026 0:25am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 16, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Invitation Acceptance API
apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx
New POST route handler to accept an invitation by ID: auth & tenancy checks, verification-code lookup (tenancy-scoped), verified-email match, optional payment/dashboard_admins constraint, transactional membership create/confirm, mark invitation used, returns 200.
Invitation CRUD & Backend Logic
apps/backend/src/app/api/latest/team-invitations/crud.tsx
Adds user_id listing (including me) with mutual-exclusivity validation vs team_id; resolves verified emails → verification codes → teams, enriches items with team_display_name, standardizes derived teamId usage and error handling, and updates onDelete to use derived teamId.
Backend E2E Tests
apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
Expanded tests for list-by-user (server/client, me), parameter validation (mutual exclusivity / required id), acceptance-by-id flows, access controls, and updated response shape including team_display_name.
JS E2E Tests (SDK)
apps/e2e/tests/js/team-invitations.test.ts
New client/server SDK e2e tests covering current-user listing, accept-by-id (client & server), multi-team scenarios, verified-email filtering, and post-accept membership/invitation assertions.
Shared Interfaces (client/server)
packages/stack-shared/src/interface/client-interface.ts, packages/stack-shared/src/interface/server-interface.ts
Added client methods listCurrentUserTeamInvitations(session) and acceptTeamInvitationById(invitationId, session); added server methods listServerUserTeamInvitations(userId) and acceptServerTeamInvitationById(invitationId, userId).
Invitation CRUD Schema
packages/stack-shared/src/interface/crud/team-invitation.ts
Adds team_display_name: string to clientRead schema for team invitations.
Template SDK types & exports
packages/template/src/lib/stack-app/teams/index.ts, packages/template/src/lib/stack-app/index.ts
Introduces SentTeamInvitation and ReceivedTeamInvitation types (received includes accept()), adds deprecated TeamInvitation alias, and re-exports new types.
Template SDK Implementations
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts, packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
Adds per-user/server invitation caches, renames mappers for sent/received invitations, adds list/use methods for team and user invitations, wires accept() to call accept route and refresh related caches (invitations, teams).
User public surface
packages/template/src/lib/stack-app/users/index.ts
Adds listTeamInvitations() and useTeamInvitations() to UserExtra and a teamInvitations async store property exposing ReceivedTeamInvitation[].

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble keys and hop with cheer,

Sent ones sit and received ones near,
I sniff the codes beneath the lawn,
I press "Accept" — a team is born! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Team invitations on user' directly relates to the main change: adding user-scoped team invitation listing and acceptance functionality.
Description check ✅ Passed The description provides comprehensive context including new endpoints, API changes, SDK updates, and medium-risk assessment, aligning well with the changeset scope.
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 team-invitations-on-user

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.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 16, 2026

Greptile Summary

This PR adds the ability for users to list and accept team invitations sent to their verified email addresses. Users can now call user.listTeamInvitations() to discover teams they've been invited to, and invitation.accept() to join those teams.

Key changes:

  • New POST /team-invitations/{id}/accept endpoint for accepting invitations
  • Enhanced GET /team-invitations to support user_id parameter for listing user-specific invitations
  • Added ReceivedTeamInvitation and SentTeamInvitation types to distinguish sender vs receiver perspectives
  • Comprehensive test coverage for both client and server SDK usage patterns

Issues found:

  • Race condition in accept endpoint: invitation is marked as used outside the transaction, allowing multiple concurrent accepts to succeed
  • Server interface method acceptServerTeamInvitationById accepts a userId parameter that isn't actually used by the endpoint - the endpoint always uses the authenticated user from the request context instead

Confidence Score: 2/5

  • This PR has a race condition bug and an interface mismatch that could cause unexpected behavior in production
  • The race condition in the accept endpoint could allow duplicate team memberships or fail when invitations are accepted concurrently. The unused userId parameter in the server interface suggests the server SDK's accept() method may not work as intended, which could break production code relying on server-side invitation acceptance.
  • Pay close attention to apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx (race condition) and packages/stack-shared/src/interface/server-interface.ts (unused parameter)

Important Files Changed

Filename Overview
apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx New endpoint for accepting team invitations. Contains race condition where invitation marking happens outside transaction, and unused userId parameter in server interface.
apps/backend/src/app/api/latest/team-invitations/crud.tsx Enhanced CRUD to support listing invitations by user_id in addition to team_id. Logic appears sound with proper validation.
packages/stack-shared/src/interface/server-interface.ts Added server methods with unused userId parameter in acceptServerTeamInvitationById - parameter accepted but never sent to endpoint.
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts Server SDK implementation, but accept() method won't work as intended - passes userId that endpoint doesn't use.

Sequence Diagram

sequenceDiagram
    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
Loading

Last reviewed commit: 8553203

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

12 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment thread apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx Outdated
Comment thread packages/stack-shared/src/interface/server-interface.ts
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 to SentTeamInvitation (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-invitations API to support user_id listing and adds a new /team-invitations/:id/accept endpoint, 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.

Comment thread apps/backend/src/app/api/latest/team-invitations/crud.tsx Outdated
Comment thread apps/backend/src/app/api/latest/team-invitations/crud.tsx Outdated
Comment thread packages/stack-shared/src/interface/server-interface.ts
Comment thread apps/backend/src/app/api/latest/team-invitations/crud.tsx Outdated
Comment thread apps/backend/src/app/api/latest/team-invitations/crud.tsx Outdated
Comment thread apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
Comment thread apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
Comment thread apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
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: 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: Unvalidated as casts on JSON data from the database.

Lines 57–58 cast code.data and code.method without runtime validation. If the shape doesn't match (e.g., a migration changed the format, or data is corrupted), the code will silently produce undefined values 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 use as/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: sequential adminRead for each team ID.

Each unique team ID triggers a separate adminRead call 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 use KnownErrors or HTTP-friendly errors, not StackAssertionError.

StackAssertionError is for internal invariant violations. A client passing both team_id and user_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.

Comment thread apps/backend/src/app/api/latest/team-invitations/crud.tsx
Comment thread apps/backend/src/app/api/latest/team-invitations/crud.tsx
Comment thread apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
Comment thread packages/stack-shared/src/interface/client-interface.ts
Comment thread packages/stack-shared/src/interface/server-interface.ts
Comment thread apps/backend/src/app/api/latest/team-invitations/crud.tsx Outdated
Comment thread packages/stack-shared/src/interface/server-interface.ts
Comment thread packages/stack-shared/src/interface/server-interface.ts
Comment thread packages/stack-shared/src/interface/server-interface.ts
Comment thread apps/backend/src/app/api/latest/team-invitations/crud.tsx
BilalG1 and others added 14 commits February 17, 2026 15:59
### 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 -->
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

🤖 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.

Comment thread packages/stack-shared/src/interface/server-interface.ts Outdated
@N2D4 N2D4 merged commit 45e8edd into dev Feb 18, 2026
6 of 11 checks passed
@N2D4 N2D4 deleted the team-invitations-on-user branch February 18, 2026 00:18
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: 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/retryTransaction and globalPrismaClient can 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: Unused inviter variable in both new listing tests.

const { userId: inviter } = await Auth.fastSignUp() appears at lines 852 and 892 but inviter is 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.

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

Comment on lines +90 to +129
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: {},
});
}
});
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

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:

  1. retryTransaction completes and adminCreate has committed the membership.
  2. 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.
  3. Additionally, if retryTransaction needs to retry its body (e.g., due to a serialization conflict), adminCreate is 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.

Comment thread apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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();
}
});
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

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.

5 participants