Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

1849 lines
67 KiB
TypeScript

import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import {
type InvitationKind,
type InvitationMembershipIntent,
type InvitationStatus,
invitation,
invitationWorkspaceGrant,
member,
organization,
permissions,
user,
workspace,
workspaceEnvironment,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole, PERMISSION_RANK, type PermissionType } from '@sim/platform-authz/workspace'
import { generateId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, asc, count, eq, inArray, lte, sql } from 'drizzle-orm'
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import {
acquireOrganizationMutationLock,
acquireOrgMembershipLock,
ensureUserInOrganizationTx,
getUserOrganization,
} from '@/lib/billing/organizations/membership'
import {
type AcceptancePlanConversion,
ensureTeamOrganizationForAcceptance,
} from '@/lib/billing/organizations/provision-seat'
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import type { DbOrTx } from '@/lib/db/types'
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
import { captureServerEvent } from '@/lib/posthog/server'
import {
attachOwnedWorkspacesToOrganizationTx,
ownedAttachableWorkspacesWhere,
} from '@/lib/workspaces/organization-workspaces'
import { getWorkspaceWithOwner, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { getInvitePlanCategoryForUser } from '@/lib/workspaces/policy'
const logger = createLogger('InvitationCore')
export const INVITATION_EXPIRY_DAYS = 7
export function computeInvitationExpiry(daysFromNow = INVITATION_EXPIRY_DAYS): Date {
return new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000)
}
export interface InvitationWithGrants {
id: string
kind: InvitationKind
email: string
organizationId: string | null
membershipIntent: InvitationMembershipIntent
inviterId: string
role: string
status: InvitationStatus
token: string
expiresAt: Date
createdAt: Date
updatedAt: Date
grants: Array<{
id: string
workspaceId: string
permission: 'admin' | 'write' | 'read'
workspaceName: string | null
}>
organizationName: string | null
inviterName: string | null
inviterEmail: string | null
}
export async function getInvitationById(
id: string,
executor: DbOrTx = db
): Promise<InvitationWithGrants | null> {
const [row] = await executor.select().from(invitation).where(eq(invitation.id, id)).limit(1)
if (!row) return null
return hydrateInvitation(row, executor)
}
/**
* Claims an invitation for a state-changing operation using the same advisory
* lock namespace and ordering as acceptance, sending, and workspace moves.
*
* The invitation advisory lock is taken first because its grants define the
* optional workspace lock set. Workspace advisory locks are then taken before
* the invitation row lock, preserving the platform's advisory-before-row lock
* order. Finally the invitation is hydrated again so authorization and mutation
* use the protected state.
*/
export async function lockInvitationForMutation(
tx: DbOrTx,
invitationId: string,
options?: {
lockCurrentGrantWorkspaces?: boolean
additionalWorkspaceIds?: string[]
}
): Promise<InvitationWithGrants | null> {
await acquireInvitationMutationLocks(tx, {
invitationIds: [invitationId],
workspaceIds: [],
})
const beforeWorkspaceLocks = await getInvitationById(invitationId, tx)
if (!beforeWorkspaceLocks) return null
const currentGrantWorkspaceIds = new Set(
beforeWorkspaceLocks.grants.map((grant) => grant.workspaceId)
)
const workspaceIds = [
...new Set([
...(options?.lockCurrentGrantWorkspaces ? currentGrantWorkspaceIds : []),
...(options?.additionalWorkspaceIds ?? []).filter((workspaceId) =>
currentGrantWorkspaceIds.has(workspaceId)
),
]),
]
if (workspaceIds.length > 0) {
await acquireInvitationMutationLocks(tx, {
invitationIds: [],
workspaceIds,
})
}
await tx.execute(sql`select id from invitation where id = ${invitationId} for update`)
return getInvitationById(invitationId, tx)
}
/**
* Locks the exact membership row that can authorize an organization mutation,
* then evaluates the role from that protected version. A concurrent demotion
* or removal either commits first and is observed here, or waits until this
* invitation transaction commits.
*/
async function lockOrganizationAdminAuthority(
tx: DbOrTx,
actorId: string,
organizationId: string
): Promise<boolean> {
const [authority] = await tx
.select({ id: member.id, role: member.role })
.from(member)
.where(and(eq(member.userId, actorId), eq(member.organizationId, organizationId)))
.for('update')
.limit(1)
return isOrgAdminRole(authority?.role)
}
/**
* Locks only the actor rows that can grant admin standing on this workspace:
* the explicit workspace permission first, then (when needed) the workspace
* organization's membership row. Callers visit workspace ids in sorted order,
* giving multi-workspace mutations a deterministic authority-row lock order.
*/
async function lockWorkspaceAdminAuthority(
tx: DbOrTx,
actorId: string,
workspaceId: string
): Promise<boolean> {
const ws = await getWorkspaceWithOwner(workspaceId, { executor: tx })
if (!ws) return false
const [explicitAuthority] = await tx
.select({ id: permissions.id, permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, actorId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.for('update')
.limit(1)
if (explicitAuthority?.permissionType === 'admin') return true
if (!ws.organizationId) return false
return lockOrganizationAdminAuthority(tx, actorId, ws.organizationId)
}
async function hydrateInvitation(
row: typeof invitation.$inferSelect,
executor: DbOrTx = db
): Promise<InvitationWithGrants> {
const grantRows = await executor
.select({
id: invitationWorkspaceGrant.id,
workspaceId: invitationWorkspaceGrant.workspaceId,
permission: invitationWorkspaceGrant.permission,
workspaceName: workspace.name,
})
.from(invitationWorkspaceGrant)
.leftJoin(workspace, eq(workspace.id, invitationWorkspaceGrant.workspaceId))
.where(eq(invitationWorkspaceGrant.invitationId, row.id))
/**
* Oldest grant first, so `grants[0]` — the primary grant that decides the
* join target and the billed account — stays the workspace the invitation
* was originally sent for even after later invites merge grants into it.
*/
.orderBy(asc(invitationWorkspaceGrant.createdAt), asc(invitationWorkspaceGrant.id))
let organizationName: string | null = null
if (row.organizationId) {
const [orgRow] = await executor
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, row.organizationId))
.limit(1)
organizationName = orgRow?.name ?? null
}
const [inviterRow] = await executor
.select({ name: user.name, email: user.email })
.from(user)
.where(eq(user.id, row.inviterId))
.limit(1)
return {
id: row.id,
kind: row.kind,
email: row.email,
organizationId: row.organizationId,
membershipIntent: row.membershipIntent,
inviterId: row.inviterId,
role: row.role,
status: row.status,
token: row.token,
expiresAt: row.expiresAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
grants: grantRows.map((grant) => ({
id: grant.id,
workspaceId: grant.workspaceId,
permission: grant.permission,
workspaceName: grant.workspaceName,
})),
organizationName,
inviterName: inviterRow?.name ?? null,
inviterEmail: inviterRow?.email ?? null,
}
}
export function isInvitationExpired(inv: Pick<InvitationWithGrants, 'expiresAt'>): boolean {
return new Date() > new Date(inv.expiresAt)
}
/**
* A workspace invitation only escalates into an EXISTING organization when
* that organization matches what was stamped at send time — a workspace that
* entered an organization after the invite went out (a member's owned
* workspaces attaching on join, an admin move) never asked that org for a
* seat, so escalation requires the inviter to currently hold admin standing
* there. A workspace with no current organization is deliberately exempt:
* acceptance trusts the live workspace over stale stamped metadata and falls
* back to the standard personal-workspace regime (Pro→Team conversion of the
* current billed account), matching long-standing tested behavior.
* Organization-kind invitations always join their STAMPED organization (the
* join target is never re-derived from a granted workspace, whose org can
* change after send), so they pass trivially here. Acceptance and the
* accept-screen preview both consume this predicate so the disclosure can
* never contradict the accepted outcome.
*/
async function stampedOrganizationAllowsEscalation(
inv: InvitationWithGrants,
workspaceOrganizationId: string | null,
executor: DbOrTx = db
): Promise<boolean> {
if (inv.kind !== 'workspace') return true
if (!workspaceOrganizationId) return true
if (inv.organizationId === workspaceOrganizationId) return true
const inviterMembership = await getUserOrganization(inv.inviterId, executor)
return (
inviterMembership?.organizationId === workspaceOrganizationId &&
isOrgAdminRole(inviterMembership.role)
)
}
/**
* True when a member-role organization invitation still has at least one
* granted workspace inside the organization it was stamped with. All grants
* leaving that organization would strand the new member with nowhere to land,
* so acceptance refuses and the preview must predict the same — both consume
* this single predicate so they cannot drift.
*/
async function hasLiveGrantInStampedOrganization(
inv: InvitationWithGrants,
executor: DbOrTx = db
): Promise<boolean> {
if (inv.kind !== 'organization' || !inv.organizationId) return true
if (isOrgAdminRole(inv.role)) return true
if (inv.grants.length === 0) return true
const [liveGrant] = await executor
.select({ id: workspace.id })
.from(workspace)
.where(
and(
inArray(
workspace.id,
inv.grants.map((grant) => grant.workspaceId)
),
eq(workspace.organizationId, inv.organizationId)
)
)
.limit(1)
return Boolean(liveGrant)
}
/** @see InvitationJoinPreviewResult.outcome */
export type InvitationJoinOutcome = 'will-join' | 'already-member' | 'external' | 'blocked'
export interface InvitationJoinPreviewResult {
/**
* What accepting will actually do, as one value rather than a set of booleans.
*
* These four outcomes need genuinely different disclosure, and collapsing any
* of them loses something the invitee must know:
* - `will-join` — a member row is created and a seat is taken.
* - `already-member` — they are in the organization already; only workspace
* access changes, so neither the join nor the external copy is true.
* - `external` — workspaces only, never a seat, nothing of theirs moves.
* - `blocked` — acceptance will fail (`upgrade-required`,
* `workspace-not-found`). Nothing is promised, because nothing happens.
*/
outcome: InvitationJoinOutcome
/**
* Name of the organization acceptance will actually join. For a workspace
* invite this is the granted workspace's LIVE organization, which can differ
* from the stamped `invitation.organizationName` — the disclosure must name
* the organization that will really gain control of the user's workspaces.
*/
organizationName: string | null
workspacesToMove: string[]
/**
* Stable ids behind `workspacesToMove`; the accept screen echoes them back
* as the disclosure token so acceptance can reject when the sweep set no
* longer matches what was disclosed.
*/
workspaceIdsToMove: string[]
}
/**
* Best-effort preview of what accepting will do for the invitee: whether a
* member row will be created and which of their owned personal workspaces
* (archived included) will follow them into the organization. Mirrors the
* acceptance decision flow without taking locks — races resolve at accept
* time; the preview only feeds disclosure copy.
*/
export async function getInvitationJoinPreview(
inviteeUserId: string,
inv: InvitationWithGrants
): Promise<InvitationJoinPreviewResult> {
const withOutcome = (outcome: InvitationJoinOutcome): InvitationJoinPreviewResult => ({
outcome,
organizationName: null,
workspacesToMove: [],
workspaceIdsToMove: [],
})
let workspaceOrganizationId = inv.organizationId
let billedAccountUserId: string | null = null
const primaryGrantWorkspaceId = inv.grants[0]?.workspaceId
if (primaryGrantWorkspaceId) {
const primaryWorkspace = await getWorkspaceWithOwner(primaryGrantWorkspaceId)
if (primaryWorkspace) {
billedAccountUserId = primaryWorkspace.billedAccountUserId
if (inv.kind === 'workspace') {
workspaceOrganizationId = primaryWorkspace.organizationId
}
}
}
/**
* Personal-workspace invites only produce an organization through billing's
* Pro→Team provisioning; with billing disabled there is nothing to join.
*/
if (!workspaceOrganizationId && !isBillingEnabled) return withOutcome('external')
/**
* Already in the target organization (nothing changes) or in a different
* one (acceptance downgrades to external or rejects).
*/
const existingMembership = await getUserOrganization(inviteeUserId)
const inDifferentOrganization =
!!existingMembership &&
(workspaceOrganizationId ? existingMembership.organizationId !== workspaceOrganizationId : true)
if (inv.membershipIntent === 'external') {
/**
* Mirrors acceptance's `external-requires-paid-plan` gate, including its
* exemptions: it only applies with billing on, to an organization-owned
* workspace, and not when externality was imposed because the invitee already
* belongs to another organization. Without this the screen promised external
* access that acceptance would refuse.
*/
if (
isBillingEnabled &&
!inDifferentOrganization &&
workspaceOrganizationId &&
(await getInvitePlanCategoryForUser(inviteeUserId)) === 'free'
) {
return withOutcome('blocked')
}
return withOutcome('external')
}
if (existingMembership) {
/**
* Already in the organization acceptance lands in: nothing about their
* standing changes. A membership in a DIFFERENT organization is the
* external case — acceptance downgrades — so it keeps the plain shape.
*/
if (!inDifferentOrganization) return withOutcome('already-member')
/**
* In a DIFFERENT organization. Acceptance only downgrades a workspace-kind
* invite with live grants to external; an organization-kind invite (or one
* with no grants) hard-fails with `already-in-organization`, so promising
* external access there would be a disclosure the accept can never honour.
*/
return withOutcome(inv.kind === 'workspace' && inv.grants.length > 0 ? 'external' : 'blocked')
}
if (!(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId)))
return withOutcome('external')
if (!(await hasLiveGrantInStampedOrganization(inv))) return withOutcome('blocked')
/**
* Mirror acceptance's billing gates: an unusable organization subscription
* (or, for personal-workspace invites, a billed owner without a convertible
* paid plan) makes acceptance fail with upgrade-required — the disclosure
* must not promise a migration that cannot happen.
*/
if (isBillingEnabled) {
if (workspaceOrganizationId) {
const orgSub = await getOrganizationSubscription(workspaceOrganizationId)
if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) return withOutcome('blocked')
} else {
const payerUserId = billedAccountUserId ?? inv.inviterId
const personalSub = await getHighestPriorityPersonalSubscription(payerUserId)
if (
!personalSub ||
!hasUsableSubscriptionStatus(personalSub.status) ||
!(isPro(personalSub.plan) || isTeam(personalSub.plan))
) {
return withOutcome('blocked')
}
}
}
const ownedWorkspaces = await db
.select({ id: workspace.id, name: workspace.name })
.from(workspace)
.where(ownedAttachableWorkspacesWhere({ userId: inviteeUserId, includeArchived: true }))
.orderBy(asc(workspace.name))
let targetOrganizationName: string | null = null
if (workspaceOrganizationId) {
const [targetOrg] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, workspaceOrganizationId))
.limit(1)
targetOrganizationName = targetOrg?.name ?? null
}
return {
outcome: 'will-join',
organizationName: targetOrganizationName,
workspacesToMove: ownedWorkspaces.map((row) => row.name),
workspaceIdsToMove: ownedWorkspaces.map((row) => row.id),
}
}
/**
* Flip any still-pending invitations for the given organization whose
* `expiresAt` has already passed to `expired`. Best-effort housekeeping
* — callers can rely on this for display freshness, but seat math also
* defensively filters by `expiresAt` at query time.
*/
export async function expireStalePendingInvitationsForOrganization(
organizationId: string
): Promise<void> {
try {
await db
.update(invitation)
.set({ status: 'expired', updatedAt: new Date() })
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending'),
lte(invitation.expiresAt, new Date())
)
)
} catch (error) {
logger.error('Failed to expire stale pending invitations for organization', {
organizationId,
error,
})
}
}
export type AcceptInvitationFailure =
| { kind: 'not-found' }
| { kind: 'workspace-not-found' }
| { kind: 'disclosure-outdated' }
| { kind: 'already-processed' }
| { kind: 'expired' }
| { kind: 'email-mismatch' }
| { kind: 'invalid-token' }
| { kind: 'already-in-organization' }
| { kind: 'no-seats-available' }
| { kind: 'upgrade-required' }
| { kind: 'external-requires-paid-plan' }
| { kind: 'server-error'; message?: string }
export type AcceptInvitationSuccess = {
success: true
invitation: InvitationWithGrants
acceptedWorkspaceIds: string[]
redirectPath: string
membershipAlreadyExists: boolean
}
export type AcceptInvitationResult =
| AcceptInvitationSuccess
| ({ success: false } & AcceptInvitationFailure)
export interface AcceptInvitationInput {
userId: string
userEmail: string
actorName?: string | null
invitationId: string
token: string | null
/**
* Workspace ids the accept screen disclosed as moving. When provided,
* acceptance fails with `disclosure-outdated` if the set it would sweep
* differs — the user must see the refreshed notice before consenting.
*/
disclosedWorkspaceIds?: string[]
/**
* The outcome the accept screen disclosed. Verified against the resolved
* outcome so a membership the user was never shown can never be created, and
* a membership they were promised can never be silently downgraded.
*/
disclosedOutcome?: InvitationJoinOutcome
request?: { headers: { get(name: string): string | null } }
}
/**
* Thrown inside the grant transaction when the invitee's org membership was
* removed concurrently (between the join and the grant) — detected under the
* membership lock. Aborts the grant so we never write workspace access for a
* user who is no longer an org member (the "zombie" state).
*/
class MembershipRevokedDuringAcceptError extends Error {
constructor() {
super('Org membership was revoked during invite acceptance')
this.name = 'MembershipRevokedDuringAcceptError'
}
}
/**
* Thrown after the member insert when the invitee's owned-workspace set no
* longer matches the pre-lock plan (a workspace was created concurrently and
* would escape the sweep). Rolls the whole acceptance back; safe to retry.
*/
class JoinerWorkspacesChangedDuringAcceptError extends Error {
constructor() {
super('Owned workspaces changed during invite acceptance')
this.name = 'JoinerWorkspacesChangedDuringAcceptError'
}
}
/**
* Thrown after a personal subscription conversion when the billing owner
* created another attachable workspace after the pre-lock sweep plan was
* captured. The conversion now holds that owner's billing-identity lock, so
* this re-check is stable; rolling back lets the retry include the new
* workspace in the advisory-lock plan instead of leaving it personally billed
* after the subscription moved to the organization.
*/
class BillingOwnerWorkspacesChangedDuringAcceptError extends Error {
constructor() {
super('Billing owner workspaces changed during invite acceptance')
this.name = 'BillingOwnerWorkspacesChangedDuringAcceptError'
}
}
/**
* Thrown when every grant on a member-role organization invite turned stale
* (the workspaces left the stamped organization), which would strand the new
* member with no workspace. Rolls the whole acceptance back.
*/
class AllGrantsStaleDuringAcceptError extends Error {
constructor() {
super('All organization-invite grants turned stale during acceptance')
this.name = 'AllGrantsStaleDuringAcceptError'
}
}
/**
* Thrown when the workspace set acceptance would sweep no longer matches the
* set the accept screen disclosed. Rolls the acceptance back so the user
* consents to the refreshed notice instead of a silent migration.
*/
class DisclosureOutdatedDuringAcceptError extends Error {
constructor() {
super('Disclosed workspace set no longer matches the sweep set')
this.name = 'DisclosureOutdatedDuringAcceptError'
}
}
interface InvitationAcceptancePostCommitEffects {
organizationId: string | null
memberRole: string | null
reconcileSeats: boolean
acceptedWorkspaceIds: string[]
/** Owned personal workspaces that followed the invitee into the org. */
attachedWorkspaceIds: string[]
syncUsageLimitUserIds: string[]
planConversions: AcceptancePlanConversion[]
acceptedInvitation: InvitationWithGrants | null
membershipAlreadyExists: boolean
}
interface InvitationAcceptanceLockPlan {
/**
* Invitation grant workspaces plus the billing owner's attachable
* workspaces (a personal Pro→Team conversion attaches those in the same
* transaction). Passed through to acceptance provisioning unchanged.
*/
workspaceIds: string[]
/**
* Workspaces the invitee owns outside any organization. When acceptance
* joins them into an organization, these rows attach in the same
* transaction, so they participate in the same deterministic lock ordering.
*/
joinerAttachWorkspaceIds: string[]
primaryWorkspace: WorkspaceWithOwner | null
}
/** Compute the complete workspace lock set before taking any workspace lock. */
async function getInvitationAcceptanceWorkspaceLockIds(
tx: DbOrTx,
inv: InvitationWithGrants,
inviteeUserId: string
): Promise<InvitationAcceptanceLockPlan> {
const grantWorkspaceIds = inv.grants.map((grant) => grant.workspaceId)
const primaryWorkspace = grantWorkspaceIds[0]
? await getWorkspaceWithOwner(grantWorkspaceIds[0], { executor: tx })
: null
/**
* Computed for every non-external invite. The post-lock workspace re-read
* can reveal an organization this pre-lock snapshot does not have (a
* concurrent attach or move), and the join-attach sweep must already hold
* these locks in that case — so no billing/organization short-circuit is
* safe here. Only external intent (immutable: it is never upgraded
* in-flight) provably rules a join out.
*/
const joinerAttachWorkspaceIds =
inv.membershipIntent === 'external'
? []
: (
await tx
.select({ id: workspace.id })
.from(workspace)
.where(ownedAttachableWorkspacesWhere({ userId: inviteeUserId, includeArchived: true }))
).map((row) => row.id)
const billingOwnerCanAttach =
isBillingEnabled &&
inv.membershipIntent !== 'external' &&
primaryWorkspace !== null &&
!primaryWorkspace.organizationId
const billingOwnerWorkspaceIds = billingOwnerCanAttach
? (
await tx
.select({ id: workspace.id })
.from(workspace)
.where(
ownedAttachableWorkspacesWhere({
userId: primaryWorkspace.billedAccountUserId,
ownerMatch: 'billing-account',
includeArchived: true,
})
)
).map((row) => row.id)
: []
return {
workspaceIds: [...new Set([...grantWorkspaceIds, ...billingOwnerWorkspaceIds])].sort(),
joinerAttachWorkspaceIds: [...new Set(joinerAttachWorkspaceIds)].sort(),
primaryWorkspace,
}
}
export async function acceptInvitation(
input: AcceptInvitationInput
): Promise<AcceptInvitationResult> {
const effects: InvitationAcceptancePostCommitEffects = {
organizationId: null,
memberRole: null,
reconcileSeats: false,
acceptedWorkspaceIds: [],
attachedWorkspaceIds: [],
syncUsageLimitUserIds: [],
planConversions: [],
acceptedInvitation: null,
membershipAlreadyExists: false,
}
const result = await db
.transaction(async (tx): Promise<AcceptInvitationResult> => {
await acquireInvitationMutationLocks(tx, {
invitationIds: [input.invitationId],
workspaceIds: [],
})
await tx.execute(sql`select id from invitation where id = ${input.invitationId} for update`)
const inv = await getInvitationById(input.invitationId, tx)
if (!inv) {
return { success: false, kind: 'not-found' }
}
/**
* Cheap validity checks run before the workspace lock plan so replayed,
* expired, or mismatched accepts pay no workspace queries or advisory
* locks. The invitation row is already advisory- and row-locked above,
* so these reads cannot race a concurrent acceptance.
*/
if (input.token && inv.token !== input.token) {
return { success: false, kind: 'invalid-token' }
}
if (inv.status !== 'pending') {
return { success: false, kind: 'already-processed' }
}
if (isInvitationExpired(inv)) {
await tx
.update(invitation)
.set({ status: 'expired', updatedAt: new Date() })
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
return { success: false, kind: 'expired' }
}
if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) {
return { success: false, kind: 'email-mismatch' }
}
const lockPlan = await getInvitationAcceptanceWorkspaceLockIds(tx, inv, input.userId)
await acquireInvitationMutationLocks(tx, {
invitationIds: [],
workspaceIds: [
...new Set([...lockPlan.workspaceIds, ...lockPlan.joinerAttachWorkspaceIds]),
],
})
// Re-read and row-lock the primary workspace only after the shared
// workspace advisory lock is held. If a move won the lock first, every
// billing and membership decision below now uses the committed post-move
// organization/billing identity rather than the pre-lock snapshot.
const lockedPrimaryWorkspace = inv.grants[0]
? await getWorkspaceWithOwner(inv.grants[0].workspaceId, {
executor: tx,
forUpdate: true,
})
: null
return acceptLockedInvitation(
input,
inv,
{ ...lockPlan, primaryWorkspace: lockedPrimaryWorkspace },
tx,
effects
)
})
.catch((error): AcceptInvitationResult => {
if (error instanceof JoinerWorkspacesChangedDuringAcceptError) {
logger.warn('Invite acceptance rolled back: owned workspaces changed concurrently', {
invitationId: input.invitationId,
userId: input.userId,
})
return {
success: false,
kind: 'server-error',
message: 'Your workspaces changed while accepting — please try again.',
}
}
if (error instanceof BillingOwnerWorkspacesChangedDuringAcceptError) {
logger.warn(
'Invite acceptance rolled back: billing owner workspaces changed concurrently',
{
invitationId: input.invitationId,
userId: input.userId,
}
)
return {
success: false,
kind: 'server-error',
message: "The workspace owner's workspaces changed while accepting — please try again.",
}
}
if (error instanceof AllGrantsStaleDuringAcceptError) {
logger.warn('Invite acceptance rolled back: every grant turned stale', {
invitationId: input.invitationId,
userId: input.userId,
})
return { success: false, kind: 'workspace-not-found' }
}
if (error instanceof DisclosureOutdatedDuringAcceptError) {
logger.warn('Invite acceptance rolled back: disclosed workspace set is outdated', {
invitationId: input.invitationId,
userId: input.userId,
})
return { success: false, kind: 'disclosure-outdated' }
}
/**
* This catch is outside `db.transaction`, so reaching it guarantees
* Postgres has rolled back every provisioning, membership, workspace,
* invitation, permission, and outbox write from the failed attempt.
*/
logger.error('Invitation acceptance transaction failed and was rolled back', {
invitationId: input.invitationId,
userId: input.userId,
error,
})
return { success: false, kind: 'server-error' }
})
if (result.success) {
await runInvitationAcceptancePostCommitEffects(input, effects)
}
return result
}
async function acceptLockedInvitation(
input: AcceptInvitationInput,
inv: InvitationWithGrants,
lockPlan: InvitationAcceptanceLockPlan,
tx: DbOrTx,
effects: InvitationAcceptancePostCommitEffects
): Promise<AcceptInvitationResult> {
let membershipAlreadyExists = false
let acceptedMembershipIntent = inv.membershipIntent
let shouldJoinOrganization = inv.membershipIntent !== 'external'
/**
* Workspace-kind invites derive their join target from the granted
* workspace's LIVE organization (the workspace is what was shared).
* Organization-kind invites always target their STAMPED organization: a
* granted workspace whose org changed after send must never redirect the
* membership into an organization the invitee was not invited to.
*/
const primaryGrant = inv.grants[0]
let billingOwnerUserId = inv.inviterId
let workspaceOrganizationId = inv.organizationId
if (primaryGrant && lockPlan.primaryWorkspace && inv.kind === 'workspace') {
billingOwnerUserId = lockPlan.primaryWorkspace.billedAccountUserId
workspaceOrganizationId = lockPlan.primaryWorkspace.organizationId
}
if (
shouldJoinOrganization &&
!(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId, tx))
) {
acceptedMembershipIntent = 'external'
shouldJoinOrganization = false
}
const existingMembership = await getUserOrganization(input.userId, tx)
const inviteeAlreadyInDifferentOrg =
!!existingMembership &&
(workspaceOrganizationId ? existingMembership.organizationId !== workspaceOrganizationId : true)
if (shouldJoinOrganization && inviteeAlreadyInDifferentOrg) {
if (inv.kind !== 'workspace' || inv.grants.length === 0) {
return { success: false, kind: 'already-in-organization' }
}
acceptedMembershipIntent = 'external'
shouldJoinOrganization = false
}
/**
* External collaborators hold access inside a paid organization without
* taking one of its seats, so the invitee has to be paying Sim elsewhere.
* The invite-time gate can go stale across the invitation's 7-day life (a
* cancelled Pro), so the same predicate runs again here.
*
* Mirrors exactly when the invite-time gate applies, which is narrower than
* "the invitation is external". Externality is imposed, not chosen, whenever
* the invitee already belongs to another organization — an account can only
* be in one, so the inviter's Member/Admin choice is overridden and
* `inviteeCanBeExternal` never runs. The same holds for the downgrades above,
* where a workspace moved organizations after the invite went out. Those
* fallbacks preserve access the invitee was already legitimately granted;
* charging them a plan requirement nobody warned the inviter about would
* strand them over someone else's action. Scoped to organization-owned
* workspaces because sharing a personal workspace has no seat economics.
*/
if (
isBillingEnabled &&
inv.membershipIntent === 'external' &&
!inviteeAlreadyInDifferentOrg &&
workspaceOrganizationId &&
(await getInvitePlanCategoryForUser(input.userId, tx)) === 'free'
) {
return { success: false, kind: 'external-requires-paid-plan' }
}
/**
* Already in the organization the invitation lands in, so acceptance grants
* the workspaces without creating a membership or taking a seat. Shared with
* the consent guard and the join block below so they cannot disagree about
* whether this acceptance creates a member.
*/
const alreadyMemberOfTargetOrganization =
!!existingMembership &&
!!workspaceOrganizationId &&
existingMembership.organizationId === workspaceOrganizationId
/**
* A member-role organization invite whose grants ALL left the stamped
* organization can never land its member anywhere — fail before any
* mutation. This must precede the disclosure check: the preview mirrors
* this gate with an empty disclosure, and rejecting on disclosure first
* would loop the client on disclosure-outdated instead of surfacing the
* real cause. The grant rows are advisory-locked, so this read cannot
* change for the rest of the transaction.
*/
if (shouldJoinOrganization && !(await hasLiveGrantInStampedOrganization(inv, tx))) {
return { success: false, kind: 'workspace-not-found' }
}
/**
* Membership consent guard. The workspace-id token cannot distinguish "you
* will join, and nothing of yours moves" from "you will not join at all" —
* both disclose an empty set — so the disclosed outcome is compared directly.
*
* Compared against whether a NEW membership gets created, not against
* `shouldJoinOrganization`: the preview reports `already-member` for an
* invitee who already belongs to the target organization (nothing changes for
* them) while the invitation's intent is still internal, and comparing the raw
* flag would reject every such acceptance as `disclosure-outdated` with a
* retry that renders the same preview.
*
* A disclosed `blocked` is skipped deliberately: the screen already told the
* invitee acceptance would fail, so the gates below must surface the real
* cause (`upgrade-required`, `workspace-not-found`) instead of a consent
* mismatch. Placed after the dead-grant gate for the same reason.
*
* Runs before any write, so a plain failure return needs no rollback.
*/
/**
* Whether acceptance will actually create a member row, decided from the same
* conditions the join block below uses — `shouldJoinOrganization` alone is not
* enough here, because it is only cleared much later (after provisioning fails
* to yield a target organization), by which point a write has happened.
*
* The last term mirrors the preview: with no organization on the workspace and
* billing disabled there is nothing to provision and nothing to join, so a
* personal or grandfathered workspace invite creates no membership. Omitting it
* rejected every such acceptance as `disclosure-outdated` on billing-disabled
* deployments, with a retry that rendered the same preview.
*/
const willCreateMembership =
shouldJoinOrganization &&
!alreadyMemberOfTargetOrganization &&
(!!workspaceOrganizationId || isBillingEnabled)
if (input.disclosedOutcome !== undefined && input.disclosedOutcome !== 'blocked') {
if ((input.disclosedOutcome === 'will-join') !== willCreateMembership) {
return { success: false, kind: 'disclosure-outdated' }
}
}
let targetOrganizationId = workspaceOrganizationId
if (shouldJoinOrganization) {
const alreadyMemberOfTarget = alreadyMemberOfTargetOrganization
let fixedSeats = false
if (isBillingEnabled && !alreadyMemberOfTarget) {
if (workspaceOrganizationId) {
await acquireOrganizationMutationLock(tx, workspaceOrganizationId)
}
const orgResult = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId,
workspaceOrganizationId,
executor: tx,
workspaceIdsToAttach: lockPlan.workspaceIds,
})
if (!orgResult.success) {
return { success: false, kind: orgResult.failureCode }
}
/**
* A personal Pro→Team conversion acquires the billing owner's
* billing-identity lock and attaches every workspace from the pre-lock
* plan inside this transaction. Re-read only after that conversion:
* anything still attachable was created between the plan read and the
* identity lock, so it never received a workspace advisory lock. Abort
* the whole conversion/acceptance and let the retry plan include it.
*
* Do not take the identity lock here before provisioning. Organization
* membership paths acquire organization → identity, and reversing that
* order would introduce a deadlock.
*/
if (!workspaceOrganizationId) {
const [unplannedBillingOwnerWorkspace] = await tx
.select({ id: workspace.id })
.from(workspace)
.where(
ownedAttachableWorkspacesWhere({
userId: billingOwnerUserId,
ownerMatch: 'billing-account',
includeArchived: true,
})
)
.limit(1)
if (unplannedBillingOwnerWorkspace) {
throw new BillingOwnerWorkspacesChangedDuringAcceptError()
}
}
targetOrganizationId = orgResult.organizationId
fixedSeats = orgResult.fixedSeats
if (orgResult.postCommitEffects) {
effects.planConversions.push(...orgResult.postCommitEffects.planConversions)
effects.syncUsageLimitUserIds.push(...orgResult.postCommitEffects.usageLimitUserIds)
}
}
// Team plans manage seats by reconciling to the member count after the
// join (and charging async), so the synchronous seat-cap validation is
// skipped. Enterprise keeps its fixed-seat validation, and when billing is
// disabled we leave validation in place unchanged.
const billingManagesSeats = isBillingEnabled && !fixedSeats
if (targetOrganizationId) {
const membershipResult = await ensureUserInOrganizationTx(tx, {
userId: input.userId,
organizationId: targetOrganizationId,
role: (inv.role || 'member') as 'admin' | 'member' | 'owner',
acceptingInvitationId: inv.id,
// If the pre-lock membership read said the user already belonged to
// this org but a concurrent removal won the org lock first, fall back
// to normal validation instead of accidentally bypassing Enterprise's
// fixed-seat cap with stale state.
skipSeatValidation: billingManagesSeats && !alreadyMemberOfTarget,
})
if (!membershipResult.success) {
if (membershipResult.existingOrgId) {
return { success: false, kind: 'already-in-organization' }
}
if (membershipResult.failureCode === 'no-seats-available') {
return { success: false, kind: 'no-seats-available' }
}
return { success: false, kind: 'server-error', message: membershipResult.error }
}
membershipAlreadyExists = membershipResult.alreadyMember
/**
* `membershipResult.alreadyMember` is true both for a genuinely
* pre-existing member AND for an invitee this very transaction just
* auto-joined (the Pro→Team conversion's `keep-external` attach joins
* org-less collaborators of the billing owner's workspaces before we
* reach here). Only the FORMER may skip the join side effects, so key
* them off the pre-acceptance membership snapshot instead — otherwise a
* collaborator-invitee silently keeps their workspaces personal, pays
* no seat, and loses their invited role.
*/
const joinedDuringThisAcceptance = !alreadyMemberOfTarget
if (joinedDuringThisAcceptance) {
effects.memberRole = inv.role || 'member'
}
/**
* An in-transaction auto-join lands everyone as `member`; restore the
* role the invitation actually granted when it is higher.
*/
if (
joinedDuringThisAcceptance &&
membershipResult.alreadyMember &&
isOrgAdminRole(inv.role)
) {
await tx
.update(member)
.set({ role: inv.role })
.where(
and(eq(member.userId, input.userId), eq(member.organizationId, targetOrganizationId))
)
}
// Grow the paid seat count to match the new member and push the charge
// to Stripe asynchronously (Team plans only; Enterprise seats are
// fixed). Best-effort: the member is already in, and a transient
// failure self-heals on the next join/removal reconcile, matching the
// removal path's seat accounting.
if (billingManagesSeats && joinedDuringThisAcceptance) {
effects.reconcileSeats = true
}
/**
* A new member's owned personal workspaces follow them into the
* organization so members never operate outside the org's purview.
* Collaborators on those workspaces stay external (`external-all`) —
* membership and seats never grow as a side effect of someone else's
* join. Fresh joins only: pre-existing members' estates are left
* untouched until an announced backfill.
*
* ensureUserInOrganizationTx holds the user's billing-identity lock,
* which personal workspace creation also takes — so the owned set is
* re-read here race-free. A set that changed since the pre-lock plan
* means a workspace escaped the advisory locks: the acceptance is
* rolled back (retry succeeds with the fresh set) instead of committing
* a member whose workspace dodged the sweep.
*/
if (joinedDuringThisAcceptance) {
const currentOwnedIds = (
await tx
.select({ id: workspace.id })
.from(workspace)
.where(ownedAttachableWorkspacesWhere({ userId: input.userId, includeArchived: true }))
).map((row) => row.id)
if (
[...currentOwnedIds].sort().join() !==
[...lockPlan.joinerAttachWorkspaceIds].sort().join()
) {
throw new JoinerWorkspacesChangedDuringAcceptError()
}
/**
* Consent is only valid for the workspace set the user saw: when the
* client supplies the disclosed ids from the join preview, a sweep
* set that differs (a workspace created or removed since the preview
* rendered) rolls the acceptance back so the refreshed notice is
* shown before any migration happens.
*/
if (
input.disclosedWorkspaceIds !== undefined &&
[...input.disclosedWorkspaceIds].sort().join() !==
[...lockPlan.joinerAttachWorkspaceIds].sort().join()
) {
throw new DisclosureOutdatedDuringAcceptError()
}
if (lockPlan.joinerAttachWorkspaceIds.length > 0) {
// No acquireOrganizationMutationLock here: ensureUserInOrganizationTx
// above already took it for this organization, and advisory locks are
// transaction-scoped, so re-taking it is two wasted round trips.
const attachResult = await attachOwnedWorkspacesToOrganizationTx(tx, {
ownerUserId: input.userId,
organizationId: targetOrganizationId,
workspaceIds: lockPlan.joinerAttachWorkspaceIds,
externalMemberPolicy: 'external-all',
ownerMatch: 'owner',
includeArchived: true,
})
effects.syncUsageLimitUserIds.push(...attachResult.usageLimitUserIds)
effects.attachedWorkspaceIds = attachResult.attachedWorkspaceIds
}
}
} else {
shouldJoinOrganization = false
}
}
/**
* Reverse disclosure guard: a will-join notice (non-empty disclosed set)
* whose acceptance resolved to no-join must not silently succeed as an
* external grant — the user consented to membership plus a migration that
* will not happen. Nothing has been written on the no-join path, so a
* plain failure return suffices; retry renders the refreshed preview.
*/
if (
!shouldJoinOrganization &&
input.disclosedWorkspaceIds !== undefined &&
input.disclosedWorkspaceIds.length > 0
) {
return { success: false, kind: 'disclosure-outdated' }
}
const acceptedWorkspaceIds: string[] = []
try {
/**
* The caller's transaction holds the invitation and workspace locks for
* this entire acceptance, including membership validation and grants.
*/
if (shouldJoinOrganization && targetOrganizationId) {
await acquireOrgMembershipLock(tx, input.userId, targetOrganizationId)
const [stillMember] = await tx
.select({ id: member.id })
.from(member)
.where(
and(eq(member.organizationId, targetOrganizationId), eq(member.userId, input.userId))
)
.limit(1)
if (!stillMember) {
throw new MembershipRevokedDuringAcceptError()
}
}
await tx
.update(invitation)
.set({
status: 'accepted',
membershipIntent: acceptedMembershipIntent,
updatedAt: new Date(),
})
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
for (const grant of inv.grants) {
/**
* Organization-invite grants are only honored while the workspace still
* belongs to the stamped organization: a workspace that detached or
* moved after the invite went out is no longer the org's to share.
*/
if (inv.kind === 'organization' && inv.organizationId) {
const [grantWorkspace] = await tx
.select({ organizationId: workspace.organizationId })
.from(workspace)
.where(eq(workspace.id, grant.workspaceId))
.limit(1)
if (!grantWorkspace || grantWorkspace.organizationId !== inv.organizationId) {
logger.warn('Skipping stale organization-invite grant; workspace left the organization', {
invitationId: inv.id,
workspaceId: grant.workspaceId,
stampedOrganizationId: inv.organizationId,
currentOrganizationId: grantWorkspace?.organizationId ?? null,
})
continue
}
}
const [existingPermission] = await tx
.select({ id: permissions.id, permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.entityId, grant.workspaceId),
eq(permissions.entityType, 'workspace'),
eq(permissions.userId, input.userId)
)
)
.limit(1)
const newPermission = grant.permission as PermissionType
const newRank = PERMISSION_RANK[newPermission] ?? 0
if (existingPermission) {
const existingRank =
PERMISSION_RANK[existingPermission.permissionType as PermissionType] ?? 0
if (newRank > existingRank) {
await tx
.update(permissions)
.set({ permissionType: newPermission, updatedAt: new Date() })
.where(eq(permissions.id, existingPermission.id))
}
} else {
await tx.insert(permissions).values({
id: generateId(),
entityType: 'workspace',
entityId: grant.workspaceId,
userId: input.userId,
permissionType: newPermission,
createdAt: new Date(),
updatedAt: new Date(),
})
}
acceptedWorkspaceIds.push(grant.workspaceId)
}
/**
* A member-role organization invite whose grants ALL turned stale would
* create a member with no workspace to land in — the exact dead end the
* invite-time grant requirement exists to prevent. Roll the whole
* acceptance (including the member insert) back instead; admins are
* exempt since they derive access to every organization workspace.
*/
if (
inv.kind === 'organization' &&
shouldJoinOrganization &&
!membershipAlreadyExists &&
!isOrgAdminRole(inv.role) &&
inv.grants.length > 0 &&
acceptedWorkspaceIds.length === 0
) {
throw new AllGrantsStaleDuringAcceptError()
}
} catch (grantError) {
if (grantError instanceof MembershipRevokedDuringAcceptError) {
logger.warn('Aborted invite acceptance: org membership revoked concurrently', {
userId: input.userId,
organizationId: targetOrganizationId,
invitationId: inv.id,
})
return { success: false, kind: 'already-processed' }
}
throw grantError
}
effects.organizationId = shouldJoinOrganization ? targetOrganizationId : null
effects.acceptedWorkspaceIds = acceptedWorkspaceIds
if (shouldJoinOrganization && targetOrganizationId && !membershipAlreadyExists) {
effects.syncUsageLimitUserIds.push(input.userId)
}
const acceptedInvitation: InvitationWithGrants = {
...inv,
organizationId: targetOrganizationId,
status: 'accepted',
membershipIntent: acceptedMembershipIntent,
}
effects.acceptedInvitation = acceptedInvitation
effects.membershipAlreadyExists = membershipAlreadyExists
const redirectPath =
acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}` : '/workspace'
return {
success: true,
invitation: acceptedInvitation,
acceptedWorkspaceIds,
redirectPath,
membershipAlreadyExists,
}
}
async function runInvitationAcceptancePostCommitEffects(
input: AcceptInvitationInput,
effects: InvitationAcceptancePostCommitEffects
): Promise<void> {
if (effects.acceptedInvitation) {
const accepted = effects.acceptedInvitation
recordAudit({
workspaceId: effects.acceptedWorkspaceIds[0] ?? null,
actorId: input.userId,
actorName: input.actorName ?? undefined,
actorEmail: input.userEmail,
action:
accepted.kind === 'workspace'
? AuditAction.INVITATION_ACCEPTED
: AuditAction.ORG_INVITATION_ACCEPTED,
resourceType:
accepted.kind === 'workspace'
? AuditResourceType.WORKSPACE
: AuditResourceType.ORGANIZATION,
resourceId: accepted.organizationId ?? effects.acceptedWorkspaceIds[0] ?? accepted.id,
description: `Accepted ${accepted.kind} invitation for ${accepted.email}`,
metadata: {
invitationId: accepted.id,
targetEmail: accepted.email,
targetRole: accepted.role,
kind: accepted.kind,
membershipIntent: accepted.membershipIntent,
workspaceIds: effects.acceptedWorkspaceIds,
membershipAlreadyExists: effects.membershipAlreadyExists,
},
request: input.request,
})
}
if (effects.organizationId && effects.memberRole) {
// Pre-join sessions keep their old expiry until the next sliding refresh;
// apply the org's session policy to them now (best-effort, never throws).
await applySessionPolicyToNewMember(input.userId, effects.organizationId)
recordAudit({
workspaceId: null,
actorId: input.userId,
action: AuditAction.ORG_MEMBER_ADDED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: effects.organizationId,
description: `Joined organization as ${effects.memberRole} via invite acceptance`,
metadata: {
invitationId: input.invitationId,
memberRole: effects.memberRole,
attachedWorkspaceIds: effects.attachedWorkspaceIds,
},
})
captureServerEvent(
input.userId,
'org_member_added',
{ organization_id: effects.organizationId, member_role: effects.memberRole },
{ groups: { organization: effects.organizationId } }
)
}
for (const conversion of effects.planConversions) {
recordAudit({
workspaceId: null,
actorId: conversion.actorId,
action: AuditAction.ORG_PLAN_CONVERTED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: conversion.organizationId,
description: `Converted ${conversion.fromPlan} to ${conversion.toPlan}`,
metadata: {
fromPlan: conversion.fromPlan,
toPlan: conversion.toPlan,
trigger: 'invite-acceptance',
},
})
captureServerEvent(conversion.actorId, 'subscription_changed', {
from_plan: conversion.fromPlan,
to_plan: conversion.toPlan,
interval: 'unchanged',
})
}
if (effects.organizationId && effects.reconcileSeats) {
try {
await reconcileOrganizationSeats({
organizationId: effects.organizationId,
reason: 'member-accepted-invite',
actorId: input.userId,
})
} catch (seatError) {
logger.error('Failed to reconcile organization seats after invite acceptance', {
userId: input.userId,
organizationId: effects.organizationId,
invitationId: input.invitationId,
error: seatError,
})
}
}
if (effects.organizationId) {
try {
await setActiveOrganizationForCurrentSession(effects.organizationId)
} catch (activeOrgError) {
logger.error('Failed to activate organization after accepting invitation', {
userId: input.userId,
organizationId: effects.organizationId,
invitationId: input.invitationId,
error: activeOrgError,
})
}
}
for (const workspaceId of effects.acceptedWorkspaceIds) {
try {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: input.userId,
})
}
} catch (envError) {
logger.error('Failed to sync workspace env credentials after invitation accept', {
userId: input.userId,
workspaceId,
invitationId: input.invitationId,
error: envError,
})
}
}
for (const userId of new Set(effects.syncUsageLimitUserIds)) {
try {
await syncUsageLimitsFromSubscription(userId)
} catch (syncError) {
logger.error('Failed to sync usage limits after joining org', {
userId,
organizationId: effects.organizationId,
invitationId: input.invitationId,
error: syncError,
})
}
}
}
export type RejectInvitationResult =
| { success: true; invitation: InvitationWithGrants }
| { success: false; kind: AcceptInvitationFailure['kind'] }
export type UpdateInvitationFailureKind =
| 'not-found'
| 'not-pending'
| 'external-role'
| 'role-not-organization-scoped'
| 'organization-forbidden'
| 'member-requires-workspace'
| 'grant-not-found'
| 'workspace-forbidden'
export type UpdateInvitationResult =
| { success: true; invitation: InvitationWithGrants }
| {
success: false
kind: UpdateInvitationFailureKind
workspaceId?: string
}
/**
* Updates a pending invitation only after claiming the invitation and all of
* its workspaces in the shared mutation lock namespace. Authorization is
* intentionally evaluated inside that transaction against the locked,
* re-hydrated invitation so a workspace move or acceptance cannot turn an
* authorized pre-lock snapshot into an unauthorized write.
*/
export async function updateInvitation(input: {
actorId: string
invitationId: string
role?: 'admin' | 'member'
grants?: Array<{ workspaceId: string; permission: PermissionType }>
}): Promise<UpdateInvitationResult> {
return db.transaction(async (tx): Promise<UpdateInvitationResult> => {
const inv = await lockInvitationForMutation(tx, input.invitationId, {
additionalWorkspaceIds: input.grants?.map((grant) => grant.workspaceId) ?? [],
})
if (!inv) return { success: false, kind: 'not-found' }
if (inv.status !== 'pending') return { success: false, kind: 'not-pending' }
if (input.role !== undefined) {
if (inv.membershipIntent === 'external') {
return { success: false, kind: 'external-role' }
}
if (!inv.organizationId) {
return { success: false, kind: 'role-not-organization-scoped' }
}
if (!(await lockOrganizationAdminAuthority(tx, input.actorId, inv.organizationId))) {
return { success: false, kind: 'organization-forbidden' }
}
if (!isOrgAdminRole(input.role) && inv.grants.length === 0) {
return { success: false, kind: 'member-requires-workspace' }
}
}
const grantsToApply = input.grants ?? []
const grantWorkspaceIds = [...new Set(grantsToApply.map((grant) => grant.workspaceId))].sort()
for (const workspaceId of grantWorkspaceIds) {
if (!inv.grants.some((grant) => grant.workspaceId === workspaceId)) {
return {
success: false,
kind: 'grant-not-found',
workspaceId,
}
}
if (!(await lockWorkspaceAdminAuthority(tx, input.actorId, workspaceId))) {
return {
success: false,
kind: 'workspace-forbidden',
workspaceId,
}
}
}
const now = new Date()
const [claimed] = await tx
.update(invitation)
.set({
...(input.role !== undefined && input.role !== inv.role ? { role: input.role } : {}),
updatedAt: now,
})
.where(and(eq(invitation.id, input.invitationId), eq(invitation.status, 'pending')))
.returning({ id: invitation.id })
if (!claimed) return { success: false, kind: 'not-pending' }
for (const update of grantsToApply) {
await tx
.update(invitationWorkspaceGrant)
.set({ permission: update.permission, updatedAt: now })
.where(
and(
eq(invitationWorkspaceGrant.invitationId, input.invitationId),
eq(invitationWorkspaceGrant.workspaceId, update.workspaceId)
)
)
}
return {
success: true,
invitation: {
...inv,
role: input.role ?? inv.role,
updatedAt: now,
grants: inv.grants.map((grant) => {
const update = grantsToApply.find(
(candidate) => candidate.workspaceId === grant.workspaceId
)
return update ? { ...grant, permission: update.permission } : grant
}),
},
}
})
}
export async function rejectInvitation(
input: AcceptInvitationInput
): Promise<RejectInvitationResult> {
return db.transaction(async (tx): Promise<RejectInvitationResult> => {
const inv = await lockInvitationForMutation(tx, input.invitationId)
if (!inv) return { success: false, kind: 'not-found' }
if (input.token && inv.token !== input.token) return { success: false, kind: 'invalid-token' }
if (inv.status !== 'pending') return { success: false, kind: 'already-processed' }
if (isInvitationExpired(inv)) {
const expired = await tx
.update(invitation)
.set({ status: 'expired', updatedAt: new Date() })
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
.returning({ id: invitation.id })
return {
success: false,
kind: expired.length > 0 ? 'expired' : 'already-processed',
}
}
if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) {
return { success: false, kind: 'email-mismatch' }
}
const now = new Date()
const rejected = await tx
.update(invitation)
.set({ status: 'rejected', updatedAt: now })
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
.returning({ id: invitation.id })
if (rejected.length === 0) {
return { success: false, kind: 'already-processed' }
}
return { success: true, invitation: { ...inv, status: 'rejected', updatedAt: now } }
})
}
export type AuthorizedInvitationRevocationResult =
| {
success: true
invitation: InvitationWithGrants
invitationCancelled: boolean
}
| {
success: false
kind:
| 'not-found'
| 'not-pending'
| 'grant-not-found'
| 'scoped-forbidden'
| 'whole-forbidden'
| 'not-cancellable'
spansMultipleWorkspaces?: boolean
}
/**
* API-facing revocation path. Unlike generic internal cleanup helpers, this
* claims the invitation and relevant workspace scopes first, then evaluates
* the actor's organization/workspace authority against the protected live
* state in the same transaction as the conditional pending-only mutation.
*/
export async function revokeInvitationAsAdmin(input: {
actorId: string
invitationId: string
workspaceId?: string
}): Promise<AuthorizedInvitationRevocationResult> {
return db.transaction(async (tx): Promise<AuthorizedInvitationRevocationResult> => {
const inv = await lockInvitationForMutation(tx, input.invitationId, {
lockCurrentGrantWorkspaces: input.workspaceId === undefined,
additionalWorkspaceIds: input.workspaceId ? [input.workspaceId] : [],
})
if (!inv) return { success: false, kind: 'not-found' }
if (inv.status !== 'pending') return { success: false, kind: 'not-pending' }
const isOrganizationAdmin = inv.organizationId
? await lockOrganizationAdminAuthority(tx, input.actorId, inv.organizationId)
: false
if (input.workspaceId) {
if (!inv.grants.some((grant) => grant.workspaceId === input.workspaceId)) {
return { success: false, kind: 'grant-not-found' }
}
if (
!isOrganizationAdmin &&
!(await lockWorkspaceAdminAuthority(tx, input.actorId, input.workspaceId))
) {
return { success: false, kind: 'scoped-forbidden' }
}
const revoked = await revokeInvitationWorkspaceGrantTx(tx, {
invitationId: input.invitationId,
workspaceId: input.workspaceId,
})
if (!revoked.revoked) return { success: false, kind: 'not-cancellable' }
return {
success: true,
invitation: inv,
invitationCancelled: revoked.invitationCancelled,
}
}
let canCancel = isOrganizationAdmin
if (!canCancel && inv.grants.length > 0) {
canCancel = true
const workspaceIds = [...new Set(inv.grants.map((grant) => grant.workspaceId))].sort()
for (const workspaceId of workspaceIds) {
if (!(await lockWorkspaceAdminAuthority(tx, input.actorId, workspaceId))) {
canCancel = false
break
}
}
}
if (!canCancel) {
return {
success: false,
kind: 'whole-forbidden',
spansMultipleWorkspaces: inv.grants.length > 1,
}
}
const cancelled = await tx
.update(invitation)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(and(eq(invitation.id, input.invitationId), eq(invitation.status, 'pending')))
.returning({ id: invitation.id })
if (cancelled.length === 0) return { success: false, kind: 'not-cancellable' }
return { success: true, invitation: inv, invitationCancelled: true }
})
}
/**
* Revokes one workspace's grant from a pending invitation, cancelling the whole
* invitation only when that was its last grant.
*
* One invitation can span several workspaces, so revoking from a single
* workspace's member list must not destroy the grants to its siblings — an
* admin of one workspace has no authority over the others. Removing the final
* grant would otherwise strand a pending invitation that grants nothing, so
* that case cancels it instead.
*
* Transaction-local: callers must already hold the canonical
* invitation/workspace advisory lock set. Keeping the grant deletion and
* final-grant cancellation in one implementation prevents direct grants,
* scoped revocation, and future callers from drifting on multi-workspace
* invitation semantics.
*/
export async function revokeInvitationWorkspaceGrantTx(
tx: DbOrTx,
{
invitationId,
workspaceId,
}: {
invitationId: string
workspaceId: string
}
): Promise<{ revoked: boolean; invitationCancelled: boolean }> {
const [pending] = await tx
.select({ id: invitation.id })
.from(invitation)
.where(and(eq(invitation.id, invitationId), eq(invitation.status, 'pending')))
.for('update')
.limit(1)
if (!pending) return { revoked: false, invitationCancelled: false }
const removed = await tx
.delete(invitationWorkspaceGrant)
.where(
and(
eq(invitationWorkspaceGrant.invitationId, invitationId),
eq(invitationWorkspaceGrant.workspaceId, workspaceId)
)
)
.returning({ id: invitationWorkspaceGrant.id })
if (removed.length === 0) return { revoked: false, invitationCancelled: false }
const [remaining] = await tx
.select({ value: count() })
.from(invitationWorkspaceGrant)
.where(eq(invitationWorkspaceGrant.invitationId, invitationId))
if ((remaining?.value ?? 0) > 0) {
await tx
.update(invitation)
.set({ updatedAt: new Date() })
.where(eq(invitation.id, invitationId))
return { revoked: true, invitationCancelled: false }
}
await tx
.update(invitation)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(eq(invitation.id, invitationId))
return { revoked: true, invitationCancelled: true }
}
/**
* Pending, unexpired invitations addressed to an email — the invitee-facing
* list (workspace-switcher Invitations section). Session-bound callers accept
* without a token, so the rows returned here must never need one.
*/
export async function listPendingInvitationsForEmail(
email: string
): Promise<InvitationWithGrants[]> {
const rows = await db
.select()
.from(invitation)
.where(
and(
sql`lower(${invitation.email}) = ${normalizeEmail(email)}`,
eq(invitation.status, 'pending'),
sql`${invitation.expiresAt} > now()`
)
)
.orderBy(invitation.createdAt)
return Promise.all(rows.map((row) => hydrateInvitation(row)))
}
export async function listInvitationsForWorkspaces(workspaceIds: string[]) {
if (workspaceIds.length === 0) return []
return db
.select({
id: invitation.id,
kind: invitation.kind,
email: invitation.email,
token: invitation.token,
status: invitation.status,
expiresAt: invitation.expiresAt,
createdAt: invitation.createdAt,
updatedAt: invitation.updatedAt,
organizationId: invitation.organizationId,
membershipIntent: invitation.membershipIntent,
inviterId: invitation.inviterId,
workspaceId: invitationWorkspaceGrant.workspaceId,
permission: invitationWorkspaceGrant.permission,
})
.from(invitationWorkspaceGrant)
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
.where(inArray(invitationWorkspaceGrant.workspaceId, workspaceIds))
}