Files
simstudioai--sim/apps/sim/lib/invitations/core.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

657 lines
21 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 { PERMISSION_RANK, type PermissionType } from '@sim/platform-authz/workspace'
import { generateId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray, lte } from 'drizzle-orm'
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import {
acquireOrgMembershipLock,
ensureUserInOrganization,
getUserOrganization,
} from '@/lib/billing/organizations/membership'
import { ensureTeamOrganizationForAcceptance } from '@/lib/billing/organizations/provision-seat'
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { captureServerEvent } from '@/lib/posthog/server'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
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): Promise<InvitationWithGrants | null> {
const [row] = await db.select().from(invitation).where(eq(invitation.id, id)).limit(1)
if (!row) return null
return hydrateInvitation(row)
}
export async function getInvitationByToken(token: string): Promise<InvitationWithGrants | null> {
const [row] = await db.select().from(invitation).where(eq(invitation.token, token)).limit(1)
if (!row) return null
return hydrateInvitation(row)
}
async function hydrateInvitation(
row: typeof invitation.$inferSelect
): Promise<InvitationWithGrants> {
const grantRows = await db
.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))
let organizationName: string | null = null
if (row.organizationId) {
const [orgRow] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, row.organizationId))
.limit(1)
organizationName = orgRow?.name ?? null
}
const [inviterRow] = await db
.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)
}
/**
* 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,
})
}
}
/**
* Flip any still-pending invitations with grants on the given workspaces
* whose `expiresAt` has already passed to `expired`.
*/
export async function expireStalePendingInvitationsForWorkspaces(
workspaceIds: string[]
): Promise<void> {
if (workspaceIds.length === 0) return
try {
const staleIds = await db
.select({ id: invitation.id })
.from(invitation)
.innerJoin(invitationWorkspaceGrant, eq(invitationWorkspaceGrant.invitationId, invitation.id))
.where(
and(
inArray(invitationWorkspaceGrant.workspaceId, workspaceIds),
eq(invitation.status, 'pending'),
lte(invitation.expiresAt, new Date())
)
)
if (staleIds.length === 0) return
await db
.update(invitation)
.set({ status: 'expired', updatedAt: new Date() })
.where(
inArray(
invitation.id,
staleIds.map((row) => row.id)
)
)
} catch (error) {
logger.error('Failed to expire stale pending invitations for workspaces', {
workspaceCount: workspaceIds.length,
error,
})
}
}
export type AcceptInvitationFailure =
| { kind: 'not-found' }
| { kind: 'already-processed' }
| { kind: 'expired' }
| { kind: 'email-mismatch' }
| { kind: 'invalid-token' }
| { kind: 'already-in-organization' }
| { kind: 'no-seats-available' }
| { kind: 'upgrade-required' }
| { 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
invitationId: string
token: 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'
}
}
/**
* An invitee who already belongs to a different organization cannot join a
* second one. A workspace invite (with grants) falls back to external access;
* anything else is rejected. Returns `true` when the caller should downgrade to
* external membership, `false` when the invite was rejected as cross-org.
*/
async function downgradeOrRejectCrossOrgInvite(inv: InvitationWithGrants): Promise<boolean> {
if (inv.kind === 'workspace' && inv.grants.length > 0) {
return true
}
await db
.update(invitation)
.set({ status: 'rejected', updatedAt: new Date() })
.where(eq(invitation.id, inv.id))
return false
}
export async function acceptInvitation(
input: AcceptInvitationInput
): Promise<AcceptInvitationResult> {
const inv = await getInvitationById(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)) {
await db
.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' }
}
let membershipAlreadyExists = false
let acceptedMembershipIntent = inv.membershipIntent
let shouldJoinOrganization = inv.membershipIntent !== 'external'
const primaryGrant = inv.grants[0]
let billingOwnerUserId = inv.inviterId
let workspaceOrganizationId = inv.organizationId
if (primaryGrant) {
const grantWorkspace = await getWorkspaceWithOwner(primaryGrant.workspaceId)
if (grantWorkspace) {
billingOwnerUserId = grantWorkspace.billedAccountUserId
workspaceOrganizationId = grantWorkspace.organizationId ?? inv.organizationId
}
}
const existingMembership = await getUserOrganization(input.userId)
const inviteeAlreadyInDifferentOrg =
!!existingMembership &&
(workspaceOrganizationId ? existingMembership.organizationId !== workspaceOrganizationId : true)
if (shouldJoinOrganization && inviteeAlreadyInDifferentOrg) {
if (await downgradeOrRejectCrossOrgInvite(inv)) {
acceptedMembershipIntent = 'external'
shouldJoinOrganization = false
} else {
return { success: false, kind: 'already-in-organization' }
}
}
let targetOrganizationId = workspaceOrganizationId
if (shouldJoinOrganization) {
const alreadyMemberOfTarget =
!!existingMembership &&
!!workspaceOrganizationId &&
existingMembership.organizationId === workspaceOrganizationId
let fixedSeats = false
if (isBillingEnabled && !alreadyMemberOfTarget) {
const orgResult = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId,
workspaceOrganizationId,
})
if (!orgResult.success) {
return { success: false, kind: orgResult.failureCode }
}
targetOrganizationId = orgResult.organizationId
fixedSeats = orgResult.fixedSeats
}
// 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 ensureUserInOrganization({
userId: input.userId,
organizationId: targetOrganizationId,
role: (inv.role || 'member') as 'admin' | 'member' | 'owner',
acceptingInvitationId: inv.id,
skipSeatValidation: billingManagesSeats,
})
if (!membershipResult.success) {
if (membershipResult.existingOrgId) {
if (await downgradeOrRejectCrossOrgInvite(inv)) {
acceptedMembershipIntent = 'external'
shouldJoinOrganization = false
} else {
return { success: false, kind: 'already-in-organization' }
}
} else if (membershipResult.failureCode === 'no-seats-available') {
return { success: false, kind: 'no-seats-available' }
} else {
return { success: false, kind: 'server-error', message: membershipResult.error }
}
} else {
membershipAlreadyExists = membershipResult.alreadyMember
if (!membershipResult.alreadyMember) {
const memberRole = inv.role || 'member'
recordAudit({
workspaceId: null,
actorId: input.userId,
action: AuditAction.ORG_MEMBER_ADDED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: targetOrganizationId,
description: `Joined organization as ${memberRole} via invite acceptance`,
metadata: { invitationId: inv.id, memberRole },
})
captureServerEvent(
input.userId,
'org_member_added',
{ organization_id: targetOrganizationId, member_role: memberRole },
{ groups: { organization: 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 && !membershipResult.alreadyMember) {
try {
await reconcileOrganizationSeats({
organizationId: targetOrganizationId,
reason: 'member-accepted-invite',
actorId: input.userId,
})
} catch (seatError) {
logger.error('Failed to reconcile organization seats after invite acceptance', {
userId: input.userId,
organizationId: targetOrganizationId,
invitationId: inv.id,
error: seatError,
})
}
}
}
} else {
shouldJoinOrganization = false
}
}
const acceptedWorkspaceIds: string[] = []
try {
await db.transaction(async (tx) => {
/**
* When this acceptance joins an organization, serialize against a
* concurrent member-removal for the same user+org and confirm the member
* still exists before granting workspace access. Without this, a removal
* landing between the join and the grant would leave the user with
* workspace access but no org membership/seat.
*/
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(eq(invitation.id, inv.id))
for (const grant of inv.grants) {
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)
}
})
} 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
}
if (shouldJoinOrganization && targetOrganizationId) {
try {
await setActiveOrganizationForCurrentSession(targetOrganizationId)
} catch (activeOrgError) {
logger.error('Failed to activate organization after accepting invitation', {
userId: input.userId,
organizationId: targetOrganizationId,
invitationId: inv.id,
error: activeOrgError,
})
}
}
for (const workspaceId of 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: inv.id,
error: envError,
})
}
}
if (shouldJoinOrganization && targetOrganizationId && !membershipAlreadyExists) {
try {
await syncUsageLimitsFromSubscription(input.userId)
} catch (syncError) {
logger.error('Failed to sync usage limits after joining org', {
userId: input.userId,
organizationId: targetOrganizationId,
invitationId: inv.id,
error: syncError,
})
}
}
const redirectPath =
inv.kind === 'workspace' && acceptedWorkspaceIds.length > 0
? `/workspace/${acceptedWorkspaceIds[0]}/home`
: '/workspace'
return {
success: true,
invitation: { ...inv, status: 'accepted', membershipIntent: acceptedMembershipIntent },
acceptedWorkspaceIds,
redirectPath,
membershipAlreadyExists,
}
}
export type RejectInvitationResult =
| { success: true; invitation: InvitationWithGrants }
| { success: false; kind: AcceptInvitationFailure['kind'] }
export async function rejectInvitation(
input: AcceptInvitationInput
): Promise<RejectInvitationResult> {
const inv = await getInvitationById(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)) {
await db
.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' }
}
await db
.update(invitation)
.set({ status: 'rejected', updatedAt: new Date() })
.where(eq(invitation.id, inv.id))
return { success: true, invitation: { ...inv, status: 'rejected' } }
}
export async function cancelInvitation(invitationId: string): Promise<boolean> {
const result = await db
.update(invitation)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(and(eq(invitation.id, invitationId), eq(invitation.status, 'pending')))
.returning({ id: invitation.id })
return result.length > 0
}
export async function listPendingInvitationsForOrganization(organizationId: string) {
return db
.select({
id: invitation.id,
kind: invitation.kind,
email: invitation.email,
role: invitation.role,
membershipIntent: invitation.membershipIntent,
status: invitation.status,
expiresAt: invitation.expiresAt,
createdAt: invitation.createdAt,
inviterName: user.name,
inviterEmail: user.email,
})
.from(invitation)
.leftJoin(user, eq(invitation.inviterId, user.id))
.where(eq(invitation.organizationId, organizationId))
.orderBy(invitation.createdAt)
}
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))
}