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
+134
View File
@@ -0,0 +1,134 @@
import { randomItem } from '@sim/utils/random'
import { hexToRgb } from '@/lib/colors'
/** Color palette for workspace accents. */
export const WORKSPACE_COLORS = [
'#2ABBF8', // Blue
'#22c55e', // Green
'#FFCC02', // Yellow
'#a855f7', // Purple
'#f97316', // Orange
'#14b8a6', // Teal
'#ff6b6b', // Coral
] as const
/** Picks a random workspace color from the hero palette. */
export function getRandomWorkspaceColor(): string {
return randomItem(WORKSPACE_COLORS)
}
const APP_COLORS = [
{ from: '#4F46E5', to: '#7C3AED' }, // indigo to purple
{ from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia
{ from: '#EC4899', to: '#F97316' }, // pink to orange
{ from: '#14B8A6', to: '#10B981' }, // teal to emerald
{ from: '#6366F1', to: '#8B5CF6' }, // indigo to violet
{ from: '#F59E0B', to: '#F97316' }, // amber to orange
]
/**
* User color palette matching terminal.tsx RUN_ID_COLORS
* These colors are used consistently across cursors, avatars, and terminal run IDs
*/
export const USER_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#FCD34D', // Yellow
] as const
interface PresenceColorPalette {
gradient: string
accentColor: string
baseColor: string
}
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/
function hashIdentifier(identifier: string | number): number {
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
return Math.abs(Math.trunc(identifier))
}
if (typeof identifier === 'string') {
return Math.abs(Array.from(identifier).reduce((acc, char) => acc + char.charCodeAt(0), 0))
}
return 0
}
function withAlpha(hexColor: string, alpha: number): string {
if (!HEX_COLOR_REGEX.test(hexColor)) {
return hexColor
}
const { r, g, b } = hexToRgb(hexColor)
return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})`
}
function buildGradient(fromColor: string, toColor: string, rotationSeed: number): string {
const rotation = (rotationSeed * 25) % 360
return `linear-gradient(${rotation}deg, ${fromColor}, ${toColor})`
}
export function getPresenceColors(
identifier: string | number,
explicitColor?: string
): PresenceColorPalette {
const paletteIndex = hashIdentifier(identifier)
if (explicitColor) {
const normalizedColor = explicitColor.trim()
const lighterShade = HEX_COLOR_REGEX.test(normalizedColor)
? withAlpha(normalizedColor, 0.85)
: normalizedColor
return {
gradient: buildGradient(lighterShade, normalizedColor, paletteIndex),
accentColor: normalizedColor,
baseColor: lighterShade,
}
}
const colorPair = APP_COLORS[paletteIndex % APP_COLORS.length]
return {
gradient: buildGradient(colorPair.from, colorPair.to, paletteIndex),
accentColor: colorPair.to,
baseColor: colorPair.from,
}
}
/**
* Gets a consistent color for a user based on their ID.
* The same user will always get the same color across cursors, avatars, and terminal.
*
* @param userId - The unique user identifier
* @returns A hex color string
*/
export function getUserColor(userId: string): string {
const hash = hashIdentifier(userId)
return USER_COLORS[hash % USER_COLORS.length]
}
/**
* Creates a stable mapping of user IDs to color indices for a list of users.
* Useful when you need to maintain consistent color assignments across renders.
*
* @param userIds - Array of user IDs to map
* @returns Map of user ID to color index
*/
export function createUserColorMap(userIds: string[]): Map<string, number> {
const colorMap = new Map<string, number>()
let colorIndex = 0
for (const userId of userIds) {
if (!colorMap.has(userId)) {
colorMap.set(userId, colorIndex++)
}
}
return colorMap
}
+104
View File
@@ -0,0 +1,104 @@
/**
* @vitest-environment node
*/
import { permissionsMock, permissionsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSelect, mockTransaction, mockArchiveWorkflowsForWorkspace } = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockTransaction: vi.fn(),
mockArchiveWorkflowsForWorkspace: vi.fn(),
}))
const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
transaction: mockTransaction,
},
}))
vi.mock('@/lib/workflows/lifecycle', () => ({
archiveWorkflowsForWorkspace: (...args: unknown[]) => mockArchiveWorkflowsForWorkspace(...args),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
import { archiveWorkspace } from './lifecycle'
function createUpdateChain() {
return {
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}
}
describe('workspace lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('archives workspace and dependent resources', async () => {
mockGetWorkspaceWithOwner.mockResolvedValue({
id: 'workspace-1',
name: 'Workspace 1',
ownerId: 'user-1',
archivedAt: null,
})
mockArchiveWorkflowsForWorkspace.mockResolvedValue(2)
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'server-1' }]),
}),
})
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]),
}),
}),
update: vi.fn().mockImplementation(() => createUpdateChain()),
delete: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
}
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
callback(tx)
)
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
expect(result).toEqual({
archived: true,
workspaceName: 'Workspace 1',
})
expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', {
requestId: 'req-1',
})
expect(tx.update).toHaveBeenCalledTimes(10)
expect(tx.delete).toHaveBeenCalledTimes(1)
})
it('is idempotent for already archived workspaces', async () => {
mockGetWorkspaceWithOwner.mockResolvedValue({
id: 'workspace-1',
name: 'Workspace 1',
ownerId: 'user-1',
archivedAt: new Date(),
})
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
expect(result).toEqual({
archived: false,
workspaceName: 'Workspace 1',
})
expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', {
requestId: 'req-1',
})
expect(mockTransaction).not.toHaveBeenCalled()
})
})
+193
View File
@@ -0,0 +1,193 @@
import { db } from '@sim/db'
import {
apiKey,
document,
invitation,
invitationWorkspaceGrant,
knowledgeBase,
knowledgeConnector,
mcpServers,
userTableDefinitions,
workflowMcpServer,
workflowSchedule,
workspace,
workspaceFiles,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { mcpService } from '@/lib/mcp/service'
import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceLifecycle')
interface ArchiveWorkspaceOptions {
requestId: string
}
export async function archiveWorkspace(
workspaceId: string,
options: ArchiveWorkspaceOptions
): Promise<{ archived: boolean; workspaceName?: string }> {
const workspaceRecord = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
if (!workspaceRecord) {
return { archived: false }
}
if (workspaceRecord.archivedAt) {
await archiveWorkflowsForWorkspace(workspaceId, options)
return { archived: false, workspaceName: workspaceRecord.name }
}
const now = new Date()
const workflowMcpServerIds = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(eq(workflowMcpServer.workspaceId, workspaceId))
await db.transaction(async (tx) => {
await tx
.update(knowledgeBase)
.set({
deletedAt: now,
updatedAt: now,
})
.where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt)))
const workspaceKbIds = await tx
.select({ id: knowledgeBase.id })
.from(knowledgeBase)
.where(eq(knowledgeBase.workspaceId, workspaceId))
const knowledgeBaseIds = workspaceKbIds.map((entry) => entry.id)
if (knowledgeBaseIds.length > 0) {
await tx
.update(document)
.set({ archivedAt: now })
.where(
and(
inArray(document.knowledgeBaseId, knowledgeBaseIds),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
await tx
.update(knowledgeConnector)
.set({ archivedAt: now, status: 'paused', updatedAt: now })
.where(
and(
inArray(knowledgeConnector.knowledgeBaseId, knowledgeBaseIds),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
}
await tx
.update(userTableDefinitions)
.set({
archivedAt: now,
updatedAt: now,
})
.where(
and(
eq(userTableDefinitions.workspaceId, workspaceId),
isNull(userTableDefinitions.archivedAt)
)
)
await tx
.update(workspaceFiles)
.set({
deletedAt: now,
})
.where(and(eq(workspaceFiles.workspaceId, workspaceId), isNull(workspaceFiles.deletedAt)))
await tx
.update(invitation)
.set({
status: 'cancelled',
updatedAt: now,
})
.where(
and(
eq(invitation.status, 'pending'),
sql`${invitation.id} IN (
SELECT ${invitationWorkspaceGrant.invitationId}
FROM ${invitationWorkspaceGrant}
WHERE ${invitationWorkspaceGrant.workspaceId} = ${workspaceId}
)`
)
)
await tx
.delete(apiKey)
.where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace')))
await tx
.update(workflowMcpServer)
.set({
deletedAt: now,
isPublic: false,
updatedAt: now,
})
.where(eq(workflowMcpServer.workspaceId, workspaceId))
await tx
.update(mcpServers)
.set({
deletedAt: now,
enabled: false,
updatedAt: now,
})
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
await tx
.update(workflowSchedule)
.set({
archivedAt: now,
updatedAt: now,
status: 'disabled',
nextRunAt: null,
lastQueuedAt: null,
})
.where(
and(
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
eq(workflowSchedule.sourceType, 'job'),
isNull(workflowSchedule.archivedAt)
)
)
await tx
.update(workspace)
.set({
archivedAt: now,
updatedAt: now,
})
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
})
await archiveWorkflowsForWorkspace(workspaceId, options)
logger.info(`[${options.requestId}] Archived workspace ${workspaceId}`)
await mcpService.clearCache(workspaceId).catch(() => undefined)
if (mcpPubSub && workflowMcpServerIds.length > 0) {
for (const server of workflowMcpServerIds) {
mcpPubSub.publishWorkflowToolsChanged({
serverId: server.id,
workspaceId,
})
}
}
return {
archived: true,
workspaceName: workspaceRecord.name,
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Utility functions for generating names for workspaces and folders
*/
import { randomItem } from '@sim/utils/random'
import { requestJson } from '@/lib/api/client/request'
import { type FolderApi, listFoldersContract } from '@/lib/api/contracts/folders'
interface NameableEntity {
name: string
}
const WORKSPACE_NOUNS = [
'Pulsar',
'Quasar',
'Nebula',
'Nova',
'Cosmos',
'Orion',
'Vega',
'Zenith',
'Horizon',
'Eclipse',
'Aurora',
'Photon',
'Vertex',
'Nexus',
'Solaris',
'Andromeda',
'Phoenix',
'Polaris',
'Sirius',
'Altair',
'Meridian',
'Titan',
'Apex',
'Aether',
'Voyager',
'Beacon',
'Sentinel',
'Pioneer',
'Equinox',
'Solstice',
'Corona',
'Stellar',
'Helix',
'Prism',
'Axiom',
'Boson',
'Cygnus',
'Draco',
'Lyra',
'Aquila',
'Perseus',
'Pegasus',
'Triton',
'Callisto',
'Europa',
'Oberon',
'Tachyon',
'Neutron',
'Graviton',
'Parallax',
] as const
/**
* Generates the next incremental name for entities following pattern: "{prefix} {number}"
*
* @param existingEntities - Array of entities with name property
* @param prefix - Prefix for the name (e.g., "Folder", "Subfolder")
* @returns Next available name (e.g., "Folder 3")
*/
export function generateIncrementalName<T extends NameableEntity>(
existingEntities: T[],
prefix: string
): string {
const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} (\\d+)$`)
const existingNumbers = existingEntities
.map((entity) => entity.name.match(pattern))
.filter((match) => match !== null)
.map((match) => Number.parseInt(match![1], 10))
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
return `${prefix} ${nextNumber}`
}
/**
* Generates a random cosmos-themed workspace name
*/
export function generateWorkspaceName(): string {
return randomItem(WORKSPACE_NOUNS)
}
async function fetchWorkspaceFolders(workspaceId: string): Promise<FolderApi[]> {
const { folders } = await requestJson(listFoldersContract, {
query: { workspaceId },
})
return folders
}
/**
* Generates the next folder name for a workspace
*/
export async function generateFolderName(workspaceId: string): Promise<string> {
const folders = await fetchWorkspaceFolders(workspaceId)
const rootFolders = folders.filter((folder) => folder.parentId === null)
return generateIncrementalName(rootFolders, 'Folder')
}
/**
* Generates the next subfolder name for a parent folder
*/
async function generateSubfolderName(workspaceId: string, parentFolderId: string): Promise<string> {
const folders = await fetchWorkspaceFolders(workspaceId)
const subfolders = folders.filter((folder) => folder.parentId === parentFolderId)
return generateIncrementalName(subfolders, 'Subfolder')
}
@@ -0,0 +1,239 @@
/**
* @vitest-environment node
*/
import { schemaMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockDbResults,
mockUpdateWhere,
mockUpdateSet,
mockDbUpdate,
mockOnConflictDoUpdate,
mockInsertValues,
mockDbInsert,
mockEnsureUserInOrganization,
mockSyncUsageLimitsFromSubscription,
mockReapplyPaidOrgJoinBillingForExistingMember,
} = vi.hoisted(() => {
const mockDbResults: { value: any[] } = { value: [] }
const mockUpdateWhere = vi.fn().mockResolvedValue(undefined)
const mockUpdateSet = vi.fn().mockReturnValue({ where: mockUpdateWhere })
const mockDbUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
const mockOnConflictDoUpdate = vi.fn().mockResolvedValue(undefined)
const mockInsertValues = vi.fn().mockReturnValue({
onConflictDoUpdate: mockOnConflictDoUpdate,
})
const mockDbInsert = vi.fn().mockReturnValue({ values: mockInsertValues })
const mockEnsureUserInOrganization = vi.fn()
const mockSyncUsageLimitsFromSubscription = vi.fn().mockResolvedValue(undefined)
const mockReapplyPaidOrgJoinBillingForExistingMember = vi.fn().mockResolvedValue({
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
})
return {
mockDbResults,
mockUpdateWhere,
mockUpdateSet,
mockDbUpdate,
mockOnConflictDoUpdate,
mockInsertValues,
mockDbInsert,
mockEnsureUserInOrganization,
mockSyncUsageLimitsFromSubscription,
mockReapplyPaidOrgJoinBillingForExistingMember,
}
})
vi.mock('@sim/db', () => {
const selectImpl = vi.fn().mockImplementation(() => {
const chain: any = {}
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.limit = vi
.fn()
.mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || []))
chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => {
const result = mockDbResults.value.shift() || []
return Promise.resolve(callback ? callback(result) : result)
})
return chain
})
const txObject = {
select: selectImpl,
update: mockDbUpdate,
insert: mockDbInsert,
}
return {
db: {
select: selectImpl,
update: mockDbUpdate,
insert: mockDbInsert,
transaction: vi.fn(async (fn: (tx: typeof txObject) => unknown) => fn(txObject)),
},
}
})
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('@/lib/billing/organizations/membership', () => ({
ensureUserInOrganization: mockEnsureUserInOrganization,
reapplyPaidOrgJoinBillingForExistingMember: mockReapplyPaidOrgJoinBillingForExistingMember,
}))
vi.mock('@/lib/billing/core/usage', () => ({
syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription,
}))
vi.mock('@sim/utils/id', () => ({
generateId: vi.fn().mockReturnValue('generated-id'),
generateShortId: vi.fn().mockReturnValue('short-id'),
}))
import {
attachOwnedWorkspacesToOrganization,
detachOrganizationWorkspaces,
WorkspaceOrganizationMembershipConflictError,
} from '@/lib/workspaces/organization-workspaces'
describe('organization workspace helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDbResults.value = []
mockEnsureUserInOrganization.mockReset()
mockSyncUsageLimitsFromSubscription.mockResolvedValue(undefined)
})
it('attaches owned workspaces to an organization and syncs existing members', async () => {
mockDbResults.value = [
[{ id: 'ws-1' }, { id: 'ws-2' }],
[{ userId: 'owner-1' }],
[{ userId: 'owner-1' }, { userId: 'member-1' }],
[{ userId: 'owner-1', organizationId: 'org-1' }],
]
mockEnsureUserInOrganization
.mockResolvedValueOnce({
success: true,
alreadyMember: true,
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
.mockResolvedValueOnce({
success: true,
alreadyMember: false,
memberId: 'member-1',
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
const result = await attachOwnedWorkspacesToOrganization({
ownerUserId: 'user-1',
organizationId: 'org-1',
})
expect(result.attachedWorkspaceIds).toEqual(['ws-1', 'ws-2'])
expect(result.addedMemberIds).toEqual(['member-1'])
expect(result.skippedMembers).toEqual([])
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith({
userId: 'owner-1',
organizationId: 'org-1',
role: 'owner',
skipSeatValidation: true,
})
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith({
userId: 'member-1',
organizationId: 'org-1',
role: 'member',
skipSeatValidation: true,
})
expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledWith('member-1')
expect(mockReapplyPaidOrgJoinBillingForExistingMember).toHaveBeenCalledWith('owner-1', 'org-1')
expect(mockReapplyPaidOrgJoinBillingForExistingMember).not.toHaveBeenCalledWith(
'member-1',
'org-1'
)
})
it('fails before attaching workspaces when an existing member belongs to another organization', async () => {
mockDbResults.value = [
[{ id: 'ws-1' }],
[{ userId: 'owner-1' }],
[{ userId: 'owner-1' }, { userId: 'member-2' }],
[{ userId: 'member-2', organizationId: 'org-2' }],
]
await expect(
attachOwnedWorkspacesToOrganization({
ownerUserId: 'user-1',
organizationId: 'org-1',
})
).rejects.toBeInstanceOf(WorkspaceOrganizationMembershipConflictError)
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
expect(mockDbUpdate).not.toHaveBeenCalled()
})
it('keeps cross-org members external and still attaches when policy is keep-external', async () => {
mockDbResults.value = [
[{ id: 'ws-1' }],
[{ userId: 'owner-1' }],
[{ userId: 'owner-1' }, { userId: 'member-2' }],
[{ userId: 'member-2', organizationId: 'org-2' }],
]
mockEnsureUserInOrganization.mockResolvedValueOnce({
success: true,
alreadyMember: true,
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
const result = await attachOwnedWorkspacesToOrganization({
ownerUserId: 'user-1',
organizationId: 'org-1',
externalMemberPolicy: 'keep-external',
})
expect(result.attachedWorkspaceIds).toEqual(['ws-1'])
expect(result.skippedMembers).toEqual([
{
userId: 'member-2',
reason: 'Already a member of another organization; kept as external workspace member',
},
])
expect(mockEnsureUserInOrganization).toHaveBeenCalledTimes(1)
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith({
userId: 'owner-1',
organizationId: 'org-1',
role: 'owner',
skipSeatValidation: true,
})
expect(mockEnsureUserInOrganization).not.toHaveBeenCalledWith(
expect.objectContaining({ userId: 'member-2' })
)
expect(mockDbUpdate).toHaveBeenCalled()
})
it('detaches organization workspaces into grandfathered shared mode', async () => {
mockDbResults.value = [[{ userId: 'owner-1' }], [{ id: 'ws-1', ownerId: 'creator-1' }]]
const result = await detachOrganizationWorkspaces('org-1')
expect(result.detachedWorkspaceIds).toEqual(['ws-1'])
expect(result.billedAccountUserId).toBe('owner-1')
expect(mockUpdateSet).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: null,
workspaceMode: 'grandfathered_shared',
billedAccountUserId: 'owner-1',
})
)
expect(mockOnConflictDoUpdate).toHaveBeenCalled()
})
})
@@ -0,0 +1,298 @@
import { db } from '@sim/db'
import { member, permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import {
ensureUserInOrganization,
reapplyPaidOrgJoinBillingForExistingMember,
} from '@/lib/billing/organizations/membership'
import { getOrganizationOwnerId, WORKSPACE_MODE } from '@/lib/workspaces/policy'
const logger = createLogger('OrganizationWorkspaces')
export interface AttachOwnedWorkspacesToOrganizationResult {
attachedWorkspaceIds: string[]
addedMemberIds: string[]
skippedMembers: Array<{ userId: string; reason: string }>
}
export interface DetachOrganizationWorkspacesResult {
detachedWorkspaceIds: string[]
billedAccountUserId: string | null
}
export class WorkspaceOrganizationMembershipConflictError extends Error {
conflicts: Array<{ userId: string; organizationId: string }>
constructor(conflicts: Array<{ userId: string; organizationId: string }>) {
super(
'One or more workspace members already belong to another organization and cannot be attached.'
)
this.name = 'WorkspaceOrganizationMembershipConflictError'
this.conflicts = conflicts
}
}
/**
* How to treat workspace members that already belong to a *different*
* organization when attaching workspaces:
* - `reject` (default): throw a conflict — used by manual org creation.
* - `keep-external`: skip them (they stay external workspace members) and
* attach anyway — used by the Pro→Team conversion, which must not abort
* just because a personal workspace already has an external collaborator.
*/
type ExternalMemberPolicy = 'reject' | 'keep-external'
interface AttachOwnedWorkspacesToOrganizationParams {
ownerUserId: string
organizationId: string
externalMemberPolicy?: ExternalMemberPolicy
}
export async function attachOwnedWorkspacesToOrganization({
ownerUserId,
organizationId,
externalMemberPolicy = 'reject',
}: AttachOwnedWorkspacesToOrganizationParams): Promise<AttachOwnedWorkspacesToOrganizationResult> {
const ownedWorkspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.ownerId, ownerUserId))
const billedAccountUserId = await getOrganizationOwnerId(organizationId)
if (!billedAccountUserId) {
logger.error('Attempted to attach workspaces to an organization without an owner', {
organizationId,
ownerUserId,
})
throw new Error(`Organization ${organizationId} has no owner membership`)
}
const ownedWorkspaceIds = ownedWorkspaces.map((ownedWorkspace) => ownedWorkspace.id)
const uniqueWorkspaceMemberIds = await getWorkspaceMemberIds(ownedWorkspaceIds)
const { joinableUserIds, externalConflicts } = await partitionWorkspaceMembersByOrg(
uniqueWorkspaceMemberIds,
organizationId
)
if (externalMemberPolicy === 'reject' && externalConflicts.length > 0) {
logger.warn('Workspace attachment blocked by members in another organization', {
organizationId,
conflictCount: externalConflicts.length,
conflictingUserIds: externalConflicts.map((conflict) => conflict.userId),
})
throw new WorkspaceOrganizationMembershipConflictError(externalConflicts)
}
const skippedMembers = externalConflicts.map((conflict) => ({
userId: conflict.userId,
reason: 'Already a member of another organization; kept as external workspace member',
}))
const addedMemberIds: string[] = []
for (const userId of joinableUserIds) {
const result = await ensureUserInOrganization({
userId,
organizationId,
role: userId === billedAccountUserId ? 'owner' : 'member',
skipSeatValidation: true,
})
if (!result.success) {
logger.error('Failed to sync workspace member into organization before attachment', {
userId,
organizationId,
ownerUserId,
error: result.error,
})
throw new Error(result.error || 'Failed to sync workspace member into organization')
}
if (result.alreadyMember) {
await reapplyPaidOrgJoinBillingForExistingMember(userId, organizationId)
} else {
addedMemberIds.push(userId)
await syncUsageLimitsFromSubscription(userId)
}
}
const attachedWorkspaceIds = await db.transaction(async (tx) => {
const touched: string[] = []
const now = new Date()
for (const ownedWorkspace of ownedWorkspaces) {
await tx
.update(workspace)
.set({
organizationId,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
billedAccountUserId,
updatedAt: now,
})
.where(eq(workspace.id, ownedWorkspace.id))
await tx
.insert(permissions)
.values({
id: generateId(),
userId: billedAccountUserId,
entityType: 'workspace',
entityId: ownedWorkspace.id,
permissionType: 'admin',
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [permissions.userId, permissions.entityType, permissions.entityId],
set: {
permissionType: 'admin',
updatedAt: now,
},
})
touched.push(ownedWorkspace.id)
}
return touched
})
logger.info('Attached owned workspaces to organization', {
ownerUserId,
organizationId,
attachedWorkspaceCount: attachedWorkspaceIds.length,
addedMemberCount: addedMemberIds.length,
skippedMemberCount: skippedMembers.length,
})
return {
attachedWorkspaceIds,
addedMemberIds,
skippedMembers,
}
}
export async function detachOrganizationWorkspaces(
organizationId: string
): Promise<DetachOrganizationWorkspacesResult> {
const organizationOwnerId = await getOrganizationOwnerId(organizationId)
if (!organizationOwnerId) {
logger.warn(
'Detaching workspaces from an organization without an owner; using workspace owner as billed account',
{ organizationId }
)
}
const organizationWorkspaces = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(
and(
eq(workspace.organizationId, organizationId),
eq(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION)
)
)
const detachedWorkspaceIds = await db.transaction(async (tx) => {
const touched: string[] = []
const now = new Date()
for (const organizationWorkspace of organizationWorkspaces) {
const billedAccountUserId = organizationOwnerId ?? organizationWorkspace.ownerId
await tx
.update(workspace)
.set({
organizationId: null,
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
billedAccountUserId,
updatedAt: now,
})
.where(eq(workspace.id, organizationWorkspace.id))
await tx
.insert(permissions)
.values({
id: generateId(),
userId: billedAccountUserId,
entityType: 'workspace',
entityId: organizationWorkspace.id,
permissionType: 'admin',
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [permissions.userId, permissions.entityType, permissions.entityId],
set: {
permissionType: 'admin',
updatedAt: now,
},
})
touched.push(organizationWorkspace.id)
}
return touched
})
logger.info('Detached organization workspaces', {
organizationId,
detachedWorkspaceCount: detachedWorkspaceIds.length,
billedAccountUserId: organizationOwnerId,
})
return {
detachedWorkspaceIds,
billedAccountUserId: organizationOwnerId,
}
}
/**
* Split workspace members into those who can join the target organization
* (members of no org, or already members of this org) and those who already
* belong to a different org (external conflicts). Single-org membership means
* each user has at most one membership row, so the split is unambiguous.
*/
async function partitionWorkspaceMembersByOrg(
userIds: string[],
organizationId: string
): Promise<{
joinableUserIds: string[]
externalConflicts: Array<{ userId: string; organizationId: string }>
}> {
if (userIds.length === 0) {
return { joinableUserIds: [], externalConflicts: [] }
}
const memberships = await db
.select({
userId: member.userId,
organizationId: member.organizationId,
})
.from(member)
.where(inArray(member.userId, userIds))
const externalConflicts = memberships.filter(
(membership) => membership.organizationId !== organizationId
)
const externalUserIds = new Set(externalConflicts.map((conflict) => conflict.userId))
const joinableUserIds = userIds.filter((userId) => !externalUserIds.has(userId))
return { joinableUserIds, externalConflicts }
}
async function getWorkspaceMemberIds(workspaceIds: string[]): Promise<string[]> {
if (workspaceIds.length === 0) {
return []
}
const rows = await db
.select({ userId: permissions.userId })
.from(permissions)
.where(
and(eq(permissions.entityType, 'workspace'), inArray(permissions.entityId, workspaceIds))
)
return [...new Set(rows.map((row) => row.userId))]
}
@@ -0,0 +1,8 @@
// Export types
export type { Member } from '@/lib/workspaces/organization/types'
// Export utility functions
export {
generateSlug,
getUserRole,
isAdminOrOwner,
} from '@/lib/workspaces/organization/utils'
@@ -0,0 +1,163 @@
interface User {
name?: string
email?: string
id?: string
image?: string | null
}
export interface Member {
id: string
role: string
user?: User
}
interface Invitation {
id: string
email: string
status: string
membershipIntent?: 'internal' | 'external'
}
export interface Organization {
id: string
name: string
slug: string
logo?: string | null
members?: Member[]
invitations?: Invitation[]
createdAt: string | Date
[key: string]: unknown
}
interface Subscription {
id: string
plan: string
status: string
seats?: number
referenceId: string
cancelAtPeriodEnd?: boolean
periodEnd?: number | Date
trialEnd?: number | Date
metadata?: any
[key: string]: unknown
}
interface WorkspaceInvitation {
workspaceId: string
permission: string
}
interface Workspace {
id: string
name: string
ownerId: string
isOwner: boolean
canInvite: boolean
}
interface OrganizationFormData {
name: string
slug: string
logo: string
}
interface MemberUsageData {
userId: string
userName: string
userEmail: string
currentUsage: number
usageLimit: number
percentUsed: number
isOverLimit: boolean
role: string
joinedAt: string
}
interface OrganizationBillingData {
organizationId: string
organizationName: string
subscriptionPlan: string
subscriptionStatus: string
totalSeats: number
usedSeats: number
seatsCount: number
totalCurrentUsage: number
totalUsageLimit: number
minimumBillingAmount: number
averageUsagePerMember: number
billingPeriodStart: string | null
billingPeriodEnd: string | null
members?: MemberUsageData[]
userRole?: string
billingBlocked?: boolean
}
interface OrganizationState {
// Core organization data
organizations: Organization[]
activeOrganization: Organization | null
// Team management
subscriptionData: Subscription | null
userWorkspaces: Workspace[]
// Organization billing and usage
organizationBillingData: OrganizationBillingData | null
// Organization settings
orgFormData: OrganizationFormData
// Loading states
isLoading: boolean
isLoadingSubscription: boolean
isLoadingOrgBilling: boolean
isCreatingOrg: boolean
isInviting: boolean
isSavingOrgSettings: boolean
// Error states
error: string | null
orgSettingsError: string | null
// Success states
inviteSuccess: boolean
orgSettingsSuccess: string | null
// Cache timestamps
lastFetched: number | null
lastSubscriptionFetched: number | null
lastOrgBillingFetched: number | null
// User permissions
hasTeamPlan: boolean
hasEnterprisePlan: boolean
}
interface OrganizationStore extends OrganizationState {
loadData: () => Promise<void>
loadOrganizationSubscription: (orgId: string) => Promise<void>
loadOrganizationBillingData: (organizationId: string, force?: boolean) => Promise<void>
loadUserWorkspaces: (userId?: string) => Promise<void>
refreshOrganization: () => Promise<void>
// Organization management
createOrganization: (name: string, slug: string) => Promise<void>
setActiveOrganization: (orgId: string) => Promise<void>
updateOrganizationSettings: () => Promise<void>
// Team management
inviteMember: (email: string, workspaceInvitations?: WorkspaceInvitation[]) => Promise<void>
removeMember: (memberId: string) => Promise<void>
cancelInvitation: (invitationId: string) => Promise<void>
transferSubscriptionToOrganization: (orgId: string) => Promise<void>
getUserRole: (userEmail?: string) => string
isAdminOrOwner: (userEmail?: string) => boolean
getUsedSeats: () => { used: number; members: number; pending: number }
setOrgFormData: (data: Partial<OrganizationFormData>) => void
clearError: () => void
clearSuccessMessages: () => void
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { calculateSeatUsage } from '@/lib/workspaces/organization/utils'
describe('calculateSeatUsage', () => {
it('does not count external pending workspace invitations as occupied seats', () => {
const seats = calculateSeatUsage({
id: 'org-1',
name: 'Acme',
slug: 'acme',
createdAt: new Date(),
members: [
{ id: 'member-1', role: 'owner' },
{ id: 'member-2', role: 'member' },
],
invitations: [
{
id: 'inv-1',
email: 'internal@example.com',
status: 'pending',
membershipIntent: 'internal',
},
{
id: 'inv-2',
email: 'external@example.com',
status: 'pending',
membershipIntent: 'external',
},
],
})
expect(seats).toEqual({ used: 3, members: 2, pending: 1 })
})
})
@@ -0,0 +1,93 @@
/**
* Utility functions for organization-related operations
* These are pure functions that compute values from organization data
*/
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { normalizeEmail } from '@sim/utils/string'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { Organization } from '@/lib/workspaces/organization/types'
/**
* Get the role of a user in an organization
*/
export function getUserRole(
organization: Organization | null | undefined,
userEmail?: string
): string {
if (!userEmail || !organization?.members) {
return 'member'
}
const currentMember = organization.members.find((m) => m.user?.email === userEmail)
return currentMember?.role ?? 'member'
}
/**
* Check if a user is an admin or owner in an organization
*/
export function isAdminOrOwner(
organization: Organization | null | undefined,
userEmail?: string
): boolean {
const role = getUserRole(organization, userEmail)
return isOrgAdminRole(role)
}
/**
* Calculate seat usage for an organization
*/
export function calculateSeatUsage(organization: Organization | null | undefined): {
used: number
members: number
pending: number
} {
if (!organization) {
return { used: 0, members: 0, pending: 0 }
}
const membersCount = organization.members?.length || 0
const pendingInvitationsCount =
organization.invitations?.filter(
(inv) => inv.status === 'pending' && inv.membershipIntent !== 'external'
).length || 0
return {
used: membersCount + pendingInvitationsCount,
members: membersCount,
pending: pendingInvitationsCount,
}
}
/**
* Get used seats from an organization
* Alias for calculateSeatUsage
*/
export function getUsedSeats(organization: Organization | null | undefined) {
return calculateSeatUsage(organization)
}
/**
* Generate a URL-friendly slug from a name
*/
export function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with hyphens
.replace(/-+/g, '-') // Replace consecutive hyphens with single hyphen
.replace(/^-|-$/g, '') // Remove leading and trailing hyphens
}
/**
* Validate organization slug format
*/
export function validateSlug(slug: string): boolean {
const slugRegex = /^[a-z0-9-_]+$/
return slugRegex.test(slug)
}
/**
* Validate email format
*/
export function validateEmail(email: string): boolean {
return quickValidateEmail(normalizeEmail(email)).isValid
}
@@ -0,0 +1,955 @@
import { db } from '@sim/db'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
checkWorkspaceAccess,
getManageableWorkspaces,
getUserEntityPermissions,
getUsersWithPermissions,
getWorkspaceById,
getWorkspaceWithOwner,
hasWorkspaceAdminAccess,
workspaceExists,
} from '@/lib/workspaces/permissions/utils'
const mockDb = db as any
type PermissionType = 'admin' | 'write' | 'read'
function createMockChain(finalResult: any) {
const chain: any = {}
chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult))
chain.select = vi.fn().mockReturnValue(chain)
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.limit = vi.fn().mockReturnValue(chain)
chain.innerJoin = vi.fn().mockReturnValue(chain)
chain.leftJoin = vi.fn().mockReturnValue(chain)
chain.orderBy = vi.fn().mockReturnValue(chain)
return chain
}
describe('Permission Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getUserEntityPermissions', () => {
it('should return null when user has no permissions', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
expect(result).toBeNull()
})
it('should return the highest permission when user has multiple permissions', async () => {
const mockResults = [
{ permissionType: 'read' as PermissionType },
{ permissionType: 'admin' as PermissionType },
{ permissionType: 'write' as PermissionType },
]
const chain = createMockChain(mockResults)
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow456')
expect(result).toBe('admin')
})
it('should return single permission when user has only one', async () => {
const mockResults = [{ permissionType: 'read' as PermissionType }]
const chain = createMockChain(mockResults)
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
expect(result).toBe('read')
})
it('should prioritize admin over other permissions', async () => {
const mockResults = [
{ permissionType: 'write' as PermissionType },
{ permissionType: 'admin' as PermissionType },
{ permissionType: 'read' as PermissionType },
]
const chain = createMockChain(mockResults)
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user999', 'workflow', 'workflow999')
expect(result).toBe('admin')
})
it('should return write permission when user only has write access', async () => {
const mockResults = [{ permissionType: 'write' as PermissionType }]
const chain = createMockChain(mockResults)
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
expect(result).toBe('write')
})
it('should prioritize write over read permissions', async () => {
const mockResults = [
{ permissionType: 'read' as PermissionType },
{ permissionType: 'write' as PermissionType },
]
const chain = createMockChain(mockResults)
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow456')
expect(result).toBe('write')
})
it('should work with workflow entity type', async () => {
const mockResults = [{ permissionType: 'admin' as PermissionType }]
const chain = createMockChain(mockResults)
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
expect(result).toBe('admin')
})
it('should work with organization entity type', async () => {
const mockResults = [{ permissionType: 'read' as PermissionType }]
const chain = createMockChain(mockResults)
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'organization', 'org456')
expect(result).toBe('read')
})
it('should handle generic entity types', async () => {
const mockResults = [{ permissionType: 'write' as PermissionType }]
const chain = createMockChain(mockResults)
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'custom_entity', 'entity123')
expect(result).toBe('write')
})
})
describe('getUsersWithPermissions', () => {
function mockSelectSequence(results: any[][]) {
let index = 0
mockDb.select.mockImplementation(() => createMockChain(results[index++] ?? []))
}
const joinedAt = new Date('2026-04-22T00:00:00.000Z')
it('should return empty array when the workspace does not exist', async () => {
mockSelectSequence([[]])
const result = await getUsersWithPermissions('workspace123')
expect(result).toEqual([])
})
it('should return users with their explicit permissions for a personal workspace', async () => {
mockSelectSequence([
[{ id: 'workspace456', ownerId: 'owner-user', organizationId: null }],
[
{
userId: 'user1',
email: 'alice@example.com',
name: 'Alice Smith',
image: 'https://example.com/alice.png',
permissionType: 'admin' as PermissionType,
joinedAt,
userOrganizationId: null,
},
],
])
const result = await getUsersWithPermissions('workspace456')
expect(result).toEqual([
{
userId: 'user1',
email: 'alice@example.com',
name: 'Alice Smith',
image: 'https://example.com/alice.png',
permissionType: 'admin',
isExternal: false,
joinedAt: '2026-04-22T00:00:00.000Z',
roleSource: 'explicit',
},
])
})
it('tags the workspace owner with roleSource owner', async () => {
mockSelectSequence([
[{ id: 'workspace456', ownerId: 'user1', organizationId: null }],
[
{
userId: 'user1',
email: 'owner@example.com',
name: 'Owner',
image: null,
permissionType: 'admin' as PermissionType,
joinedAt,
userOrganizationId: null,
},
],
])
const result = await getUsersWithPermissions('workspace456')
expect(result[0].roleSource).toBe('owner')
})
it('merges organization admins as derived workspace admins', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'owner-user', organizationId: 'org-1' }],
[
{
userId: 'member-user',
email: 'member@example.com',
name: 'Member',
image: null,
permissionType: 'read' as PermissionType,
joinedAt,
userOrganizationId: 'org-1',
},
],
[
{
userId: 'org-admin-user',
email: 'orgadmin@example.com',
name: 'Org Admin',
image: null,
joinedAt,
},
],
])
const result = await getUsersWithPermissions('ws')
const orgAdmin = result.find((u) => u.userId === 'org-admin-user')
expect(orgAdmin).toMatchObject({
permissionType: 'admin',
roleSource: 'org-admin',
isExternal: false,
})
})
it('marks users as external when they are not members of the workspace organization', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'internal-user', organizationId: 'org-1' }],
[
{
userId: 'internal-user',
email: 'internal@example.com',
name: 'Internal User',
image: null,
permissionType: 'admin' as PermissionType,
joinedAt,
userOrganizationId: 'org-1',
},
{
userId: 'external-user',
email: 'external@example.com',
name: 'External User',
image: null,
permissionType: 'write' as PermissionType,
joinedAt,
userOrganizationId: 'org-2',
},
],
[],
])
const result = await getUsersWithPermissions('ws')
const byEmail = new Map(result.map((u) => [u.email, u.isExternal]))
expect(byEmail.get('internal@example.com')).toBe(false)
expect(byEmail.get('external@example.com')).toBe(true)
})
it('marks a non-owner member of another org as external on a personal workspace', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
[
{
userId: 'owner-user',
email: 'owner@example.com',
name: 'Owner',
image: null,
permissionType: 'admin' as PermissionType,
joinedAt,
userOrganizationId: null,
},
{
userId: 'guest-user',
email: 'guest@example.com',
name: 'Guest',
image: null,
permissionType: 'write' as PermissionType,
joinedAt,
userOrganizationId: 'org-guest',
},
],
])
const result = await getUsersWithPermissions('workspace-personal')
const byEmail = new Map(result.map((u) => [u.email, u.isExternal]))
expect(byEmail.get('owner@example.com')).toBe(false)
expect(byEmail.get('guest@example.com')).toBe(true)
})
it('should return multiple users sorted by email', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
[
{
userId: 'user1',
email: 'a-admin@example.com',
name: 'Admin User',
image: null,
permissionType: 'admin' as PermissionType,
joinedAt,
userOrganizationId: null,
},
{
userId: 'user2',
email: 'b-writer@example.com',
name: 'Writer User',
image: null,
permissionType: 'write' as PermissionType,
joinedAt,
userOrganizationId: null,
},
{
userId: 'user3',
email: 'c-reader@example.com',
name: 'Reader User',
image: null,
permissionType: 'read' as PermissionType,
joinedAt,
userOrganizationId: null,
},
],
])
const result = await getUsersWithPermissions('workspace456')
expect(result).toHaveLength(3)
expect(result[0].permissionType).toBe('admin')
expect(result[1].permissionType).toBe('write')
expect(result[2].permissionType).toBe('read')
})
it('should handle users with empty names', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'owner-user', organizationId: null }],
[
{
userId: 'user1',
email: 'test@example.com',
name: '',
image: null,
permissionType: 'read' as PermissionType,
joinedAt,
userOrganizationId: null,
},
],
])
const result = await getUsersWithPermissions('workspace123')
expect(result[0].name).toBe('')
})
})
describe('hasWorkspaceAdminAccess', () => {
it('should return true for the workspace owner via their explicit admin row', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ ownerId: 'user123' }])
}
return createMockChain([{ permissionType: 'admin' }])
})
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
expect(result).toBe(true)
})
it('should return true when user has direct admin permission', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ ownerId: 'other-user' }])
}
return createMockChain([{ permissionType: 'admin' }])
})
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
expect(result).toBe(true)
})
it('should return false when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
expect(result).toBe(false)
})
it('should return false when user has no admin access', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ ownerId: 'other-user' }])
}
return createMockChain([])
})
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
expect(result).toBe(false)
})
it('should return false when user has write permission but not admin', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ ownerId: 'other-user' }])
}
return createMockChain([])
})
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
expect(result).toBe(false)
})
it('should return false when user has read permission but not admin', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ ownerId: 'other-user' }])
}
return createMockChain([])
})
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
expect(result).toBe(false)
})
it('should handle empty workspace ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await hasWorkspaceAdminAccess('user123', '')
expect(result).toBe(false)
})
it('should handle empty user ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await hasWorkspaceAdminAccess('', 'workspace456')
expect(result).toBe(false)
})
})
describe('Edge Cases and Security Tests', () => {
it('should handle SQL injection attempts in user IDs', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions(
"'; DROP TABLE users; --",
'workspace',
'workspace123'
)
expect(result).toBeNull()
})
it('should handle very long entity IDs', async () => {
const longEntityId = 'a'.repeat(1000)
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', 'workspace', longEntityId)
expect(result).toBeNull()
})
it('should handle unicode characters in entity names', async () => {
const chain = createMockChain([{ permissionType: 'read' as PermissionType }])
mockDb.select.mockReturnValue(chain)
const result = await getUserEntityPermissions('user123', '📝workspace', '🏢org-id')
expect(result).toBe('read')
})
it('should verify permission hierarchy ordering is consistent', () => {
const permissionOrder: Record<PermissionType, number> = { admin: 3, write: 2, read: 1 }
expect(permissionOrder.admin).toBeGreaterThan(permissionOrder.write)
expect(permissionOrder.write).toBeGreaterThan(permissionOrder.read)
})
it('should handle workspace ownership checks with null owner IDs', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ ownerId: null }])
}
return createMockChain([])
})
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
expect(result).toBe(false)
})
it('should handle null user ID correctly when owner ID is different', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ ownerId: 'other-user' }])
}
return createMockChain([])
})
const result = await hasWorkspaceAdminAccess(null as any, 'workspace456')
expect(result).toBe(false)
})
})
describe('getManageableWorkspaces', () => {
it('should return empty array when user has no manageable workspaces', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getManageableWorkspaces('user123')
expect(result).toEqual([])
})
it('should return owned workspaces', async () => {
const mockWorkspaces = [
{ id: 'ws1', name: 'My Workspace 1', ownerId: 'user123' },
{ id: 'ws2', name: 'My Workspace 2', ownerId: 'user123' },
]
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain(mockWorkspaces) // Owned workspaces
}
return createMockChain([]) // No admin workspaces
})
const result = await getManageableWorkspaces('user123')
expect(result).toEqual([
{ id: 'ws1', name: 'My Workspace 1', ownerId: 'user123', accessType: 'owner' },
{ id: 'ws2', name: 'My Workspace 2', ownerId: 'user123', accessType: 'owner' },
])
})
it('should return workspaces with direct admin permissions', async () => {
const mockAdminWorkspaces = [{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' }]
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([]) // No owned workspaces
}
return createMockChain(mockAdminWorkspaces) // Admin workspaces
})
const result = await getManageableWorkspaces('user123')
expect(result).toEqual([
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user', accessType: 'direct' },
])
})
it('should combine owned and admin workspaces without duplicates', async () => {
const mockOwnedWorkspaces = [
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123' },
{ id: 'ws2', name: 'Another Workspace', ownerId: 'user123' },
]
const mockAdminWorkspaces = [
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123' }, // Duplicate (should be filtered)
{ id: 'ws3', name: 'Shared Workspace', ownerId: 'other-user' },
]
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain(mockOwnedWorkspaces) // Owned workspaces
}
return createMockChain(mockAdminWorkspaces) // Admin workspaces
})
const result = await getManageableWorkspaces('user123')
expect(result).toHaveLength(3)
expect(result).toEqual([
{ id: 'ws1', name: 'My Workspace', ownerId: 'user123', accessType: 'owner' },
{ id: 'ws2', name: 'Another Workspace', ownerId: 'user123', accessType: 'owner' },
{ id: 'ws3', name: 'Shared Workspace', ownerId: 'other-user', accessType: 'direct' },
])
})
it('should handle empty workspace names', async () => {
const mockWorkspaces = [{ id: 'ws1', name: '', ownerId: 'user123' }]
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain(mockWorkspaces)
}
return createMockChain([])
})
const result = await getManageableWorkspaces('user123')
expect(result[0].name).toBe('')
})
it('should handle multiple admin permissions for same workspace', async () => {
const mockAdminWorkspaces = [
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' },
{ id: 'ws1', name: 'Shared Workspace', ownerId: 'other-user' }, // Duplicate
]
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([]) // No owned workspaces
}
return createMockChain(mockAdminWorkspaces) // Admin workspaces with duplicates
})
const result = await getManageableWorkspaces('user123')
expect(result).toHaveLength(1)
})
it('should handle empty user ID gracefully', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getManageableWorkspaces('')
expect(result).toEqual([])
})
})
describe('getWorkspaceById', () => {
it.concurrent('should return workspace when it exists', async () => {
const chain = createMockChain([{ id: 'workspace123' }])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceById('workspace123')
expect(result).toEqual({ id: 'workspace123' })
})
it.concurrent('should return null when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceById('non-existent')
expect(result).toBeNull()
})
it.concurrent('should handle empty workspace ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceById('')
expect(result).toBeNull()
})
})
describe('getWorkspaceWithOwner', () => {
it.concurrent('should return workspace with owner when it exists', async () => {
const chain = createMockChain([{ id: 'workspace123', ownerId: 'owner456' }])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceWithOwner('workspace123')
expect(result).toEqual({ id: 'workspace123', ownerId: 'owner456' })
})
it.concurrent('should return null when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceWithOwner('non-existent')
expect(result).toBeNull()
})
it.concurrent('should handle workspace with null owner ID', async () => {
const chain = createMockChain([{ id: 'workspace123', ownerId: null }])
mockDb.select.mockReturnValue(chain)
const result = await getWorkspaceWithOwner('workspace123')
expect(result).toEqual({ id: 'workspace123', ownerId: null })
})
})
describe('workspaceExists', () => {
it.concurrent('should return true when workspace exists', async () => {
const chain = createMockChain([{ id: 'workspace123' }])
mockDb.select.mockReturnValue(chain)
const result = await workspaceExists('workspace123')
expect(result).toBe(true)
})
it.concurrent('should return false when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await workspaceExists('non-existent')
expect(result).toBe(false)
})
it.concurrent('should handle empty workspace ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await workspaceExists('')
expect(result).toBe(false)
})
})
describe('checkWorkspaceAccess', () => {
it('should return exists=false when workspace does not exist', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await checkWorkspaceAccess('non-existent', 'user123')
expect(result).toEqual({
exists: false,
hasAccess: false,
canWrite: false,
canAdmin: false,
workspace: null,
permission: null,
})
})
it('should return full access for the workspace owner via their explicit admin row', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'user123' }])
}
return createMockChain([{ permissionType: 'admin' }])
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result).toEqual({
exists: true,
hasAccess: true,
canWrite: true,
canAdmin: true,
workspace: { id: 'workspace123', ownerId: 'user123' },
permission: 'admin',
})
})
it('should return hasAccess=false when user has no permissions', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
}
return createMockChain([]) // No permissions
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result.exists).toBe(true)
expect(result.hasAccess).toBe(false)
expect(result.canWrite).toBe(false)
})
it('should return canWrite=true when user has admin permission', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
}
return createMockChain([{ permissionType: 'admin' }])
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result.exists).toBe(true)
expect(result.hasAccess).toBe(true)
expect(result.canWrite).toBe(true)
})
it('should return canWrite=true when user has write permission', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
}
return createMockChain([{ permissionType: 'write' }])
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result.exists).toBe(true)
expect(result.hasAccess).toBe(true)
expect(result.canWrite).toBe(true)
})
it('should return canWrite=false when user has read permission', async () => {
let callCount = 0
mockDb.select.mockImplementation(() => {
callCount++
if (callCount === 1) {
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
}
return createMockChain([{ permissionType: 'read' }])
})
const result = await checkWorkspaceAccess('workspace123', 'user123')
expect(result.exists).toBe(true)
expect(result.hasAccess).toBe(true)
expect(result.canWrite).toBe(false)
})
it('should handle empty user ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await checkWorkspaceAccess('workspace123', '')
expect(result.exists).toBe(false)
expect(result.hasAccess).toBe(false)
})
it('should handle empty workspace ID', async () => {
const chain = createMockChain([])
mockDb.select.mockReturnValue(chain)
const result = await checkWorkspaceAccess('', 'user123')
expect(result.exists).toBe(false)
expect(result.hasAccess).toBe(false)
})
})
describe('organization admin inheritance', () => {
function mockSelectSequence(results: any[][]) {
let index = 0
mockDb.select.mockImplementation(() => createMockChain(results[index++] ?? []))
}
it('checkWorkspaceAccess grants admin to org admins without an explicit row', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
[],
[{ role: 'admin' }],
])
const result = await checkWorkspaceAccess('ws', 'org-admin-user')
expect(result.hasAccess).toBe(true)
expect(result.canWrite).toBe(true)
expect(result.canAdmin).toBe(true)
})
it('getUserEntityPermissions returns admin for an org owner without an explicit row', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
[],
[{ role: 'owner' }],
])
const result = await getUserEntityPermissions('org-owner-user', 'workspace', 'ws')
expect(result).toBe('admin')
})
it('hasWorkspaceAdminAccess is true for an org admin of the workspace org', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
[],
[{ role: 'admin' }],
])
const result = await hasWorkspaceAdminAccess('org-admin-user', 'ws')
expect(result).toBe(true)
})
it('does not elevate a plain org member', async () => {
mockSelectSequence([
[{ id: 'ws', ownerId: 'other-user', organizationId: 'org-1' }],
[],
[{ role: 'member' }],
])
const result = await checkWorkspaceAccess('ws', 'org-member-user')
expect(result.hasAccess).toBe(false)
expect(result.canAdmin).toBe(false)
})
it('does not elevate org admins on a workspace with no organization', async () => {
mockSelectSequence([[{ id: 'ws', ownerId: 'other-user', organizationId: null }], []])
const result = await checkWorkspaceAccess('ws', 'some-user')
expect(result.hasAccess).toBe(false)
})
})
})
@@ -0,0 +1,541 @@
import { db } from '@sim/db'
import { member, permissions, user, type WorkspaceMode, workspace } from '@sim/db/schema'
import {
isOrgAdminRole,
ORG_ADMIN_ROLES,
PERMISSION_RANK,
type PermissionType,
permissionSatisfies,
resolveEffectiveWorkspacePermission,
} from '@sim/platform-authz/workspace'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { HttpError } from '@/lib/core/utils/http-error'
import { getOrgAdminWorkspaceRows } from '@/lib/workspaces/utils'
export type { PermissionType }
export interface WorkspaceBasic {
id: string
}
export interface WorkspaceWithOwner {
id: string
name: string
ownerId: string
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
archivedAt?: Date | null
}
export interface WorkspaceAccess {
exists: boolean
hasAccess: boolean
canWrite: boolean
canAdmin: boolean
workspace: WorkspaceWithOwner | null
/** The viewer's raw effective permission, or `null` when the workspace doesn't exist or they have none. */
permission: PermissionType | null
}
/**
* Check if a workspace exists
*
* @param workspaceId - The workspace ID to check
* @returns True if the workspace exists, false otherwise
*/
export async function workspaceExists(
workspaceId: string,
options?: { includeArchived?: boolean }
): Promise<boolean> {
const { includeArchived = false } = options ?? {}
const [ws] = await db
.select({ id: workspace.id })
.from(workspace)
.where(
includeArchived
? eq(workspace.id, workspaceId)
: and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))
)
.limit(1)
return !!ws
}
/**
* Get a workspace by ID for existence check
*
* @param workspaceId - The workspace ID to look up
* @returns The workspace if found, null otherwise
*/
export async function getWorkspaceById(
workspaceId: string,
options?: { includeArchived?: boolean }
): Promise<WorkspaceBasic | null> {
const exists = await workspaceExists(workspaceId, options)
return exists ? { id: workspaceId } : null
}
/**
* Get a workspace with owner info by ID
*
* @param workspaceId - The workspace ID to look up
* @returns The workspace with owner info if found, null otherwise
*/
export async function getWorkspaceWithOwner(
workspaceId: string,
options?: { includeArchived?: boolean }
): Promise<WorkspaceWithOwner | null> {
const { includeArchived = false } = options ?? {}
const [ws] = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
organizationId: workspace.organizationId,
workspaceMode: workspace.workspaceMode,
billedAccountUserId: workspace.billedAccountUserId,
archivedAt: workspace.archivedAt,
})
.from(workspace)
.where(
includeArchived
? eq(workspace.id, workspaceId)
: and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))
)
.limit(1)
return ws || null
}
/**
* Resolve the effective workspace permission for a user under the governance
* inheritance model: the owners/admins of the organization that owns the
* workspace are derived workspace admins. Returns the higher of any explicit
* grant and the org-admin derivation. The workspace owner is not special-cased —
* they always hold an explicit `admin` row, so the resolver's lookup covers them.
*
* Delegates to the shared resolver in `@sim/platform-authz/workspace` so the
* rule has a single source of truth shared with the realtime server.
*
* @param userId - The user to resolve the permission for
* @param ws - The workspace (organization already loaded)
*/
export async function getEffectiveWorkspacePermission(
userId: string,
ws: Pick<WorkspaceWithOwner, 'id' | 'organizationId'>
): Promise<PermissionType | null> {
return resolveEffectiveWorkspacePermission(userId, ws.id, ws.organizationId)
}
/**
* Check workspace access for a user
*
* Verifies the workspace exists and the user has access to it.
* Returns access level (read/write) based on ownership, explicit permissions,
* and organization-admin inheritance.
*
* @param workspaceId - The workspace ID to check
* @param userId - The user ID to check access for
* @returns WorkspaceAccess object with exists, hasAccess, canWrite, and workspace data
*/
export async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceAccess> {
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws) {
return {
exists: false,
hasAccess: false,
canWrite: false,
canAdmin: false,
workspace: null,
permission: null,
}
}
const permission = await getEffectiveWorkspacePermission(userId, ws)
const hasAccess = permission !== null
const canWrite = permissionSatisfies(permission, 'write')
const canAdmin = permissionSatisfies(permission, 'admin')
return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws, permission }
}
/**
* Thrown when a user attempts to access a workspace they don't have access to,
* or that doesn't exist / has been archived. Carries `statusCode = 403` so the
* centralized route wrapper maps it to HTTP 403 instead of defaulting to 500.
* The `message` is intentionally client-safe and is exposed to API responses.
*/
export class WorkspaceAccessDeniedError extends HttpError {
readonly statusCode = 403
readonly workspaceId: string
constructor(workspaceId: string) {
super(`Workspace access denied: ${workspaceId}`)
this.name = 'WorkspaceAccessDeniedError'
this.workspaceId = workspaceId
}
}
export function isWorkspaceAccessDeniedError(error: unknown): error is WorkspaceAccessDeniedError {
return error instanceof WorkspaceAccessDeniedError
}
export async function assertActiveWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceAccess> {
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.exists || !access.hasAccess) {
throw new WorkspaceAccessDeniedError(workspaceId)
}
return access
}
/**
* Get the highest permission level a user has for a specific entity
*
* @param userId - The ID of the user to check permissions for
* @param entityType - The type of entity (e.g., 'workspace', 'workflow', etc.)
* @param entityId - The ID of the specific entity
* @returns Promise<PermissionType | null> - The highest permission the user has for the entity, or null if none
*/
export async function getUserEntityPermissions(
userId: string,
entityType: string,
entityId: string
): Promise<PermissionType | null> {
if (entityType === 'workspace') {
return (await checkWorkspaceAccess(entityId, userId)).permission
}
const result = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, entityType),
eq(permissions.entityId, entityId)
)
)
if (result.length === 0) {
return null
}
const highestPermission = result.reduce((highest, current) => {
return PERMISSION_RANK[current.permissionType] > PERMISSION_RANK[highest.permissionType]
? current
: highest
})
return highestPermission.permissionType
}
/**
* Retrieves a list of users with their associated permissions for a given workspace.
*
* A member is `isExternal` when they hold workspace access but belong to a
* different organization than the workspace (or to any organization when the
* workspace is personal/grandfathered and has none). The workspace owner is
* never external. This mirrors the accept-time `external` membership intent so
* the UI tag matches how the member actually joined.
*
* @param workspaceId - The ID of the workspace to retrieve user permissions for.
* @returns A promise that resolves to an array of user objects, each containing user details and their permission type.
*/
export type MemberRoleSource = 'owner' | 'explicit' | 'org-admin'
export interface WorkspaceMemberWithRole {
userId: string
email: string
name: string
image: string | null
permissionType: PermissionType
isExternal: boolean
joinedAt: string
/**
* Where the effective role comes from. `org-admin` and `owner` roles are
* derived and cannot be changed through the member UI.
*/
roleSource: MemberRoleSource
}
export async function getUsersWithPermissions(
workspaceId: string
): Promise<WorkspaceMemberWithRole[]> {
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws) return []
const explicitRows = await db
.select({
userId: user.id,
email: user.email,
name: user.name,
image: user.image,
permissionType: permissions.permissionType,
joinedAt: permissions.createdAt,
userOrganizationId: member.organizationId,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.leftJoin(member, eq(member.userId, user.id))
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)))
const byUser = new Map<string, WorkspaceMemberWithRole>()
for (const row of explicitRows) {
const isOwner = row.userId === ws.ownerId
byUser.set(row.userId, {
userId: row.userId,
email: row.email,
name: row.name,
image: row.image ?? null,
permissionType: row.permissionType,
isExternal: !isOwner && row.userOrganizationId !== ws.organizationId,
joinedAt: row.joinedAt.toISOString(),
roleSource: isOwner ? 'owner' : 'explicit',
})
}
if (ws.organizationId) {
const orgAdmins = await db
.select({
userId: user.id,
email: user.email,
name: user.name,
image: user.image,
joinedAt: member.createdAt,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(
and(
eq(member.organizationId, ws.organizationId),
inArray(member.role, [...ORG_ADMIN_ROLES])
)
)
for (const row of orgAdmins) {
const isOwner = row.userId === ws.ownerId
const existing = byUser.get(row.userId)
if (existing) {
existing.permissionType = 'admin'
existing.isExternal = false
if (existing.roleSource !== 'owner') {
existing.roleSource = isOwner ? 'owner' : 'org-admin'
}
} else {
byUser.set(row.userId, {
userId: row.userId,
email: row.email,
name: row.name,
image: row.image ?? null,
permissionType: 'admin',
isExternal: false,
joinedAt: row.joinedAt.toISOString(),
roleSource: isOwner ? 'owner' : 'org-admin',
})
}
}
}
return Array.from(byUser.values()).sort((a, b) => a.email.localeCompare(b.email))
}
/** Lightweight profile data for workspace member display (avatars, owner cells). */
export interface WorkspaceMemberProfile {
userId: string
name: string
image: string | null
}
/**
* Fetches minimal profile data (id, name, image) for all members of a workspace.
* Use this instead of getUsersWithPermissions when you only need display info.
*/
export async function getWorkspaceMemberProfiles(
workspaceId: string
): Promise<WorkspaceMemberProfile[]> {
const rows = await db
.select({
userId: user.id,
name: user.name,
image: user.image,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
.where(
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId),
isNull(workspace.archivedAt)
)
)
return rows
}
export interface WorkspacePermissionsForViewer {
users: WorkspaceMemberWithRole[]
total: number
viewer: {
userId: string
isAdmin: boolean
permissionType: PermissionType
}
}
/**
* Builds the workspace permissions payload for a viewer: the full member list plus
* the viewer's own resolved permission. Shared by `GET /api/workspaces/[id]/permissions`
* and the sidebar prefetch so the two never drift.
*
* @param workspaceId - The workspace ID to build permissions for
* @param userId - The viewer's user ID
* @returns The permissions payload, or `null` if the workspace doesn't exist or the viewer lacks access
*/
export async function getWorkspacePermissionsForViewer(
workspaceId: string,
userId: string
): Promise<WorkspacePermissionsForViewer | null> {
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws) return null
const permission = await getEffectiveWorkspacePermission(userId, ws)
if (permission === null) return null
const users = await getUsersWithPermissions(workspaceId)
return {
users,
total: users.length,
viewer: { userId, isAdmin: permission === 'admin', permissionType: permission },
}
}
/**
* Check if a user has admin access to a specific workspace
*
* @param userId - The ID of the user to check
* @param workspaceId - The ID of the workspace to check
* @returns Promise<boolean> - True if the user has admin access to the workspace, false otherwise
*/
export async function hasWorkspaceAdminAccess(
userId: string,
workspaceId: string
): Promise<boolean> {
return (await checkWorkspaceAccess(workspaceId, userId)).canAdmin
}
/**
* Check whether a user is an owner or admin of a specific organization.
*
* @param userId - The ID of the user to check
* @param organizationId - The ID of the organization to check
* @returns Promise<boolean> - True when the user is the organization owner or an admin
*/
export async function isOrganizationAdminOrOwner(
userId: string,
organizationId: string
): Promise<boolean> {
const [row] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
return isOrgAdminRole(row?.role)
}
/**
* Check whether a user is a member (any role) of a specific organization.
*
* @param userId - The ID of the user to check
* @param organizationId - The ID of the organization to check
* @returns Promise<boolean> - True when the user has an organization membership row
*/
export async function isOrganizationMember(
userId: string,
organizationId: string
): Promise<boolean> {
const [row] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
return !!row
}
/**
* Get a list of workspaces that the user has access to
*
* @param userId - The ID of the user to check
* @returns Promise<Array<{
* id: string
* name: string
* ownerId: string
* accessType: 'direct' | 'owner'
* }>> - A list of workspaces that the user has access to
*/
export async function getManageableWorkspaces(userId: string): Promise<
Array<{
id: string
name: string
ownerId: string
accessType: 'direct' | 'owner'
}>
> {
const ownedWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
})
.from(workspace)
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt)))
const adminWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
})
.from(workspace)
.innerJoin(permissions, eq(permissions.entityId, workspace.id))
.where(
and(
isNull(workspace.archivedAt),
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.permissionType, 'admin')
)
)
const orgAdminWorkspaces = (await getOrgAdminWorkspaceRows(userId, 'active')).map((ws) => ({
id: ws.id,
name: ws.name,
ownerId: ws.ownerId,
}))
const ownedSet = new Set(ownedWorkspaces.map((w) => w.id))
const seen = new Set(ownedSet)
const combined: Array<{
id: string
name: string
ownerId: string
accessType: 'direct' | 'owner'
}> = ownedWorkspaces.map((ws) => ({ ...ws, accessType: 'owner' as const }))
for (const ws of [...adminWorkspaces, ...orgAdminWorkspaces]) {
if (seen.has(ws.id)) continue
seen.add(ws.id)
combined.push({ ...ws, accessType: 'direct' as const })
}
return combined
}
@@ -0,0 +1,8 @@
/**
* Shared constants for workspace policy messaging. Separated from
* `policy.ts` so client components can import them without pulling in
* server-only dependencies (`@sim/db`, drizzle helpers, etc.).
*/
export const UPGRADE_TO_INVITE_REASON = 'Upgrade to invite more members'
export const CONTACT_OWNER_TO_UPGRADE_REASON = 'Contact workspace owner to upgrade'
+430
View File
@@ -0,0 +1,430 @@
/**
* @vitest-environment node
*/
import { schemaMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetUserOrganization,
mockGetOrganizationSubscription,
mockGetHighestPrioritySubscription,
mockDbResults,
mockFeatureFlags,
} = vi.hoisted(() => {
const mockGetUserOrganization = vi.fn()
const mockGetOrganizationSubscription = vi.fn()
const mockGetHighestPrioritySubscription = vi.fn()
const mockDbResults: { value: any[] } = { value: [] }
const mockFeatureFlags = { isBillingEnabled: true }
return {
mockGetUserOrganization,
mockGetOrganizationSubscription,
mockGetHighestPrioritySubscription,
mockDbResults,
mockFeatureFlags,
}
})
vi.mock('@sim/db', () => ({
db: {
select: vi.fn().mockImplementation(() => {
const chain: any = {}
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.limit = vi
.fn()
.mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || []))
chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => {
const result = mockDbResults.value.shift() || []
return Promise.resolve(callback ? callback(result) : result)
})
return chain
}),
},
}))
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('@/lib/billing/organizations/membership', () => ({
getUserOrganization: mockGetUserOrganization,
}))
vi.mock('@/lib/billing/core/billing', () => ({
getOrganizationSubscription: mockGetOrganizationSubscription,
}))
vi.mock('@/lib/billing/core/plan', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return mockFeatureFlags.isBillingEnabled
},
}))
import {
getWorkspaceCreationPolicy,
getWorkspaceInvitePolicy,
WORKSPACE_MODE,
} from '@/lib/workspaces/policy'
import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants'
describe('getWorkspaceCreationPolicy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDbResults.value = []
mockFeatureFlags.isBillingEnabled = true
mockGetUserOrganization.mockResolvedValue(null)
mockGetOrganizationSubscription.mockResolvedValue(null)
mockGetHighestPrioritySubscription.mockResolvedValue(null)
})
it('blocks free users once they already own one non-organization workspace', async () => {
mockDbResults.value = [[{ value: 1 }]]
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
expect(result.canCreate).toBe(false)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
expect(result.maxWorkspaces).toBe(1)
expect(result.currentWorkspaceCount).toBe(1)
})
it('allows pro users to create up to three personal workspaces', async () => {
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'pro_6000',
status: 'active',
})
mockDbResults.value = [[{ value: 2 }]]
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
expect(result.maxWorkspaces).toBe(3)
expect(result.currentWorkspaceCount).toBe(2)
})
it('allows max users to create up to ten personal workspaces', async () => {
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'pro_25000',
status: 'active',
})
mockDbResults.value = [[{ value: 5 }]]
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
expect(result.maxWorkspaces).toBe(10)
expect(result.currentWorkspaceCount).toBe(5)
})
it('blocks max users once they already own ten personal workspaces', async () => {
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'pro_25000',
status: 'active',
})
mockDbResults.value = [[{ value: 10 }]]
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
expect(result.canCreate).toBe(false)
expect(result.maxWorkspaces).toBe(10)
expect(result.currentWorkspaceCount).toBe(10)
})
it('allows unlimited personal workspaces when billing is disabled', async () => {
mockFeatureFlags.isBillingEnabled = false
mockDbResults.value = [[{ value: 9 }]]
const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })
expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
expect(result.maxWorkspaces).toBeNull()
expect(result.currentWorkspaceCount).toBe(9)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
it('without pinning, a null active org falls back to the caller membership org', async () => {
mockFeatureFlags.isBillingEnabled = false
mockGetUserOrganization.mockResolvedValue({
organizationId: 'user-org',
role: 'admin',
memberId: 'member-1',
})
mockDbResults.value = [[{ userId: 'owner-1' }]]
const result = await getWorkspaceCreationPolicy({
userId: 'user-1',
activeOrganizationId: null,
})
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
expect(result.organizationId).toBe('user-org')
})
it('pins to the source org: a personal source (null) stays personal regardless of caller org', async () => {
mockFeatureFlags.isBillingEnabled = false
mockGetUserOrganization.mockResolvedValue({
organizationId: 'user-org',
role: 'admin',
memberId: 'member-1',
})
mockDbResults.value = [[{ value: 0 }]]
const result = await getWorkspaceCreationPolicy({
userId: 'user-1',
activeOrganizationId: null,
pinOrganization: true,
})
expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
expect(result.organizationId).toBeNull()
expect(result.billedAccountUserId).toBe('user-1')
})
it('allows org admins on a team plan to create organization workspaces', async () => {
mockGetUserOrganization.mockResolvedValueOnce({
organizationId: 'org-1',
role: 'admin',
memberId: 'member-1',
})
mockGetOrganizationSubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'team_6000',
status: 'active',
})
mockDbResults.value = [[{ userId: 'owner-1' }]]
const result = await getWorkspaceCreationPolicy({
userId: 'user-1',
activeOrganizationId: 'org-1',
})
expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
expect(result.organizationId).toBe('org-1')
expect(result.billedAccountUserId).toBe('owner-1')
})
it('allows org admins to create organization workspaces when billing is disabled', async () => {
mockFeatureFlags.isBillingEnabled = false
mockGetUserOrganization.mockResolvedValueOnce({
organizationId: 'org-1',
role: 'admin',
memberId: 'member-1',
})
mockDbResults.value = [[{ userId: 'owner-1' }]]
const result = await getWorkspaceCreationPolicy({
userId: 'user-1',
activeOrganizationId: 'org-1',
})
expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
expect(result.organizationId).toBe('org-1')
expect(result.billedAccountUserId).toBe('owner-1')
expect(mockGetOrganizationSubscription).not.toHaveBeenCalled()
})
it('blocks non-admin org members from creating organization workspaces', async () => {
mockGetUserOrganization.mockResolvedValueOnce({
organizationId: 'org-1',
role: 'member',
memberId: 'member-1',
})
mockGetOrganizationSubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'enterprise',
status: 'active',
})
mockDbResults.value = [[{ userId: 'owner-1' }]]
const result = await getWorkspaceCreationPolicy({
userId: 'user-1',
activeOrganizationId: 'org-1',
})
expect(result.canCreate).toBe(false)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
expect(result.reason).toContain('owners and admins')
})
it('blocks users without org membership from creating workspaces in the active org context', async () => {
mockDbResults.value = [[], [{ userId: 'owner-1' }]]
const result = await getWorkspaceCreationPolicy({
userId: 'external-user-1',
activeOrganizationId: 'org-1',
})
expect(result.canCreate).toBe(false)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
expect(result.organizationId).toBe('org-1')
expect(result.billedAccountUserId).toBe('owner-1')
expect(result.reason).toContain('owners and admins')
expect(mockGetOrganizationSubscription).not.toHaveBeenCalled()
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
})
describe('getWorkspaceInvitePolicy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFeatureFlags.isBillingEnabled = true
mockGetOrganizationSubscription.mockResolvedValue(null)
mockGetHighestPrioritySubscription.mockResolvedValue(null)
})
const baseState = {
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: 'owner-1',
ownerId: 'owner-1',
} as const
it('allows invites unconditionally when billing is disabled', async () => {
mockFeatureFlags.isBillingEnabled = false
const result = await getWorkspaceInvitePolicy(baseState)
expect(result.allowed).toBe(true)
expect(result.upgradeRequired).toBe(false)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
it('blocks free personal workspaces with an upgrade prompt', async () => {
const result = await getWorkspaceInvitePolicy(baseState)
expect(result.allowed).toBe(false)
expect(result.upgradeRequired).toBe(true)
expect(result.reason).toBe(UPGRADE_TO_INVITE_REASON)
})
it('allows pro personal workspaces and defers the team upgrade to acceptance', async () => {
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'pro_6000',
status: 'active',
})
const result = await getWorkspaceInvitePolicy(baseState)
expect(result.allowed).toBe(true)
expect(result.requiresSeat).toBe(false)
expect(result.upgradeRequired).toBe(false)
})
it('allows team org workspaces without an invite-time seat gate', async () => {
mockGetOrganizationSubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'team_6000',
status: 'active',
})
const result = await getWorkspaceInvitePolicy({
...baseState,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId: 'org-1',
})
expect(result.allowed).toBe(true)
expect(result.requiresSeat).toBe(false)
expect(result.organizationId).toBe('org-1')
})
it('keeps the fixed-seat gate for enterprise org workspaces', async () => {
mockGetOrganizationSubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'enterprise',
status: 'active',
})
const result = await getWorkspaceInvitePolicy({
...baseState,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId: 'org-1',
})
expect(result.allowed).toBe(true)
expect(result.requiresSeat).toBe(true)
})
it('blocks org workspaces whose organization has no usable subscription', async () => {
mockGetOrganizationSubscription.mockResolvedValueOnce(null)
const result = await getWorkspaceInvitePolicy({
...baseState,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId: 'org-1',
})
expect(result.allowed).toBe(false)
expect(result.upgradeRequired).toBe(true)
})
it('blocks org workspaces without an organization id', async () => {
const result = await getWorkspaceInvitePolicy({
...baseState,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
})
expect(result.allowed).toBe(false)
expect(result.upgradeRequired).toBe(true)
})
it('allows grandfathered workspaces when the billed user has a team plan', async () => {
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'team_6000',
status: 'active',
})
const result = await getWorkspaceInvitePolicy({
...baseState,
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
})
expect(result.allowed).toBe(true)
expect(result.upgradeRequired).toBe(false)
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledWith('owner-1')
})
it('allows grandfathered workspaces when the billed user has a pro plan', async () => {
mockGetHighestPrioritySubscription.mockResolvedValueOnce({
id: 'sub-1',
plan: 'pro_6000',
status: 'active',
})
const result = await getWorkspaceInvitePolicy({
...baseState,
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
})
expect(result.allowed).toBe(true)
expect(result.upgradeRequired).toBe(false)
})
it('blocks grandfathered workspaces when the billed user is on a free plan', async () => {
mockGetHighestPrioritySubscription.mockResolvedValueOnce(null)
const result = await getWorkspaceInvitePolicy({
...baseState,
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
})
expect(result.allowed).toBe(false)
expect(result.upgradeRequired).toBe(true)
expect(result.reason).toBe(UPGRADE_TO_INVITE_REASON)
})
})
+406
View File
@@ -0,0 +1,406 @@
import { db } from '@sim/db'
import { member, type WorkspaceMode, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, count, eq, isNull } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserOrganization } from '@/lib/billing/organizations/membership'
import type { PlanCategory } from '@/lib/billing/plan-helpers'
import { getPlanType, isEnterprise, isMax, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants'
const logger = createLogger('WorkspacePolicy')
export const WORKSPACE_MODE = {
PERSONAL: 'personal',
ORGANIZATION: 'organization',
GRANDFATHERED_SHARED: 'grandfathered_shared',
} as const satisfies Record<string, WorkspaceMode>
interface WorkspaceOwnershipState {
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
ownerId: string
}
export {
CONTACT_OWNER_TO_UPGRADE_REASON,
UPGRADE_TO_INVITE_REASON,
} from '@/lib/workspaces/policy-constants'
export interface WorkspaceInvitePolicy {
allowed: boolean
reason: string | null
requiresSeat: boolean
organizationId: string | null
upgradeRequired: boolean
}
export interface WorkspaceCreationPolicy {
canCreate: boolean
workspaceMode: WorkspaceMode
organizationId: string | null
billedAccountUserId: string
maxWorkspaces: number | null
currentWorkspaceCount: number
reason: string | null
status: number
}
interface GetWorkspaceCreationPolicyParams {
userId: string
activeOrganizationId?: string | null
/**
* When true, `activeOrganizationId` is authoritative: it is used exactly as given
* (including `null`, which means a personal workspace) and never falls back to the
* caller's membership org. Forks set this so the child always lands in the SOURCE's
* org, not whatever org the acting user happens to belong to.
*/
pinOrganization?: boolean
}
export function isOrganizationWorkspace(
workspaceState: Pick<WorkspaceOwnershipState, 'workspaceMode' | 'organizationId'>
): boolean {
return (
workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
workspaceState.organizationId !== null &&
workspaceState.organizationId.length > 0
)
}
/**
* Computes whether new members can be invited to the given workspace
* under the active product policy.
*
* Any paid billed account (Pro, Team, or Enterprise) may invite — the
* seat, and any Pro→Team upgrade, is provisioned when the invitee
* accepts, so invites are no longer seat-gated at this layer. Free
* accounts are blocked with an upgrade tooltip because there is no
* payment method to charge at acceptance.
*
* - `organization`: allowed; the org already holds a Team/Enterprise
* subscription. Only Enterprise keeps an invite-time `requiresSeat`
* gate (fixed seats).
* - `personal` / `grandfathered_shared`: allowed when the billed user is
* Pro/Team/Enterprise. For Pro, acceptance creates the org and moves the
* subscription to the Team tier.
*
* Billing-disabled deployments always allow invites. Existing members
* keep their access — this policy only governs *new* invitations.
*/
export async function getWorkspaceInvitePolicy(
workspaceState: WorkspaceOwnershipState
): Promise<WorkspaceInvitePolicy> {
const billedPlanCategory = isBillingEnabled
? await resolveBilledPlanCategory(workspaceState)
: 'free'
return evaluateWorkspaceInvitePolicy(workspaceState, { billedPlanCategory })
}
/**
* Pure evaluator — given the billed account's resolved plan category,
* returns the policy synchronously. Exposed so bulk callers (e.g. listing
* every workspace a user can see) can batch the subscription lookups by
* unique billed account user rather than re-querying per workspace.
*/
export function evaluateWorkspaceInvitePolicy(
workspaceState: WorkspaceOwnershipState,
context: { billedPlanCategory: PlanCategory }
): WorkspaceInvitePolicy {
if (!isBillingEnabled) {
return {
allowed: true,
reason: null,
requiresSeat: false,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
}
if (workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION) {
if (workspaceState.organizationId === null || context.billedPlanCategory === 'free') {
return blockInvite(workspaceState.organizationId)
}
return {
allowed: true,
reason: null,
requiresSeat: context.billedPlanCategory === 'enterprise',
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
}
switch (context.billedPlanCategory) {
case 'pro':
case 'team':
return {
allowed: true,
reason: null,
requiresSeat: false,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
case 'enterprise':
return {
allowed: true,
reason: null,
requiresSeat: true,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
default:
return blockInvite(workspaceState.organizationId)
}
}
function blockInvite(organizationId: string | null): WorkspaceInvitePolicy {
return {
allowed: false,
reason: UPGRADE_TO_INVITE_REASON,
requiresSeat: false,
organizationId,
upgradeRequired: true,
}
}
async function resolveBilledPlanCategory(
workspaceState: WorkspaceOwnershipState
): Promise<PlanCategory> {
if (
workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
workspaceState.organizationId
) {
return getInvitePlanCategoryForOrganization(workspaceState.organizationId)
}
return getInvitePlanCategoryForUser(workspaceState.billedAccountUserId)
}
/**
* Resolve the invite-governing plan category for an organization from its
* subscription. Exposed so bulk callers can batch by unique organization id.
* Returns `'free'` when there is no usable subscription so lapsed orgs are
* blocked consistently with accept-time provisioning.
*/
export async function getInvitePlanCategoryForOrganization(
organizationId: string
): Promise<PlanCategory> {
try {
const orgSub = await getOrganizationSubscription(organizationId)
if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) return 'free'
return getPlanType(orgSub.plan)
} catch (error) {
logger.error('Failed to resolve organization subscription for invite policy', {
organizationId,
error,
})
return 'free'
}
}
/**
* Resolve the invite-governing plan category for a single billed account
* user. Exposed so bulk callers can batch by unique user id. Returns
* `'free'` when there is no usable paid subscription.
*/
export async function getInvitePlanCategoryForUser(userId: string): Promise<PlanCategory> {
try {
const sub = await getHighestPrioritySubscription(userId)
if (!sub || !hasUsableSubscriptionStatus(sub.status)) return 'free'
return getPlanType(sub.plan)
} catch (error) {
logger.error('Failed to resolve subscription for invite policy', { userId, error })
return 'free'
}
}
export async function getWorkspaceCreationPolicy({
userId,
activeOrganizationId,
pinOrganization = false,
}: GetWorkspaceCreationPolicyParams): Promise<WorkspaceCreationPolicy> {
const membership = await getUserOrganization(userId)
const organizationId = pinOrganization
? (activeOrganizationId ?? null)
: (activeOrganizationId ?? membership?.organizationId ?? null)
const orgRole =
organizationId == null
? undefined
: membership?.organizationId === organizationId
? membership.role
: (
await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
)[0]?.role
if (activeOrganizationId && !orgRole) {
const billedAccountUserId = await requireOrganizationOwnerId(activeOrganizationId)
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId: activeOrganizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: 'Only organization owners and admins can create organization workspaces.',
status: 403,
}
}
if (!isBillingEnabled) {
if (organizationId && orgRole) {
const billedAccountUserId = await requireOrganizationOwnerId(organizationId)
if (!isOrgAdminRole(orgRole)) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: 'Only organization owners and admins can create organization workspaces.',
status: 403,
}
}
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: null,
status: 200,
}
}
const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId)
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces: null,
currentWorkspaceCount,
reason: null,
status: 200,
}
}
if (organizationId && orgRole) {
const organizationSubscription = await getOrganizationSubscription(organizationId)
if (
organizationSubscription &&
hasUsableSubscriptionStatus(organizationSubscription.status) &&
(isTeam(organizationSubscription.plan) || isEnterprise(organizationSubscription.plan))
) {
const billedAccountUserId = await requireOrganizationOwnerId(organizationId)
if (!isOrgAdminRole(orgRole)) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: 'Only organization owners and admins can create organization workspaces.',
status: 403,
}
}
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: null,
status: 200,
}
}
}
const highestPrioritySubscription = await getHighestPrioritySubscription(userId)
const plan = highestPrioritySubscription?.plan
const maxWorkspaces = isMax(plan) ? 10 : isPro(plan) ? 3 : 1
const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId)
if (currentWorkspaceCount >= maxWorkspaces) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces,
currentWorkspaceCount,
reason: `This plan supports up to ${maxWorkspaces} personal workspace${maxWorkspaces === 1 ? '' : 's'}.`,
status: 403,
}
}
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces,
currentWorkspaceCount,
reason: null,
status: 200,
}
}
async function countNonOrganizationOwnedWorkspaces(userId: string): Promise<number> {
const [result] = await db
.select({ value: count() })
.from(workspace)
.where(and(eq(workspace.ownerId, userId), isNull(workspace.organizationId)))
return result?.value ?? 0
}
/**
* Returns the userId of the organization owner, or `null` if the
* organization has no owner row. Unexpected DB errors propagate to the
* caller so data-integrity issues surface loudly rather than being
* silently fallen back to the caller's identity.
*/
export async function getOrganizationOwnerId(organizationId: string): Promise<string | null> {
const [ownerMembership] = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.limit(1)
return ownerMembership?.userId ?? null
}
/**
* Like `getOrganizationOwnerId` but throws when no owner row exists.
* Use when the caller needs a guaranteed billed-account userId — every
* Better Auth organization is expected to have exactly one owner, so a
* missing owner is a data-integrity issue that should surface loudly.
*/
async function requireOrganizationOwnerId(organizationId: string): Promise<string> {
const ownerId = await getOrganizationOwnerId(organizationId)
if (!ownerId) {
logger.error('Organization is missing its owner membership row', { organizationId })
throw new Error(`Organization ${organizationId} has no owner membership`)
}
return ownerId
}
+194
View File
@@ -0,0 +1,194 @@
/**
* @vitest-environment node
*/
import { db } from '@sim/db'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({
db: { select: vi.fn() },
}))
import {
listAccessibleWorkspaceRowsForUser,
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
} from '@/lib/workspaces/utils'
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
function createMockChain(finalResult: unknown) {
const chain: any = {}
chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult))
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.innerJoin = vi.fn().mockReturnValue(chain)
chain.limit = vi.fn().mockReturnValue(chain)
chain.orderBy = vi.fn().mockReturnValue(chain)
return chain
}
function createSelectChain(result: unknown) {
const limit = vi.fn().mockResolvedValue(result)
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(result),
limit,
})
return { from, where, limit }
}
function createGroupedSelectChain(result: unknown) {
const groupBy = vi.fn().mockResolvedValue(result)
const where = vi.fn().mockReturnValue({ groupBy })
const from = vi.fn().mockReturnValue({ where })
return { from, where, groupBy }
}
function createUpdateChain(result: unknown) {
const returning = vi.fn().mockResolvedValue(result)
const where = vi.fn().mockReturnValue({ returning })
const set = vi.fn().mockReturnValue({ where })
return { set, where, returning }
}
describe('reassignWorkflowOwnershipForWorkspaceMemberRemovalTx', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reassigns departing member workflows to the workspace billed account', async () => {
const workspaceSelect = createSelectChain([
{ id: 'workspace-1', billedAccountUserId: 'billed-1' },
])
const workflowCountSelect = createGroupedSelectChain([
{ workspaceId: 'workspace-1', workflowCount: 2 },
])
const workflowUpdate = createUpdateChain([])
const tx = {
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
update: vi.fn().mockReturnValue(workflowUpdate),
}
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx: tx as any,
workspaceIds: ['workspace-1'],
departingUserId: 'departing-1',
})
expect(tx.update).toHaveBeenCalledTimes(1)
expect(workflowUpdate.set).toHaveBeenCalledWith(
expect.objectContaining({ updatedAt: expect.any(Date) })
)
expect(result).toEqual({
reassigned: [{ workspaceId: 'workspace-1', newWorkflowUserId: 'billed-1', workflowCount: 2 }],
unresolved: [],
})
})
it('marks a workspace unresolved when the departing member is the billed account', async () => {
const workspaceSelect = createSelectChain([
{ id: 'workspace-1', billedAccountUserId: 'departing-1' },
])
const workflowCountSelect = createGroupedSelectChain([
{ workspaceId: 'workspace-1', workflowCount: 1 },
])
const tx = {
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
update: vi.fn(),
}
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx: tx as any,
workspaceIds: ['workspace-1'],
departingUserId: 'departing-1',
})
expect(tx.update).not.toHaveBeenCalled()
expect(result).toEqual({ reassigned: [], unresolved: ['workspace-1'] })
})
it('does not mark a workspace unresolved when the departing billing account owns no workflows', async () => {
const workspaceSelect = createSelectChain([
{ id: 'workspace-1', billedAccountUserId: 'departing-1' },
])
const workflowCountSelect = createGroupedSelectChain([])
const tx = {
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
update: vi.fn(),
}
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx: tx as any,
workspaceIds: ['workspace-1'],
departingUserId: 'departing-1',
})
expect(tx.update).not.toHaveBeenCalled()
expect(result).toEqual({ reassigned: [], unresolved: [] })
})
it('marks a workspace unresolved when no billed account is configured', async () => {
const workspaceSelect = createSelectChain([{ id: 'workspace-1', billedAccountUserId: null }])
const workflowCountSelect = createGroupedSelectChain([
{ workspaceId: 'workspace-1', workflowCount: 1 },
])
const tx = {
select: vi.fn().mockReturnValueOnce(workspaceSelect).mockReturnValueOnce(workflowCountSelect),
update: vi.fn(),
}
const result = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx: tx as any,
workspaceIds: ['workspace-1'],
departingUserId: 'departing-1',
})
expect(tx.update).not.toHaveBeenCalled()
expect(result).toEqual({ reassigned: [], unresolved: ['workspace-1'] })
})
})
describe('listAccessibleWorkspaceRowsForUser', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('elevates an org admin to admin on an org workspace where they hold a lower explicit grant', async () => {
const orgWorkspace = { id: 'ws-1', name: 'Shared', ownerId: 'owner-x', organizationId: 'org-1' }
mockDb.select
.mockReturnValueOnce(createMockChain([{ workspace: orgWorkspace, permissionType: 'write' }]))
.mockReturnValueOnce(createMockChain([{ organizationId: 'org-1', role: 'admin' }]))
.mockReturnValueOnce(createMockChain([orgWorkspace]))
const rows = await listAccessibleWorkspaceRowsForUser('user-1', 'active')
expect(rows).toEqual([{ workspace: orgWorkspace, permissionType: 'admin' }])
})
it('keeps a lower explicit grant on a workspace owned by a different organization', async () => {
const externalWorkspace = {
id: 'ws-ext',
name: 'External',
ownerId: 'owner-y',
organizationId: 'org-2',
}
const orgWorkspace = { id: 'ws-1', name: 'Shared', ownerId: 'owner-x', organizationId: 'org-1' }
mockDb.select
.mockReturnValueOnce(
createMockChain([{ workspace: externalWorkspace, permissionType: 'write' }])
)
.mockReturnValueOnce(createMockChain([{ organizationId: 'org-1', role: 'admin' }]))
.mockReturnValueOnce(createMockChain([orgWorkspace]))
const rows = await listAccessibleWorkspaceRowsForUser('user-1', 'active')
expect(rows).toEqual([
{ workspace: externalWorkspace, permissionType: 'write' },
{ workspace: orgWorkspace, permissionType: 'admin' },
])
})
})
+507
View File
@@ -0,0 +1,507 @@
import { db } from '@sim/db'
import { member, permissions, workflow, workspace as workspaceTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import type { PermissionType } from '@sim/platform-authz/workspace'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { generateId } from '@sim/utils/id'
import { and, count, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
const logger = createLogger('WorkspaceUtils')
export interface WorkspaceBillingSettings {
billedAccountUserId: string | null
allowPersonalApiKeys: boolean
}
export type WorkspaceScope = 'active' | 'archived' | 'all'
export async function getWorkspaceBillingSettings(
workspaceId: string
): Promise<WorkspaceBillingSettings | null> {
if (!workspaceId) {
return null
}
const rows = await db
.select({
billedAccountUserId: workspaceTable.billedAccountUserId,
allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys,
})
.from(workspaceTable)
.where(and(eq(workspaceTable.id, workspaceId), isNull(workspaceTable.archivedAt)))
.limit(1)
if (!rows.length) {
return null
}
return {
billedAccountUserId: rows[0].billedAccountUserId ?? null,
allowPersonalApiKeys: rows[0].allowPersonalApiKeys ?? false,
}
}
export async function getWorkspaceBilledAccountUserId(workspaceId: string): Promise<string | null> {
const settings = await getWorkspaceBillingSettings(workspaceId)
return settings?.billedAccountUserId ?? null
}
/**
* Workspaces the user administers purely through organization owner/admin role,
* with no explicit permission row required. Empty when the user is not an org
* owner/admin. Implements the workspace-permission inheritance model.
*/
export async function getOrgAdminWorkspaceRows(
userId: string,
scope: WorkspaceScope = 'active'
): Promise<Array<typeof workspaceTable.$inferSelect>> {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!membership || !isOrgAdminRole(membership.role)) {
return []
}
const orgFilter = eq(workspaceTable.organizationId, membership.organizationId)
const where =
scope === 'all'
? orgFilter
: scope === 'archived'
? and(orgFilter, sql`${workspaceTable.archivedAt} IS NOT NULL`)
: and(orgFilter, isNull(workspaceTable.archivedAt))
return db.select().from(workspaceTable).where(where).orderBy(desc(workspaceTable.createdAt))
}
/**
* Every workspace a user can access: explicit permission grants plus workspaces
* derived from organization owner/admin role. Deduped with explicit rows first.
*/
export async function listAccessibleWorkspaceRowsForUser(
userId: string,
scope: WorkspaceScope = 'active'
): Promise<
Array<{ workspace: typeof workspaceTable.$inferSelect; permissionType: PermissionType }>
> {
const explicit = await db
.select({ workspace: workspaceTable, permissionType: permissions.permissionType })
.from(permissions)
.innerJoin(workspaceTable, eq(permissions.entityId, workspaceTable.id))
.where(
scope === 'all'
? and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))
: scope === 'archived'
? and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
sql`${workspaceTable.archivedAt} IS NOT NULL`
)
: and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
isNull(workspaceTable.archivedAt)
)
)
.orderBy(desc(workspaceTable.createdAt))
const orgRows = await getOrgAdminWorkspaceRows(userId, scope)
if (orgRows.length === 0) {
return explicit
}
const orgWorkspaceIds = new Set(orgRows.map((ws) => ws.id))
const seen = new Set(explicit.map((row) => row.workspace.id))
const elevatedExplicit = explicit.map((row) =>
orgWorkspaceIds.has(row.workspace.id) ? { ...row, permissionType: 'admin' as const } : row
)
const derived = orgRows
.filter((ws) => !seen.has(ws.id))
.map((ws) => ({ workspace: ws, permissionType: 'admin' as const }))
return [...elevatedExplicit, ...derived]
}
export async function listUserWorkspaces(userId: string, scope: WorkspaceScope = 'active') {
const rows = await listAccessibleWorkspaceRowsForUser(userId, scope)
return rows.map(({ workspace: ws, permissionType }) => ({
workspaceId: ws.id,
workspaceName: ws.name,
role: ws.ownerId === userId ? 'owner' : permissionType,
}))
}
export interface ReassignBilledAccountResult {
reassigned: Array<{ workspaceId: string; newBilledAccountUserId: string }>
unresolved: string[]
}
export interface ReassignWorkflowOwnershipResult {
reassigned: Array<{ workspaceId: string; newWorkflowUserId: string; workflowCount: number }>
unresolved: string[]
}
export const WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR =
'Cannot remove the workspace billing account. Please reassign billing first.'
export class WorkspaceBillingAccountRemovalError extends Error {
constructor() {
super(WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR)
this.name = 'WorkspaceBillingAccountRemovalError'
}
}
export async function transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId,
}: {
tx: DbOrTx
workspaceId: string
departingUserId: string
}): Promise<boolean> {
const [workspaceRow] = await tx
.select({
ownerId: workspaceTable.ownerId,
billedAccountUserId: workspaceTable.billedAccountUserId,
})
.from(workspaceTable)
.where(eq(workspaceTable.id, workspaceId))
.limit(1)
if (!workspaceRow || workspaceRow.ownerId !== departingUserId) {
return false
}
const newOwnerId = workspaceRow.billedAccountUserId
if (!newOwnerId || newOwnerId === departingUserId) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx
.update(workspaceTable)
.set({ ownerId: newOwnerId, updatedAt: new Date() })
.where(eq(workspaceTable.id, workspaceId))
const [existingNewOwnerPermission] = await tx
.select({ id: permissions.id })
.from(permissions)
.where(
and(
eq(permissions.userId, newOwnerId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (existingNewOwnerPermission) {
await tx
.update(permissions)
.set({ permissionType: 'admin', updatedAt: new Date() })
.where(eq(permissions.id, existingNewOwnerPermission.id))
return true
}
const now = new Date()
await tx.insert(permissions).values({
id: generateId(),
userId: newOwnerId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'admin',
createdAt: now,
updatedAt: now,
})
return true
}
/**
* Reassigns workflows owned by a user who is about to lose access to one or more workspaces.
*
* Workflow execution, webhook provider config, and environment variables intentionally resolve
* through `workflow.userId`, so that identity must remain an active workspace identity. The
* replacement is the workspace billed account: the same stable identity used for server-side
* billing/permission actor resolution, and one the member-removal routes protect from removal.
*/
export async function reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds,
departingUserId,
}: {
tx: DbOrTx
workspaceIds: string[]
departingUserId: string
}): Promise<ReassignWorkflowOwnershipResult> {
const uniqueWorkspaceIds = Array.from(new Set(workspaceIds.filter(Boolean)))
if (uniqueWorkspaceIds.length === 0) {
return { reassigned: [], unresolved: [] }
}
const workspaceRows = await tx
.select({
id: workspaceTable.id,
billedAccountUserId: workspaceTable.billedAccountUserId,
})
.from(workspaceTable)
.where(inArray(workspaceTable.id, uniqueWorkspaceIds))
const reassigned: ReassignWorkflowOwnershipResult['reassigned'] = []
const unresolved: string[] = []
const reassignmentWorkspaceIds: string[] = []
const workflowCounts = await tx
.select({
workspaceId: workflow.workspaceId,
workflowCount: count(workflow.id),
})
.from(workflow)
.where(
and(eq(workflow.userId, departingUserId), inArray(workflow.workspaceId, uniqueWorkspaceIds))
)
.groupBy(workflow.workspaceId)
const workflowCountsByWorkspaceId = new Map<string, number>()
for (const { workspaceId, workflowCount } of workflowCounts) {
if (!workspaceId || workflowCount === 0) continue
workflowCountsByWorkspaceId.set(workspaceId, workflowCount)
}
for (const ws of workspaceRows) {
const workflowCount = workflowCountsByWorkspaceId.get(ws.id) ?? 0
if (workflowCount === 0) {
continue
}
const replacementUserId =
ws.billedAccountUserId !== departingUserId ? ws.billedAccountUserId : null
if (!replacementUserId) {
unresolved.push(ws.id)
continue
}
reassignmentWorkspaceIds.push(ws.id)
}
if (reassignmentWorkspaceIds.length > 0) {
await tx
.update(workflow)
.set({
userId: sql<string>`(
select ${workspaceTable.billedAccountUserId}
from ${workspaceTable}
where ${workspaceTable.id} = ${workflow.workspaceId}
)`,
updatedAt: new Date(),
})
.where(
and(
eq(workflow.userId, departingUserId),
inArray(workflow.workspaceId, reassignmentWorkspaceIds)
)
)
const billedAccountByWorkspaceId = new Map(
workspaceRows.map((ws) => [ws.id, ws.billedAccountUserId])
)
for (const workspaceId of reassignmentWorkspaceIds) {
const workflowCount = workflowCountsByWorkspaceId.get(workspaceId) ?? 0
const newWorkflowUserId = billedAccountByWorkspaceId.get(workspaceId)
if (!newWorkflowUserId) continue
reassigned.push({
workspaceId,
newWorkflowUserId,
workflowCount,
})
}
}
if (reassigned.length > 0 || unresolved.length > 0) {
logger.info('Reassigned workflow ownership for removed workspace member', {
departingUserId,
reassigned,
unresolved,
})
}
return { reassigned, unresolved }
}
/**
* Reassigns `billedAccountUserId` on every workspace that points to `departingUserId` to
* another eligible user, so the user can be deleted without violating the `workspace.billed_account_user_id`
* foreign key (`ON DELETE NO ACTION`).
*
* Preference order for the replacement:
* 1. The workspace owner (if different from the departing user)
* 2. Any existing workspace admin
*
* Returns the list of workspaces that could not be reassigned (no owner + no admin). Callers should
* block user deletion when `unresolved.length > 0` so we never leave an orphaned billing reference.
*/
export async function reassignBilledAccountForUser(
departingUserId: string
): Promise<ReassignBilledAccountResult> {
const billedWorkspaces = await db
.select({
id: workspaceTable.id,
ownerId: workspaceTable.ownerId,
})
.from(workspaceTable)
.where(eq(workspaceTable.billedAccountUserId, departingUserId))
if (billedWorkspaces.length === 0) {
return { reassigned: [], unresolved: [] }
}
const reassigned: ReassignBilledAccountResult['reassigned'] = []
const unresolved: string[] = []
for (const ws of billedWorkspaces) {
let replacement: string | null = ws.ownerId !== departingUserId ? ws.ownerId : null
if (!replacement) {
const [admin] = await db
.select({ userId: permissions.userId })
.from(permissions)
.where(
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, ws.id),
eq(permissions.permissionType, 'admin'),
ne(permissions.userId, departingUserId)
)
)
.limit(1)
replacement = admin?.userId ?? null
}
if (!replacement) {
unresolved.push(ws.id)
continue
}
await db
.update(workspaceTable)
.set({ billedAccountUserId: replacement, updatedAt: new Date() })
.where(eq(workspaceTable.id, ws.id))
reassigned.push({ workspaceId: ws.id, newBilledAccountUserId: replacement })
}
if (reassigned.length > 0) {
logger.info('Reassigned workspace billed account for departing user', {
departingUserId,
reassignedCount: reassigned.length,
unresolvedCount: unresolved.length,
})
}
return { reassigned, unresolved }
}
export interface ReassignOwnedWorkspacesResult {
reassigned: Array<{ workspaceId: string; newOwnerId: string }>
unresolved: string[]
}
/**
* Reassigns `ownerId` on every workspace owned by `departingUserId` to another
* eligible user, so the user can be deleted without the `workspace.owner_id`
* `ON DELETE CASCADE` silently deleting their workspaces.
*
* Preference order for the replacement:
* 1. The workspace billed account (if different from the departing user)
* 2. Any other workspace admin
*
* Returns workspaces that could not be reassigned (no distinct billed account and
* no other admin). Callers MUST block user deletion when `unresolved.length > 0`
* so the cascade can never nuke a workspace.
*/
export async function reassignOwnedWorkspacesForUser(
departingUserId: string
): Promise<ReassignOwnedWorkspacesResult> {
const ownedWorkspaces = await db
.select({
id: workspaceTable.id,
billedAccountUserId: workspaceTable.billedAccountUserId,
})
.from(workspaceTable)
.where(eq(workspaceTable.ownerId, departingUserId))
if (ownedWorkspaces.length === 0) {
return { reassigned: [], unresolved: [] }
}
const reassigned: ReassignOwnedWorkspacesResult['reassigned'] = []
const unresolved: string[] = []
for (const ws of ownedWorkspaces) {
let replacement: string | null =
ws.billedAccountUserId !== departingUserId ? ws.billedAccountUserId : null
if (!replacement) {
const [admin] = await db
.select({ userId: permissions.userId })
.from(permissions)
.where(
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, ws.id),
eq(permissions.permissionType, 'admin'),
ne(permissions.userId, departingUserId)
)
)
.limit(1)
replacement = admin?.userId ?? null
}
if (!replacement) {
unresolved.push(ws.id)
continue
}
const now = new Date()
await db
.update(workspaceTable)
.set({ ownerId: replacement, updatedAt: now })
.where(eq(workspaceTable.id, ws.id))
// Owners are admins — guarantee the new owner holds an admin permission row.
await db
.insert(permissions)
.values({
id: generateId(),
userId: replacement,
entityType: 'workspace',
entityId: ws.id,
permissionType: 'admin',
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [permissions.userId, permissions.entityType, permissions.entityId],
set: { permissionType: 'admin', updatedAt: now },
})
reassigned.push({ workspaceId: ws.id, newOwnerId: replacement })
}
if (reassigned.length > 0) {
logger.info('Reassigned workspace ownership for departing user', {
departingUserId,
reassignedCount: reassigned.length,
unresolvedCount: unresolved.length,
})
}
return { reassigned, unresolved }
}