chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+579
View File
@@ -0,0 +1,579 @@
/**
* @vitest-environment node
*/
import { auditMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockEnsureUserInOrganization,
mockGetUserOrganization,
mockAcquireOrgMembershipLock,
mockEnsureTeamOrganizationForAcceptance,
mockReconcileOrganizationSeats,
mockGetWorkspaceWithOwner,
mockSetActiveOrganizationForCurrentSession,
mockSyncUsageLimitsFromSubscription,
mockSyncWorkspaceEnvCredentials,
mockIsWorkspaceOnEnterprisePlan,
mockFeatureFlags,
} = vi.hoisted(() => ({
mockEnsureUserInOrganization: vi.fn(),
mockGetUserOrganization: vi.fn(),
mockAcquireOrgMembershipLock: vi.fn(),
mockEnsureTeamOrganizationForAcceptance: vi.fn(),
mockReconcileOrganizationSeats: vi.fn(),
mockGetWorkspaceWithOwner: vi.fn(),
mockSetActiveOrganizationForCurrentSession: vi.fn(),
mockSyncUsageLimitsFromSubscription: vi.fn(),
mockSyncWorkspaceEnvCredentials: vi.fn(),
mockIsWorkspaceOnEnterprisePlan: vi.fn(async () => true),
mockFeatureFlags: { isBillingEnabled: true },
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/billing/organizations/membership', () => ({
ensureUserInOrganization: mockEnsureUserInOrganization,
getUserOrganization: mockGetUserOrganization,
acquireOrgMembershipLock: mockAcquireOrgMembershipLock,
}))
vi.mock('@/lib/billing/organizations/provision-seat', () => ({
ensureTeamOrganizationForAcceptance: mockEnsureTeamOrganizationForAcceptance,
}))
vi.mock('@/lib/billing/organizations/seats', () => ({
reconcileOrganizationSeats: mockReconcileOrganizationSeats,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getWorkspaceWithOwner: mockGetWorkspaceWithOwner,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return mockFeatureFlags.isBillingEnabled
},
}))
vi.mock('@/lib/auth/active-organization', () => ({
setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession,
}))
vi.mock('@/lib/billing/core/subscription', () => ({
isWorkspaceOnEnterprisePlan: mockIsWorkspaceOnEnterprisePlan,
}))
vi.mock('@/lib/billing/core/usage', () => ({
syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription,
}))
vi.mock('@/lib/credentials/environment', () => ({
syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials,
}))
vi.mock('@sim/audit', () => auditMock)
import { acceptInvitation } from '@/lib/invitations/core'
function queueWhereResponses(responses: unknown[][]) {
const queue = [...responses]
dbChainMockFns.where.mockImplementation(() => {
const result = queue.shift() ?? []
const thenable = Promise.resolve(result) as Promise<unknown[]> & {
limit: ReturnType<typeof vi.fn>
orderBy: ReturnType<typeof vi.fn>
returning: ReturnType<typeof vi.fn>
groupBy: ReturnType<typeof vi.fn>
}
thenable.limit = vi.fn(() => Promise.resolve(result))
thenable.orderBy = vi.fn(() => Promise.resolve(result))
thenable.returning = vi.fn(() => Promise.resolve(result))
thenable.groupBy = vi.fn(() => Promise.resolve(result))
return thenable as ReturnType<typeof dbChainMockFns.where>
})
}
describe('acceptInvitation', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockFeatureFlags.isBillingEnabled = true
mockGetUserOrganization.mockResolvedValue(null)
mockGetWorkspaceWithOwner.mockResolvedValue(null)
mockEnsureTeamOrganizationForAcceptance.mockResolvedValue({
success: true,
organizationId: 'org-1',
fixedSeats: false,
})
mockReconcileOrganizationSeats.mockResolvedValue({
changed: true,
previousSeats: 1,
seats: 2,
})
mockEnsureUserInOrganization.mockResolvedValue({
success: true,
alreadyMember: false,
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
})
})
it('accepts external workspace invitations without joining the organization', async () => {
queueWhereResponses([
[
{
id: 'inv-1',
kind: 'workspace',
email: 'external@example.com',
organizationId: 'org-1',
membershipIntent: 'external',
inviterId: 'inviter-1',
role: 'member',
status: 'pending',
token: 'tok-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
},
],
[
{
id: 'grant-1',
workspaceId: 'workspace-1',
permission: 'write',
workspaceName: 'Workspace',
},
],
[{ name: 'Acme' }],
[{ name: 'Inviter', email: 'inviter@example.com' }],
[],
[],
[{ variables: {} }],
])
const result = await acceptInvitation({
userId: 'external-user',
userEmail: 'external@example.com',
invitationId: 'inv-1',
token: 'tok-1',
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.acceptedWorkspaceIds).toEqual(['workspace-1'])
expect(result.membershipAlreadyExists).toBe(false)
}
expect(mockEnsureTeamOrganizationForAcceptance).not.toHaveBeenCalled()
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled()
expect(mockSyncUsageLimitsFromSubscription).not.toHaveBeenCalled()
expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'external-user',
entityType: 'workspace',
entityId: 'workspace-1',
permissionType: 'write',
})
)
})
it('falls back to external access when an internal workspace invitee already joined another organization', async () => {
mockGetUserOrganization.mockResolvedValueOnce({
organizationId: 'org-2',
role: 'member',
memberId: 'member-2',
})
queueWhereResponses([
[
{
id: 'inv-1',
kind: 'workspace',
email: 'invitee@example.com',
organizationId: 'org-1',
membershipIntent: 'internal',
inviterId: 'inviter-1',
role: 'member',
status: 'pending',
token: 'tok-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
},
],
[
{
id: 'grant-1',
workspaceId: 'workspace-1',
permission: 'read',
workspaceName: 'Workspace',
},
],
[{ name: 'Acme' }],
[{ name: 'Inviter', email: 'inviter@example.com' }],
[],
[],
[{ variables: {} }],
])
const result = await acceptInvitation({
userId: 'invitee-user',
userEmail: 'invitee@example.com',
invitationId: 'inv-1',
token: 'tok-1',
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.invitation.membershipIntent).toBe('external')
expect(result.acceptedWorkspaceIds).toEqual(['workspace-1'])
}
// The single-org pre-check short-circuits to external access without
// provisioning a seat or attempting an organization join.
expect(mockEnsureTeamOrganizationForAcceptance).not.toHaveBeenCalled()
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
status: 'accepted',
membershipIntent: 'external',
})
)
expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'invitee-user',
entityType: 'workspace',
entityId: 'workspace-1',
permissionType: 'read',
})
)
})
it('provisions a team seat and joins the org created from a Pro owner conversion', async () => {
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
id: 'workspace-1',
name: 'Workspace',
ownerId: 'owner-1',
organizationId: null,
workspaceMode: 'personal',
billedAccountUserId: 'owner-1',
})
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
success: true,
organizationId: 'org-new',
fixedSeats: false,
})
queueWhereResponses([
[
{
id: 'inv-1',
kind: 'workspace',
email: 'invitee@example.com',
organizationId: null,
membershipIntent: 'internal',
inviterId: 'owner-1',
role: 'member',
status: 'pending',
token: 'tok-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
},
],
[
{
id: 'grant-1',
workspaceId: 'workspace-1',
permission: 'write',
workspaceName: 'Workspace',
},
],
[{ name: 'Owner', email: 'owner@example.com' }],
// Grant-txn membership re-check under the lock: member still present.
[{ id: 'member-1' }],
])
const result = await acceptInvitation({
userId: 'invitee-user',
userEmail: 'invitee@example.com',
invitationId: 'inv-1',
token: 'tok-1',
})
expect(result.success).toBe(true)
expect(mockEnsureTeamOrganizationForAcceptance).toHaveBeenCalledWith({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'invitee-user',
organizationId: 'org-new',
acceptingInvitationId: 'inv-1',
skipSeatValidation: true,
})
)
// Seats grow to match the new member; the Stripe charge is deferred to the
// seat-sync outbox.
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
organizationId: 'org-new',
reason: 'member-accepted-invite',
actorId: 'invitee-user',
})
expect(mockSetActiveOrganizationForCurrentSession).toHaveBeenCalledWith('org-new')
expect(auditMock.recordAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'invitee-user',
action: auditMock.AuditAction.ORG_MEMBER_ADDED,
resourceType: auditMock.AuditResourceType.ORGANIZATION,
resourceId: 'org-new',
metadata: expect.objectContaining({ invitationId: 'inv-1', memberRole: 'member' }),
})
)
})
it('does not record an ORG_MEMBER_ADDED audit for a user who is already a member', async () => {
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
id: 'workspace-1',
name: 'Workspace',
ownerId: 'owner-1',
organizationId: 'org-1',
workspaceMode: 'organization',
billedAccountUserId: 'owner-1',
})
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
success: true,
organizationId: 'org-1',
fixedSeats: false,
})
mockEnsureUserInOrganization.mockResolvedValueOnce({
success: true,
alreadyMember: true,
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
})
queueWhereResponses([
[
{
id: 'inv-1',
kind: 'workspace',
email: 'invitee@example.com',
organizationId: 'org-1',
membershipIntent: 'internal',
inviterId: 'owner-1',
role: 'member',
status: 'pending',
token: 'tok-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
},
],
[
{
id: 'grant-1',
workspaceId: 'workspace-1',
permission: 'write',
workspaceName: 'Workspace',
},
],
[{ name: 'Acme' }],
[{ name: 'Owner', email: 'owner@example.com' }],
[{ id: 'member-1' }],
])
const result = await acceptInvitation({
userId: 'invitee-user',
userEmail: 'invitee@example.com',
invitationId: 'inv-1',
token: 'tok-1',
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.membershipAlreadyExists).toBe(true)
}
expect(auditMock.recordAudit).not.toHaveBeenCalledWith(
expect.objectContaining({ action: auditMock.AuditAction.ORG_MEMBER_ADDED })
)
})
it('does not reconcile seats for an Enterprise organization (fixed seats)', async () => {
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
id: 'workspace-1',
name: 'Workspace',
ownerId: 'owner-1',
organizationId: 'org-1',
workspaceMode: 'organization',
billedAccountUserId: 'owner-1',
})
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
success: true,
organizationId: 'org-1',
fixedSeats: true,
})
queueWhereResponses([
[
{
id: 'inv-1',
kind: 'workspace',
email: 'invitee@example.com',
organizationId: 'org-1',
membershipIntent: 'internal',
inviterId: 'owner-1',
role: 'member',
status: 'pending',
token: 'tok-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
},
],
[
{
id: 'grant-1',
workspaceId: 'workspace-1',
permission: 'write',
workspaceName: 'Workspace',
},
],
[{ name: 'Acme' }],
[{ name: 'Owner', email: 'owner@example.com' }],
// Grant-txn membership re-check under the lock: member still present.
[{ id: 'member-1' }],
])
const result = await acceptInvitation({
userId: 'invitee-user',
userEmail: 'invitee@example.com',
invitationId: 'inv-1',
token: 'tok-1',
})
expect(result.success).toBe(true)
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith(
expect.objectContaining({ organizationId: 'org-1', skipSeatValidation: false })
)
expect(mockReconcileOrganizationSeats).not.toHaveBeenCalled()
})
it('blocks acceptance with upgrade-required when the owner has no usable plan', async () => {
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
id: 'workspace-1',
name: 'Workspace',
ownerId: 'owner-1',
organizationId: 'org-1',
workspaceMode: 'organization',
billedAccountUserId: 'owner-1',
})
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
success: false,
failureCode: 'upgrade-required',
})
queueWhereResponses([
[
{
id: 'inv-1',
kind: 'workspace',
email: 'invitee@example.com',
organizationId: 'org-1',
membershipIntent: 'internal',
inviterId: 'owner-1',
role: 'member',
status: 'pending',
token: 'tok-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
},
],
[
{
id: 'grant-1',
workspaceId: 'workspace-1',
permission: 'write',
workspaceName: 'Workspace',
},
],
[{ name: 'Acme' }],
[{ name: 'Owner', email: 'owner@example.com' }],
])
const result = await acceptInvitation({
userId: 'invitee-user',
userEmail: 'invitee@example.com',
invitationId: 'inv-1',
token: 'tok-1',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.kind).toBe('upgrade-required')
}
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
expect(mockReconcileOrganizationSeats).not.toHaveBeenCalled()
})
it('aborts when the org membership is revoked concurrently during the grant', async () => {
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
id: 'workspace-1',
name: 'Workspace',
ownerId: 'owner-1',
organizationId: 'org-1',
workspaceMode: 'organization',
billedAccountUserId: 'owner-1',
})
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
success: true,
organizationId: 'org-1',
fixedSeats: false,
})
queueWhereResponses([
[
{
id: 'inv-1',
kind: 'workspace',
email: 'invitee@example.com',
organizationId: 'org-1',
membershipIntent: 'internal',
inviterId: 'owner-1',
role: 'member',
status: 'pending',
token: 'tok-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
},
],
[
{
id: 'grant-1',
workspaceId: 'workspace-1',
permission: 'write',
workspaceName: 'Workspace',
},
],
[{ name: 'Acme' }],
[{ name: 'Owner', email: 'owner@example.com' }],
// Grant-txn membership re-check finds no member row (removed concurrently).
[],
])
const result = await acceptInvitation({
userId: 'invitee-user',
userEmail: 'invitee@example.com',
invitationId: 'inv-1',
token: 'tok-1',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.kind).toBe('already-processed')
}
// Aborted before granting workspace access — no zombie permission write.
expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled()
})
})
+656
View File
@@ -0,0 +1,656 @@
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))
}
@@ -0,0 +1,214 @@
/**
* @vitest-environment node
*/
import {
auditMock,
auditMockFns,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetUserOrganization,
mockSyncWorkspaceEnvCredentials,
mockCancelPendingInvitation,
mockSendWorkspaceAddedEmail,
mockCaptureServerEvent,
mockWorkspaceMemberAdded,
} = vi.hoisted(() => ({
mockGetUserOrganization: vi.fn(),
mockSyncWorkspaceEnvCredentials: vi.fn(),
mockCancelPendingInvitation: vi.fn(),
mockSendWorkspaceAddedEmail: vi.fn(),
mockCaptureServerEvent: vi.fn(),
mockWorkspaceMemberAdded: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/billing/organizations/membership', () => ({
getUserOrganization: mockGetUserOrganization,
}))
vi.mock('@/lib/core/telemetry', () => ({
PlatformEvents: { workspaceMemberAdded: mockWorkspaceMemberAdded },
}))
vi.mock('@/lib/credentials/environment', () => ({
syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials,
}))
vi.mock('@/lib/invitations/send', () => ({
cancelPendingInvitation: mockCancelPendingInvitation,
sendWorkspaceAddedEmail: mockSendWorkspaceAddedEmail,
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: mockCaptureServerEvent,
}))
import { grantWorkspaceAccessDirectly, isSameOrgMember } from '@/lib/invitations/direct-grant'
/**
* Drives `db.select().from().where()` results in call order. Both an awaited
* `where()` and a chained `.limit()` resolve to the same per-call value.
*/
function queueWhereResponses(responses: unknown[][]) {
const queue = [...responses]
dbChainMockFns.where.mockImplementation(() => {
const result = queue.shift() ?? []
const thenable = Promise.resolve(result) as Promise<unknown[]> & {
limit: ReturnType<typeof vi.fn>
orderBy: ReturnType<typeof vi.fn>
returning: ReturnType<typeof vi.fn>
groupBy: ReturnType<typeof vi.fn>
}
thenable.limit = vi.fn(() => Promise.resolve(result))
thenable.orderBy = vi.fn(() => Promise.resolve(result))
thenable.returning = vi.fn(() => Promise.resolve(result))
thenable.groupBy = vi.fn(() => Promise.resolve(result))
return thenable as ReturnType<typeof dbChainMockFns.where>
})
}
const baseInput = {
userId: 'user-2',
email: 'Member@Example.com',
workspaceId: 'ws-1',
workspaceName: 'Workspace 1',
permission: 'write' as const,
organizationId: 'org-1',
actorId: 'user-1',
actorName: 'Owner',
actorEmail: 'owner@example.com',
}
describe('grantWorkspaceAccessDirectly', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockSendWorkspaceAddedEmail.mockResolvedValue({ success: true })
// Insert path reports the new row via `.returning()`.
dbChainMockFns.returning.mockResolvedValue([{ id: 'perm-new' }])
})
it('inserts a permission row when the user has no existing access', async () => {
const result = await grantWorkspaceAccessDirectly({ ...baseInput })
expect(result).toEqual({ outcome: 'added', permission: 'write' })
expect(dbChainMockFns.insert).toHaveBeenCalled()
expect(dbChainMockFns.update).not.toHaveBeenCalled()
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: 'member.added', resourceId: 'ws-1' })
)
expect(mockWorkspaceMemberAdded).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: 'ws-1' })
)
expect(mockSendWorkspaceAddedEmail).toHaveBeenCalledWith(
expect.objectContaining({ email: 'member@example.com', workspaceId: 'ws-1' })
)
})
it('reports unchanged (no audit/email) when a concurrent insert wins the race', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([])
const result = await grantWorkspaceAccessDirectly({ ...baseInput })
expect(result).toEqual({ outcome: 'unchanged', permission: 'write' })
expect(dbChainMockFns.insert).toHaveBeenCalled()
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled()
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
})
it('does not upgrade an existing lower permission (invites never modify access)', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'read' }])
const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'admin' })
expect(result).toEqual({ outcome: 'unchanged', permission: 'read' })
expect(dbChainMockFns.update).not.toHaveBeenCalled()
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
})
it('no-ops when the user already has access', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'admin' }])
const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'write' })
expect(result).toEqual({ outcome: 'unchanged', permission: 'admin' })
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
expect(dbChainMockFns.update).not.toHaveBeenCalled()
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled()
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
})
it('skips the email when notify is false', async () => {
const result = await grantWorkspaceAccessDirectly({ ...baseInput, notify: false })
expect(result.outcome).toBe('added')
expect(auditMockFns.mockRecordAudit).toHaveBeenCalled()
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
})
it('syncs workspace env credentials when env variables exist', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([]) // existing permission lookup
.mockResolvedValueOnce([{ variables: { API_KEY: 'x', BASE_URL: 'y' } }]) // env lookup
await grantWorkspaceAccessDirectly({ ...baseInput })
expect(mockSyncWorkspaceEnvCredentials).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'ws-1',
actingUserId: 'user-2',
envKeys: ['API_KEY', 'BASE_URL'],
})
)
})
it('supersedes lingering pending workspace invitations for the same email', async () => {
queueWhereResponses([
[], // existing permission lookup (transaction)
[{ invitationId: 'old-inv' }], // supersede lookup
[], // env lookup
])
await grantWorkspaceAccessDirectly({ ...baseInput })
expect(mockCancelPendingInvitation).toHaveBeenCalledWith('old-inv')
})
})
describe('isSameOrgMember', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('returns false when the workspace has no organization', async () => {
expect(await isSameOrgMember('user-2', null)).toBe(false)
expect(mockGetUserOrganization).not.toHaveBeenCalled()
})
it('returns false when the user belongs to no organization', async () => {
mockGetUserOrganization.mockResolvedValueOnce(null)
expect(await isSameOrgMember('user-2', 'org-1')).toBe(false)
})
it('returns true when the user belongs to the workspace organization', async () => {
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' })
expect(await isSameOrgMember('user-2', 'org-1')).toBe(true)
})
it('returns false when the user belongs to a different organization', async () => {
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' })
expect(await isSameOrgMember('user-2', 'org-1')).toBe(false)
})
})
+235
View File
@@ -0,0 +1,235 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import {
invitation,
invitationWorkspaceGrant,
permissions,
workspaceEnvironment,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getUserOrganization } from '@/lib/billing/organizations/membership'
import { PlatformEvents } from '@/lib/core/telemetry'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { cancelPendingInvitation, sendWorkspaceAddedEmail } from '@/lib/invitations/send'
import { captureServerEvent } from '@/lib/posthog/server'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('InvitationDirectGrant')
export type DirectGrantOutcome =
| { outcome: 'added'; permission: PermissionType }
| { outcome: 'unchanged'; permission: PermissionType }
export interface GrantWorkspaceAccessDirectlyInput {
/** Registered user receiving access. */
userId: string
/** Invitee email (used for notification + audit; normalized internally). */
email: string
workspaceId: string
workspaceName: string
permission: PermissionType
/** Organization that owns the workspace. */
organizationId: string
actorId: string
actorName: string
actorEmail?: string | null
request?: NextRequest
/** Send the lightweight "you've been added" email. Defaults to true. */
notify?: boolean
}
/**
* Returns whether the given user is already a member of the workspace's
* organization. Only same-org members are eligible for direct (no-acceptance)
* workspace access.
*/
export async function isSameOrgMember(
userId: string,
workspaceOrganizationId: string | null
): Promise<boolean> {
if (!workspaceOrganizationId) return false
const membership = await getUserOrganization(userId)
return !!membership && membership.organizationId === workspaceOrganizationId
}
/**
* Cancels any pending single-workspace invitations that grant exactly this
* workspace to this email. Multi-workspace organization invitations are left
* untouched — their remaining grants stay valid and the accept flow upserts
* permissions idempotently.
*/
async function supersedePendingWorkspaceInvites(
workspaceId: string,
normalizedEmail: string
): Promise<void> {
const rows = await db
.select({ invitationId: invitation.id })
.from(invitation)
.innerJoin(invitationWorkspaceGrant, eq(invitationWorkspaceGrant.invitationId, invitation.id))
.where(
and(
eq(invitation.kind, 'workspace'),
eq(invitation.email, normalizedEmail),
eq(invitation.status, 'pending'),
eq(invitationWorkspaceGrant.workspaceId, workspaceId)
)
)
for (const row of rows) {
await cancelPendingInvitation(row.invitationId)
}
}
/**
* Grants a user workspace access immediately, without an invitation or
* acceptance step. Intended for users who already belong to the workspace's
* organization and are not yet members of the workspace. Idempotent: when a
* permission already exists it is left untouched (no-op) — invites never modify
* or upgrade an existing member's permission.
*/
export async function grantWorkspaceAccessDirectly(
input: GrantWorkspaceAccessDirectlyInput
): Promise<DirectGrantOutcome> {
const normalizedEmail = normalizeEmail(input.email)
const result = await db.transaction(async (tx): Promise<DirectGrantOutcome> => {
const [existing] = await tx
.select({ id: permissions.id, permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.entityId, input.workspaceId),
eq(permissions.entityType, 'workspace'),
eq(permissions.userId, input.userId)
)
)
.limit(1)
if (existing) {
return { outcome: 'unchanged', permission: existing.permissionType as PermissionType }
}
const inserted = await tx
.insert(permissions)
.values({
id: generateId(),
entityType: 'workspace',
entityId: input.workspaceId,
userId: input.userId,
permissionType: input.permission,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoNothing()
.returning({ id: permissions.id })
if (inserted.length === 0) {
return { outcome: 'unchanged', permission: input.permission }
}
return { outcome: 'added', permission: input.permission }
})
if (result.outcome === 'unchanged') {
return result
}
try {
await supersedePendingWorkspaceInvites(input.workspaceId, normalizedEmail)
} catch (error) {
logger.error('Failed to supersede pending workspace invitations after direct grant', {
workspaceId: input.workspaceId,
error,
})
}
try {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, input.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: input.workspaceId,
envKeys: wsEnvKeys,
actingUserId: input.userId,
})
}
} catch (error) {
logger.error('Failed to sync workspace env credentials after direct grant', {
workspaceId: input.workspaceId,
userId: input.userId,
error,
})
}
try {
PlatformEvents.workspaceMemberAdded({
workspaceId: input.workspaceId,
addedBy: input.actorId,
addedUserId: input.userId,
role: input.permission,
})
} catch {
/**
* Telemetry must not fail the grant.
*/
}
captureServerEvent(
input.actorId,
'workspace_member_added',
{
workspace_id: input.workspaceId,
member_role: input.permission,
},
{
groups: { workspace: input.workspaceId },
}
)
recordAudit({
workspaceId: input.workspaceId,
actorId: input.actorId,
actorName: input.actorName,
actorEmail: input.actorEmail,
action: AuditAction.MEMBER_ADDED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: input.workspaceId,
resourceName: normalizedEmail,
description: `Added existing organization member ${normalizedEmail} as ${input.permission}`,
metadata: {
targetEmail: normalizedEmail,
targetRole: input.permission,
organizationId: input.organizationId,
workspaceName: input.workspaceName,
addedUserId: input.userId,
},
request: input.request,
})
if (input.notify ?? true) {
try {
await sendWorkspaceAddedEmail({
email: normalizedEmail,
inviterName: input.actorName,
workspaceId: input.workspaceId,
workspaceName: input.workspaceName,
})
} catch (error) {
logger.error('Failed to send workspace added email', {
workspaceId: input.workspaceId,
email: normalizedEmail,
error,
})
}
}
return result
}
+356
View File
@@ -0,0 +1,356 @@
import { db } from '@sim/db'
import {
type InvitationKind,
type InvitationMembershipIntent,
invitation,
invitationWorkspaceGrant,
organization,
workspace,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray, ne, sql } from 'drizzle-orm'
import {
getEmailSubject,
renderBatchInvitationEmail,
renderInvitationEmail,
renderWorkspaceAddedEmail,
renderWorkspaceInvitationEmail,
} from '@/components/emails'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { computeInvitationExpiry } from '@/lib/invitations/core'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { getBrandConfig } from '@/ee/whitelabeling'
const logger = createLogger('InvitationSend')
interface WorkspaceGrantInput {
workspaceId: string
permission: 'admin' | 'write' | 'read'
}
export interface CreatePendingInvitationInput {
kind: InvitationKind
email: string
inviterId: string
organizationId: string | null
membershipIntent?: InvitationMembershipIntent
role: 'admin' | 'member'
grants: WorkspaceGrantInput[]
expiresAt?: Date
}
export interface CreatePendingInvitationResult {
invitationId: string
token: string
expiresAt: Date
}
export async function createPendingInvitation(
input: CreatePendingInvitationInput
): Promise<CreatePendingInvitationResult> {
const invitationId = generateId()
const token = generateId()
const expiresAt = input.expiresAt ?? computeInvitationExpiry()
const now = new Date()
await db.transaction(async (tx) => {
await tx.insert(invitation).values({
id: invitationId,
kind: input.kind,
email: normalizeEmail(input.email),
inviterId: input.inviterId,
organizationId: input.organizationId,
membershipIntent: input.membershipIntent ?? 'internal',
role: input.role,
status: 'pending',
token,
expiresAt,
createdAt: now,
updatedAt: now,
})
for (const grant of input.grants) {
await tx.insert(invitationWorkspaceGrant).values({
id: generateId(),
invitationId,
workspaceId: grant.workspaceId,
permission: grant.permission,
createdAt: now,
updatedAt: now,
})
}
})
return { invitationId, token, expiresAt }
}
async function countPendingInvitationsForOrganization(organizationId: string): Promise<number> {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(invitation)
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending'),
ne(invitation.membershipIntent, 'external')
)
)
return row?.count ?? 0
}
async function findPendingInvitationByOrgEmail(params: {
organizationId: string | null
email: string
}) {
const normalized = normalizeEmail(params.email)
if (params.organizationId) {
const [row] = await db
.select()
.from(invitation)
.where(
and(
eq(invitation.organizationId, params.organizationId),
eq(invitation.email, normalized),
eq(invitation.status, 'pending')
)
)
.limit(1)
return row ?? null
}
const [row] = await db
.select()
.from(invitation)
.where(
and(
sql`${invitation.organizationId} IS NULL`,
eq(invitation.email, normalized),
eq(invitation.status, 'pending')
)
)
.limit(1)
return row ?? null
}
export async function findPendingGrantForWorkspaceEmail(params: {
workspaceId: string
email: string
}) {
const normalized = normalizeEmail(params.email)
const [row] = await db
.select({
invitationId: invitation.id,
grantId: invitationWorkspaceGrant.id,
})
.from(invitationWorkspaceGrant)
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
.where(
and(
eq(invitationWorkspaceGrant.workspaceId, params.workspaceId),
eq(invitation.email, normalized),
eq(invitation.status, 'pending')
)
)
.limit(1)
return row ?? null
}
export async function cancelPendingInvitation(invitationId: string): Promise<void> {
await db
.update(invitation)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(and(eq(invitation.id, invitationId), eq(invitation.status, 'pending')))
}
export interface SendInvitationEmailInput {
invitationId: string
token: string
kind: InvitationKind
email: string
inviterName: string
organizationId: string | null
organizationRole: 'admin' | 'member'
grants: WorkspaceGrantInput[]
}
export interface SendInvitationEmailResult {
success: boolean
error?: string
}
export async function sendInvitationEmail(
input: SendInvitationEmailInput
): Promise<SendInvitationEmailResult> {
const inviteUrl = `${getBaseUrl()}/invite/${input.invitationId}?token=${input.token}`
if (input.kind === 'workspace') {
if (input.grants.length === 0) {
return { success: false, error: 'Workspace invitation is missing a workspace grant' }
}
const grantWorkspaceIds = input.grants.map((grant) => grant.workspaceId)
const workspaceRows = await db
.select({ id: workspace.id, name: workspace.name })
.from(workspace)
.where(inArray(workspace.id, grantWorkspaceIds))
const workspaceNames = grantWorkspaceIds.map(
(id) => workspaceRows.find((row) => row.id === id)?.name || 'a workspace'
)
const emailHtml = await renderWorkspaceInvitationEmail(
input.inviterName,
workspaceNames,
inviteUrl
)
const brandName = getBrandConfig().name
const subject =
workspaceNames.length === 1
? `You've been invited to join "${workspaceNames[0]}" on ${brandName}`
: `You've been invited to join ${workspaceNames.length} workspaces on ${brandName}`
const result = await sendEmail({
to: input.email,
subject,
html: emailHtml,
from: getFromEmailAddress(),
emailType: 'transactional',
})
if (!result.success) {
return { success: false, error: result.message }
}
return { success: true }
}
if (!input.organizationId) {
return { success: false, error: 'Organization invitation missing organization id' }
}
const [orgRow] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, input.organizationId))
.limit(1)
const organizationName = orgRow?.name || 'organization'
if (input.grants.length > 0) {
const workspaceIds = input.grants.map((grant) => grant.workspaceId)
const workspaceRows = await db
.select({ id: workspace.id, name: workspace.name })
.from(workspace)
.where(inArray(workspace.id, workspaceIds))
const grantPayloads = input.grants.map((grant) => ({
workspaceId: grant.workspaceId,
workspaceName:
workspaceRows.find((row) => row.id === grant.workspaceId)?.name || 'Unknown Workspace',
permission: grant.permission,
}))
const emailHtml = await renderBatchInvitationEmail(
input.inviterName,
organizationName,
input.organizationRole,
grantPayloads,
inviteUrl
)
const result = await sendEmail({
to: input.email,
subject: getEmailSubject('batch-invitation'),
html: emailHtml,
emailType: 'transactional',
})
if (!result.success) {
return { success: false, error: result.message }
}
return { success: true }
}
const emailHtml = await renderInvitationEmail(input.inviterName, organizationName, inviteUrl)
const result = await sendEmail({
to: input.email,
subject: getEmailSubject('invitation'),
html: emailHtml,
emailType: 'transactional',
})
if (!result.success) {
return { success: false, error: result.message }
}
return { success: true }
}
export interface SendWorkspaceAddedEmailInput {
email: string
inviterName: string
workspaceId: string
workspaceName: string
}
/**
* Lightweight notification sent when an existing organization member is added
* directly to a workspace. Unlike an invitation email, this links straight to
* the workspace and has no acceptance step.
*/
export async function sendWorkspaceAddedEmail(
input: SendWorkspaceAddedEmailInput
): Promise<SendInvitationEmailResult> {
const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}/home`
const emailHtml = await renderWorkspaceAddedEmail(
input.inviterName,
input.workspaceName,
workspaceLink
)
const result = await sendEmail({
to: input.email,
subject: getEmailSubject('workspace-added'),
html: emailHtml,
from: getFromEmailAddress(),
emailType: 'transactional',
})
if (!result.success) {
return { success: false, error: result.message }
}
return { success: true }
}
export async function prepareInvitationResend(params: {
invitationId: string
rotateToken?: boolean
currentToken: string
}): Promise<{ tokenForEmail: string; nextExpiresAt: Date; nextToken: string | null }> {
const nextExpiresAt = computeInvitationExpiry()
const nextToken = params.rotateToken ? generateId() : null
const tokenForEmail = nextToken ?? params.currentToken
return { tokenForEmail, nextExpiresAt, nextToken }
}
export async function persistInvitationResend(params: {
invitationId: string
nextToken: string | null
nextExpiresAt: Date
}): Promise<void> {
const [row] = await db
.update(invitation)
.set({
expiresAt: params.nextExpiresAt,
updatedAt: new Date(),
...(params.nextToken ? { token: params.nextToken } : {}),
})
.where(and(eq(invitation.id, params.invitationId), eq(invitation.status, 'pending')))
.returning({ id: invitation.id })
if (!row) {
throw new Error(`Invitation ${params.invitationId} not found or no longer pending`)
}
logger.info('Persisted invitation resend', {
invitationId: params.invitationId,
rotated: !!params.nextToken,
})
}
@@ -0,0 +1,231 @@
/**
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetUserOrganization,
mockValidateSeatAvailability,
mockGrantWorkspaceAccessDirectly,
mockCreatePendingInvitation,
mockSendInvitationEmail,
mockCancelPendingInvitation,
mockFindPendingGrantForWorkspaceEmail,
mockWorkspaceMemberInvited,
mockCaptureServerEvent,
} = vi.hoisted(() => ({
mockGetUserOrganization: vi.fn(),
mockValidateSeatAvailability: vi.fn(),
mockGrantWorkspaceAccessDirectly: vi.fn(),
mockCreatePendingInvitation: vi.fn(),
mockSendInvitationEmail: vi.fn(),
mockCancelPendingInvitation: vi.fn(),
mockFindPendingGrantForWorkspaceEmail: vi.fn(),
mockWorkspaceMemberInvited: vi.fn(),
mockCaptureServerEvent: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/billing/organizations/membership', () => ({
getUserOrganization: mockGetUserOrganization,
}))
vi.mock('@/lib/billing/validation/seat-management', () => ({
validateSeatAvailability: mockValidateSeatAvailability,
}))
vi.mock('@/lib/core/telemetry', () => ({
PlatformEvents: { workspaceMemberInvited: mockWorkspaceMemberInvited },
}))
vi.mock('@/lib/invitations/direct-grant', () => ({
grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly,
}))
vi.mock('@/lib/invitations/send', () => ({
createPendingInvitation: mockCreatePendingInvitation,
sendInvitationEmail: mockSendInvitationEmail,
cancelPendingInvitation: mockCancelPendingInvitation,
findPendingGrantForWorkspaceEmail: mockFindPendingGrantForWorkspaceEmail,
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: mockCaptureServerEvent,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getWorkspaceWithOwner: vi.fn(),
}))
vi.mock('@/lib/workspaces/policy', () => ({
getWorkspaceInvitePolicy: vi.fn(),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
validateInvitationsAllowed: vi.fn(),
}))
import { createWorkspaceInvitation } from '@/lib/invitations/workspace-invitations'
function queueWhereResponses(responses: unknown[][]) {
const queue = [...responses]
dbChainMockFns.where.mockImplementation(() => {
const result = queue.shift() ?? []
const thenable = Promise.resolve(result) as Promise<unknown[]> & {
limit: ReturnType<typeof vi.fn>
}
thenable.limit = vi.fn(() => Promise.resolve(result))
return thenable as ReturnType<typeof dbChainMockFns.where>
})
}
function makeContext() {
return {
workspaceId: 'ws-1',
inviterId: 'user-1',
inviterName: 'Owner',
inviterEmail: 'owner@example.com',
workspaceDetails: {
id: 'ws-1',
name: 'Workspace 1',
ownerId: 'user-1',
organizationId: 'org-1',
billedAccountUserId: 'user-1',
},
invitePolicy: {
allowed: true,
reason: null,
requiresSeat: false,
organizationId: 'org-1',
upgradeRequired: false,
},
// The function only reads the fields above at runtime.
} as Parameters<typeof createWorkspaceInvitation>[0]['context']
}
const request = createMockRequest(
'POST',
{},
{},
'http://localhost/api/workspaces/invitations/batch'
)
describe('createWorkspaceInvitation', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' })
mockCreatePendingInvitation.mockResolvedValue({ invitationId: 'inv-1', token: 'tok-1' })
mockSendInvitationEmail.mockResolvedValue({ success: true })
mockFindPendingGrantForWorkspaceEmail.mockResolvedValue(null)
})
it('directly grants access to an existing member of the workspace organization', async () => {
queueWhereResponses([[{ id: 'user-2', email: 'member@example.com' }], []])
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' })
const result = await createWorkspaceInvitation({
context: makeContext(),
email: 'member@example.com',
permission: 'write',
request,
})
expect(result.instantAdd).toBe(true)
expect(result.outcome).toBe('added')
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-2',
workspaceId: 'ws-1',
permission: 'write',
organizationId: 'org-1',
})
)
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
})
it('rejects an existing workspace member without upgrading their permission', async () => {
queueWhereResponses([
[{ id: 'user-2', email: 'member@example.com' }],
[{ id: 'perm-1', permissionType: 'read' }],
])
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' })
await expect(
createWorkspaceInvitation({
context: makeContext(),
email: 'member@example.com',
permission: 'admin',
request,
})
).rejects.toThrow('already has access')
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
})
it('creates an external pending invitation when the user belongs to a different org', async () => {
queueWhereResponses([[{ id: 'user-3', email: 'ext@example.com' }], []])
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' })
const result = await createWorkspaceInvitation({
context: makeContext(),
email: 'ext@example.com',
permission: 'read',
request,
})
expect(result.instantAdd).toBeFalsy()
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'workspace', membershipIntent: 'external' })
)
expect(mockSendInvitationEmail).toHaveBeenCalled()
})
it('creates an internal pending invitation when the registered user has no org', async () => {
queueWhereResponses([[{ id: 'user-4', email: 'noorg@example.com' }], []])
mockGetUserOrganization.mockResolvedValueOnce(null)
const result = await createWorkspaceInvitation({
context: makeContext(),
email: 'noorg@example.com',
permission: 'write',
request,
})
expect(result.instantAdd).toBeFalsy()
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'workspace', membershipIntent: 'internal' })
)
})
it('creates a pending invitation for a brand-new email', async () => {
queueWhereResponses([[]])
const result = await createWorkspaceInvitation({
context: makeContext(),
email: 'new@example.com',
permission: 'read',
request,
})
expect(result.instantAdd).toBeFalsy()
expect(mockGetUserOrganization).not.toHaveBeenCalled()
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'workspace', membershipIntent: 'internal' })
)
})
})
@@ -0,0 +1,345 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { type InvitationMembershipIntent, permissions, user } from '@sim/db/schema'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getUserOrganization } from '@/lib/billing/organizations/membership'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
import { PlatformEvents } from '@/lib/core/telemetry'
import {
type DirectGrantOutcome,
grantWorkspaceAccessDirectly,
} from '@/lib/invitations/direct-grant'
import {
cancelPendingInvitation,
createPendingInvitation,
findPendingGrantForWorkspaceEmail,
sendInvitationEmail,
} from '@/lib/invitations/send'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getWorkspaceWithOwner,
hasWorkspaceAdminAccess,
type PermissionType,
type WorkspaceWithOwner,
} from '@/lib/workspaces/permissions/utils'
import { getWorkspaceInvitePolicy, type WorkspaceInvitePolicy } from '@/lib/workspaces/policy'
import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check'
export interface WorkspaceInvitationContext {
workspaceId: string
inviterId: string
inviterName: string
inviterEmail?: string | null
workspaceDetails: WorkspaceWithOwner
invitePolicy: WorkspaceInvitePolicy
}
export interface WorkspaceInvitationResult {
id: string
workspaceId: string
email: string
permission: PermissionType
membershipIntent: InvitationMembershipIntent
expiresAt: Date | undefined
/** True when the user was granted access directly (no pending invitation). */
instantAdd?: boolean
/** Direct-grant outcome when `instantAdd` is true. */
outcome?: DirectGrantOutcome['outcome']
}
export class WorkspaceInvitationError extends Error {
status: number
email?: string
upgradeRequired?: boolean
constructor({
message,
status,
email,
upgradeRequired,
}: {
message: string
status: number
email?: string
upgradeRequired?: boolean
}) {
super(message)
this.name = 'WorkspaceInvitationError'
this.status = status
this.email = email
this.upgradeRequired = upgradeRequired
}
}
export async function prepareWorkspaceInvitationContext({
workspaceId,
inviterId,
inviterName,
inviterEmail,
}: {
workspaceId: string
inviterId: string
inviterName: string
inviterEmail?: string | null
}): Promise<WorkspaceInvitationContext> {
await validateInvitationsAllowed(inviterId, workspaceId)
const isAdmin = await hasWorkspaceAdminAccess(inviterId, workspaceId)
if (!isAdmin) {
throw new WorkspaceInvitationError({
message: 'You need admin permissions to invite users',
status: 403,
})
}
const workspaceDetails = await getWorkspaceWithOwner(workspaceId)
if (!workspaceDetails) {
throw new WorkspaceInvitationError({ message: 'Workspace not found', status: 404 })
}
const invitePolicy = await getWorkspaceInvitePolicy(workspaceDetails)
if (!invitePolicy.allowed) {
throw new WorkspaceInvitationError({
message: invitePolicy.reason ?? 'Invites are disabled for this workspace.',
status: 403,
upgradeRequired: invitePolicy.upgradeRequired,
})
}
return {
workspaceId,
inviterId,
inviterName,
inviterEmail,
workspaceDetails,
invitePolicy,
}
}
export async function createWorkspaceInvitation({
context,
email,
permission = 'read',
request,
}: {
context: WorkspaceInvitationContext
email: string
permission?: string
request: NextRequest
}): Promise<WorkspaceInvitationResult> {
const validPermissions: PermissionType[] = ['admin', 'write', 'read']
if (!validPermissions.includes(permission as PermissionType)) {
throw new WorkspaceInvitationError({
message: `Invalid permission: must be one of ${validPermissions.join(', ')}`,
status: 400,
email,
})
}
const invitationPermission = permission as PermissionType
const normalizedEmail = normalizeEmail(email)
let membershipIntent: InvitationMembershipIntent = 'internal'
const existingUser = await db
.select()
.from(user)
.where(sql`lower(${user.email}) = ${normalizedEmail}`)
.then((rows) => rows[0])
if (existingUser) {
const workspaceOrganizationId = context.workspaceDetails.organizationId
const existingMembership = workspaceOrganizationId
? await getUserOrganization(existingUser.id)
: null
const existingPermission = await db
.select()
.from(permissions)
.where(
and(
eq(permissions.entityId, context.workspaceId),
eq(permissions.entityType, 'workspace'),
eq(permissions.userId, existingUser.id)
)
)
.then((rows) => rows[0])
/**
* Already a workspace member: reject. Invites never change an existing
* member's permission — role changes go through the members list, not the
* invite flow. (The client also blocks re-inviting current teammates.)
*/
if (existingPermission) {
throw new WorkspaceInvitationError({
message: `${normalizedEmail} already has access to this workspace`,
status: 400,
email: normalizedEmail,
})
}
/**
* Invitee already belongs to the workspace's organization (and is not yet a
* member of this workspace): grant access directly, with no invitation or
* acceptance step.
*/
if (
workspaceOrganizationId &&
existingMembership &&
existingMembership.organizationId === workspaceOrganizationId
) {
const directGrant = await grantWorkspaceAccessDirectly({
userId: existingUser.id,
email: normalizedEmail,
workspaceId: context.workspaceId,
workspaceName: context.workspaceDetails.name,
permission: invitationPermission,
organizationId: workspaceOrganizationId,
actorId: context.inviterId,
actorName: context.inviterName,
actorEmail: context.inviterEmail,
request,
})
return {
id: existingUser.id,
workspaceId: context.workspaceId,
email: normalizedEmail,
permission: invitationPermission,
membershipIntent: 'internal',
expiresAt: undefined,
instantAdd: true,
outcome: directGrant.outcome,
}
}
if (workspaceOrganizationId) {
if (existingMembership && existingMembership.organizationId !== workspaceOrganizationId) {
membershipIntent = 'external'
} else if (context.invitePolicy.requiresSeat && !existingMembership) {
const seatValidation = await validateSeatAvailability(workspaceOrganizationId, 1)
if (!seatValidation.canInvite) {
throw new WorkspaceInvitationError({
message: seatValidation.reason || 'No available seats for this organization.',
status: 400,
email: normalizedEmail,
})
}
}
}
} else if (context.invitePolicy.requiresSeat && context.invitePolicy.organizationId) {
const seatValidation = await validateSeatAvailability(context.invitePolicy.organizationId, 1)
if (!seatValidation.canInvite) {
throw new WorkspaceInvitationError({
message: seatValidation.reason || 'No available seats for this organization.',
status: 400,
email: normalizedEmail,
})
}
}
const existingInvitation = await findPendingGrantForWorkspaceEmail({
workspaceId: context.workspaceId,
email: normalizedEmail,
})
if (existingInvitation) {
throw new WorkspaceInvitationError({
message: `${normalizedEmail} has already been invited to this workspace`,
status: 400,
email: normalizedEmail,
})
}
const { invitationId, token } = await createPendingInvitation({
kind: 'workspace',
email: normalizedEmail,
inviterId: context.inviterId,
organizationId: context.workspaceDetails.organizationId,
membershipIntent,
role: 'member',
grants: [
{
workspaceId: context.workspaceId,
permission: invitationPermission,
},
],
})
try {
PlatformEvents.workspaceMemberInvited({
workspaceId: context.workspaceId,
invitedBy: context.inviterId,
inviteeEmail: normalizedEmail,
role: invitationPermission,
membershipIntent,
})
} catch {
/**
* Telemetry must not fail invitation creation.
*/
}
captureServerEvent(
context.inviterId,
'workspace_member_invited',
{
workspace_id: context.workspaceId,
invitee_role: invitationPermission,
membership_intent: membershipIntent,
},
{
groups: { workspace: context.workspaceId },
setOnce: { first_invitation_sent_at: new Date().toISOString() },
}
)
const emailResult = await sendInvitationEmail({
invitationId,
token,
kind: 'workspace',
email: normalizedEmail,
inviterName: context.inviterName,
organizationId: context.workspaceDetails.organizationId,
organizationRole: 'member',
grants: [{ workspaceId: context.workspaceId, permission: invitationPermission }],
})
if (!emailResult.success) {
await cancelPendingInvitation(invitationId)
throw new WorkspaceInvitationError({
message: emailResult.error || 'Failed to send invitation email',
status: 502,
email: normalizedEmail,
})
}
recordAudit({
workspaceId: context.workspaceId,
actorId: context.inviterId,
actorName: context.inviterName,
actorEmail: context.inviterEmail,
action: AuditAction.MEMBER_INVITED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: context.workspaceId,
resourceName: normalizedEmail,
description: `Invited ${normalizedEmail} as ${invitationPermission}`,
metadata: {
targetEmail: normalizedEmail,
targetRole: invitationPermission,
membershipIntent,
workspaceName: context.workspaceDetails.name,
invitationId,
},
request,
})
return {
id: invitationId,
workspaceId: context.workspaceId,
email: normalizedEmail,
permission: invitationPermission,
membershipIntent,
expiresAt: undefined,
}
}