diff --git a/apps/sim/app/api/credentials/[id]/members/route.test.ts b/apps/sim/app/api/credentials/[id]/members/route.test.ts index b7960846808..8d08a511c53 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.test.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.test.ts @@ -75,6 +75,7 @@ describe('/api/credentials/[id]/members compatibility', () => { joinedAt: new Date('2026-08-02T00:00:00.000Z'), userName: 'Member', userEmail: 'member@example.com', + explicitAdmin: false, }, ]) }) diff --git a/apps/sim/app/api/organizations/[id]/removal-impact/route.ts b/apps/sim/app/api/organizations/[id]/removal-impact/route.ts index 8b3e9c8a3a9..2085f741d40 100644 --- a/apps/sim/app/api/organizations/[id]/removal-impact/route.ts +++ b/apps/sim/app/api/organizations/[id]/removal-impact/route.ts @@ -1,52 +1,25 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getMemberRemovalImpactContract } from '@/lib/api/contracts/organization' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' -import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('OrganizationRemovalImpactAPI') +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { previewOrganizationMemberRemoval } from '@/lib/organizations/application/members' /** - * Identity-bound credentials the target user owns in this organization's - * workspaces — the set that stops working when their workspace access is - * revoked. Readable by org admins (removing someone) and by the user - * themself (leaving). + * Identity-bound credentials the target user owns in this organization's workspaces. Readable by + * org admins (removing someone) and by the user themself (leaving). */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getMemberRemovalImpactContract, request, context) - if (!parsed.success) return parsed.response - - const { id: organizationId } = parsed.data.params - const { userId: targetUserId } = parsed.data.query - - try { - const isSelf = targetUserId === session.user.id - if (!isSelf && !(await isOrganizationOwnerOrAdmin(session.user.id, organizationId))) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } - - const credentials = await getOrganizationTransferCredentialDependencies( - targetUserId, - organizationId - ) - - return NextResponse.json({ credentials }) - } catch (error) { - logger.error('Failed to compute member removal impact', { - organizationId, - targetUserId, - error, - }) - return NextResponse.json({ error: 'Failed to compute removal impact' }, { status: 500 }) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getMemberRemovalImpactContract, + auth: internalSessionAuth, + operation: previewOrganizationMemberRemoval.operation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing organization member administration behavior.', + }), + errorPolicy: internalOrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ organizationId: params.id, userId: query.userId }), + useCase: previewOrganizationMemberRemoval, + present: ({ credentials }) => ({ credentials }), +}) diff --git a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts index 53ef7dab812..7a6fc78758b 100644 --- a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts @@ -149,16 +149,21 @@ export const POST = withRouteHandler( userId: session.user.id, organizationId, memberId: selfMember.id, + actorUserId: session.user.id, spareSessionToken: session.session.token, }) deliverRunStops(removeResult.stoppedRuns) if (!removeResult.success) { - logger.error('Transfer succeeded but self-removal failed', { + const outcome = { organizationId, userId: session.user.id, error: removeResult.error, - }) + refusalCode: removeResult.refusalCode, + } + if (removeResult.refusalCode) + logger.info('Transfer succeeded; policy kept the previous owner as admin', outcome) + else logger.error('Transfer succeeded but self-removal failed', outcome) return NextResponse.json( { success: true, diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts index 19d3211a318..9458d61bfdb 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts @@ -39,7 +39,7 @@ export const GET = defineV2JsonRoute({ }), useCase: listCredentialMembersUseCase, present: ({ members, nextCursorKeys }, { params, query }) => ({ - data: members.map((member) => ({ + data: members.map(({ explicitAdmin, ...member }) => ({ ...member, joinedAt: member.joinedAt?.toISOString() ?? null, })), diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 0c3eeb6a67b..db049b18fe5 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -3,12 +3,31 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { deleteWorkspaceBodySchema, updateWorkspaceContract } from '@/lib/api/contracts' -import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { + deleteWorkspaceBodySchema, + updateWorkspaceContract, + updateWorkspacePutContract, +} from '@/lib/api/contracts' +import { validationErrorResponse } from '@/lib/api/server' import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response' +import { + defineInternalJsonRoute, + type InternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { getSession } from '@/lib/auth' -import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer' +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, +} from '@/lib/core/application' import { captureServerEvent } from '@/lib/posthog/server' +import { + updateWorkspace, + workspaceUpdateOperations, +} from '@/lib/workspaces/application/update-workspace' import { archiveWorkspace } from '@/lib/workspaces/lifecycle' import { presentWorkspace } from '@/lib/workspaces/response' @@ -18,10 +37,8 @@ import { db } from '@sim/db' import { permissions, workspace } from '@sim/db/schema' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deliverRunStops } from '@/lib/mothership/request/application/stop-delivery' -import { - getEffectiveWorkspacePermission, - getUserEntityPermissions, -} from '@/lib/workspaces/permissions/utils' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import type { WorkspaceUpdatePatch, WorkspaceUpdateResult } from '@/lib/workspaces/update' export const GET = withRouteHandler( async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -60,174 +77,35 @@ export const GET = withRouteHandler( } ) -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceContract, request, context) - if (!parsed.success) return parsed.response - - const workspaceId = parsed.data.params.id - - // Check if user has admin permissions to update workspace - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const body = parsed.data.body - const { name, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body - - if ( - name === undefined && - logoUrl === undefined && - billedAccountUserId === undefined && - allowPersonalApiKeys === undefined - ) { - return NextResponse.json({ error: 'No updates provided' }, { status: 400 }) - } - - const existingWorkspace = await db - .select() - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .then((rows) => rows[0]) - - if (!existingWorkspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const updateData: Record = {} - - if (name !== undefined) { - updateData.name = name - } - - if (logoUrl !== undefined) { - updateData.logoUrl = logoUrl - } - - if (allowPersonalApiKeys !== undefined) { - updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys) - } - - if (billedAccountUserId !== undefined) { - if ( - existingWorkspace.organizationId && - existingWorkspace.workspaceMode === 'organization' - ) { - return NextResponse.json( - { - error: - 'Organization workspaces use organization billing and cannot change billed account.', - }, - { status: 400 } - ) - } - - if (existingWorkspace.workspaceMode === 'personal') { - return NextResponse.json( - { - error: - 'Personal workspaces are always billed to their owner and cannot change billed account.', - }, - { status: 400 } - ) - } - - const candidateId = billedAccountUserId - - const candidatePermission = await getEffectiveWorkspacePermission( - candidateId, - existingWorkspace - ) - if (candidatePermission !== 'admin') { - return NextResponse.json( - { error: 'Billed account must be a workspace admin' }, - { status: 400 } - ) - } - } - - if (Object.keys(updateData).length === 0 && billedAccountUserId === undefined) { - return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 }) - } - - updateData.updatedAt = new Date() - - if (billedAccountUserId !== undefined) { - await db.transaction(async (tx) => { - await changeWorkspaceStoragePayerInTx(tx, { - workspaceId, - organizationId: existingWorkspace.organizationId, - billedAccountUserId, - expectedCurrentPayer: { - organizationId: existingWorkspace.organizationId, - billedAccountUserId: existingWorkspace.billedAccountUserId, - }, - }) - await tx.update(workspace).set(updateData).where(eq(workspace.id, workspaceId)) - }) - } else { - await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId)) - } - - const updatedWorkspace = await db - .select() - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .then((rows) => rows[0]) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.WORKSPACE_UPDATED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: updatedWorkspace?.name ?? existingWorkspace.name, - description: `Updated workspace "${updatedWorkspace?.name ?? existingWorkspace.name}"`, - metadata: { - changes: { - ...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }), - ...(logoUrl !== undefined && { - logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl }, - }), - ...(allowPersonalApiKeys !== undefined && { - allowPersonalApiKeys: { - from: existingWorkspace.allowPersonalApiKeys, - to: allowPersonalApiKeys, - }, - }), - ...(billedAccountUserId !== undefined && { - billedAccountUserId: { - from: existingWorkspace.billedAccountUserId, - to: billedAccountUserId, - }, - }), - }, - }, - request, - }) - - return NextResponse.json({ - workspace: { - ...(updatedWorkspace ? presentWorkspace(updatedWorkspace) : {}), - permissions: userPermission, - }, - }) - } catch (error) { - logger.error('Error updating workspace:', error) - return NextResponse.json({ error: 'Failed to update workspace' }, { status: 500 }) - } - } -) +const updateWorkspaceErrorPolicy: InternalErrorPolicy = { + project: (error) => + error instanceof NoWorkspaceAccessError || + error instanceof InsufficientWorkspacePermissionsError + ? internalErrorResponse(403, { error: 'Insufficient permissions' }) + : internalOrchestrationErrorPolicy.project(error), + unhandled: () => internalErrorResponse(500, { error: 'Failed to update workspace' }), +} + +const updateWorkspaceRoute = { + auth: internalSessionAuth, + operation: workspaceUpdateOperations.update, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing workspace update admission' }), + errorPolicy: updateWorkspaceErrorPolicy, + mapInput: ({ params, body }: { params: { id: string }; body: WorkspaceUpdatePatch }) => ({ + workspaceId: params.id, + patch: body, + }), + useCase: updateWorkspace, + /** Only a workspace admin reaches a successful update, so the caller's role is known. */ + present: ({ updated }: WorkspaceUpdateResult) => ({ + workspace: { ...presentWorkspace(updated), permissions: 'admin' as const }, + }), +} + +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkspaceContract, + ...updateWorkspaceRoute, +}) export const DELETE = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -344,9 +222,7 @@ export const DELETE = withRouteHandler( } ) -export const PUT = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - // Reuse the PATCH handler implementation for PUT requests - return PATCH(request, { params }) - } -) +export const PUT = defineInternalJsonRoute({ + contract: updateWorkspacePutContract, + ...updateWorkspaceRoute, +}) diff --git a/apps/sim/app/extension/[workspaceId]/workspace.tsx b/apps/sim/app/extension/[workspaceId]/workspace.tsx index 30c3011d48d..e0b5b393dff 100644 --- a/apps/sim/app/extension/[workspaceId]/workspace.tsx +++ b/apps/sim/app/extension/[workspaceId]/workspace.tsx @@ -5,6 +5,7 @@ import { isApiClientError } from '@/lib/api/client/errors' import { ExtensionChat } from '@/app/extension/[workspaceId]/chat' import ExtensionLoading from '@/app/extension/loading' import { ExtensionSignIn } from '@/app/extension/sign-in' +import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal' import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' @@ -65,6 +66,7 @@ export function ExtensionWorkspace({ workspaceId }: ExtensionWorkspaceProps) { > + diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx index a5790cd9e35..6f44c352d39 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx @@ -13,6 +13,7 @@ import { toast, } from '@sim/emcn' import { PanelLeft, Send, Settings } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { IdentityTile } from '@/components/identity-tile/identity-tile' import { getOrganizationSettingsHref } from '@/components/settings/navigation' @@ -20,7 +21,10 @@ import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface' import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' +import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { holdRowFocus } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' +import { useRenameOrganization } from '@/hooks/queries/organization' import { useUploadOrganizationLogo } from '@/hooks/queries/organization-logo' function getOrganizationInitial(name: string): string { @@ -29,7 +33,8 @@ function getOrganizationInitial(name: string): string { interface OrganizationHeaderProps { organization: OrganizationSurfaceOrganization - canEditLogo: boolean + /** Organization admins may change its logo and name. */ + canEditIdentity: boolean canInviteMembers: boolean isCollapsed: boolean /** Expands the rail; the collapsed header is itself the expand control. */ @@ -42,21 +47,33 @@ interface OrganizationHeaderProps { * into its settings; collapsed, it becomes the rail's expand control, swapping * the mark for a panel glyph on hover exactly as the workspace header does. The * mark is the organization's uploaded logo or its initial on the neutral tile. + * Admins change the logo and rename the organization from the card. */ export function OrganizationHeader({ organization, - canEditLogo, + canEditIdentity, canInviteMembers, isCollapsed, onExpandSidebar, }: OrganizationHeaderProps) { const fileInputRef = useRef(null) + const nameInputRef = useRef(null) const router = useRouter() const { mutate: uploadLogo, isPending: isUploadingLogo } = useUploadOrganizationLogo( organization.id ) + const renameOrganization = useRenameOrganization() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) - const initial = getOrganizationInitial(organization.name) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [draftName, setDraftName] = useState(null) + /** + * The name just saved, shown until the refreshed server prop replaces the one + * it was saved over; without it the old name flashes back in between. + */ + const [savedName, setSavedName] = useState<{ name: string; replaces: string } | null>(null) + if (savedName && savedName.replaces !== organization.name) setSavedName(null) + const organizationName = savedName?.name ?? organization.name + const initial = getOrganizationInitial(organizationName) if (isCollapsed) { return ( @@ -85,13 +102,37 @@ export function OrganizationHeader({ } const { memberCount } = organization + const stopRenaming = () => { + setDraftName(null) + renameOrganization.reset() + } + + const submitRename = () => { + if (draftName === null || renameOrganization.isPending) return + const name = draftName.trim() + if (!name || name === organizationName) { + stopRenaming() + return + } + renameOrganization.mutate( + { orgId: organization.id, name }, + { + onSuccess: () => { + setSavedName({ name, replaces: organization.name }) + stopRenaming() + }, + onError: () => requestAnimationFrame(() => nameInputRef.current?.focus()), + } + ) + } + const logo = ( - + ) return (
- {canEditLogo && ( + {canEditIdentity && ( )} - + { + // The input's blur saves the draft; closing now would unmount it and hide a failure. + if (!open && draftName !== null) return + setIsMenuOpen(open) + }} + > } rightAdornment={} > - {organization.name} + {organizationName}
- {canEditLogo ? ( + {canEditIdentity ? ( - +
+ {draftName !== null ? ( + event.currentTarget.select()} + onChange={(event) => { + setDraftName(event.target.value) + if (renameOrganization.isError) renameOrganization.reset() + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + submitRename() + } else if (event.key === 'Escape') { + event.preventDefault() + stopRenaming() + } + }} + onBlur={submitRename} + /> + ) : canEditIdentity ? ( + { + event.preventDefault() + setDraftName(organizationName) + }} + > + + + ) : ( + + )} + {renameOrganization.isError && ( + + {getErrorMessage(renameOrganization.error, 'Failed to rename organization')} + + )} {memberCount} {memberCount === 1 ? 'member' : 'members'} @@ -176,7 +269,7 @@ export function OrganizationHeader({ open={isInviteModalOpen} onOpenChange={setIsInviteModalOpen} organizationId={organization.id} - isOrganizationAdmin={canEditLogo} + isOrganizationAdmin={canEditIdentity} canInvite={canInviteMembers} /> )} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx index e465ddbc458..5d7d19dc225 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -201,7 +201,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar({ > (null) const selected = accounts.find((account) => account.credentialId === selectedId) const permissionAccounts = accounts.filter((account) => @@ -118,7 +118,10 @@ export function AccountActionsMenu({ disabled: disconnect.isPending, onClick: () => { if (!selected || disconnect.isPending) return - disconnect.mutate(selected.credentialId, { onSuccess: () => setSelectedId(null) }) + disconnect.mutate( + { credentialId: selected.credentialId, organizationId }, + { onSuccess: () => setSelectedId(null) } + ) }, }} > diff --git a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx index 74d0d8d798d..f886a70644a 100644 --- a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx @@ -30,7 +30,7 @@ export function AccountConnectionActions({ canConnect, connectionPending, }: AccountConnectionActionsProps) { - const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) + const disconnect = useDisconnectPersonalOrganizationAccount() const [confirming, setConfirming] = useState(false) const pending = connectionPending || disconnect.isPending const actions: SettingsAction[] = [] @@ -79,7 +79,10 @@ export function AccountConnectionActions({ pending: disconnect.isPending, disabled: disconnect.isPending, onClick: () => - disconnect.mutate(account.credentialId, { onSuccess: () => setConfirming(false) }), + disconnect.mutate( + { credentialId: account.credentialId, organizationId }, + { onSuccess: () => setConfirming(false) } + ), }} > {disconnect.error?.message} diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx index 11d861a8b03..ca9506b40c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx @@ -1,12 +1,20 @@ 'use client' + +import { useState } from 'react' +import { ChipConfirmModal, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' import { credentialRoleLockReason, MEMBER_ROLE_OPTIONS, type MemberRole, MemberRow, + type MemberRowAction, } from '@/components/permissions' +import { useSession } from '@/lib/auth/client' import { + useLeaveCredential, useRemoveWorkspaceCredentialMember, useUpsertWorkspaceCredentialMember, useWorkspaceCredentialMembers, @@ -17,24 +25,38 @@ const logger = createLogger('CredentialMembersSection') interface CredentialMembersSectionProps { credentialId: string + credentialName: string isAdmin: boolean + /** Where the viewer lands after leaving, since leaving revokes their access. */ + leaveHref: string } /** - * Active-member list for a credential: avatar + identity, a role dropdown, and a - * remove action. The last remaining admin cannot be demoted or removed. Shared - * by every credential detail surface. + * Active-member list for a credential: avatar + identity, a role dropdown, a + * remove action for admins, and a leave action on the viewer's own row. The + * last remaining admin cannot be demoted, removed, or leave. Shared by every + * credential detail surface. */ -export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMembersSectionProps) { +export function CredentialMembersSection({ + credentialId, + credentialName, + isAdmin, + leaveHref, +}: CredentialMembersSectionProps) { + const router = useRouter() + const { data: session } = useSession() const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers(credentialId) const upsertMember = useUpsertWorkspaceCredentialMember() const removeMember = useRemoveWorkspaceCredentialMember() + const leaveCredential = useLeaveCredential() + + const [isLeaveConfirmOpen, setIsLeaveConfirmOpen] = useState(false) + const viewerId = session?.user?.id const activeMembers = members.filter((member) => member.status === 'active') - const explicitAdminCount = activeMembers.filter( - (member) => member.role === 'admin' && member.roleSource !== 'workspace-admin' - ).length + const explicitAdminCount = activeMembers.filter((member) => member.explicitAdmin).length + const showActions = isAdmin || activeMembers.some((member) => member.userId === viewerId) const handleChangeMemberRole = async (userId: string, role: MemberRole) => { const current = activeMembers.find((member) => member.userId === userId) @@ -54,6 +76,19 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe } } + const handleConfirmLeave = async () => { + try { + await leaveCredential.mutateAsync(credentialId) + setIsLeaveConfirmOpen(false) + router.push(leaveHref) + } catch (error) { + toast.error("Couldn't leave credential", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + logger.error('Failed to leave credential', error) + } + } + return ( {membersLoading ? null : ( @@ -64,22 +99,56 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe member.role === 'admin' && member.roleSource !== 'workspace-admin' && explicitAdminCount <= 1 + const actionDisabled = roleLocked || lockReason !== null + const action: MemberRowAction | null = + member.userId === viewerId + ? { + label: 'Leave', + disabled: actionDisabled, + disabledReason: + lockReason ?? + (roleLocked ? 'Make someone else an admin before leaving' : null), + onSelect: () => setIsLeaveConfirmOpen(true), + } + : isAdmin + ? { + label: 'Remove', + disabled: actionDisabled, + onSelect: () => handleRemoveMember(member.userId), + } + : null return ( handleChangeMemberRole(member.userId, role)} - onRemove={() => handleRemoveMember(member.userId)} /> ) })}
)} + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index d168f05f423..5762d963c63 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -16,6 +16,7 @@ import { usePathname } from 'next/navigation' import { getDesktopBridge } from '@/lib/desktop' import { SidebarChromeProvider } from '@/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context' import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek' +import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' import { SIDEBAR_NO_MOTION_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { isMobileViewport, useIsMobile } from '@/hooks/use-is-mobile' @@ -446,6 +447,7 @@ export function WorkspaceChrome({
)} {!isFullscreen && } +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/chat-plan-modal.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/chat-plan-modal.tsx new file mode 100644 index 00000000000..6382d5f735e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/chat-plan-modal.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useEffect } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalDescription, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { useRouter } from 'next/navigation' +import { CHAT_PLAN_REQUIRED_UPGRADE } from '@/lib/billing/chat-plan' +import { rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' +import { usePlanUpgradeLink } from '@/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { useChatPlanModalStore } from '@/stores/modals/chat-plan/store' + +/** + * Answers a send the workspace's plan refuses: Chat is on paid plans, with the way to upgrade + * for a viewer who may, and who to ask for one who may not. Render it once per workspace shell; + * leaving the shell closes it, so a refusal never reopens in the next workspace or organization. + */ +export function ChatPlanModal() { + const workspaceId = useOptionalWorkspaceHostContext()?.workspace.id + const isOpen = useChatPlanModalStore((state) => state.workspaceId === workspaceId) + const close = useChatPlanModalStore((state) => state.close) + + useEffect(() => close, [close]) + + return ( + !open && close()} + srTitle='Paid Plan Required' + size='sm' + > + + + ) +} + +interface ChatPlanModalContentProps { + onClose: () => void +} + +/** Mounted only while the modal shows, so a closed modal reads no session or billing context. */ +function ChatPlanModalContent({ onClose }: ChatPlanModalContentProps) { + const router = useRouter() + const { href, opensInNewTab, buttonLabel, canManageBilling, unavailableMessage } = + usePlanUpgradeLink(CHAT_PLAN_REQUIRED_UPGRADE) + + const upgrade = () => { + onClose() + if (opensInNewTab) { + window.open(href, '_blank', 'noopener,noreferrer') + return + } + rememberSettingsReturnUrl(href) + router.push(href) + } + + return ( + <> + Paid Plan Required + + + {CHAT_PLAN_REQUIRED_UPGRADE.message} + {!canManageBilling && ` ${unavailableMessage}`} + + + {canManageBilling ? ( + + ) : ( + + )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/index.ts new file mode 100644 index 00000000000..ff1589cb0f7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/index.ts @@ -0,0 +1 @@ +export { ChatPlanModal } from './chat-plan-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/chat-plan-notice.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/chat-plan-notice.tsx deleted file mode 100644 index 7bad390b5fa..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/chat-plan-notice.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import { CHAT_PLAN_REQUIRED_UPGRADE } from '@/lib/billing/chat-plan' -import { PlanUsageLimitDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' -import { useChatPlanRequired } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-required' - -/** - * The upgrade card above the composer while the workspace's plan does not include Chat, the same - * card run admission writes, so a free payer learns why Chat is held before sending. Render it - * beside every `UserInput`, which disables send on the same verdict. - */ -export function ChatPlanNotice() { - const chatPlanRequired = useChatPlanRequired() - if (!chatPlanRequired) return null - - return ( -
- -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/index.ts deleted file mode 100644 index 60edaf2cd0a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ChatPlanNotice } from './chat-plan-notice' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts index 2d4cbf2290c..fb354716ea3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts @@ -1,5 +1,4 @@ export { ChatMessageAttachments } from './chat-message-attachments' -export { ChatPlanNotice } from './chat-plan-notice' export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context' export { ContextMentionIcon } from './context-mention-icon' export { CreditsChip } from './credits-chip' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index 99084915309..2f43b29539e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -27,7 +27,6 @@ export { credentialTagHasVisibleCard, formatCredentialSubmissionMessage, isHttpUrl, - PlanUsageLimitDisplay, parseCredentialSubmissionMessage, parseCredentialSubmissionProgress, parseCredentialTagBody, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index fa60fa4238d..a082d663828 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -30,18 +30,9 @@ import { } from '@sim/emcn/icons' import { isRecordLike, omit } from '@sim/utils/object' import { useParams } from 'next/navigation' -import { getOrganizationSettingsHref } from '@/components/settings/navigation' -import { useSettingsNavigationTarget } from '@/components/settings/settings-navigation-provider' import { useSession } from '@/lib/auth/client' import { CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan' -import { - buildHostedUpgradeUrl, - buildUpgradeHref, - HOSTED_BILLING_SETTINGS_URL, -} from '@/lib/billing/upgrade-reasons' -import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport' -import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { readLatestOAuthChatAttempt } from '@/lib/credentials/oauth-chat-attempt' import { getDesktopBridge } from '@/lib/desktop' @@ -64,7 +55,6 @@ import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { organizationSecretNameSchema } from '@/lib/organization-secrets/validation' import { finishTerminalHandoff, isTerminalAvailable } from '@/lib/terminal/transport' -import { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { @@ -93,6 +83,7 @@ import { } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection' import { usePersonalCredentialConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-personal-credential-connection' import { ResourceWorkspaceHost } from '@/app/workspace/[workspaceId]/home/components/resource-workspace-host' +import { usePlanUpgradeLink } from '@/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link' import type { ChatMessageContext, MothershipResource, @@ -121,7 +112,6 @@ import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFileAtPath, useWorkspaceFileLookup } from '@/hooks/queries/workspace-files' import { useWorkspaceUsageGate } from '@/hooks/queries/workspace-usage' -import { useSettingsNavigation } from '@/hooks/use-settings-navigation' export interface OptionsItemData { title: string @@ -3500,45 +3490,14 @@ interface PlanUsageLimitDisplayProps { * The payer's plan limit, or a free plan that does not include Chat: the card links to where * the plan or the limit can change, and a workspace's plan to its upgrade page. */ -export function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) { - const { data: session } = useSession() +function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) { const hostContext = useOptionalWorkspaceHostContext() - const organizationContext = useOptionalOrganizationContext() - const { getSettingsHref } = useSettingsNavigation() - const { hosted } = useDeploymentShape() - const navigationTarget = useSettingsNavigationTarget() - const opensInNewTab = !hosted || navigationTarget === '_blank' + const { href, opensInNewTab, buttonLabel, canManageBilling, unavailableMessage } = + usePlanUpgradeLink(data) const planRequired = data.reason === CHAT_PLAN_REQUIRED_REASON - const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit' - - // Self-hosted plan and limit both live on the hosted account, so local - // workspace billing roles say nothing about who may change them. - const href = hosted - ? organizationContext - ? getOrganizationSettingsHref(organizationContext.organization.id, 'billing') - : planRequired && hostContext - ? buildUpgradeHref(hostContext.workspace.id) - : getSettingsHref({ section: 'billing' }) - : data.action === 'upgrade_plan' - ? buildHostedUpgradeUrl() - : HOSTED_BILLING_SETTINGS_URL - const canManageBilling = - !hosted || - (organizationContext - ? organizationContext.viewer.isAdmin - : Boolean(hostContext && canManageWorkspaceBilling(hostContext, session?.user?.id))) const usageGate = useWorkspaceUsageGate( data.action === 'increase_limit' && !canManageBilling ? hostContext?.workspace.id : undefined ) - const action = planRequired - ? 'upgrade this workspace’s plan' - : 'manage this workspace’s usage limits' - const unavailableMessage = - organizationContext && !planRequired - ? 'Contact an organization admin to manage usage limits.' - : organizationContext || hostContext?.hostOrganizationId - ? `Contact an organization admin to ${action}.` - : `Only the workspace owner can ${action}.` return (
- {!isLoading && } (function UserI const canSubmit = (editor.value.trim().length > 0 || hasFiles || hasAdditionalAttachments) && !isSending && - !hasUploadingFiles && - !chatPlanRequired + !hasUploadingFiles /** * Sync the editor when the `defaultValue` prop changes post-mount — e.g. @@ -532,7 +532,10 @@ const UserInputImpl = forwardRef(function UserI }, [resetTranscript, history.reset]) const handleSubmit = useCallback(() => { - if (chatPlanRequired) return + if (chatPlanRequired) { + useChatPlanModalStore.getState().open(workspaceId) + return + } const currentFiles = filesRef.current const currentEditor = editorRef.current @@ -558,7 +561,7 @@ const UserInputImpl = forwardRef(function UserI ) if (accepted === false) return clearComposer() - }, [chatPlanRequired, onSubmit, clearComposer]) + }, [chatPlanRequired, workspaceId, onSubmit, clearComposer]) /** * Enter policy for the editor: mirror canSubmit's uploading guard (Enter diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 724943dfac0..1e3523389f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -41,7 +41,6 @@ import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { - ChatPlanNotice, ChatSurfaceProvider, CreditsChip, MothershipChat, @@ -443,7 +442,6 @@ function HomeContent({ chatId, userName, userId }: HomeProps) { What should we get done{firstName ? `, ${firstName}` : ''}?
- { if ((!message.trim() && !fileAttachments?.length) || !scopeKey) return - if (chatPlanRequired) return + if (chatPlanRequired) { + useChatPlanModalStore.getState().open(workspaceId) + return + } const queueStore = useMothershipQueueStore.getState() const activeChatKey = chatKeyRef.current @@ -5302,7 +5306,10 @@ export function useChat( const msg = id === undefined ? queue?.[0] : queue?.find((queued) => queued.id === id) if (!msg || queueState.editing[chatKey] === msg.id) return if (queuedMessageDispatchIds.has(msg.id)) return - if (chatPlanRequired) return + if (chatPlanRequired) { + useChatPlanModalStore.getState().open(workspaceId) + return + } /* Sent by hand, it still must not run a second turn: if the history shows it accepted, it is already in the chat. A history that cannot be read does not hold it back here; the user asked to send it, and the server deduplicates it diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link.ts new file mode 100644 index 00000000000..dc3857425b6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link.ts @@ -0,0 +1,81 @@ +'use client' + +import { getOrganizationSettingsHref } from '@/components/settings/navigation' +import { useSettingsNavigationTarget } from '@/components/settings/settings-navigation-provider' +import { useSession } from '@/lib/auth/client' +import { CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan' +import { + buildHostedUpgradeUrl, + buildUpgradeHref, + HOSTED_BILLING_SETTINGS_URL, +} from '@/lib/billing/upgrade-reasons' +import type { UsageUpgradeTagPayload } from '@/lib/billing/usage-upgrade' +import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' + +interface PlanUpgradeLinkInput { + reason: string + action: UsageUpgradeTagPayload['action'] +} + +interface PlanUpgradeLink { + href: string + opensInNewTab: boolean + buttonLabel: string + /** Whether the viewer may change the plan or limit; when false, show `unavailableMessage`. */ + canManageBilling: boolean + unavailableMessage: string +} + +/** + * Where a usage-upgrade prompt sends the viewer, and whether they may go: the payer's plan or + * limit, or a free plan that does not include Chat. A workspace's plan links to its upgrade page; + * self-hosted plans and limits live on the hosted account. + */ +export function usePlanUpgradeLink(data: PlanUpgradeLinkInput): PlanUpgradeLink { + const { data: session } = useSession() + const hostContext = useOptionalWorkspaceHostContext() + const organizationContext = useOptionalOrganizationContext() + const { getSettingsHref } = useSettingsNavigation() + const { hosted } = useDeploymentShape() + const navigationTarget = useSettingsNavigationTarget() + const planRequired = data.reason === CHAT_PLAN_REQUIRED_REASON + + // Self-hosted plan and limit both live on the hosted account, so local + // workspace billing roles say nothing about who may change them. + const href = hosted + ? organizationContext + ? getOrganizationSettingsHref(organizationContext.organization.id, 'billing') + : planRequired && hostContext + ? buildUpgradeHref(hostContext.workspace.id) + : getSettingsHref({ section: 'billing' }) + : data.action === 'upgrade_plan' + ? buildHostedUpgradeUrl() + : HOSTED_BILLING_SETTINGS_URL + const canManageBilling = + !hosted || + (organizationContext + ? organizationContext.viewer.isAdmin + : Boolean(hostContext && canManageWorkspaceBilling(hostContext, session?.user?.id))) + const action = planRequired + ? 'upgrade this workspace’s plan' + : 'manage this workspace’s usage limits' + const unavailableMessage = organizationContext + ? planRequired + ? 'Contact an organization admin to upgrade this organization’s plan.' + : 'Contact an organization admin to manage usage limits.' + : hostContext?.hostOrganizationId + ? `Contact an organization admin to ${action}.` + : `Only the workspace owner can ${action}.` + + return { + href, + opensInNewTab: !hosted || navigationTarget === '_blank', + buttonLabel: data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit', + canManageBilling, + unavailableMessage, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 06c5a43ec9f..71005eaf063 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -318,7 +318,12 @@ export function ConnectedCredentialDetail({ {credential.type !== 'personal_token' && ( - + )} {credential.type === 'personal_token' && credential.instanceUrl && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment.tsx new file mode 100644 index 00000000000..dae1a7f0c73 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment.tsx @@ -0,0 +1,59 @@ +'use client' + +import { Chip } from '@sim/emcn' +import type { ViewerConnectorMembership } from '@/lib/api/contracts/knowledge/connectors' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useStartConnectorMemberEnrollment } from '@/hooks/queries/kb/connectors' + +type UnconnectedMembership = Exclude + +/** Revoked and unverified viewers get the server's refusal as the row, with no action to retry. */ +const MEMBERSHIP_STATES: Record = { + not_enrolled: { title: 'Your account is not connected', action: 'Connect your account' }, + invited: { title: 'Your account is not connected', action: 'Connect your account' }, + needs_reauth: { title: 'Your account needs to be reconnected', action: 'Reconnect' }, + revoked: { title: 'An admin removed your access to Connected accounts' }, + unverified_email: { title: 'Verify your email address before connecting an account' }, +} + +interface ConnectorMemberEnrollmentProps { + knowledgeBaseId: string + connectorId: string + isSearchIndex: boolean + membership: UnconnectedMembership +} + +export function ConnectorMemberEnrollment({ + knowledgeBaseId, + connectorId, + isSearchIndex, + membership, +}: ConnectorMemberEnrollmentProps) { + const enrollment = useStartConnectorMemberEnrollment() + const { title, action } = MEMBERSHIP_STATES[membership] + + return ( + <> + + enrollment.mutate({ knowledgeBaseId, connectorId, directOAuth: isSearchIndex }) + } + > + {action} + + ) : undefined + } + /> + {enrollment.error && ( +

+ {enrollment.error.message} +

+ )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index b2848f37e64..5eec846a5e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -12,6 +12,7 @@ import { ConnectorActionFeedback, ConnectorActions, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions' +import { ConnectorMemberEnrollment } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment' import { ConnectorRecovery } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-recovery' import { ConnectorSyncHistory } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history' import { getConnectorSyncState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-state' @@ -171,6 +172,14 @@ function ConnectorCard({ Updating access )} + {connector.viewerMembership && connector.viewerMembership !== 'connected' && ( + + )} null } ) +const OrganizationAccounts = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts' + ).then((module) => module.OrganizationAccounts), + { loading: () => null } +) + const logger = createLogger('General') /** Human-friendly timezone options for the picker, common zones first. */ @@ -122,8 +126,6 @@ export function General() { }) const [showResetPasswordModal, setShowResetPasswordModal] = useState(false) const resetPassword = useResetPassword() - const [showSignOutOtherDevicesModal, setShowSignOutOtherDevicesModal] = useState(false) - const signOutOtherDevices = useSignOutOtherDevices() const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false) @@ -227,14 +229,6 @@ export function General() { ) } - const confirmSignOutOtherDevices = () => { - signOutOtherDevices.mutate(undefined, { - onSuccess: () => toast.success('Signed out of other devices'), - onError: (error) => toast.error(getErrorMessage(error)), - onSettled: () => setShowSignOutOtherDevicesModal(false), - }) - } - const handleThemeChange = async (value: string) => { await updateSetting.mutateAsync({ key: 'theme', value: value as 'system' | 'light' | 'dark' }) } @@ -289,6 +283,10 @@ export function General() { return setView(null, { history: 'replace' })} /> } + if (view === 'organization-accounts' && !isAuthDisabled) { + return setView(null, { history: 'replace' })} /> + } + const actions: SettingsAction[] = [ ...(hosted ? [ @@ -302,11 +300,6 @@ export function General() { ...(session?.user?.id && !isAuthDisabled ? [ { id: 'sign-out', text: 'Sign out', onSelect: () => signOutAndRedirect(router.push) }, - { - id: 'sign-out-other-devices', - text: 'Sign out other devices', - onSelect: () => setShowSignOutOtherDevicesModal(true), - }, { id: 'reset-password', text: 'Reset password', @@ -619,6 +612,10 @@ export function General() { setView('authorized-apps')}>Manage
+
+ + setView('organization-accounts')}>Manage +
setShowDeleteAccountModal(true)}>Delete @@ -659,19 +656,6 @@ export function General() { /> - - className?: string + disabled?: boolean onChange: (e: React.ChangeEvent) => void onScroll: (scrollLeft: number) => void } @@ -143,6 +146,7 @@ function FormattedInput({ envVarProps, availableEnvVars, className, + disabled, onChange, onScroll, }: FormattedInputProps) { @@ -159,12 +163,18 @@ function FormattedInput({ ref={ref} placeholder={placeholder} value={value} + disabled={disabled} onChange={onChange} onScroll={handleScroll} onInput={handleScroll} inputClassName='font-sans text-transparent caret-[var(--text-primary)]' /> -
+
{formatDisplayText(value, { availableEnvVars })}
@@ -316,6 +326,7 @@ export function McpServerFormModal({ allowedMcpDomains, domainPolicyUnavailable = false, domainPolicyError, + canChangeDestination = true, }: McpServerFormModalProps) { const urlInputRef = useRef(null) @@ -715,6 +726,9 @@ export function McpServerFormModal({ type='custom' title='Server URL' error={isDomainBlocked ? 'Domain not permitted by server policy' : undefined} + hint={ + canChangeDestination ? undefined : 'Only workspace admins can change the server URL' + } > handleInputChange('url', e.target.value)} onScroll={setUrlScrollLeft} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index eefe088e261..bcc31895cad 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -664,6 +664,7 @@ export function MCP() { allowedMcpDomains={allowedMcpDomains} domainPolicyUnavailable={domainPolicyUnavailable} domainPolicyError={domainPolicyError} + canChangeDestination={workspacePermissions.canAdmin} /> )} {deleteConfirmModal} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts.tsx new file mode 100644 index 00000000000..6988dfe45ad --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, toast } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import type { PersonalOrganizationAccount } from '@/lib/api/contracts/organization-accounts' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { + getManagedMcpConnector, + isManagedMcpConnectorId, +} from '@/lib/credential-groups/managed-mcp-connectors' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + useDisconnectPersonalOrganizationAccount, + usePersonalOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' + +const STATUS_LABELS = { + active: null, + needs_reauth: 'Reconnect needed', + revoked: 'Disconnected', +} as const satisfies Record + +const LOAD_ERROR = 'Failed to load connected accounts' + +interface OrganizationAccountsProps { + onBack: () => void +} + +function accountService(account: PersonalOrganizationAccount) { + if (account.kind === 'mcp') { + if (!isManagedMcpConnectorId(account.providerId)) return null + return { + name: getManagedMcpConnector(account.providerId).name, + icon: getManagedMcpConnectorIcon(account.providerId), + } + } + return getServiceConfigByProviderId(account.providerId) +} + +function groupByOrganization(accounts: PersonalOrganizationAccount[]) { + const groups = new Map() + for (const account of accounts) { + const group = groups.get(account.organizationId) + if (group) group.accounts.push(account) + else groups.set(account.organizationId, { name: account.organizationName, accounts: [account] }) + } + return [...groups.entries()].sort(([, left], [, right]) => left.name.localeCompare(right.name)) +} + +/** + * The accounts this person connected for an organization. Membership does not + * gate it: someone who left an organization still disconnects their own grants + * here, because the organization's Integrations page is closed to them. + */ +export function OrganizationAccounts({ onBack }: OrganizationAccountsProps) { + const accounts = usePersonalOrganizationAccounts() + const disconnect = useDisconnectPersonalOrganizationAccount() + const [pendingDisconnectId, setPendingDisconnectId] = useState(null) + + const list = accounts.data?.pages.flatMap((page) => page.accounts) ?? [] + const pendingDisconnect = + list.find((account) => account.credentialId === pendingDisconnectId) ?? null + + const confirmDisconnect = () => { + if (!pendingDisconnect) return + disconnect.mutate( + { + credentialId: pendingDisconnect.credentialId, + organizationId: pendingDisconnect.organizationId, + }, + { + onSuccess: () => toast.success(`Disconnected ${pendingDisconnect.displayName}`), + onError: (error) => toast.error(getErrorMessage(error, 'Failed to disconnect account')), + onSettled: () => setPendingDisconnectId(null), + } + ) + } + + return ( + <> + + {accounts.isError && accounts.data === undefined ? ( + accounts.refetch()} + /> + ) : accounts.isPending ? ( + Loading connected accounts… + ) : list.length === 0 ? ( + No accounts connected for an organization + ) : ( + <> + {groupByOrganization(list).map(([organizationId, group]) => ( + +
+ {group.accounts.map((account) => { + const service = accountService(account) + const statusLabel = STATUS_LABELS[account.status] + return ( + : undefined} + title={account.displayName} + description={service?.name ?? account.providerId} + badge={ + statusLabel && ( + + {statusLabel} + + ) + } + trailing={ + account.status !== 'revoked' && ( + setPendingDisconnectId(account.credentialId), + }, + ]} + /> + ) + } + /> + ) + })} +
+
+ ))} + {accounts.isError && ( + + accounts.isFetchNextPageError ? accounts.fetchNextPage() : accounts.refetch() + } + variant='inline' + /> + )} + {accounts.hasNextPage && !accounts.isError && ( + accounts.fetchNextPage()} + > + {accounts.isFetchingNextPage ? 'Loading…' : 'Load more'} + + )} + + )} +
+ + { + if (!open && !disconnect.isPending) setPendingDisconnectId(null) + }} + srTitle='Disconnect account' + title='Disconnect account' + text={[ + 'Disconnecting ', + { text: pendingDisconnect?.displayName ?? 'this account', bold: true }, + ' removes it from ', + { text: pendingDisconnect?.organizationName ?? 'the organization', bold: true }, + '. ', + { text: 'Workflows using this account will lose access.', error: true }, + ]} + confirm={{ + label: 'Disconnect', + onClick: confirmDisconnect, + pending: disconnect.isPending, + pendingLabel: 'Disconnecting…', + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index 42ca096a093..b8e1a79e13d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -194,25 +194,6 @@ export function OrganizationMemberLists({ ...(isSelf && isOwner && onTransferOwnership ? [{ label: 'Transfer ownership', onSelect: () => onTransferOwnership() }] : []), - ...(canManage && isSelf && !isOwner - ? [ - { - label: 'Leave organization', - destructive: true, - onSelect: () => - onRemoveMember({ - id: member.memberId, - role: member.role, - user: { - id: member.userId, - name: member.name, - email: member.email, - image: member.image, - }, - }), - }, - ] - : []), ])} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index e60537723d2..8e25128c180 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -1,8 +1,9 @@ 'use client' import { useState } from 'react' -import { Plus } from '@sim/emcn' +import { Plus, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' import { useSession } from '@/lib/auth/client' @@ -10,7 +11,7 @@ import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBaseUrl } from '@/lib/core/utils/urls' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' -import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization' +import { generateSlug, type Member } from '@/lib/workspaces/organization' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { SettingsEmptyState, @@ -30,6 +31,7 @@ import { useMemberRemovalImpact, useOrganization, useOrganizationRoster, + useOrganizationSummary, useRemoveMember, useTransferOwnership, } from '@/hooks/queries/organization' @@ -83,7 +85,33 @@ export function TeamManagement({ const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess - const adminOrOwner = isAdminOrOwner(organization, session?.user?.email) + const { + data: roster, + isLoading: isLoadingRoster, + error: rosterError, + isFetchedAfterMount: isRosterFetchedAfterMount, + isFetching: isRosterFetching, + refetch: refetchRoster, + } = useOrganizationRoster(organizationId) + + // Only a row naming the caller (the roster) or the server's own role read is authoritative: + // Better Auth's full-organization read caps its member list and can omit or lag the caller. + const rosterRole = roster?.members.find((member) => member.userId === session?.user?.id)?.role + const organizationSummary = useOrganizationSummary(organizationId, { + enabled: rosterError !== null, + refetchOnMount: 'always', + }) + // Fetched on this mount, so the identity-scoped role belongs to the current session. + const summaryRole = organizationSummary.isFetchedAfterMount + ? organizationSummary.data?.userRole + : undefined + const confirmedRole = rosterRole ?? summaryRole + const adminOrOwner = isOrgAdminRole( + confirmedRole ?? + organization?.members.find((member) => member.user.email === session?.user?.email)?.role ?? + 'member' + ) + const canLeave = confirmedRole !== undefined && confirmedRole !== 'owner' const { data: organizationBillingData, @@ -94,15 +122,6 @@ export function TeamManagement({ refetch: refetchOrganizationBilling, } = useOrganizationPlanSeats(organizationId, { enabled: billingEnabled && adminOrOwner }) - const { - data: roster, - isLoading: isLoadingRoster, - error: rosterError, - isFetchedAfterMount: isRosterFetchedAfterMount, - isFetching: isRosterFetching, - refetch: refetchRoster, - } = useOrganizationRoster(organizationId) - const removeMemberMutation = useRemoveMember() const transferOwnershipMutation = useTransferOwnership() const openBillingPortal = useOpenBillingPortal() @@ -224,6 +243,17 @@ export function TeamManagement({ }) } + const handleLeave = () => { + if (!session?.user) return + setRemoveMemberDialog({ + open: true, + memberId: session.user.id, + memberName: 'yourself', + isSelfRemoval: true, + isExternalRemoval: false, + }) + } + const confirmRemoveMember = async () => { const { memberId, isSelfRemoval } = removeMemberDialog if (!session?.user || !memberId) return @@ -275,6 +305,8 @@ export function TeamManagement({ if (result.left) { window.location.href = APP_ENTRY_PATH + } else if (result.warning) { + toast.warning(`Ownership transferred. ${result.warning}`) } } catch (error) { logger.error('Failed to transfer ownership', error) @@ -365,20 +397,31 @@ export function TeamManagement({ onChange: setMemberQuery, placeholder: 'Search members...', }} - actions={ - adminOrOwner + actions={[ + ...(canLeave ? [ { + id: 'leave-organization', + text: 'Leave organization', + variant: 'destructive' as const, + onSelect: handleLeave, + }, + ] + : []), + ...(adminOrOwner + ? [ + { + id: 'invite', text: 'Invite', icon: Plus, - variant: 'primary', + variant: 'primary' as const, onSelect: () => setInviteModalOpen(true), disabled: invitationsDisabled, tooltip: invitationsDisabled ? 'Invitations are disabled' : undefined, }, ] - : [] - } + : []), + ]} > {billingEnabled && adminOrOwner && diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index 3b46902192d..9226ce4cf42 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -291,7 +291,14 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { )} - {!isPersonal && } + {!isPersonal && ( + + )} {!isPersonal && ( diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx index d271d37f0c3..c264f9df597 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx @@ -47,10 +47,17 @@ export function SkillEditorsCard({ editors, canEdit }: SkillEditorsCardProps) { }} roleOptions={SKILL_EDITOR_ROLE_OPTIONS} lockReason={skillEditorLockReason(editor.isWorkspaceAdmin)} - canManage={canEdit} + showActions={canEdit} + action={ + canEdit + ? { + label: 'Remove', + disabled: editor.isWorkspaceAdmin, + onSelect: () => editors.removeEditor(editor.userId), + } + : null + } roleDisabled - removeDisabled={editor.isWorkspaceAdmin} - onRemove={() => editors.removeEditor(editor.userId)} /> ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 7703b71ad3b..dbd6036d4d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -1322,6 +1322,7 @@ export function Table({ label: 'Rename', icon: Pencil, onClick: handleStartTableRename, + disabled: userPermissions.canEdit !== true, }, ...(userPermissions.canAdmin ? [ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index ee655e297e5..e6bd6c73672 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -29,7 +29,10 @@ import { SIDEBAR_RAIL_CHIP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { interleaveSiblings } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' +import { + holdRowFocus, + interleaveSiblings, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -217,15 +220,6 @@ interface CollapsedWorkflowFlyoutItemProps { canRename?: boolean } -/** - * Prevents Radix's mouse pointer handlers from transferring focus. Row handlers keep - * actions submenus open; content capture handlers preserve inline rename focus, - * including inside portaled folders. Radix skips handlers for defaulted events. - */ -const holdRowFocus = (e: React.PointerEvent) => { - if (e.pointerType === 'mouse') e.preventDefault() -} - const EDIT_ROW_CLASS = cn( chipVariants({ active: true, fullWidth: true }), 'min-w-0 cursor-default select-none text-small' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts index 62f818b88bb..a6ed4725eb6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts @@ -1,10 +1,19 @@ -import type { MouseEvent } from 'react' +import type { MouseEvent, PointerEvent } from 'react' import type { MothershipResource } from '@/lib/mothership/resource-types' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' +/** + * Prevents Radix's mouse pointer handlers from transferring focus. Row handlers keep + * actions submenus open; content capture handlers preserve inline rename focus, + * including inside portaled folders. Radix skips handlers for defaulted events. + */ +export function holdRowFocus(event: PointerEvent) { + if (event.pointerType === 'mouse') event.preventDefault() +} + /** Distinguishes sidebar background clicks from controls and portaled overlays. */ export function isSidebarBackgroundClick(event: MouseEvent): boolean { return ( diff --git a/apps/sim/components/permissions/index.ts b/apps/sim/components/permissions/index.ts index 04caa7302b8..71e7f1465fc 100644 --- a/apps/sim/components/permissions/index.ts +++ b/apps/sim/components/permissions/index.ts @@ -6,7 +6,7 @@ export { SKILL_EDITOR_ROLE_OPTIONS, type SkillEditorRole, } from './member-role-options' -export { MemberRow, type MemberRowMember } from './member-row' +export { MemberRow, type MemberRowAction, type MemberRowMember } from './member-row' export { type OrgRole, OrgRoleSelector, diff --git a/apps/sim/components/permissions/member-row.tsx b/apps/sim/components/permissions/member-row.tsx index 07cb8fa251a..8c7f3e00726 100644 --- a/apps/sim/components/permissions/member-row.tsx +++ b/apps/sim/components/permissions/member-row.tsx @@ -4,6 +4,15 @@ import { Avatar, Chip, ChipDropdown, cn, OverflowText } from '@sim/emcn' import type { MemberRole } from './member-role-options' import { RoleLockTooltip } from './role-lock' +/** The row's trailing chip: Remove for a manager, Leave on the viewer's own row. */ +export interface MemberRowAction { + label: string + disabled: boolean + /** Why the action is disabled, shown on hover. */ + disabledReason?: string | null + onSelect: () => void +} + export interface MemberRowMember { userId: string userName: string | null @@ -16,10 +25,10 @@ interface MemberRowProps { member: MemberRowMember /** Why the role is fixed (derived access); null when editable. */ lockReason: string | null - /** Whether the viewer can act on rows (shows the Remove column). */ - canManage: boolean + /** Whether the list reserves a trailing action column; the same for every row. */ + showActions: boolean + action: MemberRowAction | null roleDisabled: boolean - removeDisabled: boolean /** * Role choices for the dropdown. A surface whose membership is binary * (skills) passes its own single option and always sets `roleDisabled`. @@ -27,30 +36,28 @@ interface MemberRowProps { roleOptions: readonly { value: TRole; label: string }[] /** Omitted on surfaces with nothing to switch between (the role stays disabled). */ onRoleChange?: (role: TRole) => void - onRemove: () => void } /** * One member row of a shared-resource member list: avatar + identity, a role - * dropdown (wrapped in a lock tooltip when the role is derived), and a remove - * action for managers. Consumers own the policy (who is locked/disabled); this - * row owns the chrome. + * dropdown (wrapped in a lock tooltip when the role is derived), and an + * optional trailing action. Consumers own the policy (who is locked/disabled); + * this row owns the chrome. */ export function MemberRow({ member, lockReason, - canManage, + showActions, + action, roleDisabled, - removeDisabled, roleOptions, onRoleChange, - onRemove, }: MemberRowProps) { return (
@@ -80,10 +87,12 @@ export function MemberRow({ onChange={(role) => onRoleChange?.(role as TRole)} /> - {canManage && ( - - Remove - + {action && ( + + + {action.label} + + )}
) diff --git a/apps/sim/content/library/best-ai-agent-evaluation-platforms-2026/index.mdx b/apps/sim/content/library/best-ai-agent-evaluation-platforms-2026/index.mdx index 508d0f233e3..00845dd25f2 100644 --- a/apps/sim/content/library/best-ai-agent-evaluation-platforms-2026/index.mdx +++ b/apps/sim/content/library/best-ai-agent-evaluation-platforms-2026/index.mdx @@ -5,10 +5,10 @@ description: 'Compare the best AI agent evaluation platforms in 2026, including seoTitle: '6 Best AI Agent Evaluation Tools in 2026, Compared' seoDescription: 'LangSmith, Braintrust, Arize Phoenix, Langfuse, Sim and n8n compared for testing AI agents: tracing, test datasets, regression checks and pricing.' date: 2026-09-24 -updated: 2026-10-01 +updated: 2026-11-03 authors: - andrew -readingTime: 11 +readingTime: 17 tags: [AI Agents, Evaluation, Observability, Comparison, Sim] ogImage: /library/best-ai-agent-evaluation-platforms-2026/cover.jpg draft: false @@ -16,9 +16,9 @@ faq: - q: "What is an AI agent evaluation platform?" a: "An AI agent evaluation platform is software that tests, scores, traces, reviews, and monitors an AI agent’s outputs and intermediate behavior." - q: "What is the best AI agent evaluation platform?" - a: "Sim is the best AI agent evaluation platform for teams that want visual agent building, evaluators, guardrails, block-level run inspection, and deployment controls in one environment; LangSmith, Braintrust, Arize Phoenix, Langfuse, and n8n are stronger for specific ecosystems or specialist requirements." + a: "Sim fits teams that want visual agent building, evaluators, block-level run inspection, and deployment controls in one environment; LangSmith, Braintrust, Arize Phoenix, Langfuse, and n8n fit specific ecosystems or specialist requirements." - q: "What is the best AI agent builder?" - a: "Sim is a leading AI agent builder for visual, self-hostable agent workflows, while the broader category is compared in Sim’s Best AI Agent Platforms and Builders in 2026 guide." + a: "Sim fits teams that need visual, self-hostable agent workflows, while Sim’s Best AI Agent Platforms and Builders in 2026 guide compares the broader category." - q: "How do you evaluate an AI agent?" a: "AI agent teams evaluate an agent by running representative test cases, scoring final answers and intermediate behavior, inspecting traces, comparing revisions, adding human review, and monitoring production traffic." - q: "What metrics should be used to evaluate AI agents?" @@ -28,7 +28,7 @@ faq: - q: "Which platform supports block-level AI agent tracing?" a: "Sim supports block-level run inspection for visual agent workflows, while n8n exposes node-level executions and LangSmith, Braintrust, Arize Phoenix, and Langfuse primarily expose span-oriented traces." - q: "What is the difference between an evaluator and a guardrail?" - a: "An evaluator measures or scores AI agent behavior, while a guardrail actively constrains, blocks, or redirects behavior that violates a rule." + a: "An evaluator measures or scores AI agent behavior, while a Guardrails block reports whether content passed or failed and requires a downstream Condition to route or stop the workflow." - q: "What is the difference between AI agent testing and AI agent monitoring?" a: "AI agent testing evaluates controlled cases before release, while AI agent monitoring observes live behavior, quality, failures, latency, and drift after deployment." - q: "Can LLM-as-a-judge evaluations be trusted?" @@ -40,7 +40,7 @@ faq: - q: "Should AI agent evaluations include human review?" a: "AI agent evaluations should include human review when criteria are subjective, evaluators disagree, errors are high-risk, or the team is still defining an acceptable-quality rubric." - q: "Is Sim good for evaluating AI agents?" - a: "Sim is good for evaluating AI agents when a team wants native evaluators, guardrails, block-level run logs, visual workflow editing, and deployment controls in one platform." + a: "Sim fits teams that want native evaluators, Conditions, block-level run logs, visual workflow editing, and deployment controls in one platform." - q: "Is Sim open source?" a: "Sim's core is open source under the OSI-approved Apache License 2.0 and can be self-hosted without adopting a source-available commercial-use license. Enterprise features in apps/sim/ee use the separate Sim Enterprise License, which requires an Enterprise subscription for production use." - q: "Is Sim free?" @@ -48,7 +48,7 @@ faq: - q: "How does Sim compare with LangSmith?" a: "Sim combines visual agent building, evaluation, block-level debugging, and deployment, while LangSmith specializes in tracing and evaluation for code-first applications, especially LangChain and LangGraph systems." - q: "How does Sim compare with Braintrust?" - a: "Sim is an integrated agent builder and runtime with evaluation features, while Braintrust is an evaluation-first platform centered on datasets, scorers, experiments, and production logging." + a: "Sim combines agent building and runtime evaluation features, while Braintrust is an evaluation-first platform centered on datasets, scorers, experiments, and production logging." - q: "How does Sim compare with n8n?" a: "Sim focuses on building and evaluating AI agents with block-level visibility, while n8n focuses on broad business-process automation with AI nodes, execution logs, and lighter evaluation workflows." - q: "Can n8n evaluate AI agents?" @@ -56,9 +56,9 @@ faq: - q: "Is n8n open source?" a: "n8n is source-available under the Sustainable Use License, which is not an OSI-approved open-source license and places conditions on some commercial uses." - q: "What is the best open-source AI agent evaluation platform?" - a: "Sim is the best fit in this comparison for buyers who require a platform with an OSI-approved Apache 2.0 core that combines agent building and evaluation, while buyers seeking evaluation-only infrastructure should confirm each alternative’s current repository license and feature boundaries." + a: "Sim fits buyers who require a platform with an OSI-approved Apache 2.0 core that combines agent building and evaluation, while buyers seeking evaluation-only infrastructure should confirm each alternative’s current repository license and feature boundaries." - q: "What is the best n8n alternative for evaluating AI agents?" - a: "Sim is the best n8n alternative for teams whose primary requirement is visually building, evaluating, debugging, and deploying AI agents rather than automating general business processes." + a: "Sim fits teams whose primary requirement is visually building, evaluating, debugging, and deploying AI agents rather than automating general business processes." - q: "What is the difference between Arize Phoenix and Langfuse?" a: "Arize Phoenix emphasizes OpenTelemetry-oriented AI observability and evaluation, while Langfuse combines LLM traces, datasets, experiments, scores, prompt tooling, and annotation workflows." - q: "What is the difference between Braintrust and LangSmith?" @@ -66,44 +66,77 @@ faq: - q: "Which AI agent evaluation platform is best for production monitoring?" a: "LangSmith, Braintrust, Arize Phoenix, and Langfuse provide specialized production tracing and quality workflows, while Sim connects production run diagnostics to the same visual environment used to change and deploy the agent." - q: "Which AI agent evaluation platform is best for regression testing?" - a: "Braintrust and LangSmith provide particularly mature experiment-oriented regression workflows, while Sim is preferable when regression findings must be resolved directly inside a visual agent workflow." + a: "Braintrust and LangSmith provide experiment-oriented regression workflows, while Sim fits teams that need to resolve regression findings directly inside a visual agent workflow." - q: "Which AI agent evaluation platform is best for human review?" a: "LangSmith, Braintrust, and Langfuse provide dedicated annotation or review workflows, while Sim and n8n are useful when human approval must be embedded in the operational workflow itself." + - q: "Can AI agent evaluation run in continuous integration?" + a: "AI agent evaluation can run in continuous integration when the platform exposes an API, SDK, or command based test process that returns machine readable scores and release thresholds." + - q: "How do humans review AI agent evaluations?" + a: "Human reviewers should score a consistent sample with a shared rubric, investigate disagreements, and use the results to calibrate automated evaluators." + - q: "How does Sim’s Evaluator block fit an evaluation workflow?" + a: "Sim’s Evaluator block scores workflow output against defined metrics so a downstream Condition can route the run to pass, retry, review, or failure handling." + - q: "How do Sim execution logs help evaluate AI agents?" + a: "Sim execution logs help teams inspect the inputs, outputs, and workflow path behind a test result or production run." + - q: "Can Sim add human approval to an evaluation workflow?" + a: "Sim can pause an evaluation workflow with the Human in the Loop block, collect reviewer fields, and use a downstream Condition to check the reviewer response before continuing." + - q: "Can Sim self host an AI agent evaluation workflow?" + a: "Sim can self host an AI agent evaluation workflow, and self hosted Sim can use Ollama, vLLM, LM Studio, or LiteLLM without requiring Enterprise." + - q: "Is LangSmith only for LangChain applications?" + a: "LangSmith can evaluate applications built outside LangChain, although LangSmith has its deepest integrations with LangChain and LangGraph." + - q: "Is Langfuse an AI agent evaluation tool?" + a: "Langfuse is an LLM engineering platform that combines tracing, datasets, scores, prompt management, and evaluation workflows for agents and other LLM applications." + - q: "Is Arize Phoenix an AI agent evaluation tool?" + a: "Arize Phoenix is an observability and evaluation toolkit that supports tracing, experiments, and evaluator-based analysis for AI applications." + - q: "Should teams use a dedicated evaluator or an agent platform with evaluation built in?" + a: "Teams should use Sim when evaluation results must directly control agent behavior, and they should use a dedicated evaluator when advanced experiment management is the primary requirement." + - q: "How often should production AI agents be evaluated?" + a: "Production AI agents should be evaluated continuously on sampled runs and whenever a model, prompt, tool, retrieval source, policy, or workflow changes." + - q: "What is the difference between an AI agent benchmark and an evaluation dataset?" + a: "An AI agent benchmark is a standardized test designed for comparison, while an evaluation dataset represents the tasks, risks, users, and operating conditions of one specific application." --- ## TL;DR -[Sim](https://docs.sim.ai/), [LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts), [Braintrust](https://www.braintrust.dev/docs/evaluate), [Arize Phoenix](https://arize.com/docs/phoenix/evaluation/llm-evals), [Langfuse](https://langfuse.com/docs/evaluation/overview), and [n8n](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows/understand-why-to-test) serve different parts of AI agent evaluation, from pre-deployment regression tests to production tracing and human review. +Direct answer: [Sim](https://docs.sim.ai/) fits teams that want to build, run, inspect, evaluate, and deploy agents in one visual workspace. For teams that need evaluation results to trigger retries, review, or failure handling inside an agent workflow, Sim keeps that testing framework in the product where the agent is built and deployed. [LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts), [Braintrust](https://www.braintrust.dev/docs/evaluate), [Arize Phoenix](https://arize.com/docs/phoenix/evaluation/llm-evals), [Langfuse](https://langfuse.com/docs/evaluation/overview), and [n8n](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows/understand-why-to-test) serve more specialized needs, from dataset-driven experiments to production tracing and automation testing. -The best choice depends on whether a team needs an integrated agent builder and evaluator, a specialized evaluation service, an observability layer, or an automation platform with testing features. This guide compares the six platforms across evaluators, block-level tracing, test cases, regression workflows, human review, deployment controls, and production monitoring. +The right choice depends on whether a team needs an integrated agent builder and evaluator, a specialized evaluation service, an observability layer, or an automation product with testing features. This guide compares the six platforms across evaluators, block-level tracing, test cases, regression workflows, human review, deployment controls, and production monitoring. -Product capabilities and commercial terms were checked against the linked vendor documentation for September 2026. Pricing and plan entitlements can change, so buyers should confirm current terms on each vendor’s own site before purchasing. +Product capabilities and commercial terms were checked against the linked vendor documentation for November 2026. Pricing and plan entitlements can change, so buyers should confirm current terms on each vendor’s own site before purchasing. ## What are the best AI agent evaluation platforms in 2026? -Sim is the strongest fit for teams that want to build, test, deploy, and inspect AI agents in one platform, while LangSmith, Braintrust, Arize Phoenix, Langfuse, and n8n lead in more specialized use cases. +Sim fits teams that want to build, test, deploy, and inspect AI agents in one platform, while LangSmith, Braintrust, Arize Phoenix, Langfuse, and n8n serve more specialized use cases. -- **[Sim](https://docs.sim.ai/):** Best for evaluating agents in the same visual environment used to build and deploy them. +- **[Sim](https://docs.sim.ai/):** Fits teams evaluating agents in the same visual environment used to build and deploy them. - **[LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts):** Best for LangChain and LangGraph teams that need datasets, experiments, traces, and review queues. - **[Braintrust](https://www.braintrust.dev/docs/evaluate):** Best for evaluation-first development with reusable scorers and experiment comparisons. - **[Arize Phoenix](https://arize.com/docs/phoenix):** Best for self-managed, OpenTelemetry-oriented AI observability and evaluation. - **[Langfuse](https://langfuse.com/docs/evaluation/overview):** Best for self-hosted LLM observability, datasets, experiments, and annotation workflows. - **[n8n](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows/understand-why-to-test):** Best for testing AI-enabled business automations alongside operational workflow controls. -Teams looking for a broader comparison of agent-building products should use [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026), which is Sim’s canonical guide for that separate buyer question. +Teams looking for a broader comparison of agent-building products can read [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026), which addresses that separate buyer question. + +| Team need | Fit | Why it fits | +|---|---|---| +| Building and evaluating agents in one workspace | Sim | Evaluator scores, execution logs, branching, human review, deployment, and self-hosting are available in the same product | +| LangChain and LangGraph teams | LangSmith | Tracing, datasets, experiments, evaluators, and ecosystem integration | +| Dataset-driven experiments | Braintrust | Experiments, scorers, datasets, and result comparison | +| Self-hosted LLM observability | Langfuse | Tracing, prompt management, datasets, scores, and evaluation workflows | +| Trace analysis and evaluation research | Arize Phoenix | Self-managed tracing and evaluation components with notebook-friendly analysis | +| Existing automation estates | n8n | Execution history and evaluation features alongside broader workflow automation | ## How do the best AI agent evaluation platforms compare? -Sim is the only platform in this scorecard centered on combining visual agent construction, block-level run inspection, evaluators, guardrails, and deployment controls in the same product. +Sim centers visual agent construction, block-level run inspection, evaluators, routing, and deployment controls in the same product. -| Platform | Evaluators | Trace granularity | Test cases | Regression workflow | Human review | Deployment controls | Production monitoring | Best fit | -|---|---|---|---|---|---|---|---|---| -| **[Sim](https://docs.sim.ai/)** | Native | Block-level | Native | Partial; evaluator-driven comparisons may require team conventions | Partial; human approval can be designed into workflows | Native | Run logs and workflow diagnostics | Teams that want one environment for building, evaluating, and operating agents | -| **[LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts)** | Native | Span-level | Native datasets | Native experiments and comparisons | Native annotation and review workflows | Available through the broader LangGraph stack rather than evaluation alone | Native tracing and online evaluation | LangChain or LangGraph applications | -| **[Braintrust](https://www.braintrust.dev/docs/evaluate)** | Native scorers | Span-level | Native datasets and cases | Native experiments and comparisons | Native feedback and annotation workflows | Not an agent-runtime deployment layer | Native logging, tracing, and online scoring | Evaluation-first AI product teams | -| **[Arize Phoenix](https://arize.com/docs/phoenix/evaluation/llm-evals)** | Native | Span-level through OpenTelemetry | Native datasets and experiments | Native experiment comparison | Partial; review workflows depend on the deployment and surrounding stack | Not an agent-runtime deployment layer | Native observability and evaluation | Teams prioritizing telemetry and self-managed observability | -| **[Langfuse](https://langfuse.com/docs/evaluation/overview)** | Native | Span-level | Native datasets | Native experiments and comparisons | Native annotation queues and scores | Not an agent-runtime deployment layer | Native traces, scores, and dashboards | Teams wanting self-hosted LLM engineering infrastructure | -| **[n8n](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows/run-quick-evaluations)** | Partial; light evaluations and workflow assertions | Node-level execution data | Native evaluation datasets for supported AI workflows | Partial; suitable for workflow test runs rather than a dedicated experiment system | Native human-in-the-loop workflow patterns | Native workflow activation and operational controls | Execution monitoring; AI-quality analysis is less specialized | Business automation teams testing AI inside operational workflows | +| Platform | Evaluators | Trace granularity | Test cases | Regression workflow | Human review | Deployment controls | Production monitoring | Pricing basis as of November 2026 | Self-hosting | Fit | +|---|---|---|---|---|---|---|---|---|---|---| +| **[Sim](https://docs.sim.ai/)** | Native | Block-level | Native | Partial; evaluator-driven comparisons may require team conventions | Human in the Loop form fields with downstream Condition routing | Native | Run logs and workflow diagnostics | Cloud plans and model usage on the [Sim pricing page](https://www.sim.ai/pricing) | Yes | Teams that want one environment for building, evaluating, and operating agents | +| **[LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts)** | Native | Span-level | Native datasets | Native experiments and comparisons | Native annotation and review workflows | Available through the broader LangGraph stack rather than evaluation alone | Native tracing and online evaluation | Plan, seat, and usage terms on the [LangSmith pricing page](https://www.langchain.com/pricing) | Enterprise self-hosted and hybrid options | LangChain or LangGraph applications | +| **[Braintrust](https://www.braintrust.dev/docs/evaluate)** | Native scorers | Span-level | Native datasets and cases | Native experiments and comparisons | Native feedback and annotation workflows | Not an agent-runtime deployment layer | Native logging, tracing, and online scoring | Plan and usage terms on the [Braintrust pricing page](https://www.braintrust.dev/pricing) | Commercial self-hosted option | Evaluation-first AI product teams | +| **[Arize Phoenix](https://arize.com/docs/phoenix/evaluation/llm-evals)** | Native | Span-level through OpenTelemetry | Native datasets and experiments | Native experiment comparison | Review workflows depend on the deployment and surrounding stack | Not an agent-runtime deployment layer | Native observability and evaluation | Phoenix is self-managed; commercial Arize terms are on the [Arize pricing page](https://arize.com/pricing/) | Yes for Phoenix | Teams prioritizing telemetry and self-managed observability | +| **[Langfuse](https://langfuse.com/docs/evaluation/overview)** | Native | Span-level | Native datasets | Native experiments and comparisons | Native annotation queues and scores | Not an agent-runtime deployment layer | Native traces, scores, and dashboards | Cloud plan and usage terms on the [Langfuse pricing page](https://langfuse.com/pricing) | Yes | Teams wanting self-hosted LLM engineering infrastructure | +| **[n8n](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows/run-quick-evaluations)** | Light and metric-based evaluations for eligible plans | Node-level execution data | Native evaluation datasets for supported AI workflows | Workflow test runs and metric comparisons | Human-in-the-loop workflow patterns | Native workflow activation and operational controls | Execution monitoring; AI-quality analysis is less specialized | Cloud plans use workflow executions on the [n8n pricing page](https://n8n.io/pricing/) | Yes, subject to the Sustainable Use License and enterprise terms | Business automation teams testing AI inside operational workflows | “Native” means the capability is documented as part of the named product. “Partial” means the platform supports the outcome but does not provide the same dedicated evaluation workflow as a specialist tool. Buyers should test their own agent architecture because a feature checklist does not reveal trace quality, evaluator reliability, or debugging speed. @@ -111,14 +144,14 @@ Official capability references: [Sim documentation](https://docs.sim.ai/), [Lang ## What facts should buyers know about each AI agent evaluation platform? -Sim, LangSmith, Braintrust, Arize Phoenix, Langfuse, and n8n differ materially in licensing, self-hosting rights, and cloud billing models. +Sim, LangSmith, Braintrust, Arize Phoenix, Langfuse, and n8n differ materially in licensing, self-hosting rights, and cloud billing models. These key facts use vendor information available as of November 2026. -- **Sim:** As of September 2026, Sim's core is licensed under the OSI-approved Apache License 2.0 and supports free self-hosting, while features in `apps/sim/ee`, such as SSO, SCIM, access control, audit logs, and white-labeling, use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use, requires an Enterprise subscription for production use, and does not permit modification or redistribution. Sim meters its hosted service through cloud plan credits and usage; confirm current terms in the [Sim repository](https://github.com/simstudioai/sim) and [Sim pricing page](https://www.sim.ai/pricing). -- **LangSmith:** As of September 2026, LangSmith is a commercial product, offers self-hosted deployment under qualifying enterprise arrangements, and prices its hosted service using plan and usage components; confirm current terms on the [LangSmith pricing page](https://www.langchain.com/pricing). -- **Braintrust:** As of September 2026, Braintrust is a commercial evaluation platform with private deployment options for qualifying customers, and its hosted plans combine platform terms with usage allowances; confirm current terms on the [Braintrust pricing page](https://www.braintrust.dev/pricing). -- **Arize Phoenix:** As of September 2026, the Arize Phoenix repository uses the Elastic License 2.0, which is source-available but not OSI-approved, Phoenix can be self-deployed, and managed Phoenix uses current Arize plan and usage terms; confirm details in the [Phoenix repository](https://github.com/Arize-ai/phoenix) and on the [Arize pricing page](https://arize.com/pricing/). -- **Langfuse:** As of September 2026, Langfuse provides a self-hostable core with separately licensed commercial features, while Langfuse Cloud uses plan and usage terms; confirm the applicable terms on the [Langfuse self-hosted pricing page](https://langfuse.com/pricing-self-host) and current charges on the [Langfuse Cloud pricing page](https://langfuse.com/pricing). -- **n8n:** As of September 2026, n8n uses the Sustainable Use License for its source-available distribution, which is not an OSI-approved open-source license, permits self-hosting subject to its terms, and bills paid plans using workflow execution allowances; confirm details on the [n8n license page](https://docs.n8n.io/privacy-and-security/sustainable-use-license) and [n8n execution documentation](https://docs.n8n.io/build/understand-workflows/understand-executions). +- **Sim:** As of November 2026, Sim's core is licensed under the OSI-approved Apache License 2.0 and supports free self-hosting, while features in `apps/sim/ee`, such as SSO, SCIM, access control, audit logs, and white-labeling, use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which is free for development, testing, and internal non-production use, requires an Enterprise subscription for production use, and does not permit modification or redistribution. Sim meters its hosted service through cloud plan credits and usage; confirm current terms in the [Sim repository](https://github.com/simstudioai/sim) and [Sim pricing page](https://www.sim.ai/pricing). +- **LangSmith:** As of November 2026, LangSmith is a commercial product, offers self-hosted deployment under qualifying enterprise arrangements, and prices its hosted service using plan and usage components; confirm current terms on the [LangSmith pricing page](https://www.langchain.com/pricing). +- **Braintrust:** As of November 2026, Braintrust is a commercial evaluation platform with private deployment options for qualifying customers, and its hosted plans combine platform terms with usage allowances; confirm current terms on the [Braintrust pricing page](https://www.braintrust.dev/pricing). +- **Arize Phoenix:** As of November 2026, the Arize Phoenix repository uses the Elastic License 2.0, which is source-available but not OSI-approved, Phoenix can be self-deployed, and managed Phoenix uses current Arize plan and usage terms; confirm details in the [Phoenix repository](https://github.com/Arize-ai/phoenix) and on the [Arize pricing page](https://arize.com/pricing/). +- **Langfuse:** As of November 2026, Langfuse provides a self-hostable core with separately licensed commercial features, while Langfuse Cloud uses plan and usage terms; confirm the applicable terms on the [Langfuse self-hosted pricing page](https://langfuse.com/pricing-self-host) and current charges on the [Langfuse Cloud pricing page](https://langfuse.com/pricing). +- **n8n:** As of November 2026, n8n uses the Sustainable Use License for its source-available distribution, which is not an OSI-approved open-source license, permits self-hosting subject to its terms, and bills paid plans using workflow execution allowances; confirm details on the [n8n license page](https://docs.n8n.io/privacy-and-security/sustainable-use-license) and [n8n execution documentation](https://docs.n8n.io/build/understand-workflows/understand-executions). ## What should an AI agent evaluation platform test? @@ -130,10 +163,33 @@ A useful evaluation program covers at least seven layers: 2. **Answer quality:** Is the answer correct, relevant, grounded, complete, and appropriately formatted? 3. **Tool behavior:** Did the agent select the correct tool, pass valid arguments, and interpret the result correctly? 4. **Workflow behavior:** Did the agent follow the intended route through blocks, nodes, or spans without unnecessary loops? -5. **Safety and policy:** Did guardrails catch prohibited, sensitive, or malformed content? +5. **Safety and policy:** Did guardrails identify prohibited, sensitive, or malformed content so a downstream Condition could route it? 6. **Operational performance:** Did the run stay within acceptable latency, token, cost, retry, and failure thresholds? 7. **Production drift:** Does live performance remain consistent after changes to prompts, models, tools, retrieval data, or external APIs? +### Production testing process + +AI agent teams can use this process before deployment: + +1. Define the task and every action the agent is allowed to take. +2. Create representative, difficult, and adversarial test cases. +3. Record expected facts, required fields, prohibited behavior, and acceptable variation. +4. Run the same test set against each prompt, model, tool, or workflow revision. +5. Use deterministic checks for schemas, numbers, tool calls, and required fields. +6. Use model-based evaluators for qualities such as relevance or groundedness. +7. Review a sample with humans to calibrate automated scores. +8. Inspect traces or execution logs for every important failure. +9. Set release thresholds and run the suite in continuous integration. +10. Continue evaluating sampled production runs after deployment. + +An average score is not sufficient evidence of readiness. Teams should examine failure categories, severe outliers, score distributions, tool errors, latency, cost, and disagreement between human reviewers and automated evaluators. + +### Metrics for production agents + +AI agent teams should measure task success, factual grounding, tool correctness, policy compliance, latency, cost, and human acceptance rather than relying on one generic quality score. Useful metrics include completion rate, correct tool selection, correct tool arguments, groundedness, retrieval quality, schema validity, escalation accuracy, harmful action rate, latency, model cost, and reviewer approval rate. + +The metric must match the risk. A writing assistant can tolerate stylistic variation, while an agent that updates a customer record needs exact tool arguments and strict permission checks. Output controls complement evaluation by constraining the result that downstream systems receive. + A platform that only scores the final response can miss the actual defect. For example, an agent may produce an acceptable answer after calling the wrong tool twice, exposing sensitive context to a model, or taking a path that is too expensive for production. For a deeper treatment of telemetry, read [AI agent observability](https://www.sim.ai/library/ai-agent-observability). ## Why does block-level tracing matter when evaluating AI agents? @@ -148,6 +204,10 @@ Sim exposes run behavior in the same block-oriented structure used to build the AI agent teams should create test cases from expected use, known failures, edge conditions, adversarial inputs, and sampled production traffic. +### What an evaluation dataset should contain + +AI agent evaluation datasets should contain normal requests, difficult edge cases, adversarial inputs, tool failures, and examples of behavior that must be escalated. Production incidents should become regression cases so a prompt, model, or tool change cannot silently reintroduce the same problem. + Each test case should include: - An input or initial state @@ -203,17 +263,35 @@ The two should form a feedback loop. Teams can sample problematic production tra ## Which AI agent evaluation platform is best for visual agent workflows? -Sim is the best fit among these six products for teams that want visual agent construction, evaluators, guardrails, block-level run logs, and deployment controls in one environment. +Sim fits teams that want visual agent construction, evaluators, routing, block-level run logs, and deployment controls in one environment. Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Sim reduces the distance between finding a failure and changing the workflow that caused it. A team can inspect the affected block, revise its prompt or configuration, rerun the workflow, and keep deployment operations in the same platform. -That advantage matters most to teams that do not want to assemble a builder, tracing backend, evaluation service, deployment system, and approval workflow from separate products. A team with a mature code-first stack may instead prefer a specialized evaluation product such as [LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts) or [Braintrust](https://www.braintrust.dev/docs/evaluate). Teams comparing broader platform categories can also review [the best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026). +### Sim evaluation workflow + +Sim evaluation workflows can follow these steps: + +1. A test case supplies a representative input and its expected facts or constraints. +2. The agent executes the same model and tool path intended for deployment. +3. The Evaluator block scores the output against named metrics and explicit ranges. +4. A Condition routes the result to pass, retry, review, or failure handling. +5. Execution logs show the inputs, outputs, and path taken during the run. +6. A Human in the Loop block pauses the run and collects reviewer fields when judgment is required. +7. A downstream Condition checks the reviewer response before the workflow continues. + +The Human in the Loop block resumes with submitted form fields rather than approving an action by itself. An approval or rejection is a field, so a downstream Condition must inspect that field before allowing a consequential action. [What Is Human in the Loop in AI Agents?](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents) explains how this review pattern fits production agents. + +Sim fits teams that want a failed evaluation to trigger an operational response. A failed support answer can go to a reviewer, a weak extraction can be retried with a revised path, and an unsafe output can be kept away from downstream systems. + +Sim's core is Apache 2.0, with the enterprise exception described by the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). [Self-hosted Sim](https://docs.sim.ai/platform/self-hosting) can use Ollama, vLLM, LM Studio, or LiteLLM without requiring Enterprise. + +This fit matters most to teams that do not want to assemble a builder, tracing backend, evaluation service, deployment system, and approval workflow from separate products. A team with a mature code-first stack may instead prefer a specialized evaluation product such as [LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts) or [Braintrust](https://www.braintrust.dev/docs/evaluate). Teams comparing broader platform categories can also review [the best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026). ## Which AI agent evaluation platform is best for LangChain and LangGraph? [LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts) is the most direct choice for LangChain and LangGraph teams because its tracing, datasets, experiments, evaluators, and review workflows are designed around that ecosystem. -[LangSmith can also trace applications outside LangChain](https://docs.langchain.com/langsmith/trace-with-opentelemetry), but its strongest fit is a team already using LangChain or LangGraph abstractions. Buyers should distinguish LangSmith’s evaluation and observability features from deployment capabilities delivered through other parts of the LangChain product stack. +[LangSmith can also trace applications outside LangChain](https://docs.langchain.com/langsmith/trace-with-opentelemetry), but its closest integration is with LangChain and LangGraph abstractions. Buyers should distinguish LangSmith’s evaluation and observability features from deployment capabilities delivered through other parts of the LangChain product stack. ## Which AI agent evaluation platform is best for evaluation-first development? @@ -233,11 +311,11 @@ License requirements should be reviewed separately from deployment architecture. [n8n can evaluate supported AI workflows](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows/run-quick-evaluations) with datasets, test runs, metrics, execution data, and human-in-the-loop workflow patterns, but n8n is primarily an automation platform rather than a specialist evaluation system. -n8n is a credible option when the agent is one part of a broader business process and operational integrations matter more than advanced experiment management. [LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts), [Braintrust](https://www.braintrust.dev/docs/evaluate), [Arize Phoenix](https://arize.com/docs/phoenix/evaluation/llm-evals), and [Langfuse](https://langfuse.com/docs/evaluation/overview) provide more specialized evaluation or observability workflows. Sim is the closer comparison when a buyer wants an integrated visual agent builder with evaluation and deployment capabilities. +n8n is the incumbent option for teams that already operate agents inside a broader automation estate and value operational integrations more than advanced experiment management. [LangSmith](https://docs.langchain.com/langsmith/evaluation-concepts), [Braintrust](https://www.braintrust.dev/docs/evaluate), [Arize Phoenix](https://arize.com/docs/phoenix/evaluation/llm-evals), and [Langfuse](https://langfuse.com/docs/evaluation/overview) provide more specialized evaluation or observability workflows. Sim fits buyers who want visual agent building with evaluation and deployment capabilities in the same workspace. ## How should a buyer choose an AI agent evaluation platform? -A technical buyer should choose Sim, LangSmith, Braintrust, Arize Phoenix, Langfuse, or n8n by matching the platform to the team’s runtime, evaluation maturity, governance requirements, and deployment model. +Heads of AI and technical buyers should choose Sim, LangSmith, Braintrust, Arize Phoenix, Langfuse, or n8n by matching the platform to the team’s agent stack, deployment model, review process, governance requirements, and highest-consequence failure modes. Choose **Sim** when the team wants to build, evaluate, deploy, and inspect visual agents in one product. @@ -251,7 +329,9 @@ Choose **[Langfuse](https://langfuse.com/docs/evaluation/overview)** when the te Choose **[n8n](https://docs.n8n.io/build/integrate-ai/test-and-improve-ai-workflows/understand-why-to-test)** when AI evaluation must sit inside a broader automation system with integrations, approvals, and operational workflow controls. -Before purchasing, run a proof of concept using the same agent, at least 50 representative cases, one known regression, one tool failure, one safety test, and several sampled production traces. Measure how quickly each platform helps the team detect, explain, and correct a failure—not just whether it can produce a score. +Before purchasing, run a proof of concept using the same agent, at least 50 representative cases, one known regression, one tool failure, one safety test, and several sampled production traces. Measure how quickly each platform helps the team detect, explain, and correct a failure, not just whether it can produce a score. + +The proof of readiness should include repeatable datasets, version comparisons, inspected failures, human calibration, release thresholds, and continuing production evaluation. A polished demonstration without this evidence is not a production test. ## What related AI agent comparisons should buyers read? diff --git a/apps/sim/content/library/best-self-hosted-ai-workflow-automation-platforms-2026/index.mdx b/apps/sim/content/library/best-self-hosted-ai-workflow-automation-platforms-2026/index.mdx index bd0910b7457..f3b19ca7155 100644 --- a/apps/sim/content/library/best-self-hosted-ai-workflow-automation-platforms-2026/index.mdx +++ b/apps/sim/content/library/best-self-hosted-ai-workflow-automation-platforms-2026/index.mdx @@ -1,163 +1,179 @@ --- slug: best-self-hosted-ai-workflow-automation-platforms-2026 -title: 'Best Self-Hosted AI Workflow Automation Platforms in 2026' -description: 'Compare the best self-hosted AI workflow automation platforms in 2026 across licensing, AI-native design, deployment control, governance, and operating effort.' -seoTitle: 'Best Self-Hosted AI Workflow Automation Tools (2026)' -seoDescription: 'Sim, n8n, Dify, Langflow and Activepieces compared for self-hosting (Flowise reached end of life): license, deployment, models, governance, costs.' +title: 'What Are the Best Self Hosted AI Workflow Automation Platforms in 2026?' +description: 'Compare the best self hosted AI workflow automation platforms for licensing, Docker, Kubernetes, air gapped deployment, SSO, audit logs, local models, data residency, and cost.' +seoTitle: 'Best Self Hosted AI Workflow Platforms 2026' +seoDescription: 'Compare self hosted AI workflow platforms by license, deployment, air gapped fit, controls, local models, data residency, and cost.' date: 2026-09-24 -updated: 2026-10-20 +updated: 2026-11-03 authors: - andrew -readingTime: 13 +readingTime: 17 tags: [AI Agents, Workflow Automation, Open Source, Self-Hosted, Sim] ogImage: /library/best-self-hosted-ai-workflow-automation-platforms-2026/cover.jpg draft: false faq: - q: "What is the best self-hosted AI workflow automation platform?" - a: "Sim is a strong fit for teams that prioritize Apache 2.0 licensing, visual AI workflows, deployment control, and an enterprise governance path." + a: "Sim fits teams that prioritize an Apache 2.0 core, visual AI workflows, deployment control, and an enterprise governance path." - q: "Which self-hosted AI workflow platform has an Apache 2.0 license?" - a: "Sim uses the Apache License 2.0, an OSI-approved permissive open-source license. Flowise also applied Apache 2.0 to most of its code, but it reached end of life on August 31, 2026." + a: "Sim's core uses the OSI approved Apache 2.0 license, with apps/sim/ee under the separate Sim Enterprise License. Flowise applied Apache 2.0 to most of its code, but it reached end of life on August 31, 2026." - q: "Is Sim open source?" - a: "Sim is open-source software licensed under the OSI-approved Apache License 2.0." + a: "Sim's core is open source under Apache 2.0, while code in apps/sim/ee uses the separate Sim Enterprise License and requires an active Enterprise subscription for production use." - q: "Is Sim free to self-host?" - a: "Sim can be self-hosted under Apache 2.0 without a proprietary runtime license, but operators remain responsible for infrastructure, model-provider, storage, networking, and maintenance costs." + a: "Sim's core can be self hosted under Apache 2.0, while production use of apps/sim/ee requires an active Enterprise subscription. Operators remain responsible for infrastructure, model provider, storage, networking, and maintenance costs." - q: "Can Sim use local models when self-hosted?" - a: "Sim supports local or private model access only through approved Enterprise configurations, so standard self-hosting should not be assumed to include unrestricted local-model access." + a: "A self hosted Sim deployment can connect to Ollama, vLLM, LM Studio through a compatible endpoint, or LiteLLM without requiring Sim Enterprise." - q: "Is n8n open source?" - a: "n8n is source-available under the Sustainable Use License, but that license is not OSI-approved and should not be described as open source in the standard licensing sense." + a: "n8n is source available under the Sustainable Use License, but that license is not OSI approved and should not be described as open source in the standard licensing sense." - q: "Is Dify open source?" - a: "Dify is source-available under the vendor-specific Dify Open Source License, which adds conditions to an Apache-based license and is not standard Apache 2.0." + a: "Dify is source available under the vendor specific Dify Open Source License, which adds conditions to an Apache based license and is not standard Apache 2.0." - q: "What is the difference between Sim and n8n?" - a: "Sim is an Apache 2.0 AI-native workflow platform, while n8n is an integration-heavy automation platform distributed under the source-available Sustainable Use License." + a: "Sim is the open source AI workspace with an Apache 2.0 core, while n8n is an integration focused automation product distributed under the source available Sustainable Use License." - q: "Is Sim a good n8n alternative?" - a: "Sim is a strong n8n alternative for teams that prioritize AI-native workflows and OSI-approved Apache 2.0 licensing over n8n’s broader general-automation orientation." + a: "Sim fits teams that prioritize AI workflows and an OSI approved Apache 2.0 core, while n8n fits broader general automation requirements." - q: "What is the best self-hosted n8n alternative for AI workflows?" - a: "Sim is a strong fit for teams leaving n8n for AI workflows because it combines visual AI orchestration with Apache 2.0 licensing, while n8n keeps the edge on integration-heavy general automation." + a: "Sim fits teams leaving n8n for AI workflows because it combines visual AI orchestration with an Apache 2.0 core, while n8n retains a broader general automation focus." - q: "What is the best open-source Zapier alternative for AI workflows?" - a: "Sim is a strong fit as an open-source Zapier alternative for AI workflows because Sim is self-hostable and licensed under Apache 2.0, although buyers focused primarily on conventional SaaS automation should also evaluate connector requirements." + a: "Sim fits teams evaluating an open source Zapier alternative for AI workflows because Sim's core is self hostable under Apache 2.0, although buyers focused on conventional software as a service automation should also evaluate connector requirements." - q: "What is the difference between Sim and Dify?" - a: "Sim focuses on AI workflow automation under Apache 2.0, while Dify focuses more specifically on building and operating LLM applications under the vendor-specific Dify Open Source License." + a: "Sim is the open source AI workspace with an Apache 2.0 core, while Dify focuses more specifically on building and operating LLM applications under the vendor specific Dify Open Source License." - q: "What is the difference between Sim and Flowise?" - a: "Sim targets broader AI workflow automation and enterprise deployment and is actively maintained, while Flowise, a visual builder for LLM chains and retrieval flows, reached end of life on August 31, 2026 and receives no upstream fixes, so existing users would maintain a fork or migrate." + a: "Sim supports building, deploying, and managing AI agents and remains actively maintained, while Flowise reached end of life on August 31, 2026 and receives no upstream fixes, so existing users would maintain a fork or migrate." - q: "What is the difference between Sim and Langflow?" - a: "Sim provides an AI-native workflow platform for visual automation, while Langflow is especially suited to Python-oriented prototyping and component-based AI development." + a: "Sim supports building, deploying, and managing AI agents, while Langflow is especially suited to Python oriented prototyping and component based AI development." - q: "What is the difference between Sim and Activepieces?" - a: "Sim centers on AI-native workflows under Apache 2.0, while Activepieces centers on approachable general business automation with an MIT-licensed core and separately licensed enterprise functionality." + a: "Sim centers on AI agents and has an Apache 2.0 core, while Activepieces centers on approachable general business automation with an MIT licensed core and separately licensed enterprise functionality." - q: "Which self-hosted workflow platform is best for integrations?" - a: "n8n is the strongest choice in this ranking for integration-heavy general automation, while Sim fits better when AI-native design and permissive licensing are the primary requirements." + a: "n8n fits integration heavy general automation, while Sim fits teams that prioritize AI agents and a permissively licensed core." - q: "Which self-hosted workflow platform is best for LLM applications?" - a: "Dify is the strongest specialist choice in this ranking for dedicated LLM applications, while Sim fits teams that want general open-source AI workflow automation." + a: "Dify fits dedicated apps that use language models and find relevant information in documents before answering, while Sim fits teams that want an AI agent to find information and act across business tools." - q: "Which self-hosted workflow platform is best for visual AI prototyping?" - a: "Langflow is a strong visual AI prototyping platform for Python-oriented component development. Flowise, which emphasized LLM flow composition, reached end of life on August 31, 2026 and is not a choice for new deployments." + a: "Langflow is a strong visual AI prototyping option for Python oriented component development. Flowise, which emphasized LLM flow composition, reached end of life on August 31, 2026 and is not a choice for new deployments." - q: "Does self-hosting mean an AI workflow platform works offline?" - a: "No self-hosted AI workflow platform should be assumed to work offline because model APIs, package registries, telemetry, authentication, plugins, and external integrations may still require network access." + a: "No self hosted AI workflow platform should be assumed to work offline because model APIs, package registries, telemetry, authentication, plugins, and external integrations may still require network access." - q: "Does self-hosting keep all AI workflow data private?" - a: "Self-hosting does not automatically keep all workflow data private because external model providers, integrations, logging services, and telemetry can still receive data." + a: "Self hosting does not automatically keep all workflow data private because external model providers, integrations, logging services, and telemetry can still receive data." - q: "Can these AI workflow platforms run on premises?" - a: "Sim, n8n, Dify, Langflow, and Activepieces offer self-hosted deployment paths (Flowise installs still run but reached end of life on August 31, 2026), but buyers must validate the exact on-premises architecture, dependencies, support terms, and enterprise controls they require." + a: "Sim, n8n, Dify, Langflow, and Activepieces offer self hosted deployment paths, while Flowise installations still run but reached end of life on August 31, 2026. Buyers must validate the exact on premises architecture, dependencies, support terms, and enterprise controls they require." - q: "Which self-hosted AI workflow platform is best for air-gapped environments?" - a: "No platform in this ranking should be selected for an air-gapped environment without vendor confirmation and architecture testing because external models, dependencies, updates, authentication, or integrations may require network access." + a: "No platform in this comparison should be selected for an air gapped environment without architecture testing because external models, dependencies, updates, authentication, or integrations may require network access." - q: "What is the best AI agent builder?" - a: "Sim is one AI agent builder in that category, but the broader head-term comparison is covered by Sim’s canonical Best AI Agent Platforms and Builders in 2026 article rather than this self-hosted workflow platform ranking." + a: "Sim fits teams that need to build, deploy, and manage AI agents with visual workflows, deployment controls, and an enterprise governance path. The Best AI Agent Platforms and Builders in 2026 guide compares the broader category." - q: "How much does a self-hosted AI workflow platform cost?" - a: "A self-hosted AI workflow platform costs more than its software license because buyers must also account for infrastructure, databases, storage, networking, model usage, monitoring, backups, upgrades, security, and engineering time." + a: "A self hosted AI workflow platform costs more than its software license because buyers must also account for infrastructure, databases, storage, networking, model usage, monitoring, backups, upgrades, security, and engineering time." - q: "What should I test before choosing a self-hosted AI workflow platform?" - a: "Sim recommends testing representative workflows, model access, integration reliability, failed runs, retries, logs, credentials, access controls, scaling, backups, upgrades, and recovery before selecting a self-hosted platform." + a: "Test representative workflows, model access, integration reliability, failed runs, retries, logs, credentials, access controls, scaling, backups, upgrades, and recovery before selecting a self hosted platform." + - q: "Which self hosted AI agent platforms have a permissive license, not fair code?" + a: "Sim's core, Flowise's community code, and Langflow have permissive OSI approved licenses, while n8n uses the source available Sustainable Use License and Dify uses an Apache based license with additional conditions. Flowise reached end of life on August 31, 2026 and is not suitable for a new deployment." + - q: "Do self hosted AI workflow platforms support SSO and audit logs?" + a: "Sim and several competing platforms offer SSO or audit capabilities through enterprise or paid editions, so buyers must evaluate the exact edition rather than assuming every self hosted package includes them." + - q: "Should a team deploy a self hosted AI workflow platform with Docker or Kubernetes?" + a: "Docker is practical for evaluation, while Kubernetes can fit organizations that need standardized orchestration, scaling, policy enforcement, and operational ownership. Buyers should follow each vendor's supported deployment documentation." + - q: "Which self hosted platform is best for LangChain workflows?" + a: "Flowise was designed for visual LangChain composition but reached end of life on August 31, 2026, so it should not be selected for a new deployment. Langflow fits Python teams that want customizable visual components." + - q: "Do self hosted AI platforms support private model endpoints?" + a: "Sim and several alternatives support private model endpoints, but each organization must ensure that model traffic, authentication, logging, and retention stay within its approved security boundary." + - q: "Can self hosted AI workflow platforms meet GDPR or HIPAA requirements?" + a: "Sim or another self hosted platform can support a GDPR or HIPAA program, but self hosting alone does not establish compliance because configuration, contracts, access controls, data flows, retention, auditing, and operating procedures remain the organization's responsibility." --- +Sim fits teams that need to deploy AI agents with local model flexibility, an Apache 2.0 core, and an enterprise path for identity and audit controls. n8n fits broad traditional automation, while Dify fits self hosted LLM applications. + ## TL;DR -Sim, n8n, Dify, Langflow, and Activepieces are widely evaluated self-hosted workflow platforms, but they differ substantially in licensing, AI-native design, deployment control, observability, governance, and operating effort. Flowise, also covered below, reached end of life on August 31, 2026 and is not a choice for new deployments. +Sim, n8n, Dify, Langflow, and Activepieces are widely evaluated self hosted workflow platforms, but they differ substantially in licensing, AI focused design, deployment control, observability, governance, and operating effort. Flowise, also covered below, reached end of life on August 31, 2026 and is not a choice for new deployments. -For teams that require OSI-approved open-source licensing, Sim is a strong fit because its Apache 2.0 license permits broad use, modification, and self-hosting without the commercial-hosting restrictions found in source-available alternatives. n8n remains the strongest incumbent for integration-heavy general automation, while Dify and Langflow are better suited to narrower LLM application or prototyping requirements. +For teams that require an OSI approved open source core, Sim is a strong fit because [Sim's core uses Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), which permits broad use, modification, and redistribution under its terms. Code in `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), and production use requires an active Sim Enterprise subscription. n8n remains the strongest incumbent for integration heavy general automation, while Dify and Langflow are better suited to narrower LLM application or prototyping requirements. -This ranking evaluates software that buyers can deploy in infrastructure they control. It does not assume that self-hosting automatically provides offline operation, air-gapped deployment, unrestricted local-model access, or every enterprise governance feature. +This comparison evaluates software that buyers can deploy in infrastructure they control. It does not assume that self hosting automatically provides offline operation, air gapped deployment, unrestricted local model access, or every enterprise governance feature. ## What are the best self-hosted AI workflow automation platforms? -Sim, the publisher, is listed first, as a strong fit for teams prioritizing an OSI-approved license, visual AI workflows, deployment control, and a path to enterprise governance. +Sim, the publisher, is listed first as a strong fit for teams prioritizing an OSI approved core license, visual AI workflows, deployment control, and a path to enterprise governance. -1. **Sim**: a strong fit for open-source, AI-native workflow automation -2. **n8n**: Best for integration-heavy general workflow automation -3. **Dify**: Best for self-hosted LLM application development and operations +1. **Sim**: a strong fit for teams building, deploying, and managing AI agents with an open source core +2. **n8n**: fits integration heavy general workflow automation +3. **Dify**: fits self hosted LLM application development and operations 4. **Flowise**: end of life since August 31, 2026; not recommended for new deployments -5. **Langflow**: Best for Python-oriented AI flow prototyping -6. **Activepieces**: Best for approachable, general-purpose business automation +5. **Langflow**: fits Python oriented AI flow prototyping +6. **Activepieces**: fits approachable, general purpose business automation -The ranking emphasizes license rights, self-hosting practicality, model access, integrations, workflow observability, governance, and the technical effort required to operate each platform. It does not rank the broader “best AI agent builder” category, which is covered by Sim’s canonical [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) comparison. +The comparison emphasizes license rights, self hosting practicality, model access, integrations, workflow observability, governance, and the technical effort required to operate each platform. It does not rank the broader “best AI agent builder” category, which is covered by Sim’s canonical [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) comparison. ## Key facts at a glance -[Sim uses Apache License 2.0 and documents self-hosted deployment](https://github.com/simstudioai/sim), shifting self-managed operating costs to infrastructure and external model-provider usage rather than a proprietary runtime license. +[Sim uses Apache License 2.0 and documents self hosted deployment](https://github.com/simstudioai/sim), shifting self-managed operating costs to infrastructure and external model provider usage rather than a proprietary runtime license. -[n8n uses its source-available Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), supports self-hosting within that license’s permissions, and leaves self-managed teams responsible for infrastructure while separate commercial terms apply to some editions. +[n8n uses its source available Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), supports self hosting within that license’s permissions, and leaves self-managed teams responsible for infrastructure while separate commercial terms apply to some editions. -[Dify uses a modified Apache 2.0 license with vendor-specific conditions](https://github.com/langgenius/dify/blob/main/LICENSE), supports self-hosted deployment, and leaves self-managed teams responsible for infrastructure, models, and related services. +[Dify uses a modified Apache 2.0 license with vendor specific conditions](https://github.com/langgenius/dify/blob/main/LICENSE), supports self hosted deployment, and leaves self-managed teams responsible for infrastructure, models, and related services. -[Flowise makes most code available under Apache 2.0 but licenses its enterprise directory and explicitly marked files commercially](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md). It supports self-hosting and leaves self-managed teams responsible for infrastructure and model-provider usage. +[Flowise makes most code available under Apache 2.0 but licenses its enterprise directory and explicitly marked files commercially](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md). It supports self hosting and leaves self-managed teams responsible for infrastructure and model provider usage. -[Langflow uses the MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE), supports self-hosting, and leaves self-managed teams responsible for infrastructure and model-provider usage. +[Langflow uses the MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE), supports self hosting, and leaves self-managed teams responsible for infrastructure and model provider usage. -[Activepieces places its core software under the MIT License while reserving enterprise functionality for a commercial license](https://github.com/activepieces/activepieces/blob/main/LICENSE), supports self-hosting, and leaves self-managed teams responsible for infrastructure plus any commercial entitlements they select. +[Activepieces places its core software under the MIT License while reserving enterprise functionality for a commercial license](https://github.com/activepieces/activepieces/blob/main/LICENSE), supports self hosting, and leaves self-managed teams responsible for infrastructure plus any commercial entitlements they select. -License statements were checked against vendor-owned license files and documentation in September 2026. Buyers should review the linked current terms before deploying any platform commercially or offering it as part of a hosted service. +License, deployment, enterprise feature, and billing descriptions were checked against vendor owned pages as of November 2026. Buyers should review the linked current terms before deploying any platform commercially or offering it as part of a hosted service. ## Self-hosted AI workflow automation platform comparison -Sim combines permissive licensing with AI-native workflow design, while n8n leads when the primary requirement is broad, conventional application automation. +Sim fits teams seeking an Apache 2.0 core and AI focused workflow design, while n8n fits teams whose primary requirement is broad, conventional application automation. + +| Platform | License | Deployment options | Air gapped fit | SSO and audit controls | Model flexibility | Pricing approach | +|---|---|---|---|---|---|---| +| **Sim** | [Apache 2.0 for Sim's core](https://github.com/simstudioai/sim/blob/main/LICENSE), with `apps/sim/ee` under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | [Docker and Kubernetes self hosting](https://docs.sim.ai/platform/self-hosting) in customer controlled infrastructure | Fits isolated use cases when models and every dependency remain internal | SSO, SCIM, access control, audit logs, data retention, and related capabilities are in `apps/sim/ee`; production use requires an Enterprise subscription | Hosted providers plus Ollama, vLLM, LM Studio through a compatible endpoint, and LiteLLM | The core can be self hosted under Apache 2.0; enterprise licensed capabilities require a subscription for production use | +| **n8n** | [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license), which is source available and not OSI approved | [Self hosting documentation](https://docs.n8n.io/deploy/host-n8n) covers customer managed deployment | Fits internal automation when every dependency is available within the approved network | [SSO is available on paid self hosted editions](https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/configure-sso); [external log streaming is an Enterprise feature](https://docs.n8n.io/administer/observe-and-log/stream-logs-to-external-systems) | AI nodes, credentials, APIs, and custom workflow logic | The Community edition is available for self hosting, while [paid plans](https://n8n.io/pricing/) add capabilities and support | +| **Dify** | [Dify Open Source License](https://github.com/langgenius/dify/blob/main/LICENSE), based on Apache 2.0 with additional conditions | [Docker Compose deployment](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) | Fits private LLM applications when models, vector stores, and tools remain within the approved boundary | [Enterprise plans](https://dify.ai/pricing/dify-enterprise) add SSO, security controls, support, and commercial authorization | Model providers, retrieval, tools, and application facing APIs | Community self hosting is available, while [commercial plans](https://dify.ai/pricing) cover managed and enterprise capabilities | +| **Flowise (end of life)** | [Apache 2.0 for most code, with commercial enterprise components](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) | Existing installs can use its [documented deployment methods](https://docs.flowiseai.com/configuration/deployment), but its [end of life notice](https://flowiseai.com/sunset) says upstream fixes ended on August 31, 2026 | Existing deployments require a maintained fork and internal dependencies | [SSO was an Enterprise feature](https://docs.flowiseai.com/configuration/sso); buyers now need a maintained fork or migration plan | Model and vector store components for LangChain focused flows | Existing users bear the cost of maintaining a fork or migrating | +| **Langflow** | [MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE) | [Docker and Docker Compose deployment](https://docs.langflow.org/deployment-docker) | Fits private component based applications when external calls are removed or routed internally | [Built in authentication is documented](https://docs.langflow.org/authentication-overview); surrounding infrastructure may provide additional identity, policy, and logging controls | Python components, model providers, vector stores, and custom integrations | The MIT licensed software can be self hosted; infrastructure and any external services are priced separately | +| **Activepieces** | [MIT licensed core with commercial enterprise functionality](https://github.com/activepieces/activepieces/blob/main/LICENSE) | [Customer managed self hosting](https://github.com/activepieces/activepieces/blob/main/README.md) | Fits internal automation when required connectors and services remain inside the boundary | Buyers should map identity, governance, security, and administration needs to the applicable edition | Connectors, APIs, and AI oriented pieces | Core software is available under MIT terms, while enterprise functionality requires commercial entitlement | -| Rank | Platform | Published license | Best fit | Model access | Integration approach | Observability and governance | Operating effort | -|---:|---|---|---|---|---|---|---| -| 1 | Sim | Apache 2.0 | AI-native workflows and agents under a permissive open-source license | Managed providers, BYOK, and configurable local endpoints | Native workflow tools plus API-based extensibility | Visual execution context with configurable enterprise controls | Moderate; infrastructure, secrets, models, and upgrades remain operator responsibilities | -| 2 | n8n | Sustainable Use License | Integration-heavy business and technical automation | AI nodes, credentials, APIs, and custom workflow logic | Strong general automation orientation | Mature workflow execution concepts; advanced organizational controls can depend on edition | Moderate; complex production estates require deliberate scaling and governance | -| 3 | Dify | Modified Apache 2.0 license | LLM applications, retrieval, prompts, and model operations | Provider-based model configuration and extensibility | AI application components, tools, APIs, and plugins | Stronger focus on LLM application operations than general process automation | Moderate to high; more supporting services may increase operational scope | -| 4 | Flowise (end of life) | Apache 2.0 for most code; commercial enterprise components | Visual LLM chains, retrieval flows, and agent experiments | Model and vector-store components | AI-focused nodes and API connections | Repository archived; no upstream fixes after August 31, 2026 | High; existing users maintain a fork or migrate | -| 5 | Langflow | MIT | Python-oriented AI prototyping and component composition | Model components and Python ecosystem access | Component graph with code-oriented extension paths | Strong development visibility; business-process governance is not its primary orientation | Moderate to high for production operations | -| 6 | Activepieces | MIT core; commercial enterprise functionality | Accessible general business automation | Connectors, APIs, and AI-oriented pieces | General SaaS and business application connectors | Familiar automation operations; confirm required enterprise controls by edition | Moderate; connector maintenance and production administration still require ownership | +Air gapped fit is an architectural assessment, not a certification. Any platform can leave the security boundary when a workflow calls a hosted model, software as a service API, external vector database, telemetry endpoint, or public package registry. -No self-hosted platform removes the need to manage compute, storage, networking, secrets, logs, backups, upgrades, model credentials, and incident response. “Free to self-host” describes software licensing, not zero total cost of ownership. +No self hosted platform removes the need to manage compute, storage, networking, secrets, logs, backups, upgrades, model credentials, and incident response. Free to self host describes software licensing, not zero total cost of ownership. ## 1. Which teams is Sim a fit for as a self-hosted AI workflow automation platform? -Sim is a strong fit for teams that want AI-native visual workflows under the permissive Apache 2.0 license. +Sim is a strong fit for teams that want visual AI workflows and an Apache 2.0 core. -Sim’s main differentiator is not merely that its source is visible: [Apache 2.0 is an OSI-approved open-source license](https://opensource.org/licenses) that permits use, modification, and redistribution subject to its terms. That distinction matters to companies evaluating long-term deployment freedom, internal customization, procurement risk, or the ability to build around the software without relying on a fair-code license. +Sim's main differentiator is not merely that its core source is visible: [Apache 2.0 is an OSI approved open source license](https://opensource.org/licenses) that permits use, modification, and redistribution subject to its terms. The separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) covers `apps/sim/ee`, including SSO, SCIM, access control, access requests, audit logs, white labeling, data retention, data drains, workspace forking, session policies, custom blocks, and credential groups; development, testing, and internal nonproduction use are free, while production use requires an active Sim Enterprise subscription. That distinction matters to companies evaluating long term deployment freedom, internal customization, procurement risk, or the ability to build around the software without relying on a fair code license. [Sim is designed to build, deploy, and monitor AI agents and workflows](https://github.com/simstudioai/sim). Teams can visually compose workflow steps, connect supported model providers and tools, and self-host the platform in infrastructure they control. -A configured self-hosted Sim deployment can connect to a local Ollama endpoint, but local or private model access should not be assumed to make every deployment offline or air-gapped. Teams with those requirements should confirm architecture, support, security, and networking details before procurement. +[A self hosted Sim deployment](https://docs.sim.ai/platform/self-hosting) can connect to Ollama, vLLM, LM Studio through a compatible endpoint, or LiteLLM without requiring Sim Enterprise, but local or private model access does not make every deployment offline or air gapped. Teams with those requirements should confirm architecture, support, security, and networking details before procurement. **Why Sim fits these priorities** -- Apache 2.0 provides clear, OSI-approved open-source rights. -- The workflow experience is designed for AI automation and agentic processes. +- Apache 2.0 provides clear, OSI approved open source rights for Sim's core. +- The workflow experience is designed for AI agents and related processes. - Self-hosting gives teams control over application infrastructure and operational boundaries. - The platform offers a path from visual building to governed enterprise deployment. -- API-oriented extensibility supports integrations beyond prebuilt tools. +- API oriented extensibility supports integrations beyond prebuilt tools. **Where another platform may fit better** -n8n may fit better when a buyer’s dominant requirement is mature, general-purpose application automation rather than AI-native workflow development. Dify may fit better when the product being built is specifically an LLM application with prompt, retrieval, and model-operations requirements. For more context, compare the [leading open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). +n8n may fit better when a buyer’s dominant requirement is mature, general purpose application automation rather than AI focused workflow development. Dify may fit better when the product being built is specifically an LLM application with prompt, retrieval, and model operations requirements. For more context, compare the [leading open source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). -As of September 2026, Sim’s license is published in its [official GitHub repository](https://github.com/simstudioai/sim/blob/main/LICENSE). +As of November 2026, Sim's core license is published in its [official GitHub repository](https://github.com/simstudioai/sim/blob/main/LICENSE), with the enterprise exception published separately. ## 2. Is n8n the best self-hosted platform for integration-heavy automation? -n8n is the strongest self-hosted choice in this ranking for teams whose main requirement is integration-heavy, general-purpose workflow automation. +n8n fits teams whose main requirement is integration heavy, general purpose workflow automation. -[n8n documents self-hosting on private infrastructure and supports AI workflows that connect multiple model providers, tools, and memory](https://docs.n8n.io/deploy/host-n8n). Its workflow model is suited to moving information among applications, invoking APIs, transforming data, applying code, and adding AI steps to broader automations. +[n8n documents self hosting on private infrastructure and supports AI workflows that connect multiple model providers, tools, and memory](https://docs.n8n.io/deploy/host-n8n). Its workflow model is suited to moving information among applications, invoking APIs, transforming data, applying code, and adding AI steps to broader automations. -The central procurement issue is licensing. [n8n says its Sustainable Use License and Enterprise License follow the fair-code model](https://docs.n8n.io/privacy-and-security/sustainable-use-license). The Sustainable Use License is source-available but is not an OSI-approved open-source license. Its terms allow many internal and non-commercial uses while restricting certain commercial hosting or resale scenarios, so legal review is appropriate when n8n will be embedded in a product or provided to customers. +The central procurement issue is licensing. [n8n says its Sustainable Use License and Enterprise License follow the fair code model](https://docs.n8n.io/privacy-and-security/sustainable-use-license). The Sustainable Use License is source available but is not an OSI approved open source license. Its terms allow many internal and noncommercial uses while restricting certain commercial hosting or resale scenarios, so legal review is appropriate when n8n will be embedded in a product or provided to customers. -n8n is a strong candidate when connector coverage and general automation maturity outweigh the need for a permissive open-source license. It is a weaker fit when Apache 2.0 or equivalent licensing is a mandatory selection criterion. See the dedicated [n8n alternatives comparison](https://www.sim.ai/library/n8n-alternatives) for adjacent options. +n8n is a strong candidate when connector coverage and general automation maturity outweigh the need for a permissive open source license. It is a weaker fit when Apache 2.0 or equivalent licensing is a mandatory selection criterion. See the dedicated [n8n alternatives comparison](https://www.sim.ai/library/n8n-alternatives) for adjacent options. ## 3. Is Dify the best self-hosted platform for LLM applications? -Dify is the strongest option in this ranking for teams specifically building self-hosted LLM applications with prompts, retrieval, models, tools, and application-facing APIs. +Dify fits teams specifically building self hosted LLM applications with prompts, retrieval, models, tools, and application facing APIs. -[Dify describes itself as a workspace for agentic workflows, RAG pipelines, model and tool support, with cloud, VPC, and self-hosted deployment](https://github.com/langgenius/dify). That focus can make it a better conceptual fit than a general automation platform when the intended output is a chatbot, retrieval-augmented generation application, AI assistant, or model-backed internal product. +[Dify describes itself as a workspace for agentic workflows, RAG pipelines, model and tool support, with cloud, VPC, and self hosted deployment](https://github.com/langgenius/dify). That focus can make it a better conceptual fit than a general automation platform when the intended output is a chatbot, retrieval augmented generation application, AI assistant, or model backed internal product. -Dify should not be described simply as Apache 2.0 software. [Its license is a modified Apache 2.0 license with additional conditions](https://github.com/langgenius/dify/blob/main/LICENSE), making it a vendor-specific source-available license rather than a standard OSI-approved Apache 2.0 grant. +Dify should not be described simply as Apache 2.0 software. [Its license is a modified Apache 2.0 license with additional conditions](https://github.com/langgenius/dify/blob/main/LICENSE), making it a vendor specific source available license rather than a standard OSI approved Apache 2.0 grant. Dify’s broader application stack can also increase operational scope. Buyers should assess databases, queues, storage, plugins, model endpoints, monitoring, backups, and upgrade procedures rather than evaluating only the visual workflow editor. The [Dify alternatives guide](https://www.sim.ai/library/dify-alternatives) explores this category in more detail. @@ -165,42 +181,44 @@ Dify’s broader application stack can also increase operational scope. Buyers s Flowise reached end of life and is not a choice for new deployments. Its maintainers [announced a code freeze on July 29, 2026 and end of life on August 31, 2026](https://flowiseai.com/sunset), and the [GitHub repository](https://github.com/FlowiseAI/Flowise) was archived in August 2026. Existing installs keep running but receive no upstream fixes, so existing users would maintain a fork or migrate. -[Flowise described itself as a visual AI-agent builder and documented Docker-based self-hosting](https://github.com/FlowiseAI/Flowise/blob/main/README.md). Its visual graph exposed how prompts, models, retrievers, memory, and tools connect. [Sim's Flowise comparison](https://www.sim.ai/comparisons/flowise) and the [Flowise alternatives guide](/library/best-flowise-alternatives-2026) cover migration options. +[Flowise described itself as a visual AI agent builder and documented Docker based self hosting](https://github.com/FlowiseAI/Flowise/blob/main/README.md). Its visual graph exposed how prompts, models, retrievers, memory, and tools connect. [Sim's Flowise comparison](https://www.sim.ai/comparisons/flowise) and the [Flowise alternatives guide](/library/best-flowise-alternatives-2026) cover migration options. Flowise's [license applies Apache 2.0 to most code but commercial terms to enterprise code and explicitly marked files](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md). ## 5. Is Langflow the best self-hosted platform for Python AI prototyping? -Langflow is a strong self-hosted choice for Python-oriented teams that want a visual way to prototype model, retrieval, tool, and agent components. +Langflow is a strong self hosted choice for Python oriented teams that want a visual way to prototype model, retrieval, tool, and agent components. -[Langflow is a Python project for building and deploying AI-powered agents and workflows](https://github.com/langflow-ai/langflow). Its component graph is useful when builders want visual composition without losing access to code-oriented customization. It is best evaluated as an AI development and prototyping environment rather than as a direct replacement for every business automation platform. +[Langflow is a Python project for building and deploying AI powered agents and workflows](https://github.com/langflow-ai/langflow). Its component graph is useful when builders want visual composition without losing access to code-oriented customization. It is best evaluated as an AI development and prototyping environment rather than as a direct replacement for every business automation platform. Production adoption requires more than exporting a successful prototype. Teams should plan for version control, testing, service deployment, identity, secrets, logs, traces, scaling, model reliability, and operational ownership. Langflow publishes the [MIT License in its official repository](https://github.com/langflow-ai/langflow/blob/main/LICENSE). ## 6. Is Activepieces the best self-hosted platform for approachable business automation? -Activepieces is a strong self-hosted option for teams that want accessible, general-purpose business automation with an open-source core. +Activepieces is a strong self hosted option for teams that want accessible, general purpose business automation with an open source core. -[Activepieces describes an extensible AI automation platform built around type-safe pieces](https://github.com/activepieces/activepieces/blob/main/README.md). Its recognizable trigger-and-action patterns make it easier to evaluate for routine SaaS and operations workflows. AI steps can participate in those automations, but the platform’s center of gravity is broader business automation rather than deeply AI-native workflow construction. +[Activepieces describes an extensible AI automation platform built around type safe pieces](https://github.com/activepieces/activepieces/blob/main/README.md). Its recognizable trigger and action patterns make it easier to evaluate for routine SaaS and operations workflows. AI steps can participate in those automations, but the platform’s center of gravity is broader business automation rather than deeply AI focused workflow construction. -Buyers should distinguish the MIT-licensed core from functionality distributed under separate enterprise terms. The exact identity, governance, security, and administration capabilities required for production should be mapped to the applicable edition before selection. Activepieces publishes this split in its [official license file](https://github.com/activepieces/activepieces/blob/main/LICENSE). +Buyers should distinguish the MIT licensed core from functionality distributed under separate enterprise terms. The exact identity, governance, security, and administration capabilities required for production should be mapped to the applicable edition before selection. Activepieces publishes this split in its [official license file](https://github.com/activepieces/activepieces/blob/main/LICENSE). ## Which license is best for a self-hosted AI workflow platform? -Sim and Langflow apply standard OSI-approved open-source terms across the published project, which matters for buyers who require them. Flowise applies Apache 2.0 to most code but has commercially licensed enterprise components. +Sim's core and Langflow use standard OSI approved open source licenses, which matters for buyers who require permissive terms. Sim's `apps/sim/ee` directory uses the separate Sim Enterprise License. Flowise applies Apache 2.0 to most code but has commercially licensed enterprise components. -- **Sim:** Apache License 2.0 +- **Sim:** Apache License 2.0 for the core, with `apps/sim/ee` under the Sim Enterprise License - **Langflow:** MIT License - **Flowise:** Apache 2.0 for most code, with commercially licensed enterprise components and marked files -- **n8n:** Sustainable Use License, which is source-available and not OSI-approved -- **Dify:** Modified Apache 2.0 license with vendor-specific conditions -- **Activepieces:** MIT-licensed core with commercially licensed enterprise functionality +- **n8n:** Sustainable Use License, which is source available and not OSI approved +- **Dify:** Modified Apache 2.0 license with vendor specific conditions +- **Activepieces:** MIT licensed core with commercially licensed enterprise functionality -A permissive license does not eliminate every legal obligation, and source availability does not make a license open source. Buyers should involve counsel when embedding a platform in a commercial product, modifying notices, redistributing software, or offering hosted access to third parties. +A permissive license is generally a better fit when an organization needs broad modification and redistribution rights, while fair code and other source available licenses can impose additional restrictions even when the repository is visible. The practical question is whether the license permits the intended internal use, modification, redistribution, embedding, managed service, or customer facing deployment. + +A permissive license does not eliminate every legal obligation, and source availability does not make a license open source. Buyers should involve counsel when embedding a platform in a commercial product, modifying notices, redistributing software, or offering hosted access to third parties. The [Apache 2.0 vs Fair Code comparison](https://www.sim.ai/library/apache-2-0-vs-fair-code) examines this distinction for Sim and n8n. ## How should buyers compare deployment control? -Sim and the other ranked platforms can be self-hosted, but deployment control must be evaluated across the entire production architecture rather than inferred from the presence of a Docker image or source repository. +Sim and the other compared platforms can be self hosted, but deployment control must be evaluated across the entire production architecture rather than inferred from the presence of a Docker image or source repository. A serious deployment review should cover: @@ -214,14 +232,14 @@ A serious deployment review should cover: - Horizontal scaling and queue behavior - High availability and disaster recovery - Telemetry and external service dependencies -- Identity provider and access-control integration +- Identity provider and access control integration - Support for restricted or disconnected networks -Self-hosting gives the operator more control, but it also transfers more responsibility to the operator. +Self hosting gives the operator more control, but it also transfers more responsibility to the operator. ## How should buyers compare model access? -Sim should be evaluated across its supported hosted providers, BYOK options, and configurable self-hosted endpoints. Local or private model access should be treated as an architecture requirement rather than assumed from self-hosting alone. +Sim should be evaluated across its supported hosted providers, BYOK options, and configurable self hosted endpoints. Self hosted Sim supports Ollama, vLLM, LM Studio through a compatible endpoint, and LiteLLM without requiring Sim Enterprise, but local or private model access remains an architecture requirement rather than a guarantee of isolation. For every platform, buyers should determine: @@ -232,21 +250,21 @@ For every platform, buyers should determine: - How retries, fallbacks, timeouts, and rate limits work - Whether usage and cost can be attributed to a workflow or team - Whether model inputs and outputs can be redacted or excluded from logs -- Whether local, private, or air-gapped inference is contractually and technically supported +- Whether local, private, or air gapped inference is contractually and technically supported -A platform being self-hosted does not mean the models it calls are self-hosted. +A platform being self hosted does not mean the models it calls are self hosted. ## How should buyers compare observability? -Sim and every competing platform should be tested with failed, retried, partial, long-running, and high-volume executions before its observability is judged production-ready. +Sim and every competing platform should be tested with failed, retried, partial, long running, and high volume executions before its observability is judged ready for production. -Useful workflow observability includes searchable run histories, step-level inputs and outputs, latency, error details, retry state, model usage, correlation identifiers, version context, and retention controls. Enterprise teams may also need export to centralized logging, metrics, tracing, security monitoring, or cost-management systems. +Useful workflow observability includes searchable run histories, step level inputs and outputs, latency, error details, retry state, model usage, correlation identifiers, version context, and retention controls. Enterprise teams may also need export to centralized logging, metrics, tracing, security monitoring, or cost management systems. -AI workflows add observability requirements that conventional automation may not expose. Teams may need to inspect model selection, token or provider usage, tool calls, retrieval context, prompt versions, structured-output failures, safety decisions, and human approval events. +AI workflows add observability requirements that conventional automation may not expose. Teams may need to inspect model selection, token or provider usage, tool calls, retrieval context, prompt versions, structured output failures, safety decisions, and human approval events. ## How should buyers compare governance and enterprise controls? -Sim and the other platforms should be compared against the buyer’s specific identity, authorization, audit, promotion, and data-governance requirements rather than a generic “enterprise-ready” label. +Sim and the other platforms should be compared against the buyer’s specific identity, authorization, audit, promotion, and data governance requirements rather than a generic “ready for enterprise use” label. The evaluation should include: @@ -261,39 +279,77 @@ The evaluation should include: - Private networking requirements - Support, response times, and upgrade assistance -Some controls may be available only through commercial or enterprise arrangements even when a platform’s core is self-hostable. +Some controls may be available only through commercial or enterprise arrangements even when a platform's core is self hostable. As of November 2026, [n8n documents SSO for Business and Enterprise self hosted editions](https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/configure-sso), [Dify lists SSO and advanced security controls for Enterprise](https://dify.ai/pricing/dify-enterprise), and Flowise has reached end of life despite its legacy documentation for enterprise SSO. + +## What should a security review cover before self hosting an AI workflow platform? + +A security review for Sim, n8n, Dify, Langflow, Activepieces, or an existing Flowise deployment should cover the complete execution path rather than treating self hosting as proof of security. + +1. **License scope:** Confirm which directories, editions, and enterprise modules use which licenses. +2. **Network egress:** List every hosted model, API, webhook, telemetry service, package registry, and update service the deployment may contact. +3. **Identity:** Require the appropriate SSO, multifactor authentication, session, and account lifecycle controls. +4. **Authorization:** Test workspace roles, credential access, workflow editing rights, deployment rights, and administrative boundaries. +5. **Secrets:** Store credentials in an approved secret system, limit who can reference them, and prevent secret values from entering logs or model prompts. +6. **Audit evidence:** Record authentication, configuration changes, credential changes, workflow deployment, execution, and administrative actions where the selected edition supports them. +7. **Model data handling:** Document whether prompts, outputs, attachments, and embeddings go to local models or external providers. +8. **Data stores:** Identify the databases, vector stores, object stores, queues, and backups that retain workflow data. +9. **Isolation:** Define whether workflows share workers, credentials, storage, or network access across teams or tenants. +10. **Supply chain:** Pin images and dependencies, scan them, produce an inventory, and define an update process. +11. **Recovery:** Test backup restoration, key rotation, worker failure, and rollback procedures. +12. **Retention:** Set retention periods for run history, logs, files, prompts, outputs, and audit records. + +The [enterprise compliance and deployment guide](https://www.sim.ai/library/enterprise-ai-agent-compliance-deployment-guide) expands this review for regulated and private infrastructure. + +## How does self hosting affect data residency? + +Self hosting Sim or another workflow platform can place its application and primary databases in a chosen region, but data residency still depends on every model, integration, log sink, backup, and support path used by the workflow. + +A deployment in an approved region does not keep data there if a workflow sends prompts to a model endpoint in another region. The same issue applies to external vector databases, observability tools, email services, web search APIs, and software as a service integrations. + +Security teams should build a data flow for each production workflow. The data flow should name the runtime region, model endpoint, database, object store, vector store, log destination, backup region, subprocessors, and retention period. + +## Can these platforms run in an air gapped environment? + +Sim, n8n, Dify, Langflow, Activepieces, and existing Flowise installations can support isolated use cases only when the organization supplies every required model, service, image, package, and integration inside the controlled environment. + +Sim fits AI use cases that require local endpoints because [self hosted Sim supports Ollama and compatible endpoints for vLLM, LM Studio, and LiteLLM](https://docs.sim.ai/platform/self-hosting). A fully isolated Sim deployment still requires an internal plan for container images, software updates, databases, model files, secrets, observability, and any tools the agent calls. + +Air gapped operation becomes impossible when a workflow depends on a public model API or external software as a service application. Teams should test the exact workflow, not merely the platform login screen, without internet egress. ## What are the operating tradeoffs of self-hosting AI workflows? -Sim and every other self-hosted platform trade greater infrastructure control for greater operational responsibility. +Sim and every other self hosted platform trade greater infrastructure control for greater operational responsibility. -Self-hosting is attractive when teams need to control deployment location, network paths, upgrade timing, data stores, or software customization. It can also reduce dependence on a vendor’s hosted runtime. +Self hosting is attractive when teams need to control deployment location, network paths, upgrade timing, data stores, or software customization. It can also reduce dependence on a vendor’s hosted runtime. -The tradeoff is that the buyer becomes responsible for platform reliability. Required work can include patching, vulnerability management, backups, database maintenance, scaling, secrets, monitoring, incident response, model-provider management, and support for workflow authors. +The tradeoff is that the buyer becomes responsible for platform reliability. Required work can include patching, vulnerability management, backups, database maintenance, scaling, secrets, monitoring, incident response, model provider management, and support for workflow authors. -The best selection therefore depends on both product capabilities and the team that will operate them. Sim’s [workflow automation buyer’s checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) provides a structured way to test those requirements. +The selection therefore depends on both product capabilities and the team that will operate them. Sim’s [workflow automation buyer’s checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) provides a structured way to test those requirements. ## Which self-hosted AI workflow automation platform should you choose? -Sim is a strong fit for AI-native workflow teams that prioritize Apache 2.0 licensing and deployment control, while each alternative has a defensible specialist use case. +Sim is a strong fit for teams that prioritize an Apache 2.0 core, visual AI workflows, local model flexibility, and deployment control, while each alternative has a defensible specialist use case. -Choose **Sim** when permissive open-source licensing, visual AI workflows, and an enterprise deployment path are the main priorities. +Choose **Sim** when an open source core, visual AI workflows, and an enterprise deployment path are the main priorities. -Choose **n8n** when broad, integration-heavy business automation matters more than receiving OSI-approved open-source rights. +Choose **n8n** when broad, integration heavy business automation matters more than receiving OSI approved open source rights. Choose **Dify** when the main deliverable is an LLM application with retrieval, prompt, model, and application operations. Do not choose **Flowise** for a new deployment: it reached end of life on August 31, 2026. Teams already running it would maintain a fork or migrate to an actively maintained visual builder. -Choose **Langflow** when Python-oriented AI developers need a visual prototyping and component-composition environment. +Choose **Langflow** when Python oriented AI developers need a visual prototyping and component composition environment. -Choose **Activepieces** when approachable trigger-and-action business automation is more important than an AI-native platform architecture. +Choose **Activepieces** when approachable trigger and action business automation is more important than an AI focused platform architecture. Before committing, run the same representative workflows on the finalists and test deployment, failure recovery, credential handling, logs, upgrades, governance, and total operating effort. ## Related comparisons -Sim’s library separates self-hosted workflow platform selection from the broader AI agent builder head term to avoid giving buyers two competing answers to the same question. +Sim's library separates self hosted workflow platform selection from the broader AI agent builder category so buyers can evaluate each scope directly. -- For the broader market ranking, read [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026). -- For licensing and deployment alternatives, compare [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). +- For the broader market, read [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026). +- For licensing and deployment alternatives, compare [Best Open Source AI Agents and Platforms in 2026](https://www.sim.ai/library/open-source-ai-agent-platforms). +- For the license distinction, read [Apache 2.0 vs Fair Code](https://www.sim.ai/library/apache-2-0-vs-fair-code). +- For governance and regulated environments, use the [Enterprise AI Agent Compliance and Deployment Guide](https://www.sim.ai/library/enterprise-ai-agent-compliance-deployment-guide). +- For procurement criteria, use the [workflow automation platform buyer checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist). diff --git a/apps/sim/content/library/best-zapier-alternatives/index.mdx b/apps/sim/content/library/best-zapier-alternatives/index.mdx index 0930129818c..b263dc480e3 100644 --- a/apps/sim/content/library/best-zapier-alternatives/index.mdx +++ b/apps/sim/content/library/best-zapier-alternatives/index.mdx @@ -1,56 +1,56 @@ --- slug: best-zapier-alternatives -title: '15 Best Zapier Alternatives in 2026, Compared' -description: 'Compare 15 Zapier alternatives and competitors in 2026, including free and self-hosted options, on current pricing, AI agents, licensing, and best fit.' +title: '16 Best Zapier Alternatives in 2026, Compared' +description: 'Compare 16 Zapier alternatives and competitors in 2026, including free and self-hosted options, on current pricing, AI agents, licensing, and best fit.' seoTitle: 'Best Zapier Alternatives and Competitors (2026)' -seoDescription: 'Compare 15 Zapier alternatives and competitors, including free and self-hosted options, on current pricing, AI agents, licensing, and best fit.' +seoDescription: 'Compare 16 Zapier alternatives and competitors, including free and self-hosted options, on current pricing, AI agents, licensing, and best fit.' date: 2026-07-01 -updated: 2026-11-02 +updated: 2026-11-03 authors: - andrew -readingTime: 16 +readingTime: 23 tags: [Zapier Alternatives, Workflow Automation, AI Agents, Sim] ogImage: /library/best-zapier-alternatives/cover.jpg draft: false faq: - q: "What is the best Zapier alternative?" - a: "It depends on the work: Sim fits teams building AI agents that need model choice, code, or self-hosting, Make fits visual business automation, n8n fits technical teams that self-host, and Microsoft Power Automate fits Microsoft-centered organizations." + a: "Sim fits teams building AI agents that need model choice, code, or self-hosting, Make fits visual business automation, n8n fits technical teams that self-host, and Microsoft Power Automate fits Microsoft-centered organizations." - q: "Who are Zapier's main competitors?" a: "Zapier's main competitors include Make, n8n, Microsoft Power Automate, Workato, Tray.ai, Activepieces, IFTTT, Pabbly Connect, Integrately, and Albato for app automation, plus Sim, Gumloop, Lindy, Relevance AI, and Bardeen for AI agents." - q: "What is the best free Zapier alternative?" - a: "For a hosted free plan, Make and Activepieces each include 1,000 credits a month, while n8n Community Edition and Sim's Apache 2.0 core can be self-hosted without license fees. Self-hosting still costs infrastructure, maintenance, and model usage." + a: "Make and Activepieces each include 1,000 credits a month on their hosted free plans, while n8n Community Edition and Sim's core can be self-hosted without license fees. Self-hosting still costs infrastructure, maintenance, and model usage." - q: "What is the best open-source Zapier alternative?" a: "Sim and Activepieces both have cores under OSI-approved licenses (Apache 2.0 and MIT), with separately licensed enterprise features. Sim is the closer fit when AI agents are central, and Activepieces when the work is conventional trigger-and-action automation." - q: "Is Zapier open source?" - a: "No. Zapier is a proprietary, cloud-hosted product and does not publish its platform source code." + a: "Zapier is a proprietary, cloud-hosted product and does not publish its platform source code." - q: "Does Zapier have a self-hosted version?" a: "Zapier does not list a self-hosted edition of its automation platform, so every Zap runs on Zapier's cloud." - q: "How much does Zapier cost in 2026?" - a: "As of November 2026, Zapier's pricing page lists a free plan with 100 tasks a month and two-step Zaps, Professional from $19.99 a month billed annually ($29.99 monthly) for 750 tasks, Team from $69 a month billed annually ($103.50 monthly) for 2,000 tasks, and custom Enterprise pricing. Prices rise with the task tier you select." + a: "Zapier's pricing page lists a free plan with 100 tasks a month and two-step Zaps, Professional from $19.99 a month billed annually ($29.99 monthly) for 750 tasks, Team from $69 a month billed annually ($103.50 monthly) for 2,000 tasks, and custom Enterprise pricing as of October 2026. Prices rise with the task tier you select." - q: "Why does Zapier get expensive at scale?" - a: "Zapier counts every successful action step as a task, so a five-action Zap that runs 1,000 times uses 5,000 tasks. As of November 2026, Professional rises from $19.99 a month for 750 tasks to $489 a month for 100,000 tasks on annual billing ($29.99 to $733.50 billed monthly)." + a: "Zapier counts every successful action step as a task, so a five-action Zap that runs 1,000 times uses 5,000 tasks. As of October 2026, Professional rises from $19.99 a month for 750 tasks to $489 a month for 100,000 tasks on annual billing ($29.99 to $733.50 billed monthly)." - q: "How are Zapier Agents priced?" - a: "Zapier Agents is priced separately for now, with an activity quota rather than tasks. The free tier includes 400 activities a month, and Agents Pro includes 1,500 activities a month for $50 billed monthly or $400 a year ($33.33 a month). Zapier is migrating Agents into AI by Zapier, which uses task-based pricing, and has not set a date to turn off Agents." + a: "Zapier Agents is priced separately for now, with a free tier of 400 activities a month and Agents Pro at 1,500 activities a month for $50 billed monthly or $400 a year ($33.33 a month). Zapier is migrating Agents into AI by Zapier, which uses task-based pricing, and has not set a date to turn off Agents." - q: "Does Zapier support AI agents?" - a: "Yes. Zapier offers Zapier Agents, Chatbots, AI by Zapier steps, and Zapier MCP, all hosted on Zapier's cloud and billed through activities or tasks." + a: "Zapier offers Zapier Agents, Chatbots, AI by Zapier steps, and Zapier MCP, all hosted on Zapier's cloud and billed through activities or tasks." - q: "Is n8n open source?" - a: "No. n8n uses the Sustainable Use License, which n8n describes as a fair-code license, and its own FAQ explains why it does not use an open-source license." + a: "n8n uses the Sustainable Use License, which n8n describes as a fair-code license, and its own FAQ explains why it does not use an open-source license." - q: "Can n8n be used commercially?" a: "n8n's license allows use for your own internal business purposes, but making n8n available to your customers, for example so they can connect their accounts and build workflows, needs a separate agreement with n8n." - q: "Is Sim open source?" a: "Sim's core is licensed under Apache 2.0, an OSI-approved open-source license, and anyone can self-host it. Enterprise features use the separate Sim Enterprise License, which requires an Enterprise subscription for production use." - q: "Is Sim free?" - a: "Sim Cloud has a free plan with 1,000 one-time credits, worth $5, and paid plans start with Pro at $25 per user per month. The Apache 2.0 core can be self-hosted without license fees, though infrastructure and model costs still apply." + a: "Sim Cloud has a free plan with 1,000 one-time credits, worth $5, and paid plans start with Pro at $25 per user per month. Sim's core can be self-hosted without license fees, though infrastructure and model costs still apply, while enterprise features use the separate Sim Enterprise License." - q: "Can Sim be self-hosted?" - a: "Yes. Sim's Apache 2.0 core can run on your own infrastructure without a subscription, and a self-hosted deployment can use local models through Ollama, vLLM, LM Studio, or LiteLLM. Production use of the enterprise features requires an active Sim Enterprise subscription." + a: "Sim's Apache 2.0 core can run on your own infrastructure without a subscription, and a self-hosted deployment can use local models through Ollama, vLLM, LM Studio, or LiteLLM. Production use of the enterprise features requires an active Sim Enterprise subscription." - q: "Is Sim better than Zapier?" a: "Sim fits better when AI agents, model choice, code, or self-hosting are the requirement. Zapier fits better when you need the widest catalog of ready-made app actions, since it lists 9,000+ apps and Sim has 270+ integrations." - q: "Is Sim better than n8n?" - a: "Sim fits teams that want an Apache 2.0 core and agents built visually, conversationally, or with code. n8n fits technical teams focused on execution-priced automation who accept its Sustainable Use License." + a: "Sim fits teams that want a self-hostable core and agents built visually, conversationally, or with code. n8n fits technical teams focused on execution-priced automation who accept its Sustainable Use License." - q: "What is the difference between Sim and Gumloop?" - a: "Sim is an open-source AI workspace with a self-hostable Apache 2.0 core, while Gumloop is a proprietary hosted product priced in credits with no self-hosted edition listed." + a: "Sim is the open-source AI workspace with a self-hostable core, while Gumloop is a proprietary hosted product priced in credits with no self-hosted edition listed." - q: "What is the best n8n alternative?" - a: "Sim is a strong n8n alternative for teams that want an OSI-approved Apache 2.0 core and AI agents built visually, conversationally, or with code. Make is the closer fit for teams that prefer fully managed visual automation." + a: "Sim fits teams that want a self-hostable core under an OSI-approved license and AI agents built visually, conversationally, or with code. Make fits teams that prefer fully managed visual automation." - q: "Is Make better than Zapier?" a: "Make fits teams that want routers, filters, and branching laid out visually and priced in credits. Zapier fits teams that want simpler linear Zaps and its larger app catalog." - q: "Is n8n better than Zapier?" @@ -58,11 +58,11 @@ faq: - q: "Is Make better than n8n?" a: "Make fits teams that want a fully managed visual service, while n8n fits technical teams that want to self-host and add code to workflows." - q: "Is Make cheaper than Zapier?" - a: "Often for multi-step work, since Make's Core plan starts at $9 a month with annual billing, but each module action uses a credit. Compare a real scenario against Zapier's task count before deciding." + a: "Make can cost less for multi-step work because its Core plan starts at $9 a month with annual billing, but each module action uses a credit. Buyers should compare a real scenario against Zapier's task count before deciding." - q: "Is n8n cheaper than Zapier?" a: "n8n can cost less for multi-step workflows because its cloud plans count one execution per workflow run regardless of steps, while Zapier counts every successful action. Self-hosting n8n adds infrastructure and maintenance costs." - q: "What is the cheapest Zapier alternative?" - a: "No single tool is always cheapest because vendors meter tasks, credits, executions, transactions, or users differently. Price one representative workflow at your real monthly volume in each tool you shortlist." + a: "Zapier alternatives meter tasks, credits, executions, transactions, or users differently, so no single tool is always cheapest. Buyers should price one representative workflow at their real monthly volume in each shortlisted tool." - q: "Are self-hosted Zapier alternatives cheaper?" a: "Self-hosting removes or reduces license fees, but you take on servers, upgrades, security patches, monitoring, backups, and model costs, so it is not automatically cheaper." - q: "What is the best Zapier alternative for developers?" @@ -80,7 +80,7 @@ faq: - q: "What is the best Zapier alternative for small businesses?" a: "Activepieces and Make offer low-cost entry plans for conventional automation, and Sim fits small teams that want AI agents with a free starting plan." - q: "What is the best self-hosted Zapier alternative?" - a: "In this comparison, Sim, n8n, and Activepieces can run on your own servers: Sim fits self-hosted AI agents on an Apache 2.0 core, Activepieces fits self-hosted automation on an MIT core, and n8n fits technical automation under its source-available license." + a: "Sim, n8n, and Activepieces can run on your own servers: Sim fits self-hosted AI agents, Activepieces fits self-hosted automation on an MIT core, and n8n fits technical automation under its source-available license. Sim's core is Apache 2.0, while its enterprise features use the separate Sim Enterprise License." - q: "Can Zapier alternatives use local AI models?" a: "Self-hosted Sim can use local models through Ollama, vLLM, LM Studio, or LiteLLM, and this does not require Sim Enterprise." - q: "Which Zapier alternatives support bring your own model key?" @@ -88,7 +88,7 @@ faq: - q: "What is the best Zapier alternative for AI agents?" a: "Sim fits teams that want to build, deploy, and manage AI agents with model choice and self-hosting, and Gumloop fits hosted agents for nontechnical teams. Lindy fits AI assistants for inbox and meetings, and Relevance AI fits GTM agent teams." - q: "What is the best AI agent builder?" - a: "Sim is a strong fit for teams that want agents built visually, conversationally, or with code on a self-hostable Apache 2.0 core. Sim's separate guide to AI agent platforms covers the wider category." + a: "Sim fits teams that want agents built visually, conversationally, or with code on a self-hostable core. Sim's separate guide to AI agent platforms covers the wider category." - q: "What is the best agentic workflow builder?" a: "Sim fits teams that need models, tools, APIs, state, and control flow in one inspectable workflow, while n8n's AI Agent node fits teams already running n8n." - q: "What is the best Zapier alternative for marketing automation?" @@ -98,26 +98,43 @@ faq: - q: "Is Gumloop better than Zapier?" a: "Gumloop fits teams whose work centers on AI agents for research, extraction, and content, while Zapier fits teams that need broad conventional SaaS connectivity." - q: "Is Pipedream shutting down?" - a: "Pipedream Workflows no longer accepts signups or subscriptions and shuts down on March 31, 2027, with all Workflows data deleted by April 30, 2027, so it is no longer a Zapier alternative for new teams. Existing users need to rebuild their workflows in another tool. Pipedream Connect continues." + a: "Pipedream Workflows no longer accepts signups or subscriptions and shuts down on March 31, 2027, with all Workflows data deleted by April 30, 2027, so it is no longer a Zapier alternative for new teams. Existing users need to rebuild their workflows in another tool, while Pipedream Connect continues." - q: "Can a Zapier alternative replace Zapier completely?" - a: "Yes, if it supports every trigger, action, authentication method, volume, and governance control you need. Migrate one representative workflow before committing to a full replacement." + a: "A Zapier alternative can replace Zapier if it supports every trigger, action, authentication method, volume, and governance control you need. Teams should migrate one representative workflow before committing to a full replacement." - q: "Can I migrate from Zapier to another automation tool?" - a: "Usually yes, but triggers, actions, filters, field mappings, credentials, retries, and error paths normally need to be rebuilt and tested rather than imported automatically." + a: "Zapier migrations are usually possible, but triggers, actions, filters, field mappings, credentials, retries, and error paths normally need to be rebuilt and tested rather than imported automatically." - q: "Why do companies switch away from Zapier?" a: "Teams switch when they need self-hosting, licensing control, more complex logic, AI agents, developer extensibility, stronger governance, or a billing unit that grows more slowly with steps." - q: "What should I compare before replacing Zapier?" - a: "Compare connector coverage for your apps, deployment and licensing, security and secrets handling, branching and retries, logs, collaboration, model support, and the billing unit that grows with production usage." + a: "Zapier alternatives should be compared on connector coverage for your apps, deployment and licensing, security and secrets handling, branching and retries, logs, collaboration, model support, and the billing unit that grows with production usage." + - q: "Which Zapier alternative is best for personal automation?" + a: "IFTTT fits simple personal, smart-home, and connected-device automations built as Applets." --- The best Zapier alternative depends on whether the work is simple triggers or AI agents. Zapier fits fixed trigger-and-action steps, such as turning a form submission into a CRM record. When an AI agent has to read unstructured input, choose its next step, and call tools, model choice, run logs, human review, and how each tool bills model calls matter more than app count. Sim is built for that agent work. For conventional automation, Make fits visual business processes, n8n fits technical teams that self-host, Activepieces fits low-cost automation on an MIT core, Microsoft Power Automate fits Microsoft 365 organizations, and Workato fits governed enterprise integration. Free options include Make, Activepieces, IFTTT, and self-hosted n8n or Sim. -This guide is published by Sim, and Sim is one of the 15 products covered. Sim is the open-source AI workspace for building, deploying, and managing AI agents, so it appears first, with its limitations stated like everyone else's. Prices were checked against each vendor's own pricing page in late October and early November 2026. +This guide is published by Sim, and Sim is one of the 16 products covered. Sim is the open-source AI workspace for building, deploying, and managing AI agents, and its profile states limitations like every other profile. Prices were checked against each vendor's own pricing page as of October 2026. > **Choose Zapier when** you need the largest catalog of ready-made app actions (Zapier lists [9,000+ apps](https://zapier.com/pricing)) for simple trigger-and-action automations that run on Zapier's cloud. > > **Choose Make, n8n, or Activepieces when** you want cheaper multi-step automation, more visual branching, or the option to self-host. > -> **Choose Sim when** AI agents are the core of the work and you want model choice, code when you need it, and an Apache 2.0 core you can self-host. Sim has [270+ integrations](https://www.sim.ai/integrations), far fewer than Zapier, so check that your apps are covered first. +> **Choose Sim when** AI agents are the core of the work and you want model choice, code when you need it, and Sim's Apache 2.0 core you can self-host, while enterprise code uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Sim has [270+ integrations](https://www.sim.ai/integrations), far fewer than Zapier, so check that your apps are covered first. + +## Zapier alternatives by buyer fit + +The alternatives are ordered to make the profiles easy to scan, not to assign a score or universal rank. + +| Alternative | Fits teams that need | +|---|---| +| Sim | Model-driven agents, human input, run logs, and a self-hostable core | +| Make | Visual routing and transformation for predefined SaaS processes | +| n8n | Technical workflow control and self-hosted general automation | +| Activepieces | Accessible trigger-and-action automation with an MIT-licensed core | +| Power Automate | Microsoft-centered cloud and desktop automation | +| Workato or Tray.ai | Governed enterprise integration programs | +| Gumloop, Lindy, Relevance AI, Dify, or Bardeen | Focused AI-agent or LLM application use cases | +| IFTTT, Pabbly Connect, Integrately, or Albato | Personal or lower-cost app automation | ## Why do teams look for a Zapier alternative? @@ -125,7 +142,7 @@ This guide is published by Sim, and Sim is one of the 15 products covered. Sim i Zapier counts [a task as any successful action that runs in Zapier](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier). Triggers, Filter and Paths steps, failed actions, and built-in apps such as Formatter, Delay, and Looping do not count. In Zapier's own example, a Zap with two actions that runs ten times uses 20 tasks, so a five-action Zap that runs 1,000 times a month uses 5,000 tasks. -As of November 2026, [Zapier's pricing page](https://zapier.com/pricing) lists: +As of October 2026, [Zapier's pricing page](https://zapier.com/pricing) lists: - **Free:** 100 tasks a month, with two-step Zaps only. - **Professional:** from $19.99 a month billed annually ($29.99 billed monthly) for 750 tasks, with multi-step Zaps, premium apps, and webhooks. @@ -147,15 +164,15 @@ Zapier also says that "AI steps, code, and SDK now all follow the same task-base Zapier Agents is priced separately for now. Agents use [an activity quota instead of tasks](https://zapier.com/pricing): the free tier runs up to 400 activities a month, and Agents Pro costs $50 billed monthly or $400 billed annually ($33.33 a month) for 1,500 activities. Zapier is [migrating Agents to AI by Zapier](https://help.zapier.com/hc/en-us/articles/47402591569805-Migrating-from-Agents-to-AI-by-Zapier), which "uses the same task-based pricing as the rest of Zapier," and it has not set a date to turn off Agents. -Pipedream is no longer ranked here. [Pipedream Workflows is in maintenance mode](https://pipedream.com/docs/workflows) and no longer accepts signups or subscriptions. Existing workflows keep running through March 31, 2027, and all Workflows data, including connected-account credentials, is deleted by April 30, 2027. Pipedream's export tool downloads the structure of your workflows as JSON, one project at a time, so steps, credentials, and triggers still need rebuilding elsewhere. Pipedream Connect continues. Teams leaving it can read [Sim vs Pipedream](https://www.sim.ai/comparisons/pipedream) or compare [Pipedream alternatives with visual builders](https://www.sim.ai/library/best-pipedream-alternatives-2026). Relay.app shut down in September 2026, and [Relay.app alternatives](https://www.sim.ai/library/best-relay-app-alternatives-2026) covers rebuilding its workflows. +Pipedream is no longer included as an active alternative here. [Pipedream Workflows is in maintenance mode](https://pipedream.com/docs/workflows) and no longer accepts signups or subscriptions. Existing workflows keep running through March 31, 2027, and all Workflows data, including connected-account credentials, is deleted by April 30, 2027. Pipedream's export tool downloads the structure of your workflows as JSON, one project at a time, so steps, credentials, and triggers still need rebuilding elsewhere. Pipedream Connect continues. Teams leaving it can read [Sim vs Pipedream](https://www.sim.ai/comparisons/pipedream) or compare [Pipedream alternatives with visual builders](https://www.sim.ai/library/best-pipedream-alternatives-2026). Relay.app shut down in September 2026, and [Relay.app alternatives](https://www.sim.ai/library/best-relay-app-alternatives-2026) covers rebuilding its workflows. ## How do these Zapier alternatives compare? -**The 15 alternatives split into AI-agent workspaces, visual app-automation tools, self-hostable automation, and enterprise integration platforms.** Prices below are from each vendor's pricing page as of early November 2026. +**The 16 alternatives split into AI-agent workspaces, visual app-automation tools, self-hostable automation, and enterprise integration platforms.** Prices below are from each vendor's pricing page as of October 2026. -| Tool | Best for | Free option | Entry paid plan | Self-host / license | +| Tool | Fits | Free option | Entry paid plan | Self-host / license | |---|---|---|---|---| -| Sim | Building and running AI agents | 1,000 one-time credits | Pro, $25 per user/month | Yes, Apache 2.0 core | +| Sim | Building and running AI agents | 1,000 one-time credits | Pro, $25 per user/month | Yes, Apache 2.0 core; [enterprise exception](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | | Make | Visual business automation | 1,000 credits/month | Core, from $9/month billed annually | No | | n8n | Technical teams that self-host | Self-hosted Community Edition | Starter, €20/month billed annually | Yes, Sustainable Use License | | Activepieces | Low-cost automation with an MIT core | 1,000 credits/month | Plus, $20/month | Yes, MIT core | @@ -167,19 +184,41 @@ Pipedream is no longer ranked here. [Pipedream Workflows is in maintenance mode] | Tray.ai | IT-led integration and MCP governance | None listed | Contact sales | No | | IFTTT | Personal and smart-home Applets | 1,000 credits/month | Starter, $2.09/month billed annually | No | | Pabbly Connect | Multi-step automation with lifetime plans | 100 tasks/month | Regional pricing | No | -| Integrately | Ready-made one-click automations | 100 tasks | Starter, $19.99/month billed annually | No | +| Integrately | Ready-made one-click automations | Free tier | 2,000-task plan, $49/month billed annually | No | | Albato | Low-cost automation with AI agents | 100 transactions | Pro, $15/month billed annually | No | | Bardeen | Browser-based GTM research | 100 credits | Basic, $10/month | No | +| Dify | Self-hosted LLM applications | Self-hosted Community edition | Professional, $590/workspace/year | Yes, Dify Open Source License | + +## How do Zapier, Sim, n8n, and Make compare? + +**Zapier, Sim, n8n, and Make fit different operating models rather than one universal hierarchy.** Sim fits model-driven agents, Zapier fits approachable SaaS triggers and actions, n8n fits technical teams that want deployment control, and Make fits detailed visual routing for deterministic automation. + +| Platform | Primary orientation | AI-agent approach | Deployment | Fit | +|---|---|---|---|---| +| Sim | AI agents and agentic workflows | Models, tools, control flow, human input, and deployment in one visual builder | Sim's core is [self-hostable](https://docs.sim.ai/platform/self-hosting) | Teams building production AI agents visually | +| Zapier | SaaS trigger-and-action automation | Hosted AI features alongside Zaps and [9,000+ apps](https://zapier.com/pricing) | Vendor-hosted | Business users connecting common SaaS applications | +| n8n | General technical automation | Agents, MCP, and AI nodes are listed on [n8n plans](https://n8n.io/pricing/) | Cloud or self-hosted under the [Sustainable Use License](https://docs.n8n.io/n8n-community-license) | Technical teams wanting deployment and code control | +| Make | Visual SaaS automation | AI Agents and AI modules are included in [Make's plan comparison](https://www.make.com/en/pricing) | Vendor-hosted | Teams that need visual routing and transformation | + +## What are the key facts about Zapier alternatives at a glance? + +**Zapier alternatives use different licenses, deployment models, and billing units, so these facts are dated as of October 2026.** Vendor pricing pages remain authoritative when packaging changes. + +- **Zapier** is a proprietary hosted service that measures successful action steps as tasks on its [automation plans](https://zapier.com/pricing). +- **Sim's core** is Apache 2.0 and self-hostable, while `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). [Workspace BYOK keys](https://docs.sim.ai/platform/costs) work on every Sim Cloud plan, and organization-level keys require Pro for Teams, Max for Teams, or Enterprise. +- **n8n** can be self-hosted under its source-available Sustainable Use License, while [n8n Cloud pricing](https://n8n.io/pricing/) meters workflow executions. +- **Make** is a proprietary hosted service whose [plans use credits](https://www.make.com/en/pricing), generally one credit for each module action in a non-AI scenario. +- **Dify** offers cloud plans and a self-hosted Community edition under the [Dify Open Source License](https://github.com/langgenius/dify/blob/main/LICENSE), which has conditions beyond an OSI-approved open-source license. -## What are the 15 best Zapier alternatives in 2026? +## What are the 16 best Zapier alternatives in 2026? **Each tool below has a clear best fit, a sourced price, and at least one real limitation.** ### 1. Sim -**Best for:** Teams that want to build, deploy, and manage AI agents visually, conversationally in Chat, or with code, with the option to self-host. For a head-to-head view, see [Sim vs Zapier](https://www.sim.ai/comparisons/zapier) and [Sim as an open-source Zapier alternative](https://www.sim.ai/library/sim-open-source-zapier-alternative). +**Fits:** Teams that want to build, deploy, and manage AI agents visually, conversationally in Chat, or with code, with the option to self-host. For a head-to-head view, see [Sim vs Zapier](https://www.sim.ai/comparisons/zapier) and [Sim as an open-source Zapier alternative](https://www.sim.ai/library/sim-open-source-zapier-alternative). -**Pricing:** The [Free plan](https://www.sim.ai/pricing) includes 1,000 one-time credits (worth $5). Pro is $25 per user per month with 6,000 monthly credits, Max is $100 per user per month with 25,000 monthly credits, and Enterprise is custom. The Apache 2.0 core can be self-hosted without license fees. +**Pricing:** The [Free plan](https://www.sim.ai/pricing) includes 1,000 one-time credits (worth $5). Pro is $25 per user per month with 6,000 monthly credits, Max is $100 per user per month with 25,000 monthly credits, and Enterprise is custom. Sim's Apache 2.0 core can be self-hosted without license fees, while enterprise code uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). **Strengths:** Agents, models, and integrations live in one workspace with Chat, Tables, Files, a Knowledge Base, and Logs that trace every run block by block. Sim connects to 28 model providers, and [workspace BYOK keys](https://docs.sim.ai/platform/costs) work on every plan, so you can pay model providers directly. A [self-hosted deployment](https://docs.sim.ai/platform/self-hosting) can use local models through Ollama, vLLM, LM Studio, or LiteLLM without Enterprise. @@ -207,7 +246,7 @@ Pipedream is no longer ranked here. [Pipedream Workflows is in maintenance mode] **Strengths:** One execution covers a whole run with unlimited steps, which favors long workflows. All plans include unlimited users and workflows, and the AI Agent node, MCP Server Trigger, and MCP Client are on every cloud plan. -**Limitations:** n8n is not open source. Its [Sustainable Use License](https://docs.n8n.io/n8n-community-license) is a fair-code license that allows use "only for your own internal business purposes or for non-commercial or personal use," so offering n8n to your customers needs a separate agreement. Sim fits teams that want an OSI-approved Apache 2.0 core and agents built conversationally as well as visually. +**Limitations:** n8n is not open source. Its [Sustainable Use License](https://docs.n8n.io/n8n-community-license) is a fair-code license that allows use "only for your own internal business purposes or for non-commercial or personal use," so offering n8n to your customers needs a separate agreement. Sim fits teams that want a self-hostable core under an OSI-approved license and agents built conversationally as well as visually. **Sources:** [n8n pricing](https://n8n.io/pricing/), [n8n license docs](https://docs.n8n.io/n8n-community-license), [n8n LICENSE file](https://github.com/n8n-io/n8n/blob/master/LICENSE.md). @@ -323,7 +362,7 @@ Pipedream is no longer ranked here. [Pipedream Workflows is in maintenance mode] **Best for:** Teams that want ready-made one-click automations between common SaaS apps. -**Pricing:** [Integrately's pricing page](https://integrately.com/pricing) lists Free with 100 tasks and single-step automations, Starter at $19.99 a month billed annually for 2,000 tasks, and Professional at $39 a month billed annually for 10,000 tasks. +**Pricing:** [Integrately's pricing page](https://integrately.com/pricing) lists a free tier and paid task allowances, including 2,000 tasks at $49 a month billed annually. **Strengths:** Starter adds multi-step automations and 5-minute update times (15 minutes on Free), and Integrately lists 1,500+ apps. @@ -355,22 +394,43 @@ Pipedream is no longer ranked here. [Pipedream Workflows is in maintenance mode] **Sources:** [Bardeen pricing](https://www.bardeen.ai/pricing). +### 16. Dify + +**Best for:** Teams that want to build and self-host LLM applications with workflows, retrieval, and model configuration. + +**Pricing:** [Dify pricing](https://dify.ai/pricing) lists a free cloud Sandbox and Professional at $590 per workspace per year, while the self-hosted Community edition is free under its own license. Enterprise pricing is custom. + +**Strengths:** Dify combines model configuration, workflows, retrieval, and application deployment in one product. Its Community edition gives teams a self-hosted path for LLM applications. + +**Limitations:** Dify focuses on LLM applications and knowledge-backed experiences rather than broad trigger-and-action business automation. Its [Dify Open Source License](https://github.com/langgenius/dify/blob/main/LICENSE) includes conditions on multi-tenant use and branding, so teams should not treat it as an OSI-approved open-source license. + +**Sources:** [Dify pricing](https://dify.ai/pricing), [Dify license](https://github.com/langgenius/dify/blob/main/LICENSE). + ## Which Zapier alternatives are free, open source, or self-hosted? -**Make, Activepieces, IFTTT, Integrately, Albato, and Pabbly Connect have ongoing free plans, Bardeen includes 100 free credits, and Sim, n8n, and Activepieces can run on your own servers.** Those are separate questions: a free hosted plan caps usage, and a self-hosted deployment removes license fees but not infrastructure costs. +**Make, Activepieces, IFTTT, Integrately, Albato, and Pabbly Connect have ongoing free plans, Bardeen includes 100 free credits, Dify has a free cloud Sandbox, and Sim, n8n, Activepieces, and Dify can run on your own servers.** Those are separate questions: a free hosted plan caps usage, and a self-hosted deployment removes license fees but not infrastructure costs. -- **Sim:** core under [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), an OSI-approved open-source license. Enterprise features use the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). +- **Sim:** Sim's core is under [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), an OSI-approved open-source license, while enterprise features use the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). - **Activepieces:** core under MIT, with cloud and enterprise features under a [commercial license](https://www.activepieces.com/docs/about/license). - **n8n:** source-available under the [Sustainable Use License](https://docs.n8n.io/n8n-community-license), which is not OSI-approved. -- **Zapier, Make, Power Automate, Workato, Tray.ai, and the rest:** vendor-hosted. Make and Workato offer on-prem agents that reach private systems, which is not the same as hosting the platform. +- **Dify:** Dify offers a free cloud Sandbox and a free self-hosted Community edition under the [Dify Open Source License](https://github.com/langgenius/dify/blob/main/LICENSE), which includes conditions on multi-tenant use and branding. +- **Zapier, Make, Power Automate, Workato, and Tray.ai:** vendor-hosted. Make and Workato offer on-prem agents that reach private systems, which is not the same as hosting the platform. Self-hosting moves upgrades, security patches, monitoring, backups, and model costs to your team. For a licensing-focused shortlist, see [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms) and [Apache 2.0 vs fair-code](https://www.sim.ai/library/apache-2-0-vs-fair-code). ## How should you choose a Zapier alternative? -**Start from the work you need to run, the people who will maintain it, and the billing unit that grows with your usage.** +**Zapier buyers should start from the work they need to run, the people who will maintain it, and the billing unit that grows with usage.** Use this decision sequence before shortlisting products: + +1. **Define the decision boundary.** Predetermined steps fit conventional automation, while work that interprets context and chooses tools calls for an agent-focused product. +2. **Separate self-hosting from source access.** A downloadable product may still use a source-available license that restricts hosting, redistribution, or multi-tenant use. +3. **Compare billing units against a real workflow.** Tasks, credits, executions, compute, model tokens, and user licenses produce different costs for the same process. +4. **Test the hardest integration.** Authentication, transformation, retries, and observability for a critical system matter more than a headline connector count. +5. **Evaluate human oversight.** Consequential work may need a pause for human input and an explicit condition before an external action proceeds. +6. **Check model and credential flexibility.** Confirm which providers, bring-your-own-key options, and local-model connections work in the deployment you plan to use. +7. **Measure production operations.** Logs, traces, evaluations, access controls, versioning, and rollback matter more as automations move beyond prototypes. -- **Choose Sim if** AI agents are central and you want model choice, Chat, code, and a self-hostable Apache 2.0 core. +- **Choose Sim if** AI agents are central and you want model choice, Chat, code, and a self-hostable core under an OSI-approved license. - **Choose Make if** operations teams want visual routing and a low-cost managed service. - **Choose n8n if** developers want to self-host and pay per workflow run. - **Choose Activepieces if** you want run-based pricing and an MIT core. @@ -392,3 +452,12 @@ Also decide whether the work needs a fixed automation or an AI agent: | Cost driver | Steps or tasks per run | Model and tool usage per run | Many teams end up with both kinds of work: deterministic steps for syncing data and an agent only where judgment is needed. [AI-native vs traditional workflow automation](https://www.sim.ai/library/ai-native-workflow-automation-vs-traditional-automation) explains that split, the [AI workflow automation buyer's checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) lists what to test, and the [best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) covers the wider agent category. + +## Related comparisons + +**Sim's related comparisons route buyers to the category that matches the work they need to automate.** + +- [Sim as an open-source Zapier alternative](https://www.sim.ai/library/sim-open-source-zapier-alternative) compares agent workflows with Zaps. +- [n8n alternatives](https://www.sim.ai/library/n8n-alternatives) focuses on self-hosted and technical workflow options. +- [Open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms) compares source and deployment choices. +- [AI-native vs traditional workflow automation](https://www.sim.ai/library/ai-native-workflow-automation-vs-traditional-automation) explains when model-driven decisions change the platform requirements. diff --git a/apps/sim/ee/access-requests/lib/application/authorization.ts b/apps/sim/ee/access-requests/lib/application/authorization.ts index f2f78ed1f14..7f9e189bb6a 100644 --- a/apps/sim/ee/access-requests/lib/application/authorization.ts +++ b/apps/sim/ee/access-requests/lib/application/authorization.ts @@ -12,7 +12,6 @@ import { authorizeOrganizationOperation } from '@/lib/core/application/organizat import { authorizeWorkspaceOperation, PrincipalKindAuthorizationError, - requireAllowedWorkspacePrincipal, } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' @@ -21,6 +20,7 @@ import { type AccessRequestOperation, type AccessRequestPrincipal, } from '@/ee/access-requests/lib/application/operations' +import { requireAccessRequestPrincipal } from '@/ee/access-requests/lib/application/principal' import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' export interface AccessRequestContext { @@ -94,7 +94,7 @@ export async function authorizeAccessRequestScope( forUpdate = false, expected?: Pick ): Promise { - requireAllowedWorkspacePrincipal(principal, operation) + requireAccessRequestPrincipal(principal, operation) if (operation.admin && scope.kind !== 'organization') { throw new OrchestrationError('forbidden', 'Organization administrator access is required') } diff --git a/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts index 26cc0b4d2f3..3088ee2c6a2 100644 --- a/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts +++ b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts @@ -1,4 +1,4 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { @@ -6,7 +6,6 @@ import { type WorkspaceUseCaseAuditEntry, } from '@/lib/core/application/authorized-workspace-use-case' import type { OperationUseCase } from '@/lib/core/application/operation' -import { requireAllowedWorkspacePrincipal } from '@/lib/core/application/workspace-authorization' import { runWithOutboundOrganization } from '@/lib/core/network/context.server' import type { DbClient, DbOrTx, DbTransaction } from '@/lib/db/types' import { @@ -18,6 +17,7 @@ import { type AccessRequestOperation, type AccessRequestPrincipal, } from '@/ee/access-requests/lib/application/operations' +import { requireAccessRequestPrincipal } from '@/ee/access-requests/lib/application/principal' import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' interface AccessRequestPreparationArgs { @@ -64,13 +64,6 @@ type ReadAccessRequestUseCase = AccessRequestUseCase mutation?: false } -function requireAccessRequestPrincipal( - principal: Principal, - operation: AccessRequestOperation -): asserts principal is AccessRequestPrincipal { - requireAllowedWorkspacePrincipal(principal, operation) -} - /** Runs preparation before any transaction opens and binds its result to `execute`. */ async function prepareExecution( definition: AccessRequestUseCase, diff --git a/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts b/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts new file mode 100644 index 00000000000..f919fff64c7 --- /dev/null +++ b/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts @@ -0,0 +1,278 @@ +/** Organization Chat reviewing access requests through the generic settings tool. */ + +import { db } from '@sim/db' +import { + auditLog, + copilotChats, + member, + organization, + outboxEvent, + permissionAccessRequest, + permissionGroup, + permissionGroupWorkspace, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env-flags', () => ({ ...envFlagsMock, isAccessControlEnabled: true })) + +import { settingsServerTool } from '@/lib/mothership/tools/server/settings' +import { createAccessRequest } from '@/ee/access-requests/lib/application/requests' +import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/ee/access-requests/lib/notification-events' + +const organizationId = generateId() +const workspaceId = generateId() +const adminId = generateId() +const memberId = generateId() +const requesterId = generateId() +const groupId = generateId() +const chats = new Map() +const requestIds: Record<'tables' | 'files', string> = { tables: '', files: '' } + +function chatContext(userId: string) { + const chatId = chats.get(userId) + if (!chatId) throw new Error('Fixture chat missing') + return { + userId, + organizationId, + chatId, + toolCallId: generateId(), + requestMode: 'agent', + copilotToolExecution: true, + } +} + +function asChat(userId: string) { + return (operation: string, input: Record) => + settingsServerTool.execute( + { scope: 'organization', action: 'execute', section: 'requests', operation, input }, + chatContext(userId) + ) as Promise<{ result: Record }> +} + +function readRequest(id: string) { + return db + .select({ + status: permissionAccessRequest.status, + decidedBy: permissionAccessRequest.decidedBy, + decisionReason: permissionAccessRequest.decisionReason, + }) + .from(permissionAccessRequest) + .where(eq(permissionAccessRequest.id, id)) + .then(([row]) => row) +} + +async function readGroupConfig() { + const [group] = await db + .select({ config: permissionGroup.config }) + .from(permissionGroup) + .where(eq(permissionGroup.id, groupId)) + return group.config +} + +function decidedEvents(id: string) { + return db + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where( + and( + eq(outboxEvent.eventType, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT), + sql`${outboxEvent.payload}->>'requestId' = ${id}` + ) + ) +} + +async function waitForAudit(resourceId: string, action: string) { + await vi.waitFor(async () => { + const [audit] = await db + .select({ actorId: auditLog.actorId, metadata: auditLog.metadata }) + .from(auditLog) + .where(and(eq(auditLog.resourceId, resourceId), eq(auditLog.action, action))) + expect(audit).toMatchObject({ + actorId: adminId, + metadata: { + operation: 'access_requests.resolve', + actor: { kind: 'organization_delegated', serviceId: 'copilot', subjectUserId: adminId }, + }, + }) + }) +} + +describe('organization Chat review of access requests', () => { + beforeAll(async () => { + const now = new Date() + const people = [adminId, memberId, requesterId] + await db.insert(user).values( + people.map((id) => ({ + id, + name: 'Access review fixture', + email: `${id}@access-review.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Access review fixture', + slug: organizationId, + createdAt: now, + updatedAt: now, + }) + await db.insert(member).values( + people.map((userId) => ({ + id: generateId(), + userId, + organizationId, + role: userId === adminId ? 'admin' : 'member', + createdAt: now, + })) + ) + await insertWorkspaceFixture(db, [ + { + id: workspaceId, + name: 'Access review fixture', + ownerId: adminId, + billedAccountUserId: adminId, + organizationId, + workspaceMode: 'organization' as const, + }, + ]) + await db.insert(permissions).values({ + id: generateId(), + userId: requesterId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'write' as const, + }) + await db.insert(permissionGroup).values({ + id: groupId, + organizationId, + name: 'Tables and files withheld', + createdBy: adminId, + config: { hideTablesTab: true, hideFilesTab: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId, + organizationId, + }) + for (const userId of [adminId, memberId]) { + const [chat] = await db + .insert(copilotChats) + .values({ + userId, + organizationId, + type: 'mothership', + config: { conversationMode: 'agent' }, + model: 'fixture-model', + }) + .returning({ id: copilotChats.id }) + chats.set(userId, chat.id) + } + const requester = { kind: 'session', userId: requesterId, sessionId: 'fixture' } as const + for (const [key, configKey] of [ + ['tables', 'hideTablesTab'], + ['files', 'hideFilesTab'], + ] as const) { + const { request } = await createAccessRequest.execute({ + principal: requester, + input: { + scope: { kind: 'workspace', workspaceId }, + target: { kind: 'feature', configKey }, + reason: 'Need it for a report', + }, + }) + requestIds[key] = request.id + } + }) + + afterAll(async () => { + const ids = Object.values(requestIds) + await db.delete(outboxEvent).where(inArray(sql`${outboxEvent.payload}->>'requestId'`, ids)) + await db + .delete(permissionAccessRequest) + .where(eq(permissionAccessRequest.organizationId, organizationId)) + await db.delete(auditLog).where(inArray(auditLog.resourceId, [...ids, groupId])) + await db.delete(copilotChats).where(eq(copilotChats.organizationId, organizationId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [adminId, memberId, requesterId])) + }) + + it("refuses a non-admin member's Chat every review operation", async () => { + const asMember = asChat(memberId) + const fingerprint = 'f'.repeat(64) + + await expect(asMember('list', {})).rejects.toMatchObject({ code: 'forbidden' }) + await expect(asMember('preview', { requestId: requestIds.tables })).rejects.toMatchObject({ + code: 'forbidden', + }) + await expect( + asMember('approve', { requestId: requestIds.tables, expectedFingerprint: fingerprint }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + asMember('deny', { requestId: requestIds.files, reason: 'No' }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(await readRequest(requestIds.tables)).toMatchObject({ status: 'pending' }) + expect(await readRequest(requestIds.files)).toMatchObject({ status: 'pending' }) + expect(await readGroupConfig()).toMatchObject({ hideTablesTab: true, hideFilesTab: true }) + }) + + it("lets an admin's Chat list, preview and approve a request, granting it", async () => { + const asAdmin = asChat(adminId) + + const section = (await settingsServerTool.execute( + { scope: 'organization', action: 'get', section: 'requests' }, + chatContext(adminId) + )) as { value: { requests: { id: string; status: string }[] } } + expect(section.value.requests.map(({ id }) => id).sort()).toEqual( + Object.values(requestIds).sort() + ) + + const { result: preview } = await asAdmin('preview', { requestId: requestIds.tables }) + expect(preview).toMatchObject({ canApply: true, resolutionKind: 'permission' }) + + const { result } = await asAdmin('approve', { + requestId: requestIds.tables, + expectedFingerprint: preview.fingerprint, + }) + + expect(result).toMatchObject({ changed: true, request: { status: 'fulfilled' } }) + expect(await readRequest(requestIds.tables)).toMatchObject({ + status: 'fulfilled', + decidedBy: adminId, + }) + expect(await readGroupConfig()).toMatchObject({ hideTablesTab: false, hideFilesTab: true }) + expect(await decidedEvents(requestIds.tables)).toHaveLength(1) + await waitForAudit(requestIds.tables, 'permission_access_request.fulfilled') + + const { result: pending } = await asAdmin('list', {}) + expect((pending.requests as { id: string }[]).map(({ id }) => id)).toEqual([requestIds.files]) + }) + + it("lets an admin's Chat deny a request with a reason, changing no policy", async () => { + const { result } = await asChat(adminId)('deny', { + requestId: requestIds.files, + reason: 'Files stay restricted this quarter', + }) + + expect(result).toMatchObject({ changed: true, request: { status: 'declined' } }) + expect(await readRequest(requestIds.files)).toEqual({ + status: 'declined', + decidedBy: adminId, + decisionReason: 'Files stay restricted this quarter', + }) + expect(await readGroupConfig()).toMatchObject({ hideTablesTab: false, hideFilesTab: true }) + expect(await decidedEvents(requestIds.files)).toHaveLength(1) + await waitForAudit(requestIds.files, 'permission_access_request.declined') + }) +}) diff --git a/apps/sim/ee/access-requests/lib/application/operations.ts b/apps/sim/ee/access-requests/lib/application/operations.ts index 13a313be0cc..e9f70949e1d 100644 --- a/apps/sim/ee/access-requests/lib/application/operations.ts +++ b/apps/sim/ee/access-requests/lib/application/operations.ts @@ -1,32 +1,52 @@ -import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { + DelegatedPrincipal, + OrganizationDelegatedPrincipal, + Principal, +} from '@sim/auth/principal' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' -/** Chat acting for the requester joins the direct callers on the workspace routes only. */ +/** + * Chat acting for the requester joins the direct callers on the workspace routes, and Chat acting + * for an organization administrator joins them on review. + */ export type AccessRequestPrincipal = | Extract | (DelegatedPrincipal & { serviceId: 'copilot' }) + | (OrganizationDelegatedPrincipal & { serviceId: 'copilot' }) export const ACCESS_REQUEST_DELEGATION_AUDIENCE = 'sim:settings' +const DIRECT_CALLERS = ['session', 'personal_api_key', 'oauth_access_token'] as const + +/** Review admits Chat acting for an organization administrator, under the settings audience. */ +const CHAT_REVIEW_POLICY = { + principalKinds: [...DIRECT_CALLERS, 'organization_delegated'], + delegationAudience: ACCESS_REQUEST_DELEGATION_AUDIENCE, + delegatedServices: ['copilot'], + contentDelivery: 'none', +} as const + function defineAccessRequestOperation( id: string, oauthScope: 'api:read' | 'api:write', - admin = false + audience: 'member' | 'admin' | 'review' = 'member' ) { + const admin = audience !== 'member' const organizationOperation = defineOrganizationOperation({ id, oauthScope, minimumRole: admin ? 'admin' : 'member', - principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + ...(audience === 'review' ? CHAT_REVIEW_POLICY : { principalKinds: DIRECT_CALLERS }), /** * permission-group-exempt: reviewing and requesting withheld access must remain reachable. */ capability: 'none', }) /** - * A member's own requests admit Chat delegated by that member. Review stays with direct - * callers: it is organization-scoped and never reached through a workspace. + * A member's own requests admit Chat delegated by that member through a workspace. Review is + * organization-scoped, so Chat reaches it only through an organization conversation; request + * settings stay with direct callers. */ const workspaceOperation = admin ? defineWorkspaceOperation({ @@ -79,27 +99,27 @@ export const accessRequestOperations = { listOrganization: defineAccessRequestOperation( 'access_requests.list_organization', 'api:read', - true + 'review' ), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - preview: defineAccessRequestOperation('access_requests.preview', 'api:read', true), + preview: defineAccessRequestOperation('access_requests.preview', 'api:read', 'review'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - resolve: defineAccessRequestOperation('access_requests.resolve', 'api:write', true), + resolve: defineAccessRequestOperation('access_requests.resolve', 'api:write', 'review'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - getSettings: defineAccessRequestOperation('access_requests.get_settings', 'api:read', true), + getSettings: defineAccessRequestOperation('access_requests.get_settings', 'api:read', 'admin'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ updateSettings: defineAccessRequestOperation( 'access_requests.update_settings', 'api:write', - true + 'admin' ), } as const diff --git a/apps/sim/ee/access-requests/lib/application/principal.ts b/apps/sim/ee/access-requests/lib/application/principal.ts new file mode 100644 index 00000000000..f94add1a8f9 --- /dev/null +++ b/apps/sim/ee/access-requests/lib/application/principal.ts @@ -0,0 +1,22 @@ +import type { Principal } from '@sim/auth/principal' +import { requireAllowedOrganizationPrincipal } from '@/lib/core/application/organization-authorization' +import { requireAllowedWorkspacePrincipal } from '@/lib/core/application/workspace-authorization' +import type { + AccessRequestOperation, + AccessRequestPrincipal, +} from '@/ee/access-requests/lib/application/operations' + +/** + * Runs before the request scope is derived. Organization-delegated Chat only ever reaches review, + * so it is checked against the organization policy; every other caller against the workspace one. + */ +export function requireAccessRequestPrincipal( + principal: Principal, + operation: AccessRequestOperation +): asserts principal is AccessRequestPrincipal { + if (principal.kind === 'organization_delegated') { + requireAllowedOrganizationPrincipal(principal, operation.organizationOperation) + } else { + requireAllowedWorkspacePrincipal(principal, operation) + } +} diff --git a/apps/sim/ee/scim/lib/managed-membership.integration.ts b/apps/sim/ee/scim/lib/managed-membership.integration.ts index 58ad21e7e34..e1550819cee 100644 --- a/apps/sim/ee/scim/lib/managed-membership.integration.ts +++ b/apps/sim/ee/scim/lib/managed-membership.integration.ts @@ -10,7 +10,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ })) async function loadRuntime() { - const [{ db }, schema, { eq, inArray, sql }, { alias }, membership, entitlement] = + const [{ db }, schema, { and, eq, inArray, sql }, { alias }, membership, entitlement] = await Promise.all([ import('@sim/db'), import('@sim/db/schema'), @@ -19,7 +19,27 @@ async function loadRuntime() { import('@/ee/scim/lib/managed-membership'), import('@/ee/scim/lib/entitlement'), ]) - return { db, schema, eq, inArray, sql, alias, ...membership, ...entitlement } + const [ + { removeOrganizationMemberRecord }, + { removeUserFromOrganization, transferOrganizationOwnership }, + ] = await Promise.all([ + import('@/lib/organizations/member-manager'), + import('@/lib/billing/organizations/membership'), + ]) + return { + db, + schema, + and, + eq, + inArray, + sql, + alias, + removeOrganizationMemberRecord, + removeUserFromOrganization, + transferOrganizationOwnership, + ...membership, + ...entitlement, + } } describe('SCIM managed membership in PostgreSQL', () => { @@ -29,6 +49,7 @@ describe('SCIM managed membership in PostgreSQL', () => { let connectionId: string let managedUserId: string let unmanagedUserId: string + let adminUserId: string let managedEmail: string beforeAll(async () => { @@ -42,6 +63,7 @@ describe('SCIM managed membership in PostgreSQL', () => { connectionId = generateId() managedUserId = generateId() unmanagedUserId = generateId() + adminUserId = generateId() managedEmail = `${managedUserId}@scim-membership.test` const { db, schema } = runtime const now = new Date() @@ -54,7 +76,7 @@ describe('SCIM managed membership in PostgreSQL', () => { })) ) await db.insert(schema.user).values( - [managedUserId, unmanagedUserId].map((id) => ({ + [managedUserId, unmanagedUserId, adminUserId].map((id) => ({ id, name: 'SCIM predicate test', email: `${id}@scim-membership.test`, @@ -99,7 +121,9 @@ describe('SCIM managed membership in PostgreSQL', () => { const { db, schema, eq, inArray } = runtime await db.delete(schema.subscription).where(eq(schema.subscription.referenceId, orgId)) await db.delete(schema.organization).where(inArray(schema.organization.id, [orgId, otherOrgId])) - await db.delete(schema.user).where(inArray(schema.user.id, [managedUserId, unmanagedUserId])) + await db + .delete(schema.user) + .where(inArray(schema.user.id, [managedUserId, unmanagedUserId, adminUserId])) }) async function invitee(organizationId: string, email: string) { @@ -194,4 +218,127 @@ describe('SCIM managed membership in PostgreSQL', () => { }) ).rejects.toMatchObject({ cause: { code: '42P01' } }) }) + + describe('removing a member', () => { + beforeEach(async () => { + const { db, schema } = runtime + await db.insert(schema.member).values([ + { id: generateId(), organizationId: orgId, userId: adminUserId, role: 'owner' }, + { id: generateId(), organizationId: orgId, userId: managedUserId, role: 'member' }, + { id: generateId(), organizationId: orgId, userId: unmanagedUserId, role: 'member' }, + ]) + }) + + async function isMember(userId: string) { + const { db, schema, and, eq } = runtime + const rows = await db + .select({ id: schema.member.id }) + .from(schema.member) + .where(and(eq(schema.member.organizationId, orgId), eq(schema.member.userId, userId))) + return rows.length > 0 + } + + it('refuses a managed member leaving on their own and keeps the membership', async () => { + const { removeOrganizationMemberRecord } = runtime + await expect( + removeOrganizationMemberRecord({ + organizationId: orgId, + userId: managedUserId, + actorUserId: managedUserId, + }) + ).rejects.toMatchObject({ + detailCode: 'SCIM_MANAGED_MEMBERSHIP', + message: expect.stringContaining('Ask your identity provider administrator'), + }) + expect(await isMember(managedUserId)).toBe(true) + }) + + it('lets an administrator remove the same managed member', async () => { + const { removeOrganizationMemberRecord } = runtime + await expect( + removeOrganizationMemberRecord({ + organizationId: orgId, + userId: managedUserId, + actorUserId: adminUserId, + }) + ).resolves.toMatchObject({ membershipType: 'internal' }) + expect(await isMember(managedUserId)).toBe(false) + }) + + it('lets an unmanaged member leave on their own', async () => { + const { removeOrganizationMemberRecord } = runtime + await expect( + removeOrganizationMemberRecord({ + organizationId: orgId, + userId: unmanagedUserId, + actorUserId: unmanagedUserId, + }) + ).resolves.toMatchObject({ membershipType: 'internal' }) + expect(await isMember(unmanagedUserId)).toBe(false) + }) + }) + + describe('transferring ownership', () => { + beforeEach(async () => { + const { db, schema } = runtime + await db.insert(schema.member).values([ + { id: generateId(), organizationId: orgId, userId: managedUserId, role: 'owner' }, + { id: generateId(), organizationId: orgId, userId: adminUserId, role: 'member' }, + ]) + }) + + async function roleOf(userId: string) { + const { db, schema, and, eq } = runtime + const [row] = await db + .select({ role: schema.member.role }) + .from(schema.member) + .where(and(eq(schema.member.organizationId, orgId), eq(schema.member.userId, userId))) + return row?.role + } + + it('transfers a managed owner’s ownership but keeps them as admin when they try to leave', async () => { + const { db, schema, and, eq, transferOrganizationOwnership, removeUserFromOrganization } = + runtime + await expect( + transferOrganizationOwnership({ + organizationId: orgId, + currentOwnerUserId: managedUserId, + newOwnerUserId: adminUserId, + }) + ).resolves.toMatchObject({ success: true }) + const [previousOwner] = await db + .select({ id: schema.member.id }) + .from(schema.member) + .where( + and(eq(schema.member.organizationId, orgId), eq(schema.member.userId, managedUserId)) + ) + await expect( + removeUserFromOrganization({ + userId: managedUserId, + organizationId: orgId, + memberId: previousOwner.id, + actorUserId: managedUserId, + }) + ).resolves.toMatchObject({ + success: false, + refusalCode: 'SCIM_MANAGED_MEMBERSHIP', + error: expect.stringContaining('Ask your identity provider administrator'), + }) + expect(await roleOf(managedUserId)).toBe('admin') + expect(await roleOf(adminUserId)).toBe('owner') + }) + + it('lets a managed owner transfer ownership and stay', async () => { + const { transferOrganizationOwnership } = runtime + await expect( + transferOrganizationOwnership({ + organizationId: orgId, + currentOwnerUserId: managedUserId, + newOwnerUserId: adminUserId, + }) + ).resolves.toMatchObject({ success: true }) + expect(await roleOf(managedUserId)).toBe('admin') + expect(await roleOf(adminUserId)).toBe('owner') + }) + }) }) diff --git a/apps/sim/ee/scim/lib/managed-membership.ts b/apps/sim/ee/scim/lib/managed-membership.ts index 8361c1af55a..2ab304e587f 100644 --- a/apps/sim/ee/scim/lib/managed-membership.ts +++ b/apps/sim/ee/scim/lib/managed-membership.ts @@ -14,7 +14,8 @@ import { isScimDeploymentEnabled, isScimEntitledForOrganization } from '@/ee/sci * remedy. * * Deliberately not applied to deprovisioning: an administrator must always be - * able to remove someone in an emergency, whatever the directory believes. + * able to remove someone in an emergency, whatever the directory believes. A + * member removing themselves is refused, since the directory would re-add them. */ /** @@ -63,6 +64,32 @@ export async function assertMembershipNotScimManaged(params: { userId: string executor: DbOrTx }): Promise { + await refuseIfDirectoryManaged( + params, + 'This member is managed by the organization’s identity provider. Make the change there, or turn off managed-membership locking in the organization’s directory settings.' + ) +} + +/** + * Refuses a directory-managed member leaving on their own. Unlike an + * administrator's removal, which must always succeed, a self-leave only lasts + * until the next sync re-adds the member, so it is refused with the remedy. + */ +export async function assertSelfLeaveNotScimManaged(params: { + organizationId: string + userId: string + executor: DbOrTx +}): Promise { + await refuseIfDirectoryManaged( + params, + 'Your membership is managed by your organization’s identity provider. Ask your identity provider administrator to remove you there.' + ) +} + +async function refuseIfDirectoryManaged( + params: { organizationId: string; userId: string; executor: DbOrTx }, + message: string +): Promise { if (!isScimDeploymentEnabled()) return const [row] = await params.executor .select({ managed: scimManagedUserPredicate(params.organizationId, sql`${params.userId}`) }) @@ -74,10 +101,7 @@ export async function assertMembershipNotScimManaged(params: { * once a managed row is found, so the common case costs nothing. */ if (!(await isScimEntitledForOrganization(params.organizationId, params.executor))) return - throw new ForbiddenOperationError( - 'SCIM_MANAGED_MEMBERSHIP', - 'This member is managed by the organization’s identity provider. Make the change there, or turn off managed-membership locking in the organization’s directory settings.' - ) + throw new ForbiddenOperationError('SCIM_MANAGED_MEMBERSHIP', message) } /** Refuses an invitation to someone the directory already provisions, while the directory can still sync. */ diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 9b0b0f5443d..1a9c0c43127 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -1,6 +1,6 @@ 'use client' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { type QueryKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput, ContractQueryInput } from '@/lib/api/contracts' import { @@ -10,6 +10,7 @@ import { getSecretReferencesContract, getSecretUsageContract, getWorkspaceCredentialContract, + leaveCredentialMembershipContract, listWorkspaceCredentialMembersContract, removeWorkspaceCredentialMemberContract, type SecretUsageScope, @@ -311,6 +312,34 @@ export function useRemoveWorkspaceCredentialMember() { }) } +/** + * Revokes the viewer's own membership of a credential. The caller navigates + * away from a credential the viewer can no longer read, so the affected caches + * are only marked stale — refetching them while the detail page is still + * mounted would flash "not found" — and whatever mounts next refetches them. + */ +export function useLeaveCredential() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (credentialId: string) => + requestJson(leaveCredentialMembershipContract, { query: { credentialId } }), + onSuccess: (_data, credentialId) => { + const staleKeys: QueryKey[] = [ + workspaceCredentialKeys.lists(), + workspaceCredentialKeys.detail(credentialId), + OAUTH_CREDENTIALS_KEY, + environmentKeys.all, + oauthConnectionsKeys.connections(), + ] + for (const queryKey of staleKeys) { + queryClient.invalidateQueries({ queryKey, refetchType: 'none' }) + } + invalidateSelectorQueries(queryClient) + }, + }) +} + /** * The trail is written by every run that resolves the secret, so it goes stale quickly. A * short window keeps "last used" meaningful without refetching on every panel interaction. diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 6674d38ff01..a8b017ff736 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' +import { generateId } from '@sim/utils/id' import { keepPreviousData, type QueryClient, @@ -48,6 +50,9 @@ import { resourceScopeFromOwner, resourceScopeKey, } from '@/lib/core/resource-scope' +import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-popup' +import { isDesktopApp } from '@/lib/desktop' +import { connectDesktopSource } from '@/lib/desktop/source-connect' import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants' import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' @@ -388,16 +393,19 @@ async function updateConnectorAccess({ interface StartConnectorMemberEnrollmentParams extends SearchConnectionOAuthQuery { knowledgeBaseId: string connectorId: string + signal?: AbortSignal } async function startConnectorMemberEnrollment({ knowledgeBaseId, connectorId, oauthCompletionId, + signal, }: StartConnectorMemberEnrollmentParams): Promise { const response = await requestJson(startKnowledgeConnectorMemberEnrollmentContract, { params: { id: knowledgeBaseId, connectorId }, query: { oauthCompletionId }, + signal, }) return response.data } @@ -464,17 +472,94 @@ export function useSearchSources( }) } -/** Mints the viewer's enrollment link for a per-member connector; the caller navigates to it. */ +interface ConnectMemberAccountParams { + knowledgeBaseId: string + connectorId: string + /** Search sources only: the server opens provider OAuth directly and reports its completion. */ + directOAuth: boolean +} + +/** + * Connects the viewer's own account to a per-member connector. Desktop and + * direct OAuth settle when the connection completes. An ordinary knowledge + * base hands the viewer an invitation page in a new tab that reports nothing + * back, so it refreshes each time the viewer returns to this tab until the + * viewer's membership reads `connected`. The listener belongs to the attempt's + * abort signal, so a newer attempt or unmount removes it. + */ export function useStartConnectorMemberEnrollment() { const queryClient = useQueryClient() + const pending = useRef(null) + useEffect(() => () => pending.current?.abort(), []) + const refresh = (knowledgeBaseId: string) => + Promise.all([ + queryClient.invalidateQueries({ queryKey: connectorKeys.lists(knowledgeBaseId) }), + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.details() }), + queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }), + queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }), + ]) return useMutation({ - mutationFn: startConnectorMemberEnrollment, - onSuccess: () => - Promise.all([ - queryClient.invalidateQueries({ queryKey: credentialGroupKeys.details() }), - queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }), - queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }), - ]), + mutationFn: async ({ + knowledgeBaseId, + connectorId, + directOAuth, + }: ConnectMemberAccountParams) => { + pending.current?.abort() + const controller = new AbortController() + pending.current = controller + const params = { id: knowledgeBaseId, connectorId } + const completionId = directOAuth ? generateId() : undefined + if (isDesktopApp()) { + await connectDesktopSource( + { kind: 'member-enrollment', params, completionId }, + controller.signal + ) + return true + } + if (completionId) { + await connectCredentialGroupInPopup( + completionId, + async (signal) => ({ + invitationLink: ( + await startConnectorMemberEnrollment({ + knowledgeBaseId, + connectorId, + oauthCompletionId: completionId, + signal, + }) + ).url, + }), + controller.signal + ) + return true + } + const tab = window.open('about:blank', '_blank') + if (tab) tab.opener = null + try { + const { url } = await startConnectorMemberEnrollment({ + knowledgeBaseId, + connectorId, + signal: controller.signal, + }) + if (tab) tab.location.replace(url) + else window.location.assign(url) + } catch (error) { + tab?.close() + throw error + } + const refreshUntilConnected = async () => { + if (document.visibilityState !== 'visible') return + await refresh(knowledgeBaseId) + const connector = getCachedConnector(queryClient, knowledgeBaseId, connectorId) + if (connector?.viewerMembership === 'connected') controller.abort() + } + document.addEventListener('visibilitychange', () => void refreshUntilConnected(), { + signal: controller.signal, + }) + return false + }, + onSettled: (completed, _error, { knowledgeBaseId }) => + completed === false ? undefined : refresh(knowledgeBaseId), }) } diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 54765a07de1..b514f67f515 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -28,6 +28,7 @@ import { type InviteOrganizationAccountPeopleBody, inviteOrganizationAccountPeopleContract, listOrganizationAccountPeopleContract, + listPersonalOrganizationAccountsContract, type OrganizationAccountPeopleQuery, type RemoveOrganizationAccountMcpProviderParams, type ResendOrganizationAccountInvitationQuery, @@ -109,16 +110,39 @@ export function useReconnectPersonalOrganizationAccount() { })) } -/** Disconnects an owned grant; indexing and source setup do not gate this operation. */ -export function useDisconnectPersonalOrganizationAccount(organizationId: string) { +/** + * The accounts the viewer connected for any organization, including ones they + * have since left — the only place a former member can still see their grants. + */ +export function usePersonalOrganizationAccounts() { + return useInfiniteQuery({ + queryKey: organizationAccountsKeys.personal(), + staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, + initialPageParam: undefined as string | undefined, + queryFn: ({ signal, pageParam }) => + requestJson(listPersonalOrganizationAccountsContract, { + query: { cursor: pageParam }, + signal, + }), + getNextPageParam: (page) => page.nextCursor ?? undefined, + }) +} + +interface DisconnectPersonalOrganizationAccountVariables { + credentialId: string + organizationId: string +} + +/** Disconnects an owned grant; indexing, source setup, and membership do not gate this operation. */ +export function useDisconnectPersonalOrganizationAccount() { const queryClient = useQueryClient() const router = useRouter() return useMutation({ - mutationFn: (credentialId: string) => + mutationFn: ({ credentialId }: DisconnectPersonalOrganizationAccountVariables) => requestJson(disconnectPersonalOrganizationAccountContract, { params: { credentialId }, }), - onSuccess: async () => { + onSuccess: async (_data, { organizationId }) => { await Promise.all([ resetOrganizationSearchAccess(queryClient, organizationId), queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), @@ -127,6 +151,7 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string) queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId), }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), ]) router.refresh() }, @@ -136,6 +161,7 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string) export const organizationAccountsKeys = { all: ['organization-accounts'] as const, connection: () => [...organizationAccountsKeys.all, 'connection'] as const, + personal: () => [...organizationAccountsKeys.all, 'personal'] as const, workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const, workspace: (workspaceId?: string) => [...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const, diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 6d58a60e748..abc1d68fdd2 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -23,6 +23,7 @@ import { type RosterWorkspaceAccess, removeOrganizationMemberContract, transferOwnershipContract, + updateOrganizationContract, updateOrganizationMemberRoleContract, updateOrganizationMemberUsageLimitContract, updateOrganizationUsageLimitContract, @@ -49,8 +50,13 @@ export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 0 export { organizationKeys } /** Revalidates organization membership and chat policy through the shared authorized read. */ -export function useOrganizationSummary(organizationId: string) { +export function useOrganizationSummary( + organizationId: string, + options?: { enabled?: boolean; refetchOnMount?: 'always' } +) { return useQuery({ + enabled: options?.enabled ?? true, + refetchOnMount: options?.refetchOnMount ?? true, queryKey: organizationKeys.summary(organizationId), queryFn: ({ signal }) => requestJson(getOrganizationContract, { @@ -167,6 +173,30 @@ export function useOrganization(orgId: string) { }) } +interface RenameOrganizationParams { + orgId: string + name: string +} + +/** + * Renames an organization. The organization surface reads its identity from the + * server layout, so a successful rename also refreshes the route. + */ +export function useRenameOrganization() { + const queryClient = useQueryClient() + const router = useRouter() + + return useMutation({ + mutationFn: ({ orgId, name }: RenameOrganizationParams) => + requestJson(updateOrganizationContract, { params: { id: orgId }, body: { name } }), + onSuccess: () => router.refresh(), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) + queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) + }, + }) +} + /** * Update organization usage limit mutation with optimistic updates */ diff --git a/apps/sim/hooks/queries/user-profile.ts b/apps/sim/hooks/queries/user-profile.ts index f7bd779f471..a482ebfbbbe 100644 --- a/apps/sim/hooks/queries/user-profile.ts +++ b/apps/sim/hooks/queries/user-profile.ts @@ -8,7 +8,6 @@ import { type UpdateUserProfileBody, updateUserProfileContract, } from '@/lib/api/contracts/user' -import { client } from '@/lib/auth/client' import { mapUserProfileResponse, USER_PROFILE_STALE_TIME, @@ -89,16 +88,3 @@ export function useResetPassword() { }, }) } - -/** - * Ends every session except this one. Better Auth's plugin endpoint is called through the auth - * client, as no Sim contract types it. - */ -export function useSignOutOtherDevices() { - return useMutation({ - mutationFn: async () => { - const { error } = await client.revokeOtherSessions() - if (error) throw new Error(error.message ?? 'Failed to sign out other devices') - }, - }) -} diff --git a/apps/sim/lib/api/contracts/access-requests.ts b/apps/sim/lib/api/contracts/access-requests.ts index fa908082ab4..5a1e7239940 100644 --- a/apps/sim/lib/api/contracts/access-requests.ts +++ b/apps/sim/lib/api/contracts/access-requests.ts @@ -30,7 +30,7 @@ export const ACCESS_REQUEST_STATUSES = [ 'closed', ] as const -export const ACCESS_REQUEST_PAGE_SIZE = 50 +const ACCESS_REQUEST_PAGE_SIZE = 50 export const ACCESS_REQUEST_MAX_PAGE_SIZE = 100 const requestIdSchema = z diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 008ec9b1955..f7e6ac573eb 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -365,6 +365,8 @@ export const workspaceCredentialMemberSchema = z.object({ userImage: z.string().nullable().optional(), /** `workspace-admin` roles are derived from workspace admin and cannot be changed. */ roleSource: z.enum(['explicit', 'workspace-admin']).optional(), + /** Holds an active explicit admin grant; the last-admin guards count these, not `role`. */ + explicitAdmin: z.boolean(), }) export type WorkspaceCredentialMember = z.output diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts index 98de8a146ac..dff8d0983f3 100644 --- a/apps/sim/lib/api/contracts/sim-links.ts +++ b/apps/sim/lib/api/contracts/sim-links.ts @@ -35,21 +35,23 @@ export type SimLinkBody = z.input const linkResponse = z.object({ link: simLinkSchema }) +export const listSimLinksQuerySchema = z + .object({ + search: z.string().max(200).optional(), + cursor: z.string().min(1).max(128).optional(), + sort: z.enum(SIM_LINK_SORTS).optional(), + dir: z.enum(SORT_DIRECTIONS).optional(), + }) + .refine((query) => !(query.sort === 'popular' && query.cursor), { + path: ['cursor'], + message: 'The most-used view is a single page', + }) + export const listSimLinksContract = defineRouteContract({ method: 'GET', path: '/api/organizations/[id]/sim-links', params, - query: z - .object({ - search: z.string().max(200).optional(), - cursor: z.string().min(1).max(128).optional(), - sort: z.enum(SIM_LINK_SORTS).optional(), - dir: z.enum(SORT_DIRECTIONS).optional(), - }) - .refine((query) => !(query.sort === 'popular' && query.cursor), { - path: ['cursor'], - message: 'The most-used view is a single page', - }), + query: listSimLinksQuerySchema, response: { mode: 'json', schema: z.object({ links: z.array(simLinkSchema), nextCursor: z.string().nullable() }), diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 71bb7ae8efb..f25b92b4e3e 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -346,6 +346,10 @@ export const getWorkspaceUsageGateContract = defineRouteContract({ }, }) +const updateWorkspaceResponseSchema = z.object({ + workspace: workspaceSchema, +}) + export const updateWorkspaceContract = defineRouteContract({ method: 'PATCH', path: '/api/workspaces/[id]', @@ -353,9 +357,19 @@ export const updateWorkspaceContract = defineRouteContract({ body: updateWorkspaceBodySchema, response: { mode: 'json', - schema: z.object({ - workspace: workspaceSchema, - }), + schema: updateWorkspaceResponseSchema, + }, +}) + +/** `PUT` is a legacy alias of {@link updateWorkspaceContract} with the same partial-update semantics. */ +export const updateWorkspacePutContract = defineRouteContract({ + method: 'PUT', + path: '/api/workspaces/[id]', + params: workspaceParamsSchema, + body: updateWorkspaceBodySchema, + response: { + mode: 'json', + schema: updateWorkspaceResponseSchema, }, }) diff --git a/apps/sim/lib/billing/chat-plan.ts b/apps/sim/lib/billing/chat-plan.ts index 1e50f9634b6..2b87add362f 100644 --- a/apps/sim/lib/billing/chat-plan.ts +++ b/apps/sim/lib/billing/chat-plan.ts @@ -4,7 +4,8 @@ export const CHAT_PLAN_REQUIRED_REASON = 'plan_required' as const /** * The card for a payer whose plan does not include Chat. Sim writes it at run admission, before * any worker run exists, so it never crosses the worker billing protocol: its reason widens only - * the tag Sim renders, not the worker-shared card. The composer shows the same card before a send. + * the tag Sim renders, not the worker-shared card. A send the composer refuses opens a modal with + * the same message. */ export const CHAT_PLAN_REQUIRED_UPGRADE = { reason: CHAT_PLAN_REQUIRED_REASON, diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index a76ec3243e2..7b1ae93aa44 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -52,6 +52,7 @@ import { enqueueCancelAtPeriodEndSync, isCancelAtPeriodEndSettled, } from '@/lib/billing/webhooks/subscription-sync' +import { type ForbiddenDetailCode, ForbiddenOperationError } from '@/lib/core/application/forbidden' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' @@ -76,6 +77,7 @@ import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { reassignSharedResourceOwnershipForWorkspaceMemberRemovalTx } from '@/lib/workspaces/resource-handoff' import { WorkspaceBillingAccountRemovalError } from '@/lib/workspaces/utils' import { endDirectoryMembershipTx } from '@/ee/scim/lib/identity/end-directory-membership' +import { assertSelfLeaveNotScimManaged } from '@/ee/scim/lib/managed-membership' export { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/identity-lock' export { WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR } from '@/lib/workspaces/utils' @@ -533,6 +535,8 @@ export interface RemoveMemberParams { export interface RemoveMemberResult { success: boolean error?: string + /** Set when policy refused the removal, as opposed to it failing. */ + refusalCode?: ForbiddenDetailCode /** * Whether the member row was actually deleted. `false` (with `success: true`) * when `requireNoOrgWorkspaceAccess` was set and the user still had workspace @@ -1384,8 +1388,11 @@ export async function removeUserFromOrganization( const result = await withInvitationSafeOrganizationAccessMutation( { userId, organizationId, scope: 'all' }, async (tx, { workspaceIds, invitationIds }) => { - if (actorUserId) + if (actorUserId) { await requireMemberManagementAuthority(tx, organizationId, actorUserId, userId) + if (actorUserId === userId) + await assertSelfLeaveNotScimManaged({ organizationId, userId, executor: tx }) + } if (requireNoOrgWorkspaceAccess && workspaceIds.length > 0) { const [remainingAccess] = await tx .select({ id: permissions.id }) @@ -1616,6 +1623,15 @@ export async function removeUserFromOrganization( return { success: false, error: error.message, billingActions, stoppedRuns: [] } } if (onError === 'throw') throw error + if (error instanceof ForbiddenOperationError) { + return { + success: false, + error: error.message, + refusalCode: error.detailCode, + billingActions, + stoppedRuns: [], + } + } logger.error('Failed to remove user from organization', { userId, diff --git a/apps/sim/lib/billing/organizations/resource-handoff.ts b/apps/sim/lib/billing/organizations/resource-handoff.ts index 6baada8240f..a5565dfd00d 100644 --- a/apps/sim/lib/billing/organizations/resource-handoff.ts +++ b/apps/sim/lib/billing/organizations/resource-handoff.ts @@ -2,11 +2,12 @@ import { knowledgeBase, member, permissionGroup, + simLink, ssoProvider, user, workspaceFiles, } from '@sim/db/schema' -import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm' +import { and, asc, eq, isNull, ne, notInArray, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { handoffFileCreatorsInTx } from '@/lib/uploads/contexts/workspace/creator-handoff' @@ -103,6 +104,7 @@ export async function reassignOrganizationSharedResourcesTx( eq(permissionGroup.createdBy, departingUserId) ) ) + await reassignSimLinksTx(tx, organizationId, departingUserId, userId) await tx .update(ssoProvider) .set({ userId }) @@ -111,3 +113,60 @@ export async function reassignOrganizationSharedResourcesTx( ) return userId } + +async function reassignSimLinksTx( + tx: DbTransaction, + organizationId: string, + fromUserId: string, + toUserId: string +) { + await tx + .update(simLink) + .set({ createdBy: toUserId, updatedAt: new Date() }) + .where(and(eq(simLink.organizationId, organizationId), eq(simLink.createdBy, fromUserId))) +} + +/** + * Organizations where a former member still created Sim links outside `excludedOrganizationIds`. + * Account deletion locks these with the shared organizations, but they never block erasure. + */ +export async function listLeftoverSimLinkOrganizationIdsForUser( + executor: DbOrTx, + userId: string, + excludedOrganizationIds: string[] +) { + const rows = await executor + .selectDistinct({ organizationId: simLink.organizationId }) + .from(simLink) + .where( + and( + eq(simLink.createdBy, userId), + excludedOrganizationIds.length > 0 + ? notInArray(simLink.organizationId, excludedOrganizationIds) + : undefined + ) + ) + .orderBy(asc(simLink.organizationId)) + return rows.map((row) => row.organizationId) +} + +/** + * Repairs Sim links a former member kept after leaving before departures handed them off. The + * caller holds each organization's mutation lock. Never blocks erasure: an organization without + * an owner successor keeps the FK's `SET NULL`. + */ +export async function handoffLeftoverSimLinksTx( + tx: DbTransaction, + departingUserId: string, + organizationIds: string[] +) { + for (const organizationId of organizationIds) { + const successorId = await findOrganizationSharedResourceSuccessor( + tx, + organizationId, + departingUserId, + true + ) + if (successorId) await reassignSimLinksTx(tx, organizationId, departingUserId, successorId) + } +} diff --git a/apps/sim/lib/core/application/organization-authorization.ts b/apps/sim/lib/core/application/organization-authorization.ts index f65538cd71e..26b9568dae0 100644 --- a/apps/sim/lib/core/application/organization-authorization.ts +++ b/apps/sim/lib/core/application/organization-authorization.ts @@ -47,6 +47,8 @@ export interface OrganizationMembershipContext extends OrganizationAuthorization export interface OrganizationAuthorizationOptions { executor?: DbOrTx forUpdate?: boolean + /** Holds the membership row until commit, so a concurrent removal waits for this write. */ + forShare?: boolean } /** Rechecks routed organization membership; owning a workspace is irrelevant to this grant. */ @@ -83,7 +85,11 @@ export async function requireOrganizationSubjectMembership( .select({ role: member.role }) .from(member) .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) - const [membership] = options.forUpdate ? await query.for('update').limit(1) : await query.limit(1) + const [membership] = options.forUpdate + ? await query.for('update').limit(1) + : options.forShare + ? await query.for('share').limit(1) + : await query.limit(1) const parsedRole = organizationRoleSchema.safeParse(membership?.role) if (!parsedRole.success) throw new OrganizationMembershipNotFoundError() if (minimumRole === 'admin' && !isOrgAdminRole(parsedRole.data)) { @@ -110,16 +116,24 @@ export async function requireOrganizationSubjectMembership( return { organizationId, userId, role: parsedRole.data } } +/** Refuses a principal kind or OAuth grant the operation does not admit, before any lookup. */ +export function requireAllowedOrganizationPrincipal( + principal: Principal, + operation: OrganizationOperation +): void { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + } + requireOAuthOperationScope(principal, operation) +} + export async function authorizeOrganizationOperation( principal: Principal, operation: OrganizationOperation, context: OrganizationAuthorizationContext, options: OrganizationAuthorizationOptions = {} ): Promise { - if (!operation.principalKinds.some((kind) => kind === principal.kind)) { - throw new PrincipalKindAuthorizationError(principal.kind, operation.id) - } - requireOAuthOperationScope(principal, operation) + requireAllowedOrganizationPrincipal(principal, operation) if (principal.kind === 'organization_delegated') { const now = Date.now() if ( diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts index 114fffdbc2b..11ede7914d9 100644 --- a/apps/sim/lib/credentials/members.ts +++ b/apps/sim/lib/credentials/members.ts @@ -30,6 +30,11 @@ export interface CredentialMemberView { userEmail: string | null userImage: string | null roleSource: 'explicit' | 'workspace-admin' + /** + * Whether the user holds an active explicit admin grant, independent of any + * inherited workspace-admin role. This is what the last-admin guards count. + */ + explicitAdmin: boolean } export async function listCredentialMembers( @@ -52,7 +57,14 @@ export async function listCredentialMembers( .where(eq(credentialMember.credentialId, credential.id)) const byUser = new Map( - explicitMembers.map((member) => [member.userId, { ...member, roleSource: 'explicit' as const }]) + explicitMembers.map((member) => [ + member.userId, + { + ...member, + roleSource: 'explicit' as const, + explicitAdmin: member.role === 'admin' && member.status === 'active', + }, + ]) ) if (isSharedCredentialType(credential.type)) { @@ -75,6 +87,7 @@ export async function listCredentialMembers( userEmail: workspaceMember.email, userImage: workspaceMember.image ?? null, roleSource: 'workspace-admin', + explicitAdmin: false, }) } } @@ -148,6 +161,7 @@ export async function listCredentialMembersPage( roleSource: sql< 'explicit' | 'workspace-admin' >`case when ${inheritedAdmin} then 'workspace-admin' else 'explicit' end`, + explicitAdmin: sql`coalesce(${credentialMember.role} = 'admin' and ${credentialMember.status} = 'active', false)`, }) .from(user) .leftJoin( diff --git a/apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts b/apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts index 6c9611e3c26..cdfbad46c1f 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts @@ -51,6 +51,7 @@ import { stopE2BSessionProcess, } from '@/lib/execution/remote-sandbox/e2b' import { observeSandboxExecution } from '@/lib/execution/remote-sandbox/observer' +import { SessionCliStaleError } from '@/lib/execution/remote-sandbox/session-cli' setEnv({ E2B_API_KEY: 'test', MOTHERSHIP_E2B_TEMPLATE_ID: 'mothership-template' }) @@ -92,7 +93,7 @@ describe('E2B session recovery', () => { const sandbox = { sandboxId: 'retained', getInfo, - files: { write: writeFile, read: readFile }, + files: { write: writeFile, read: readFile, remove: vi.fn().mockResolvedValue(undefined) }, setTimeout, commands: { run: (command: string, options: { background?: boolean }) => @@ -613,6 +614,131 @@ describe('E2B session recovery', () => { expect(stopCommand).toHaveBeenCalledOnce() expect(killSandbox).not.toHaveBeenCalled() }) + + it('returns what a session command printed before its own timeout stopped it', async () => { + runCommand.mockImplementation( + async ( + _command: string, + options: { onStdout?: (chunk: string) => void; onStderr?: (chunk: string) => void } + ) => { + options.onStdout?.('rendered 1 of 3\n') + options.onStderr?.('slow frame\n') + options.onStdout?.('rendered 2 of 3\n') + return { + pid: 7, + wait: async () => { + throw Object.assign( + new Error("deadline_exceeded: This error is likely due to exceeding 'timeoutMs'"), + { name: 'TimeoutError' } + ) + }, + } + } + ) + const sandbox = await sessionSandbox('reconnected') + const result = await sandbox?.runCommand('render', { timeoutMs: 1000 }) + expect(result).toMatchObject({ + timedOut: true, + exitCode: 124, + stdout: 'rendered 1 of 3\nrendered 2 of 3\n', + stderr: 'slow frame\n', + }) + }) + describe('when an abort lands before E2B replies', () => { + /** Streams one chunk, then the abort lands while the command waits on E2B. */ + function abortWhileWaiting(controller: AbortController, reason: Error): void { + runCommand.mockImplementationOnce( + async (_command: string, options: { onStdout?: (chunk: string) => void }) => { + options.onStdout?.('rendered 1 of 3\n') + return { + pid: 8, + wait: () => + new Promise((_resolve, reject) => { + controller.signal.addEventListener( + 'abort', + () => reject(new Error('stream aborted')), + { once: true } + ) + controller.abort(reason) + }), + } + } + ) + } + + async function run(kind: 'code' | 'command', signal: AbortSignal, oneShot = false) { + const sandbox = oneShot + ? await e2bProvider.create('mothership', {}) + : await sessionSandbox('reconnected') + if (!sandbox) throw new Error('Missing sandbox') + return kind === 'code' + ? sandbox.runCode('render()', { timeoutMs: 10_000, signal }) + : sandbox.runCommand('render', { timeoutMs: 10_000, signal }) + } + + it.each(['code', 'command'] as const)( + 'returns what the %s printed when its own deadline stops it', + async (kind) => { + const controller = new AbortController() + abortWhileWaiting(controller, new DOMException('timeout', 'AbortError')) + const result = await run(kind, controller.signal) + expect(result).toMatchObject({ timedOut: true, stdout: 'rendered 1 of 3\n' }) + expect(stopCommand).toHaveBeenCalledOnce() + } + ) + + it('keeps a Stop of running code a cancellation', async () => { + const controller = new AbortController() + abortWhileWaiting(controller, new Error('User Stop')) + await expect(run('code', controller.signal)).rejects.toThrow('stream aborted') + expect(stopCommand).toHaveBeenCalledOnce() + }) + + it('leaves a one-shot sandbox’s deadline to the caller', async () => { + const controller = new AbortController() + abortWhileWaiting(controller, new DOMException('timeout', 'AbortError')) + await expect(run('code', controller.signal, true)).rejects.toThrow('stream aborted') + }) + + it('never reports a Stop of a command as its timeout', async () => { + const controller = new AbortController() + abortWhileWaiting(controller, new Error('User Stop')) + const result = await run('command', controller.signal) + expect(result.timedOut).toBeUndefined() + expect(stopCommand).toHaveBeenCalledOnce() + }) + }) + + it.each([ + ['stale', SessionCliStaleError], + ['failed', Error], + ] as const)( + 'rejects a launch whose receipt reports a %s CLI, whatever its process returned', + async (cli, expected) => { + runCommand.mockResolvedValue({ + pid: 9, + wait: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }) + stopCommand.mockResolvedValue({ + stdout: JSON.stringify({ settled: true, cli }), + stderr: '', + exitCode: 0, + }) + const sandbox = await sessionSandbox('reconnected') + const launched = sandbox?.runCommand('touch /tmp/marker', { + timeoutMs: 1000, + verifyCli: { path: '/home/user/.sim-cli/r/cli.mjs', artifacts: { a: 'b' } }, + }) + await expect(launched).rejects.toBeInstanceOf(expected) + await expect(launched).rejects.toThrow(cli === 'stale' ? 'changed' : 'verification failed') + const [command] = runCommand.mock.calls.at(-1) ?? [] + expect(command).toContain( + Buffer.from( + JSON.stringify({ path: '/home/user/.sim-cli/r/cli.mjs', artifacts: { a: 'b' } }) + ).toString('base64') + ) + } + ) }) describe('E2B session lease', () => { diff --git a/apps/sim/lib/execution/remote-sandbox/e2b.ts b/apps/sim/lib/execution/remote-sandbox/e2b.ts index fb40976763d..6d0059ccd55 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b.ts @@ -17,6 +17,7 @@ import { SANDBOX_RELEASE_GENERATION_ERROR, } from '@sim/utils/sandbox-references' import { env } from '@/lib/core/config/env' +import { isTimeoutAbortReason } from '@/lib/core/execution-limits' import { recordSandboxProviderLimit } from '@/lib/core/execution-limits/metrics' import { isPayloadSizeLimitError, @@ -55,6 +56,10 @@ import { tailStreamedSandboxOutput, } from '@/lib/execution/remote-sandbox/output-limits' import { resolveSandboxDirectoryEntryPath } from '@/lib/execution/remote-sandbox/paths' +import { + SessionCliStaleError, + SessionCliVerificationError, +} from '@/lib/execution/remote-sandbox/session-cli' import { withSandboxSessionLock } from '@/lib/execution/remote-sandbox/session-lock' import { type SessionProcessIdentity, @@ -82,6 +87,7 @@ import type { SandboxImageMaterialization, SandboxKind, SandboxProvider, + SessionCliVerification, } from '@/lib/execution/remote-sandbox/types' const logger = createLogger('E2BSandboxProvider') @@ -319,11 +325,17 @@ async function listAllSandboxes(paginator: SandboxPaginator): Promise { +): Promise { signal.throwIfAborted() const result = await sandbox.commands.run(sessionProcessStopCommand(processId), { user: 'root', @@ -335,6 +347,7 @@ async function stopSessionCommand( if (result.exitCode !== 0 || !isRecordLike(receipt) || receipt.settled !== true) { throw new Error('Sandbox command namespace exit is unconfirmed') } + return receipt.cli === 'stale' || receipt.cli === 'failed' ? { cli: receipt.cli } : {} } /** Recovers the exact recorded workbench; expiry is absence, while lookup failure stays unresolved. */ @@ -373,6 +386,14 @@ export async function stopE2BSessionProcess( if (sandbox) await stopSessionCommand(sandbox, process.id, signal) } +/** Joins what the stream callbacks observed with the streams the SDK returned, without repeating either. */ +function mergeObservedOutput(observed: string, returned: string): string { + if (!observed) return returned + if (!returned || observed.endsWith(returned)) return observed + if (returned.startsWith(observed)) return returned + return observed + returned +} + class E2BSandboxHandle implements SandboxHandle { private killed = false private killPromise: Promise | null = null @@ -405,6 +426,10 @@ class E2BSandboxHandle implements SandboxHandle { return this.sandbox.sandboxId } + get verifiesCliAtLaunch(): boolean { + return this.sessionKey !== undefined + } + outlives(lifetimeMs: number, fromMs: number): boolean { return ( this.sessionDeadlineAtMs !== undefined && @@ -433,6 +458,7 @@ class E2BSandboxHandle implements SandboxHandle { javascriptPreload?: string maxOutputBytes?: number signal?: AbortSignal + verifyCli?: SessionCliVerification } ): Promise { const runId = generateShortId(12) @@ -459,6 +485,7 @@ class E2BSandboxHandle implements SandboxHandle { maxOutputBytes: options.maxOutputBytes, signal: options.signal, atMostOnce: true, + verifyCli: options.verifyCli, envs: { ...options.envs, SIM_CODE_PATH: codePath, @@ -511,6 +538,25 @@ class E2BSandboxHandle implements SandboxHandle { command: string, options: RunCommandOptions, operation: 'code' | 'command' + ): Promise { + let refusal: SessionCommandReceipt['cli'] + const outcome = await this.runOwnedCommand(command, options, operation, (receipt) => { + refusal = receipt.cli + }).then( + (result) => ({ result }), + (error: unknown) => ({ error }) + ) + if (refusal === 'stale') throw new SessionCliStaleError() + if (refusal === 'failed') throw new SessionCliVerificationError() + if ('error' in outcome) throw outcome.error + return outcome.result + } + + private async runOwnedCommand( + command: string, + options: RunCommandOptions, + operation: 'code' | 'command', + onReceipt: (receipt: SessionCommandReceipt) => void ): Promise { if (this.sessionKey !== undefined) options.signal?.throwIfAborted() let commandDeadlineAtMs: number | undefined @@ -529,9 +575,9 @@ class E2BSandboxHandle implements SandboxHandle { let observedStderr = '' const processId = this.sessionKey !== undefined ? generateId() : undefined let processRecorded = false - let stopping: Promise | undefined - const stopCommand = (): Promise => { - if (!processId || !processRecorded) return Promise.resolve() + let stopping: Promise | undefined + const stopCommand = (): Promise => { + if (!processId || !processRecorded) return Promise.resolve(undefined) stopping ??= stopSessionCommand(this.sandbox, processId).catch((error: unknown) => { logger.warn('Failed to stop sandbox process', { sandboxId: this.sandboxId, @@ -587,7 +633,12 @@ class E2BSandboxHandle implements SandboxHandle { commandDeadlineAtMs = Date.now() + processOptions.timeoutMs started = await this.sandbox.commands.run( processId - ? sessionProcessCommand(processId, prepared.command, options.rootUser) + ? sessionProcessCommand( + processId, + prepared.command, + options.rootUser, + options.verifyCli + ) : prepared.command, { ...processOptions, @@ -676,12 +727,6 @@ class E2BSandboxHandle implements SandboxHandle { // process handle because `background: true` is fixed above. result = started } - const mergeObservedOutput = (observed: string, returned: string): string => { - if (!observed) return returned - if (!returned || observed.endsWith(returned)) return observed - if (returned.startsWith(observed)) return returned - return observed + returned - } const stdout = mergeObservedOutput(observedStdout, result.stdout ?? '') const stderr = mergeObservedOutput(observedStderr, result.stderr ?? '') assertSandboxProcessOutputWithinLimit([stdout, stderr], options.maxOutputBytes) @@ -705,6 +750,11 @@ class E2BSandboxHandle implements SandboxHandle { providerFailure: 'provider_limit', } } + // A short workbench run hands the command its whole budget, so the caller's deadline can + // stop it before E2B's own timeout replies; a Stop stays a cancellation. + const timedOut = + isE2BExecutionTimeout(error) || + (this.sessionKey !== undefined && isTimeoutAbortReason(options.signal?.reason)) // The SDK throws on non-zero exit; callers want the streams, not a throw. const failure = error as { stdout?: string @@ -717,7 +767,7 @@ class E2BSandboxHandle implements SandboxHandle { failure.stdout === undefined && failure.stderr === undefined && failure.exitCode === undefined && - !isE2BExecutionTimeout(error) + !timedOut ) { throw error } @@ -727,16 +777,21 @@ class E2BSandboxHandle implements SandboxHandle { ) const tailIfStreamed = (value: string | undefined, retain: boolean) => retain || value === undefined ? value : tailStreamedSandboxOutput(value) - const failureStdout = tailIfStreamed(failure.stdout, retainStdout) - const failureStderr = tailIfStreamed(failure.stderr, retainStderr) - if (isE2BExecutionTimeout(error)) { + if (timedOut) { + // The SDK's timeout error carries no streams; the callbacks observed the run's output. + const stdout = mergeObservedOutput(observedStdout, failure.stdout ?? '') + const stderr = mergeObservedOutput(observedStderr, failure.stderr ?? '') return { - stdout: failureStdout ?? '', - stderr: failureStderr ?? failure.message ?? '', + stdout: retainStdout ? stdout : tailStreamedSandboxOutput(stdout), + stderr: + (retainStderr ? stderr : tailStreamedSandboxOutput(stderr)) || + `Command timed out after ${options.timeoutMs}ms`, exitCode: 124, timedOut: true, } } + const failureStdout = tailIfStreamed(failure.stdout, retainStdout) + const failureStderr = tailIfStreamed(failure.stderr, retainStderr) return { stdout: failureStdout ?? '', stderr: failureStderr ?? failure.message ?? getErrorMessage(error), @@ -746,11 +801,12 @@ class E2BSandboxHandle implements SandboxHandle { options.signal?.removeEventListener('abort', onAbort) /** The named SDK process can exit before its namespace; only the kernel receipt settles it. */ if (processId && processRecorded) { - await stopCommand().catch((error: unknown) => { + const receipt = await stopCommand().catch((error: unknown) => { reportUnsettledSandboxProcess(processId) throw error }) await settleSandboxProcess(processId) + if (receipt) onReceipt(receipt) } } } diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index 265271b7b51..f682f3621f1 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -9,6 +9,7 @@ import { isTimeoutAbortReason, } from '@/lib/core/execution-limits' import { recordSandboxTeardownFailure } from '@/lib/core/execution-limits/metrics' +import { withinDeadline } from '@/lib/core/utils/deadline' import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime' import { SANDBOX_SYSTEM_PATH } from '@/lib/execution/remote-sandbox/cli-tools.server' import { describeProcessExit } from '@/lib/execution/remote-sandbox/code-failure' @@ -59,12 +60,21 @@ import { ensureSessionSandbox, SESSION_SANDBOX_IDLE_MS, } from '@/lib/execution/remote-sandbox/session' -import { sessionCommandPath } from '@/lib/execution/remote-sandbox/session-cli' +import { + ensureSessionCli, + SessionCliStaleError, + sessionCliVerification, + sessionCommandPath, +} from '@/lib/execution/remote-sandbox/session-cli' import { readSessionSecretProvenance, recordSessionFileInput, } from '@/lib/execution/remote-sandbox/session-file-provenance' import { withSandboxSessionLock } from '@/lib/execution/remote-sandbox/session-lock' +import { + SandboxExecutionTimeoutError, + sandboxTimeoutProgress, +} from '@/lib/execution/remote-sandbox/timeout' import type { CreateSandboxOptions, SandboxCodeResult, @@ -82,6 +92,7 @@ import type { SandboxProviderId, SandboxSessionRequest, SandboxShellExecutionRequest, + SessionCliVerification, } from '@/lib/execution/remote-sandbox/types' export type { @@ -179,6 +190,11 @@ function throwIfAborted(signal?: AbortSignal): void { throw signal.reason instanceof Error ? signal.reason : new Error('Execution cancelled') } +/** Throws for a cancellation; the run's own deadline passes so a timed-out run can report. */ +function throwIfCancelled(signal: AbortSignal): void { + if (!isTimeoutAbortReason(signal.reason)) throwIfAborted(signal) +} + function abortReason(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason @@ -254,10 +270,87 @@ async function withSandboxExecutionBudget( } } +/** + * Launches a session command that verifies the deployment CLI as it starts. A launch that found + * the CLI changed started nothing, so the CLI is repaired under the session lock and the command + * launched once more; a second refusal means something keeps changing it, and the call fails. + */ +async function launchWithVerifiedCli( + sandbox: SandboxHandle, + lease: SandboxLease, + session: SandboxSessionRequest | undefined, + signal: AbortSignal, + phases: SandboxPhaseTimer, + launch: (verifyCli: SessionCliVerification | undefined) => Promise +): Promise { + const cli = lease.session && sandbox.verifiesCliAtLaunch ? session?.cli : undefined + if (!session || !cli) return launch(undefined) + const verifyCli = sessionCliVerification(cli) + try { + return await launch(verifyCli) + } catch (error) { + if (!(error instanceof SessionCliStaleError)) throw error + } + const endCli = phases.start('cli') + await withSandboxSessionLock(session.key, signal, (lockSignal) => + ensureSessionCli(sandbox, cli, lockSignal, remainingSandboxBudgetMs(signal)) + ) + endCli() + try { + return await launch(verifyCli) + } catch (error) { + if (error instanceof SessionCliStaleError) { + throw new Error('Workbench CLI installation could not be verified') + } + throw error + } +} + function throwIfSandboxTimedOut(result: { timedOut?: boolean }): void { if (result.timedOut) throw new DOMException('timeout', 'AbortError') } +/** Bounds the output-directory listing a timeout report adds; a slow listing reports no files. */ +const TIMED_OUT_OUTPUT_LISTING_MS = 3_000 + +/** + * A workbench run stopped by its own timeout reports what it produced, because the workbench and + * its files outlive the call and the next call can resume from them. A one-shot sandbox is torn + * down with everything in it, so it keeps the plain timeout. + */ +async function throwIfRunTimedOut( + result: { timedOut?: boolean }, + streams: Array, + run: { + sandbox: SandboxHandle + lease: SandboxLease + outputSandboxDir: string | undefined + timeoutMs: number + signal: AbortSignal + } +): Promise { + if (!result.timedOut) return + throwIfCancelled(run.signal) + if (!run.lease.session) throw new DOMException('timeout', 'AbortError') + const { outputSandboxDir, sandbox } = run + // Half of what the call has left, so the report lands before the call's deadline. + const listingMs = Math.min( + TIMED_OUT_OUTPUT_LISTING_MS, + (getRemainingExecutionMs(run.signal) ?? Number.POSITIVE_INFINITY) / 2 + ) + // withinDeadline refuses an aborted signal, so a run its own deadline stopped lists no files. + const listed = outputSandboxDir + ? await withinDeadline( + () => sandbox.listFiles(outputSandboxDir, { depth: SANDBOX_OUTPUT_DIR_MAX_DEPTH }), + Date.now() + listingMs, + run.signal + ).catch(() => []) + : [] + // A cancellation that arrives while listing stays a cancellation. + throwIfCancelled(run.signal) + throw new SandboxExecutionTimeoutError(sandboxTimeoutProgress(streams, run.timeoutMs, listed)) +} + function bindSandboxAbort( sandbox: SandboxHandle, provider: SandboxProviderId, @@ -827,21 +920,32 @@ function installBudgetMs(timeoutMs: number): number { */ const MIN_EXPORT_BUDGET_MS = 10_000 +/** + * Held back from a workbench run without an export reserve, capped at a quarter of its budget, + * so the run's own timeout answers and reports its progress before the call's deadline. + */ +const MAX_TIMEOUT_REPORT_BUDGET_MS = 4_000 + /** * The budget handed to user code, less an export reserve when this request will * read files back. Short budgets are left alone: taking the reserve out of one - * would starve the code to buy time for an export it never reaches. + * would starve the code to buy time for an export it never reaches. A workbench + * run, whose timeout reports progress, still keeps a smaller report reserve. */ function codeBudgetMs( req: { outputSandboxPath?: string; outputSandboxPaths?: string[]; outputSandboxDir?: string }, - signal: AbortSignal + signal: AbortSignal, + reportsTimeout: boolean ): number { const remainingMs = remainingSandboxBudgetMs(signal) const exportsFiles = Boolean( req.outputSandboxDir || req.outputSandboxPath || req.outputSandboxPaths?.length ) - if (!exportsFiles || remainingMs <= MIN_EXPORT_BUDGET_MS * 2) return remainingMs - return remainingMs - MIN_EXPORT_BUDGET_MS + if (exportsFiles && remainingMs > MIN_EXPORT_BUDGET_MS * 2) { + return remainingMs - MIN_EXPORT_BUDGET_MS + } + if (!reportsTimeout) return remainingMs + return remainingMs - Math.min(MAX_TIMEOUT_REPORT_BUDGET_MS, Math.floor(remainingMs / 4)) } /** @@ -969,22 +1073,40 @@ async function executeInSandboxWithinBudget( Object.keys(privateInputFiles.environment).length > 0 let execution: SandboxCodeResult + let runTimeoutMs = 0 try { const endRun = phases.start('run') - execution = await sandbox.runCode(code, { - timeoutMs: codeBudgetMs(req, signal), - javascriptPreload: buildJavaScriptRuntimeBindingsSource(req.runtimeBindings ?? []), - maxOutputBytes: MAX_SANDBOX_PROCESS_OUTPUT_BYTES, + execution = await launchWithVerifiedCli( + sandbox, + lease, + req.session, signal, - ...(hasExecutionEnvironment ? { envs: executionEnvironment } : {}), - }) + phases, + (verifyCli) => { + runTimeoutMs = codeBudgetMs(req, signal, Boolean(lease.session)) + return sandbox.runCode(code, { + timeoutMs: runTimeoutMs, + javascriptPreload: buildJavaScriptRuntimeBindingsSource(req.runtimeBindings ?? []), + maxOutputBytes: MAX_SANDBOX_PROCESS_OUTPUT_BYTES, + signal, + ...(hasExecutionEnvironment ? { envs: executionEnvironment } : {}), + verifyCli, + }) + } + ) endRun() } catch (error) { throwIfAborted(signal) throw error } + await throwIfRunTimedOut(execution, [execution.text, execution.stdout, execution.stderr], { + sandbox, + lease, + outputSandboxDir: req.outputSandboxDir, + timeoutMs: runTimeoutMs, + signal, + }) throwIfAborted(signal) - throwIfSandboxTimedOut(execution) if (execution.error) { const errorMessage = `${execution.error.name}: ${execution.error.value}` @@ -1166,30 +1288,49 @@ async function executeShellInSandboxWithinBudget( endPrepare() let result: SandboxCommandResult + let runTimeoutMs = 0 try { const endRun = phases.start('run') - result = await sandbox.runCommand(code, { - envs: { - ...selected?.envs, - ...envs, - ...req.session?.envs, - PATH: sessionCommandPath(req.session, selected?.envs?.PATH ?? SANDBOX_SYSTEM_PATH), - ...privateInputFiles.environment, - ...(req.session && req.outputSandboxDir ? { SIM_OUTPUT_DIR: req.outputSandboxDir } : {}), - }, - timeoutMs: codeBudgetMs(req, signal), - maxOutputBytes: MAX_SANDBOX_PROCESS_OUTPUT_BYTES, + const commandEnvironment = { + ...selected?.envs, + ...envs, + ...req.session?.envs, + PATH: sessionCommandPath(req.session, selected?.envs?.PATH ?? SANDBOX_SYSTEM_PATH), + ...privateInputFiles.environment, + ...(req.session && req.outputSandboxDir ? { SIM_OUTPUT_DIR: req.outputSandboxDir } : {}), + } + result = await launchWithVerifiedCli( + sandbox, + lease, + req.session, signal, - rootUser: !lease.session, - atMostOnce: true, - }) + phases, + (verifyCli) => { + runTimeoutMs = codeBudgetMs(req, signal, Boolean(lease.session)) + return sandbox.runCommand(code, { + envs: commandEnvironment, + timeoutMs: runTimeoutMs, + maxOutputBytes: MAX_SANDBOX_PROCESS_OUTPUT_BYTES, + signal, + rootUser: !lease.session, + atMostOnce: true, + verifyCli, + }) + } + ) endRun() } catch (error) { throwIfAborted(signal) throw error } + await throwIfRunTimedOut(result, [result.stdout, result.stderr], { + sandbox, + lease, + outputSandboxDir: req.outputSandboxDir, + timeoutMs: runTimeoutMs, + signal, + }) throwIfAborted(signal) - throwIfSandboxTimedOut(result) const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n') diff --git a/apps/sim/lib/execution/remote-sandbox/output-limits.ts b/apps/sim/lib/execution/remote-sandbox/output-limits.ts index e260660aa19..209246cc79b 100644 --- a/apps/sim/lib/execution/remote-sandbox/output-limits.ts +++ b/apps/sim/lib/execution/remote-sandbox/output-limits.ts @@ -48,7 +48,8 @@ const STREAMED_OUTPUT_TRUNCATION_NOTE = */ export function tailStreamedSandboxOutput( value: string | undefined, - limitBytes = MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES + limitBytes = MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES, + truncationNote = STREAMED_OUTPUT_TRUNCATION_NOTE ): string { if (!value) return '' const buffer = Buffer.from(value, 'utf8') @@ -56,7 +57,7 @@ export function tailStreamedSandboxOutput( let start = buffer.length - limitBytes while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1 - return `${STREAMED_OUTPUT_TRUNCATION_NOTE}${buffer.subarray(start).toString('utf8')}` + return `${truncationNote}${buffer.subarray(start).toString('utf8')}` } /** diff --git a/apps/sim/lib/execution/remote-sandbox/phase-timing.ts b/apps/sim/lib/execution/remote-sandbox/phase-timing.ts index ecc418075a2..6205e0e85a4 100644 --- a/apps/sim/lib/execution/remote-sandbox/phase-timing.ts +++ b/apps/sim/lib/execution/remote-sandbox/phase-timing.ts @@ -1,7 +1,8 @@ /** * Phases of one sandbox execution, in the order they run. `lease` spans the whole acquisition - * (session lock included); for a session, `lookup` and `cli` are the parts of it spent finding or - * creating the machine and verifying its deployment tooling. + * (session lock included); for a session, `lookup` is the part of it spent finding or creating the + * machine. `cli` is time spent installing or repairing deployment tooling: on a new machine, or + * after a launch refused a changed CLI. A reused machine verifies it inside `run`. */ type SandboxPhase = | 'resolve' diff --git a/apps/sim/lib/execution/remote-sandbox/session-cli-launch.live.test.ts b/apps/sim/lib/execution/remote-sandbox/session-cli-launch.live.test.ts new file mode 100644 index 00000000000..6baefa9725f --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/session-cli-launch.live.test.ts @@ -0,0 +1,253 @@ +/** + * Launch-time CLI verification against a real E2B workbench: a command starts only while the + * deployment CLI still matches, the check resists the user's shell and Python hooks, a refused + * launch starts nothing, and a repaired CLI launches again. + * + * Enable with `SANDBOX_SESSION_SMOKE=1`. Requires `E2B_API_KEY` and `MOTHERSHIP_E2B_TEMPLATE_ID`. + * Writes a JSON report of each check to `SESSION_CLI_LAUNCH_REPORT_PATH` when set. Creates one + * short-lived sandbox and always kills it. + */ +import { writeFile } from 'node:fs/promises' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { afterAll, describe, expect, it } from 'vitest' +import { e2bProvider } from '@/lib/execution/remote-sandbox/e2b' +import { + ensureSessionCli, + SESSION_CLI_CHECK_SCRIPT, + SessionCliStaleError, + sessionCliVerification, +} from '@/lib/execution/remote-sandbox/session-cli' +import type { SandboxHandle, SandboxSessionRequest } from '@/lib/execution/remote-sandbox/types' + +const smokeEnabled = process.env.SANDBOX_SESSION_SMOKE === '1' +const TIMEOUT_MS = 30_000 +const SAMPLES = 7 + +interface CheckReport { + name: string + status: 'passed' | 'failed' + durationMs: number + detail?: unknown +} +const report: CheckReport[] = [] + +async function check(name: string, run: () => Promise): Promise { + const startedAt = performance.now() + try { + const detail = await run() + report.push({ name, status: 'passed', durationMs: performance.now() - startedAt, detail }) + return detail + } catch (error) { + report.push({ + name, + status: 'failed', + durationMs: performance.now() - startedAt, + detail: getErrorMessage(error), + }) + throw error + } +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b) + return Math.round(sorted[Math.floor(sorted.length / 2)]) +} + +afterAll(async () => { + const path = process.env.SESSION_CLI_LAUNCH_REPORT_PATH + if (smokeEnabled && path) await writeFile(path, JSON.stringify(report, null, 2)) +}) + +describe.skipIf(!smokeEnabled)('session CLI verification at launch', () => { + it( + 'starts a command only while the deployment CLI matches its release', + async () => { + const directory = `/home/user/.sim-cli/live-${generateShortId(10)}` + const cli: NonNullable = { + path: `${directory}/cli.mjs`, + content: 'console.log("cli")\n', + runtime: { path: `${directory}/runtime.mjs`, content: 'export const runtime = 1\n' }, + } + const verifyCli = sessionCliVerification(cli) + const sandbox: SandboxHandle = await e2bProvider.create('mothership', { + sessionKey: `mothership-chat:live-cli-${generateShortId(10)}`, + lifetimeMs: 10 * 60_000, + }) + const run = (command: string, verify = true) => + sandbox.runCommand(command, { + timeoutMs: TIMEOUT_MS, + atMostOnce: true, + rootUser: false, + ...(verify ? { verifyCli } : {}), + }) + const ranMarker = async (marker: string) => + (await run(`test -e /tmp/${marker} && echo yes || echo no`, false)).stdout.trim() + const expectRefused = async (marker: string) => { + await expect(run(`touch /tmp/${marker}`)).rejects.toBeInstanceOf(SessionCliStaleError) + expect(await ranMarker(marker)).toBe('no') + } + try { + expect(sandbox.verifiesCliAtLaunch).toBe(true) + await check('install CLI', () => + ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + ) + + await check('verified launch runs the command as the user', async () => { + const result = await run(`id -un; test -L ${directory}/sim && echo linked`) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('user') + expect(result.stdout).toContain('linked') + return result.stdout.trim() + }) + + await check('warm-call cost: separate check + launch vs verified launch', async () => { + const separate: number[] = [] + const merged: number[] = [] + const unverified: number[] = [] + for (let sample = 0; sample < SAMPLES; sample += 1) { + let startedAt = performance.now() + await run('true', false) + unverified.push(performance.now() - startedAt) + startedAt = performance.now() + await ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + await run('true', false) + separate.push(performance.now() - startedAt) + startedAt = performance.now() + await run('true') + merged.push(performance.now() - startedAt) + } + const result = { + separateMedianMs: median(separate), + mergedMedianMs: median(merged), + unverifiedLaunchMedianMs: median(unverified), + } + return result + }) + + await check('a modified CLI refuses the launch and starts nothing', async () => { + await run(`echo 'tampered' >> ${cli.path}`, false) + await expectRefused('ran-after-edit') + }) + + await check('the user shell and Python hooks cannot fake the check', async () => { + await run( + [ + 'cp -a ~/.profile /tmp/profile.bak 2>/dev/null || true', + `printf 'python3() { return 0; }\\n' >> ~/.profile`, + 'site=$(/usr/bin/env python3 -c "import site; print(site.getusersitepackages())")', + 'mkdir -p "$site"', + `printf 'import sys; sys.exit(0)\\n' > "$site/sim-live.pth"`, + 'echo "$site" > /tmp/sim-live-site', + ].join('\n'), + false + ) + try { + await expectRefused('ran-after-hooks') + const shellCheck = await sandbox.runCommand( + `python3 - <<'SIM_SESSION_CLI'\n${SESSION_CLI_CHECK_SCRIPT}SIM_SESSION_CLI`, + { + timeoutMs: TIMEOUT_MS, + atMostOnce: true, + rootUser: false, + envs: { + SIM_CLI_PATH: verifyCli.path, + SIM_CLI_ARTIFACTS: JSON.stringify(verifyCli.artifacts), + }, + } + ) + await expect( + ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + ).rejects.toThrow('could not be verified') + await expectRefused('ran-after-hooked-repair') + return { + loginShellCheck: + shellCheck.exitCode === 0 ? 'passed a tampered CLI' : 'refused a tampered CLI', + } + } finally { + await run( + 'rm -f "$(cat /tmp/sim-live-site)/sim-live.pth"; if [ -e /tmp/profile.bak ]; then cp -a /tmp/profile.bak ~/.profile; else rm -f ~/.profile; fi', + false + ) + } + }) + + await check('once the hooks are gone, repair restores the CLI', async () => { + await ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + expect((await run(`grep -c tampered ${cli.path} || true`, false)).stdout.trim()).toBe('0') + expect((await run('echo ok')).stdout.trim()).toBe('ok') + }) + + await check('a symlinked artifact refuses the launch', async () => { + await ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + await run(`rm -f ${cli.path} && ln -s /etc/hostname ${cli.path}`, false) + await expectRefused('ran-after-symlink') + }) + + await check('a missing runtime refuses the launch', async () => { + await run(`rm -f ${cli.path} ${cli.runtime?.path}`, false) + await expectRefused('ran-after-delete') + }) + + await check('an unreadable or non-regular artifact is refused and repaired', async () => { + await ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + await run(`chmod 000 ${cli.path}`, false) + await expectRefused('ran-after-chmod') + await ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + await run(`rm -f ${cli.runtime?.path} && mkfifo ${cli.runtime?.path}`, false) + const startedAt = performance.now() + await expectRefused('ran-after-fifo') + const refusedInMs = Math.round(performance.now() - startedAt) + await ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + expect((await run('echo ok')).stdout.trim()).toBe('ok') + return { fifoRefusedInMs: refusedInMs } + }) + + await check('a Stop during a slow check settles and starts nothing', async () => { + await run(`rm -f ${cli.runtime?.path} && truncate -s 6G ${cli.runtime?.path}`, false) + const controller = new AbortController() + const startedAt = performance.now() + setTimeout(() => controller.abort(new Error('User Stop')), 1_000) + // An aborted session command returns its killed process's result; the caller's own + // abort check turns it into the cancellation. + const stopped = await sandbox + .runCommand('touch /tmp/ran-after-stop', { + timeoutMs: TIMEOUT_MS, + atMostOnce: true, + rootUser: false, + verifyCli, + signal: controller.signal, + }) + .then( + (result) => `exit ${result.exitCode}`, + (error: unknown) => getErrorMessage(error) + ) + const settledInMs = Math.round(performance.now() - startedAt) + expect(settledInMs).toBeLessThan(10_000) + expect(await ranMarker('ran-after-stop')).toBe('no') + await ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + return { settledInMs, stopped } + }) + + await check('a repaired CLI launches again', async () => { + await run(`echo 'tampered' >> ${cli.path}`, false) + await expectRefused('ran-before-repair') + await ensureSessionCli(sandbox, cli, AbortSignal.timeout(TIMEOUT_MS), TIMEOUT_MS) + const result = await run('touch /tmp/ran-after-repair && echo done') + expect(result.stdout.trim()).toBe('done') + expect(await ranMarker('ran-after-repair')).toBe('yes') + }) + + await check('a verified launch keeps its exit code and streams', async () => { + const result = await run('echo out; echo err >&2; exit 3') + expect(result).toMatchObject({ exitCode: 3 }) + expect(result.stdout).toContain('out') + expect(result.stderr).toContain('err') + }) + } finally { + await sandbox.kill() + } + }, + 10 * 60_000 + ) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/session-cli.ts b/apps/sim/lib/execution/remote-sandbox/session-cli.ts index b7bdfb0480a..d252ea6cb96 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-cli.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-cli.ts @@ -1,22 +1,30 @@ import { createHash } from 'node:crypto' import { posix } from 'node:path' import { withSandboxFilePublication } from '@/lib/execution/remote-sandbox/file-publication' -import type { SandboxHandle, SandboxSessionRequest } from '@/lib/execution/remote-sandbox/types' +import type { + SandboxHandle, + SandboxSessionRequest, + SessionCliVerification, +} from '@/lib/execution/remote-sandbox/types' -/** A valid release is reused; activation repairs only its own versioned launcher. */ -const CHECK_CLI_COMMAND = `python3 - <<'SIM_SESSION_CLI' -import hashlib, json, os, stat, sys, tempfile +/** + * Exits 0 when every artifact matches its digest, and 10 when one is missing, unreadable, not a + * regular file, a symlink, or changed, so repair overwrites it. Opening never follows a link or + * blocks on a FIFO. A valid release is reused; activation repairs only its own versioned launcher. + */ +export const SESSION_CLI_CHECK_SCRIPT = `import hashlib, json, os, stat, sys, tempfile path = os.environ['SIM_CLI_PATH'] for artifact, expected in json.loads(os.environ['SIM_CLI_ARTIFACTS']).items(): - if os.path.islink(artifact): - sys.exit(10) - digest = hashlib.sha256() try: - with open(artifact, 'rb') as source: - for chunk in iter(lambda: source.read(65536), b''): - digest.update(chunk) - except FileNotFoundError: + descriptor = os.open(artifact, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except OSError: sys.exit(10) + digest = hashlib.sha256() + with os.fdopen(descriptor, 'rb') as source: + if not stat.S_ISREG(os.fstat(source.fileno()).st_mode): + sys.exit(10) + for chunk in iter(lambda: source.read(65536), b''): + digest.update(chunk) if digest.hexdigest() != expected: sys.exit(10) if stat.S_IMODE(os.stat(path).st_mode) != 0o755: @@ -27,7 +35,54 @@ if not os.path.islink(launcher) or os.readlink(launcher) != os.path.basename(pat staged = os.path.join(directory, 'sim') os.symlink(os.path.basename(path), staged) os.replace(staged, launcher) -SIM_SESSION_CLI` +` + +const CHECK_CLI_COMMAND = `python3 - <<'SIM_SESSION_CLI' +${SESSION_CLI_CHECK_SCRIPT}SIM_SESSION_CLI` + +/** + * A session command refused to start because the deployment CLI it would run with no longer + * matches this release. Nothing the caller asked for ran; repair the CLI and launch again. + */ +export class SessionCliStaleError extends Error { + constructor() { + super('Workbench CLI changed since it was verified') + this.name = 'SessionCliStaleError' + } +} + +/** A launch could not complete the CLI check (an unreadable artifact, a hung check), so it ran nothing. */ +export class SessionCliVerificationError extends Error { + constructor() { + super('Workbench CLI verification failed') + this.name = 'SessionCliVerificationError' + } +} + +const verifications = new WeakMap< + NonNullable, + SessionCliVerification +>() + +/** The digests a launch verifies before it starts the command, hashed once per CLI request. */ +export function sessionCliVerification( + cli: NonNullable +): SessionCliVerification { + let verification = verifications.get(cli) + if (!verification) { + verification = { + path: cli.path, + artifacts: Object.fromEntries( + [cli, ...(cli.runtime ? [cli.runtime] : [])].map(({ path, content }) => [ + path, + createHash('sha256').update(content).digest('hex'), + ]) + ), + } + verifications.set(cli, verification) + } + return verification +} /** Each running script keeps the CLI release chosen by the Sim instance that started it. */ export function sessionCommandPath( @@ -50,24 +105,37 @@ export async function ensureSessionCli( if (remaining <= 0) throw new DOMException('timeout', 'AbortError') return remaining } + const verification = sessionCliVerification(cli) + /** + * A handle that verifies at launch checks through its own supervisor, which the user's shell + * profile and Python site hooks cannot reach; the shell form sources the user's login profile. + */ const check = async (): Promise => { - const result = await sandbox.runCommand(CHECK_CLI_COMMAND, { - envs: { - SIM_CLI_PATH: cli.path, - SIM_CLI_ARTIFACTS: JSON.stringify( - Object.fromEntries( - [cli, ...(cli.runtime ? [cli.runtime] : [])].map(({ path, content }) => [ - path, - createHash('sha256').update(content).digest('hex'), - ]) - ) - ), - }, + const options = { timeoutMs: Math.min(30_000, remainingMs()), maxOutputBytes: 64 * 1024, rootUser: false, atMostOnce: true, signal, + } + if (sandbox.verifiesCliAtLaunch) { + try { + const result = await sandbox.runCommand('true', { ...options, verifyCli: verification }) + signal.throwIfAborted() + if (result.timedOut) throw new DOMException('timeout', 'AbortError') + if (result.exitCode !== 0) throw new SessionCliVerificationError() + return true + } catch (error) { + if (error instanceof SessionCliStaleError) return false + throw error + } + } + const result = await sandbox.runCommand(CHECK_CLI_COMMAND, { + ...options, + envs: { + SIM_CLI_PATH: verification.path, + SIM_CLI_ARTIFACTS: JSON.stringify(verification.artifacts), + }, }) signal.throwIfAborted() if (result.timedOut) throw new DOMException('timeout', 'AbortError') diff --git a/apps/sim/lib/execution/remote-sandbox/session-process.ts b/apps/sim/lib/execution/remote-sandbox/session-process.ts index 940cbc79277..313072ec171 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-process.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-process.ts @@ -1,3 +1,6 @@ +import { SESSION_CLI_CHECK_SCRIPT } from '@/lib/execution/remote-sandbox/session-cli' +import type { SessionCliVerification } from '@/lib/execution/remote-sandbox/types' + export interface SessionProcessIdentity { id: string sandboxId: string @@ -7,8 +10,15 @@ export interface SessionProcessIdentity { /** * PID 1 owns every descendant, including new sessions and double forks. A root-only * tombstone serializes Stop with launch; pidfds confirm exit without signalling a reused PID. + * + * With a CLI verification, PID 1 checks the deployment CLI as the ordinary user after recording + * itself and before it starts the command. The check runs outside the lock, so Stop can kill the + * namespace while it runs; the closed tombstone is re-read before the command starts. The isolated + * interpreter ignores the user's environment and site packages, which the user can edit. A + * mismatch starts nothing and is reported on the Stop receipt. */ const PROCESS_RUNTIME = `import base64, contextlib, fcntl, json, os, pathlib, pwd, re, select, signal, subprocess, sys, tempfile +CLI_CHECK = base64.b64decode('${Buffer.from(SESSION_CLI_CHECK_SCRIPT).toString('base64')}').decode() action, identity = sys.argv[1:3] if not re.fullmatch(r"[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}", identity): @@ -60,7 +70,7 @@ if action == 'run': elif action == 'init': if os.getpid() != 1: raise RuntimeError('Command requires its own PID namespace') - parent_proc = int(sys.argv[5]) + parent_proc = int(sys.argv[6]) with locked() as state: if state.get('closed'): sys.exit(125) @@ -71,14 +81,37 @@ elif action == 'init': namespace=os.readlink('self/ns/pid', dir_fd=parent_proc)) save(state) os.close(parent_proc) + def entering(account): + def enter(): + os.initgroups(account.pw_name, account.pw_gid) + os.setgid(account.pw_gid) + os.setuid(account.pw_uid) + return enter + refusal = None + if sys.argv[5] != '-': + expected = json.loads(base64.b64decode(sys.argv[5])) + checker = pwd.getpwnam('user') + try: + checked = subprocess.run([sys.executable, '-I', '-c', CLI_CHECK], cwd=checker.pw_dir, + env={'HOME': checker.pw_dir, 'PATH': '/usr/local/bin:/usr/bin:/bin', + 'SIM_CLI_PATH': expected['path'], 'SIM_CLI_ARTIFACTS': json.dumps(expected['artifacts'])}, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=entering(checker), timeout=30).returncode + except subprocess.TimeoutExpired: + checked = None + if checked != 0: + refusal = 'stale' if checked == 10 else 'failed' + with locked() as state: + if state.get('closed'): + sys.exit(125) + if refusal: + state['cli'] = refusal + save(state) + sys.exit(125) owner = pwd.getpwnam(sys.argv[4]) - def enter_user(): - os.initgroups(owner.pw_name, owner.pw_gid) - os.setgid(owner.pw_gid) - os.setuid(owner.pw_uid) environment = dict(os.environ, HOME=owner.pw_dir, USER=owner.pw_name, LOGNAME=owner.pw_name) child = subprocess.Popen(['/bin/bash', '-lc', base64.b64decode(sys.argv[3]).decode()], - cwd=owner.pw_dir, env=environment, preexec_fn=enter_user) + cwd=owner.pw_dir, env=environment, preexec_fn=entering(owner)) while True: waited_pid, waited_status = os.wait() if waited_pid == child.pid: @@ -114,7 +147,7 @@ elif action == 'stop': raise RuntimeError('Command namespace exit is unconfirmed') finally: os.close(descriptor) - print(json.dumps({'settled': True})) + print(json.dumps({'settled': True, 'cli': state.get('cli')})) else: raise ValueError('Invalid command action') ` @@ -129,8 +162,14 @@ function commandIdentity(identity: string): string { } /** The caller invokes the supervisor as root; the requested command retains its ordinary user. */ -export function sessionProcessCommand(identity: string, command: string, rootUser = false): string { - return `${PROCESS_LAUNCHER} run ${commandIdentity(identity)} '${Buffer.from(command).toString('base64')}' ${rootUser ? 'root' : 'user'}` +export function sessionProcessCommand( + identity: string, + command: string, + rootUser = false, + verifyCli?: SessionCliVerification +): string { + const verification = verifyCli ? Buffer.from(JSON.stringify(verifyCli)).toString('base64') : '-' + return `${PROCESS_LAUNCHER} run ${commandIdentity(identity)} '${Buffer.from(command).toString('base64')}' ${rootUser ? 'root' : 'user'} '${verification}'` } /** Safe before launch as well as after a lost launch reply: the tombstone prevents future work. */ diff --git a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts index 20562abee59..4be2c186461 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts @@ -14,11 +14,14 @@ import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CodeLanguage } from '@/lib/execution/languages' import { SANDBOX_OUTPUT_DIR_SENTINEL } from '@/lib/execution/remote-sandbox/paths' +import { SessionCliStaleError } from '@/lib/execution/remote-sandbox/session-cli' import type { SandboxCodeResult, SandboxCommandResult, SandboxHandle, SandboxProvider, + SandboxSessionRequest, + SessionCliVerification, } from '@/lib/execution/remote-sandbox/types' const { mockCreate, mockFindSessionSandbox, mockResolveWorkspaceSandbox } = vi.hoisted(() => ({ @@ -41,6 +44,7 @@ vi.mock('@/lib/core/execution-limits/metrics', () => ({ recordSandboxProviderLimit: vi.fn(), })) +import { isTimeoutAbortReason } from '@/lib/core/execution-limits' import { executeInSandbox, executeShellInSandbox, @@ -56,6 +60,10 @@ import { recordSessionFileInput, } from '@/lib/execution/remote-sandbox/session-file-provenance' import { writeSessionSandboxFile } from '@/lib/execution/remote-sandbox/session-files' +import { + MAX_SANDBOX_TIMEOUT_OUTPUT_TAIL_BYTES, + SandboxExecutionTimeoutError, +} from '@/lib/execution/remote-sandbox/timeout' remoteSandboxProviderMockFns.mockResolveProvider.mockImplementation( (): SandboxProvider => ({ @@ -160,6 +168,46 @@ function slowDown(handle: SandboxHandle): void { const CLI = { path: '/home/user/.sim-cli/release/cli.mjs', content: 'deployment-code' } +/** + * A session handle whose launches verify the CLI first, as E2B's supervisor does: a launch whose + * CLI no longer matches starts nothing and rejects. + */ +function launchVerifyingSandbox(id: string, options: { alwaysStale?: boolean } = {}) { + const { handle: base, calls } = fakeSandbox(id) + const ran: string[] = [] + const unverifiedLaunches: string[] = [] + const verify = async (launched: string, verifyCli: SessionCliVerification | undefined) => { + if (!verifyCli) { + unverifiedLaunches.push(launched) + return + } + const check = await base.runCommand('verify', { + timeoutMs: 1_000, + envs: { + SIM_CLI_PATH: verifyCli.path, + SIM_CLI_ARTIFACTS: JSON.stringify(verifyCli.artifacts), + }, + }) + calls.runCommand.pop() + if (options.alwaysStale || check.exitCode !== 0) throw new SessionCliStaleError() + } + const handle: SandboxHandle = { + ...base, + verifiesCliAtLaunch: true, + async runCommand(command, runOptions) { + await verify(command, runOptions.verifyCli) + ran.push(command) + return base.runCommand(command, runOptions) + }, + async runCode(code, runOptions) { + await verify(code, runOptions.verifyCli) + ran.push(code) + return base.runCode(code, runOptions) + }, + } + return { handle, calls, ran, unverifiedLaunches } +} + const CODE_REQUEST = { code: 'print(1)', language: CodeLanguage.Python, @@ -805,6 +853,56 @@ describe('session sandbox lease', () => { } }) + describe('a reused workbench that verifies its CLI at launch', () => { + it.each(['code', 'shell'] as const)( + 'runs %s with no separate CLI check command', + async (kind) => { + const { handle, calls, ran, unverifiedLaunches } = launchVerifyingSandbox(`launch-${kind}`) + mockFindSessionSandbox.mockResolvedValue(handle) + const request = { ...CODE_REQUEST, session: { key: `launch-${kind}`, cli: CLI } } + const execute = () => + kind === 'code' + ? executeInSandbox(request) + : executeShellInSandbox({ ...request, envs: {} }) + await execute() + calls.runCommand.length = 0 + ran.length = 0 + await execute() + expect(calls.runCommand.filter(({ envs }) => envs?.SIM_CLI_PATH)).toEqual([]) + expect(ran).toEqual([CODE_REQUEST.code]) + expect(unverifiedLaunches).not.toContain(CODE_REQUEST.code) + } + ) + + it.each(['code', 'shell'] as const)( + 'repairs a changed CLI and runs the %s exactly once', + async (kind) => { + const { handle, calls, ran, unverifiedLaunches } = launchVerifyingSandbox(`repair-${kind}`) + mockFindSessionSandbox.mockResolvedValue(handle) + const request = { ...CODE_REQUEST, session: { key: `repair-${kind}`, cli: CLI } } + await (kind === 'code' + ? executeInSandbox(request) + : executeShellInSandbox({ ...request, envs: {} })) + expect(ran.filter((launched) => launched === CODE_REQUEST.code)).toHaveLength(1) + expect(calls.runCommand.some(({ envs }) => envs?.SIM_FILE_TARGET === CLI.path)).toBe(true) + expect(unverifiedLaunches).not.toContain(CODE_REQUEST.code) + } + ) + + it('fails without running the code when the CLI cannot be kept verified', async () => { + const { handle, ran } = launchVerifyingSandbox('stale', { alwaysStale: true }) + mockFindSessionSandbox.mockResolvedValue(handle) + await expect( + executeShellInSandbox({ + ...CODE_REQUEST, + envs: {}, + session: { key: 'stale', cli: CLI }, + }) + ).rejects.toThrow('Workbench CLI installation could not be verified') + expect(ran).not.toContain(CODE_REQUEST.code) + }) + }) + it('does not rewrite an unchanged executable while earlier code can still use it', async () => { const { handle } = fakeSandbox('unchanged-tooling') mockFindSessionSandbox.mockResolvedValue(handle) @@ -941,6 +1039,277 @@ describe('session sandbox lease', () => { expect(calls.killed).toBe(false) }) + describe('a workbench run stopped by its own timeout', () => { + const OUTPUT_DIR = '/tmp/sim/outputs/call-timeout' + + async function timedOutRun( + kind: 'code' | 'shell', + streams: { stdout: string; stderr: string }, + listFiles: SandboxHandle['listFiles'], + options: { + acceptOutputProvenance?: SandboxSessionRequest['acceptOutputProvenance'] + /** Holds the provider's timed-out reply until it settles. */ + reply?: (signal: AbortSignal | undefined) => Promise + signal?: AbortSignal + timeoutMs?: number + } = {} + ): Promise { + const { acceptOutputProvenance, reply, signal, timeoutMs } = options + const { handle } = fakeSandbox(`timeout-${kind}`) + mockFindSessionSandbox.mockResolvedValue(handle) + mockCreate.mockResolvedValue(handle) + handle.runCode = async (_code, runOptions) => { + await reply?.(runOptions.signal) + return { text: '', ...streams, timedOut: true } + } + handle.runCommand = async (_command, runOptions) => { + await reply?.(runOptions.signal) + return { ...streams, exitCode: 124, timedOut: true } + } + handle.listFiles = listFiles + const request = { + ...CODE_REQUEST, + ...(timeoutMs ? { timeoutMs } : {}), + session: { + key: `timeout-${kind}`, + ...(acceptOutputProvenance ? { acceptOutputProvenance } : {}), + }, + outputSandboxDir: OUTPUT_DIR, + ...(signal ? { signal } : {}), + } + return ( + kind === 'code' + ? executeInSandbox(request) + : executeShellInSandbox({ ...request, envs: {} }) + ).then( + () => { + throw new Error('Expected the run to time out') + }, + (error: unknown) => error + ) + } + + it.each(['code', 'shell'] as const)( + 'reports the %s output so far and the files already exported', + async (kind) => { + const error = await timedOutRun( + kind, + { stdout: 'rendered 1 of 3\nrendered 2 of 3', stderr: 'slow frame' }, + async () => [ + { + path: `${OUTPUT_DIR}/${SANDBOX_OUTPUT_DIR_SENTINEL}`, + relativePath: SANDBOX_OUTPUT_DIR_SENTINEL, + kind: 'file', + size: 0, + }, + { path: `${OUTPUT_DIR}/frames`, relativePath: 'frames', kind: 'directory', size: 0 }, + { + path: `${OUTPUT_DIR}/frames/2.png`, + relativePath: 'frames/2.png', + kind: 'file', + size: 9, + }, + { + path: `${OUTPUT_DIR}/frames/1.png`, + relativePath: 'frames/1.png', + kind: 'file', + size: 9, + }, + ] + ) + expect(isTimeoutAbortReason(error)).toBe(true) + if (!(error instanceof SandboxExecutionTimeoutError)) throw error + expect(error.progress.output).toBe('rendered 1 of 3\nrendered 2 of 3\nslow frame') + expect(error.progress.outputFiles).toEqual([ + `${OUTPUT_DIR}/frames/1.png`, + `${OUTPUT_DIR}/frames/2.png`, + ]) + expect(error.progress.stoppedAfterMs).toBeGreaterThan(0) + expect(error.progress.stoppedAfterMs).toBeLessThanOrEqual(CODE_REQUEST.timeoutMs) + } + ) + + it.each([ + ['fails', async () => Promise.reject(new Error('listing unavailable'))], + ['never answers', () => new Promise(() => {})], + ] as const)('still reports the output when the file listing %s', async (_case, listFiles) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const pending = timedOutRun('shell', { stdout: 'step 1', stderr: '' }, listFiles) + await vi.runAllTimersAsync() + const error = await pending + if (!(error instanceof SandboxExecutionTimeoutError)) throw error + expect(error.progress.output).toBe('step 1') + expect(error.progress.outputFiles).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('reports the output of a short run whose own timeout answers after a round trip', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + try { + const { handle } = fakeSandbox('timeout-short') + mockFindSessionSandbox.mockResolvedValue(handle) + // The provider answers its own timeout one round trip late, and stops on the call's abort. + handle.runCommand = (_command, options) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve({ stdout: 'step 1', stderr: '', exitCode: 124, timedOut: true }), + (options?.timeoutMs ?? 0) + 300 + ) + options?.signal?.addEventListener('abort', () => { + clearTimeout(timer) + reject(options.signal?.reason) + }) + }) + handle.listFiles = () => new Promise(() => {}) + const pending = executeShellInSandbox({ + ...CODE_REQUEST, + timeoutMs: 10_000, + envs: {}, + session: { key: 'timeout-short' }, + outputSandboxDir: OUTPUT_DIR, + }).then( + () => undefined, + (rejection: unknown) => rejection + ) + await vi.runAllTimersAsync() + const error = await pending + if (!(error instanceof SandboxExecutionTimeoutError)) throw error + expect(error.progress.output).toBe('step 1') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a cancellation that arrives while listing the outputs a cancellation', async () => { + const { handle } = fakeSandbox('timeout-cancelled') + mockFindSessionSandbox.mockResolvedValue(handle) + const controller = new AbortController() + handle.runCommand = async () => ({ + stdout: 'step 1', + stderr: '', + exitCode: 124, + timedOut: true, + }) + handle.listFiles = async () => { + controller.abort(new Error('User Stop')) + return new Promise(() => {}) + } + const error = await executeShellInSandbox({ + ...CODE_REQUEST, + envs: {}, + session: { key: 'timeout-cancelled' }, + outputSandboxDir: OUTPUT_DIR, + signal: controller.signal, + }).then( + () => undefined, + (rejection: unknown) => rejection + ) + expect(error).not.toBeInstanceOf(SandboxExecutionTimeoutError) + expect(isTimeoutAbortReason(error)).toBe(false) + }) + + describe('when an abort lands before the provider replies', () => { + /** + * The provider never replies within the command's budget, while any abort makes the run + * yield what already streamed as timed out, so only the caller tells its own deadline apart + * from a Stop. + */ + function shortRun( + kind: 'code' | 'shell', + options: { onLaunch?: () => void; signal?: AbortSignal } = {} + ): Promise { + return timedOutRun(kind, { stdout: 'rendered 1 of 3', stderr: '' }, async () => [], { + timeoutMs: 10_000, + signal: options.signal, + reply: (signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(), { once: true }) + options.onLaunch?.() + }), + }) + } + + it.each(['code', 'shell'] as const)( + 'reports the %s output that already streamed when its own deadline fires', + async (kind) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const pending = shortRun(kind) + await vi.advanceTimersByTimeAsync(10_000) + const error = await pending + if (!(error instanceof SandboxExecutionTimeoutError)) throw error + expect(error.progress.output).toBe('rendered 1 of 3') + expect(error.progress.outputFiles).toEqual([]) + } finally { + vi.useRealTimers() + } + } + ) + + it.each(['code', 'shell'] as const)('keeps a %s Stop a cancellation', async (kind) => { + const controller = new AbortController() + const error = await shortRun(kind, { + signal: controller.signal, + onLaunch: () => controller.abort(new Error('User Stop')), + }) + expect(error).not.toBeInstanceOf(SandboxExecutionTimeoutError) + expect(getErrorMessage(error)).toBe('User Stop') + }) + + it('keeps a Stop a cancellation on a provider without workbenches', async () => { + const { mockResolveProvider } = remoteSandboxProviderMockFns + const workbenchProvider = mockResolveProvider.getMockImplementation() + if (!workbenchProvider) throw new Error('Missing provider mock') + mockResolveProvider.mockImplementation(() => ({ + ...workbenchProvider(), + findSessionSandbox: undefined, + })) + try { + const controller = new AbortController() + const error = await shortRun('shell', { + signal: controller.signal, + onLaunch: () => controller.abort(new Error('User Stop')), + }) + expect(isTimeoutAbortReason(error)).toBe(false) + expect(getErrorMessage(error)).toBe('User Stop') + } finally { + mockResolveProvider.mockImplementation(workbenchProvider) + } + }) + }) + + it('withholds the output when the workbench secret provenance is unavailable', async () => { + const error = await timedOutRun( + 'shell', + { stdout: 'token=secret-value', stderr: '' }, + async () => [], + { + acceptOutputProvenance: async () => { + throw new Error( + 'Workbench output withheld because its secret provenance is unavailable' + ) + }, + } + ) + expect(error).not.toBeInstanceOf(SandboxExecutionTimeoutError) + expect(getErrorMessage(error)).not.toContain('secret-value') + }) + + it('keeps only the end of an output too large to return', async () => { + const lines = Array.from({ length: 20_000 }, (_, index) => `frame ${index}`).join('\n') + const error = await timedOutRun('shell', { stdout: lines, stderr: '' }, async () => []) + if (!(error instanceof SandboxExecutionTimeoutError)) throw error + expect(Buffer.byteLength(error.progress.output)).toBeLessThanOrEqual( + MAX_SANDBOX_TIMEOUT_OUTPUT_TAIL_BYTES + 64 + ) + expect(error.progress.output.endsWith('frame 19999')).toBe(true) + expect(error.progress.output.startsWith('[earlier output omitted]')).toBe(true) + }) + }) + it('keeps the one-shot teardown when no session is requested', async () => { const { handle, calls } = fakeSandbox('sb-oneshot') mockCreate.mockResolvedValue(handle) diff --git a/apps/sim/lib/execution/remote-sandbox/session.ts b/apps/sim/lib/execution/remote-sandbox/session.ts index cdd9cb01b10..1fc3ffe628c 100644 --- a/apps/sim/lib/execution/remote-sandbox/session.ts +++ b/apps/sim/lib/execution/remote-sandbox/session.ts @@ -99,7 +99,8 @@ export async function ensureSessionSandbox(args: { if (!created.sandbox.outlives?.(lifetimeMs, requestedAtMs)) await created.sandbox.extendLifetime?.(lifetimeMs) signal.throwIfAborted() - if (session.cli) { + // A reused machine's commands verify the CLI as they launch, so only a new one installs it here. + if (session.cli && !(existing && created.sandbox.verifiesCliAtLaunch)) { const endCli = args.phases?.start('cli') await ensureSessionCli(created.sandbox, session.cli, signal, args.bootstrapTimeoutMs) endCli?.() diff --git a/apps/sim/lib/execution/remote-sandbox/timeout.ts b/apps/sim/lib/execution/remote-sandbox/timeout.ts new file mode 100644 index 00000000000..a0dce09fd95 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/timeout.ts @@ -0,0 +1,64 @@ +import { tailStreamedSandboxOutput } from '@/lib/execution/remote-sandbox/output-limits' +import { SANDBOX_OUTPUT_DIR_SENTINEL } from '@/lib/execution/remote-sandbox/paths' +import type { SandboxDirectoryEntry } from '@/lib/execution/remote-sandbox/types' + +/** Output kept from a timed-out workbench run: its end says where the work stopped. */ +export const MAX_SANDBOX_TIMEOUT_OUTPUT_TAIL_BYTES = 64 * 1024 + +/** Output-directory files named in a timeout message; the rest are counted. */ +const MAX_NAMED_PERSISTED_FILES = 20 + +const TIMEOUT_OUTPUT_TRUNCATION_NOTE = '[earlier output omitted]\n' + +/** What a workbench run had produced when its own timeout stopped it. */ +export interface SandboxTimeoutProgress { + /** Tail of the combined output streams. */ + output: string + /** The timeout the code ran under, after which it was stopped. */ + stoppedAfterMs: number + /** Files already in the call's output directory; they persist with the workbench. */ + outputFiles: string[] +} + +/** + * A workbench process stopped by its own timeout. It is still the `timeout` abort every caller + * already handles (`isTimeoutAbortReason` accepts it), and additionally carries the run's progress + * so the caller can return it through its normal output path. + */ +export class SandboxExecutionTimeoutError extends DOMException { + constructor(readonly progress: SandboxTimeoutProgress) { + super('timeout', 'AbortError') + } +} + +export function sandboxTimeoutProgress( + streams: Array, + stoppedAfterMs: number, + listed: SandboxDirectoryEntry[] +): SandboxTimeoutProgress { + return { + output: tailStreamedSandboxOutput( + streams.filter(Boolean).join('\n'), + MAX_SANDBOX_TIMEOUT_OUTPUT_TAIL_BYTES, + TIMEOUT_OUTPUT_TRUNCATION_NOTE + ), + stoppedAfterMs, + outputFiles: listed + .filter( + (entry) => entry.kind === 'file' && entry.relativePath !== SANDBOX_OUTPUT_DIR_SENTINEL + ) + .map((entry) => entry.path) + .sort(), + } +} + +/** Appended to the route's timeout error so the next call resumes the work instead of redoing it. */ +export function describeSandboxTimeoutProgress(progress: SandboxTimeoutProgress): string { + const named = progress.outputFiles.slice(0, MAX_NAMED_PERSISTED_FILES) + const unnamed = progress.outputFiles.length - named.length + const files = named.length + ? ` Already in its output directory: ${named.join(', ')}${unnamed > 0 ? ` and ${unnamed} more` : ''}.` + : '' + const output = progress.output ? ' Its output so far is in stdout.' : '' + return ` The code was stopped after ${Math.round(progress.stoppedAfterMs / 1000)} s.${output} The workbench and the files the code wrote persist.${files} Rerun only the remaining work.` +} diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index 254b513d784..9c4131116da 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -236,6 +236,12 @@ export interface SandboxCodeResult { providerFailure?: 'provider_limit' } +/** Expected digests of a session's deployment CLI artifacts, keyed by sandbox path. */ +export interface SessionCliVerification { + path: string + artifacts: Record +} + export interface RunCommandOptions { envs?: Record timeoutMs: number @@ -247,6 +253,11 @@ export interface RunCommandOptions { rootUser?: boolean /** Never relaunch this command when provider acknowledgement is ambiguous. */ atMostOnce?: boolean + /** + * Session commands only: start the command only if these artifacts still match. A mismatch + * rejects with `SessionCliStaleError` and runs nothing. + */ + verifyCli?: SessionCliVerification onStdout?: (chunk: string) => void onStderr?: (chunk: string) => void } @@ -257,6 +268,11 @@ export interface RunCommandOptions { */ export interface SandboxHandle { readonly sandboxId: string + /** + * Every command this session handle launches honors `verifyCli` inside its own owned process, + * so a reused session needs no separate verification command. + */ + readonly verifiesCliAtLaunch?: boolean /** * Runs code in the language fixed at {@link SandboxProvider.create} time. * Language is bound at creation rather than per call because Daytona applies it @@ -273,6 +289,7 @@ export interface SandboxHandle { javascriptPreload?: string maxOutputBytes?: number signal?: AbortSignal + verifyCli?: SessionCliVerification } ): Promise runCommand(command: string, options: RunCommandOptions): Promise diff --git a/apps/sim/lib/function-execution/execute-request.test.ts b/apps/sim/lib/function-execution/execute-request.test.ts index eb9ebabc528..da0e15b0c4d 100644 --- a/apps/sim/lib/function-execution/execute-request.test.ts +++ b/apps/sim/lib/function-execution/execute-request.test.ts @@ -39,6 +39,7 @@ import { SandboxOutputFileError, SandboxOutputLimitError, } from '@/lib/execution/remote-sandbox/output-limits' +import { SandboxExecutionTimeoutError } from '@/lib/execution/remote-sandbox/timeout' const { mockExecuteInIsolatedVM, @@ -3457,6 +3458,39 @@ describe('Function execution request', () => { expect(directData.__resolvedSecretNames).toEqual(['API_KEY']) }) + it('returns a timed-out workbench run’s output with the secret names it may reveal', async () => { + envFlagsMock.isRemoteSandboxEnabled = true + mockExecuteShellInSandbox.mockRejectedValueOnce( + new SandboxExecutionTimeoutError({ + output: 'secret-value\nrendered 2 of 3', + stoppedAfterMs: 290_000, + outputFiles: ['/tmp/sim/outputs/call-1/frame-1.png'], + }) + ) + + const response = await POST( + createMockRequest( + 'POST', + { + code: 'printf "%s\\n" "$API_KEY"; ./render', + language: 'shell', + envVars: { API_KEY: 'secret-value' }, + timeout: 300_000, + }, + { 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' } + ) + ) + const data = await response.json() + + expect(response.status).toBe(408) + expect(data.output.stdout).toBe('secret-value\nrendered 2 of 3') + expect(data.output.cost).toBeUndefined() + expect(data.__resolvedSecretNames).toEqual(['API_KEY']) + expect(data.error).toMatch(/^Function execution timed out after 300000ms\. /) + expect(data.error).toContain('stopped after 290 s') + expect(data.error).toContain('/tmp/sim/outputs/call-1/frame-1.png') + }) + it('returns nonzero shell stderr as a visible 422 error and diagnostic output', async () => { envFlagsMock.isRemoteSandboxEnabled = true const stderr = "error: unknown flag: --short\nSee 'kubectl version --help' for usage." diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index f0c4ba15b4d..7b50c729753 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -90,6 +90,10 @@ import { readTrustedSandboxOutputCost, } from '@/lib/execution/remote-sandbox/output-limits' import { MAX_BLOCK_MOUNTED_FILES, SANDBOX_OUTPUT_DIR } from '@/lib/execution/remote-sandbox/paths' +import { + describeSandboxTimeoutProgress, + SandboxExecutionTimeoutError, +} from '@/lib/execution/remote-sandbox/timeout' import type { SandboxCollectedFile, SandboxFile, @@ -3361,12 +3365,24 @@ export async function executeFunctionRequest( isTimeoutAbortReason(req.signal.reason) || isTimeoutAbortReason(error?.cause ?? error) || (executionDeadlineAt !== undefined && Date.now() >= executionDeadlineAt) + const timeoutMessage = `Function execution timed out${timeoutForError ? ` after ${timeoutForError}ms` : ''}` + // Only a route context carries the secret provenance that partial output must cross with. + const progress = + timedOut && routeContext && error instanceof SandboxExecutionTimeoutError + ? error.progress + : undefined const abortResponse = { success: false, - error: timedOut - ? `Function execution timed out${timeoutForError ? ` after ${timeoutForError}ms` : ''}` - : 'Function execution was cancelled', - output: { result: null, stdout: cleanStdout(stdout), executionTime }, + error: !timedOut + ? 'Function execution was cancelled' + : progress + ? `${timeoutMessage}.${describeSandboxTimeoutProgress(progress)}` + : timeoutMessage, + output: { + result: null, + stdout: cleanStdout(progress ? progress.output : stdout), + executionTime, + }, } logger.warn(`[${requestId}] Function execution ${timedOut ? 'timed out' : 'was cancelled'}`, { executionTime, diff --git a/apps/sim/lib/media/falai-audio.ts b/apps/sim/lib/media/falai-audio.ts index 5b611a73204..e9705d9e89e 100644 --- a/apps/sim/lib/media/falai-audio.ts +++ b/apps/sim/lib/media/falai-audio.ts @@ -141,7 +141,7 @@ function buildInput( export async function generateFalAudio(params: GenerateFalAudioParams): Promise { const type: AudioType = params.type || 'speech' - const apiKey = getFalApiKey() + const { apiKey, envVarName } = getFalApiKey() // Voice cloning: a reference sample routes to a zero-shot clone model (F5-TTS), // which conditions on the sample (ref_audio_url) and speaks the prompt in that voice. @@ -152,7 +152,13 @@ export async function generateFalAudio(params: GenerateFalAudioParams): Promise< ref_audio_url: params.voiceSampleDataUri, model_type: 'F5-TTS', } - const { requestId, data } = await runFalQueue(model, input, apiKey, params.signal) + const { requestId, data } = await runFalQueue( + model, + input, + apiKey, + { tool: 'generate_audio', envVarName }, + params.signal + ) const cost = await recordFalCost(apiKey, model, requestId, params.onCost) const url = extractFalMediaUrl(data, ['audio', 'audio_url', 'audio_file', 'output']) if (!url) throw new Error('No audio URL in Fal.ai clone response') @@ -172,7 +178,13 @@ export async function generateFalAudio(params: GenerateFalAudioParams): Promise< const input = buildInput(type, params, model) // For fal audio models the model ID is the queue endpoint. - const { requestId, data } = await runFalQueue(model, input, apiKey, params.signal) + const { requestId, data } = await runFalQueue( + model, + input, + apiKey, + { tool: 'generate_audio', envVarName }, + params.signal + ) const cost = await recordFalCost(apiKey, model, requestId, params.onCost) const url = extractFalMediaUrl(data, ['audio', 'audio_url', 'audio_file', 'output']) if (!url) throw new Error('No audio URL in Fal.ai response') diff --git a/apps/sim/lib/media/falai-hosted-key-metrics.test.ts b/apps/sim/lib/media/falai-hosted-key-metrics.test.ts new file mode 100644 index 00000000000..11b51a2e55f --- /dev/null +++ b/apps/sim/lib/media/falai-hosted-key-metrics.test.ts @@ -0,0 +1,90 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock' +import { inputValidationMock } from '@sim/testing/mocks/input-validation.mock' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { sentMetricData } = vi.hoisted(() => ({ + sentMetricData: [] as Array<{ + MetricName?: string + Dimensions?: Array<{ Name?: string; Value?: string }> + }>, +})) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@aws-sdk/client-cloudwatch', () => ({ + StandardUnit: { Count: 'Count', None: 'None', Milliseconds: 'Milliseconds' }, + PutMetricDataCommand: class { + constructor(readonly input: { MetricData: typeof sentMetricData }) {} + }, + CloudWatchClient: class { + async send(command: { input: { MetricData: typeof sentMetricData } }) { + sentMetricData.push(...command.input.MetricData) + } + }, +})) + +import { runFalQueue } from '@/lib/media/falai' +import { flushHostedKeyMetrics } from '@/lib/monitoring/metrics' + +function stubFalSubmit(status: number, body: unknown) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(body, status)) + ) +} + +async function failedHostedKeyDimensions(): Promise>> { + await flushHostedKeyMetrics() + return sentMetricData + .filter((datum) => datum.MetricName === 'Failed') + .map((datum) => Object.fromEntries((datum.Dimensions ?? []).map((d) => [d.Name, d.Value]))) +} + +describe('a fal submit the hosted account refuses', () => { + beforeEach(() => { + vi.useFakeTimers() + setEnvFlags({ isHosted: true }) + sentMetricData.length = 0 + }) + + afterEach(() => { + vi.useRealTimers() + resetEnvFlagsMock() + }) + + it.each([ + [403, { detail: 'User is locked. Reason: Exhausted balance.' }], + [401, { detail: 'Invalid API key' }], + ])('records a hosted-key auth failure against the refused key for a %i', async (status, body) => { + stubFalSubmit(status, body) + + await expect( + runFalQueue('fal-ai/model', {}, 'key', { + tool: 'generate_audio', + envVarName: 'FALAI_API_KEY_2', + }) + ).rejects.toThrow(`Fal.ai API error: ${status}`) + + expect(await failedHostedKeyDimensions()).toEqual([ + expect.objectContaining({ + Provider: 'falai', + Tool: 'generate_audio', + Key: 'FALAI_API_KEY_2', + Reason: 'auth', + }), + ]) + }) + + it('records nothing when fal rejects the request input', async () => { + stubFalSubmit(422, { detail: [{ msg: 'field required' }] }) + + await expect( + runFalQueue('fal-ai/model', {}, 'key', { + tool: 'generate_video', + envVarName: 'FALAI_API_KEY_1', + }) + ).rejects.toThrow('Fal.ai API error: 422') + + expect(await failedHostedKeyDimensions()).toEqual([]) + }) +}) diff --git a/apps/sim/lib/media/falai-video.ts b/apps/sim/lib/media/falai-video.ts index cdea37ea4cc..ddeb7e56b6d 100644 --- a/apps/sim/lib/media/falai-video.ts +++ b/apps/sim/lib/media/falai-video.ts @@ -148,7 +148,7 @@ export async function generateFalVideo(params: GenerateFalVideoParams): Promise< ) } - const apiKey = getFalApiKey() + const { apiKey, envVarName } = getFalApiKey() let endpoint = config.endpoint const input: Record = { prompt: params.prompt } @@ -177,7 +177,13 @@ export async function generateFalVideo(params: GenerateFalVideoParams): Promise< input.prompt_optimizer = params.promptOptimizer } - const { requestId, data } = await runFalQueue(endpoint, input, apiKey, params.signal) + const { requestId, data } = await runFalQueue( + endpoint, + input, + apiKey, + { tool: 'generate_video', envVarName }, + params.signal + ) const cost = await recordFalCost(apiKey, endpoint, requestId, params.onCost) const url = extractFalMediaUrl(data, ['video', 'output']) if (!url) throw new Error('No video URL in Fal.ai response') diff --git a/apps/sim/lib/media/falai.test.ts b/apps/sim/lib/media/falai.test.ts index a9dd83f2da7..410ec48b575 100644 --- a/apps/sim/lib/media/falai.test.ts +++ b/apps/sim/lib/media/falai.test.ts @@ -13,6 +13,7 @@ import { downloadFalMedia, type FalCostRecorder, runFalQueue } from '@/lib/media import { generateFalAudio } from '@/lib/media/falai-audio' import { generateFalVideo } from '@/lib/media/falai-video' +const FAL_KEY_USE = { tool: 'generate_video', envVarName: 'FALAI_API_KEY' } as const const CANCEL_URL = 'https://queue.fal.run/fal-ai/model/requests/req-1/cancel' const STATUS_URL = 'https://queue.fal.run/fal-ai/model/requests/req-1/status' @@ -67,7 +68,13 @@ describe('runFalQueue on a request that never finishes', () => { it('stops polling and cancels the request once its caller gives up', async () => { const fal = stubStuckFalQueue() const caller = new AbortController() - const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal) + const generation = runFalQueue( + 'fal-ai/model', + { prompt: 'p' }, + 'key', + FAL_KEY_USE, + caller.signal + ) const outcome = generation.then( () => 'resolved', () => 'rejected' @@ -87,7 +94,13 @@ describe('runFalQueue on a request that never finishes', () => { it('ends a poll that never answers when its caller gives up', async () => { const fal = stubStuckFalQueue({ statusHangs: true }) const caller = new AbortController() - const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal) + const generation = runFalQueue( + 'fal-ai/model', + { prompt: 'p' }, + 'key', + FAL_KEY_USE, + caller.signal + ) const outcome = generation.then( () => 'resolved', () => 'rejected' @@ -104,7 +117,7 @@ describe('runFalQueue on a request that never finishes', () => { it('fails and cancels after its own ceiling even when nothing aborts it', async () => { const fal = stubStuckFalQueue() - const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key') + const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', FAL_KEY_USE) const outcome = generation.then( () => 'resolved', (error: unknown) => String(error) @@ -119,9 +132,13 @@ describe('runFalQueue on a request that never finishes', () => { it('warns when fal refuses to cancel a request', async () => { const fal = stubStuckFalQueue({ cancelStatus: 500 }) const caller = new AbortController() - const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal).catch( - () => undefined - ) + const generation = runFalQueue( + 'fal-ai/model', + { prompt: 'p' }, + 'key', + FAL_KEY_USE, + caller.signal + ).catch(() => undefined) await vi.advanceTimersByTimeAsync(3_500) caller.abort() await generation @@ -137,7 +154,7 @@ describe('runFalQueue on a request that never finishes', () => { const fal = stubStuckFalQueue() await expect( - runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', AbortSignal.abort()) + runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', FAL_KEY_USE, AbortSignal.abort()) ).rejects.toThrow() expect(fal.calls).toEqual([]) }) @@ -160,9 +177,13 @@ describe('runFalQueue on a request that never finishes', () => { }) ) const caller = new AbortController() - const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal).catch( - () => 'rejected' - ) + const generation = runFalQueue( + 'fal-ai/model', + { prompt: 'p' }, + 'key', + FAL_KEY_USE, + caller.signal + ).catch(() => 'rejected') await vi.advanceTimersByTimeAsync(0) caller.abort(new Error('watchdog')) @@ -175,9 +196,13 @@ describe('runFalQueue on a request that never finishes', () => { it('asks fal never to start the request past that ceiling', async () => { const fal = stubStuckFalQueue() const caller = new AbortController() - const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal).catch( - () => undefined - ) + const generation = runFalQueue( + 'fal-ai/model', + { prompt: 'p' }, + 'key', + FAL_KEY_USE, + caller.signal + ).catch(() => undefined) await vi.advanceTimersByTimeAsync(0) caller.abort() await generation diff --git a/apps/sim/lib/media/falai.ts b/apps/sim/lib/media/falai.ts index 40db1bd5f6e..e92dadac42b 100644 --- a/apps/sim/lib/media/falai.ts +++ b/apps/sim/lib/media/falai.ts @@ -13,6 +13,7 @@ import { readResponseTextWithLimit, readResponseToBufferWithLimit, } from '@/lib/core/utils/stream-limits' +import { hostedKeyMetrics } from '@/lib/monitoring/metrics' import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' const logger = createLogger('FalMediaClient') @@ -33,20 +34,33 @@ const FAL_CANCEL_TIMEOUT_MS = 5000 */ const FAL_SUBMIT_TIMEOUT_MS = 60_000 +/** Who spent a hosted Fal.ai key: the Mothership tool and the env var the key came from. */ +interface FalKeyUse { + tool: 'generate_audio' | 'generate_video' + envVarName: string +} + +/** + * Statuses fal answers when the hosted account itself refuses the call (a revoked key, or an + * account locked for an exhausted balance), so every generation fails until someone acts. + */ +const FAL_ACCOUNT_REFUSAL_STATUSES = new Set([401, 402, 403]) + /** * Resolves a hosted Fal.ai API key from the numbered env pool * (FALAI_API_KEY_COUNT + FALAI_API_KEY_1..N), round-robined by minute, * mirroring getRotatingApiKey. Falls back to a single FALAI_API_KEY for dev. */ -export function getFalApiKey(): string { +export function getFalApiKey(): { apiKey: string; envVarName: string } { const count = Number.parseInt(process.env.FALAI_API_KEY_COUNT || '0', 10) - const keys: string[] = [] + const keys: Array<{ apiKey: string; envVarName: string }> = [] for (let i = 1; i <= count; i++) { - const key = process.env[`FALAI_API_KEY_${i}`] - if (key) keys.push(key) + const envVarName = `FALAI_API_KEY_${i}` + const apiKey = process.env[envVarName] + if (apiKey) keys.push({ apiKey, envVarName }) } if (keys.length === 0 && process.env.FALAI_API_KEY) { - keys.push(process.env.FALAI_API_KEY) + keys.push({ apiKey: process.env.FALAI_API_KEY, envVarName: 'FALAI_API_KEY' }) } if (keys.length === 0) { throw new Error( @@ -101,6 +115,7 @@ export async function runFalQueue( endpoint: string, input: Record, apiKey: string, + keyUse: FalKeyUse, signal?: AbortSignal ): Promise { const ceiling = new AbortController() @@ -110,7 +125,7 @@ export async function runFalQueue( ) const queueSignal = signal ? AbortSignal.any([signal, ceiling.signal]) : ceiling.signal try { - return await submitAndPollFalQueue(endpoint, input, apiKey, queueSignal) + return await submitAndPollFalQueue(endpoint, input, apiKey, keyUse, queueSignal) } finally { clearTimeout(ceilingTimer) } @@ -120,6 +135,7 @@ async function submitAndPollFalQueue( endpoint: string, input: Record, apiKey: string, + keyUse: FalKeyUse, queueSignal: AbortSignal ): Promise { // Only a submit already under way outlives its caller; a stopped call never starts one. @@ -135,6 +151,14 @@ async function submitAndPollFalQueue( signal: AbortSignal.timeout(FAL_SUBMIT_TIMEOUT_MS), }) if (!createResponse.ok) { + if (FAL_ACCOUNT_REFUSAL_STATUSES.has(createResponse.status)) { + hostedKeyMetrics.recordFailed({ + provider: 'falai', + tool: keyUse.tool, + key: keyUse.envVarName, + reason: 'auth', + }) + } const err = await readResponseTextWithLimit(createResponse, { maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, label: 'Fal.ai create error response', diff --git a/apps/sim/lib/monitoring/metrics.ts b/apps/sim/lib/monitoring/metrics.ts index 9639078773f..9562fe609db 100644 --- a/apps/sim/lib/monitoring/metrics.ts +++ b/apps/sim/lib/monitoring/metrics.ts @@ -46,11 +46,6 @@ type QueueReason = 'actor_requests' | 'dimension' | 'queue_position' /** `metering` marks a provider call that succeeded but could not be priced. */ type FailureReason = 'rate_limited' | 'auth' | 'other' | 'metering' -// Hosted keys exist only on Sim Cloud, whose app and workers can always reach -// CloudWatch (task role or workload identity). Elsewhere recorders are no-ops, -// so they stay always-safe to call. -const ENABLED = isHosted - const ENVIRONMENT = process.env.OTEL_DEPLOYMENT_ENVIRONMENT || process.env.DEPLOYMENT_ENVIRONMENT || @@ -98,13 +93,16 @@ function buildDimensions(labels: Record) { return dimensions } +// Hosted keys exist only on Sim Cloud, whose app and workers can always reach +// CloudWatch (task role or workload identity). Elsewhere recorders are no-ops, +// so they stay always-safe to call. function enqueue( MetricName: string, Value: number, Unit: StandardUnit, labels: Record ): void { - if (!ENABLED) return + if (!isHosted) return buffer.push({ MetricName, Value, @@ -129,7 +127,7 @@ export async function flushHostedKeyMetrics(): Promise { logger.warn('Dropped hosted-key metric points (buffer cap reached)', { dropped }) dropped = 0 } - if (!ENABLED || buffer.length === 0) return + if (!isHosted || buffer.length === 0) return const pending = buffer buffer = [] for (let i = 0; i < pending.length; i += MAX_BATCH) { diff --git a/apps/sim/lib/mothership/request/session/recovery-storm.integration.ts b/apps/sim/lib/mothership/request/session/recovery-storm.integration.ts index 6d9ebcbdea1..8653273b06b 100644 --- a/apps/sim/lib/mothership/request/session/recovery-storm.integration.ts +++ b/apps/sim/lib/mothership/request/session/recovery-storm.integration.ts @@ -26,6 +26,8 @@ const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => { } const worker = { runs: new Map(), + /** The backoff each successful takeover persisted, in claim order, by run id. */ + claims: new Map(), unexpectedRequests: [] as { path: string; reason: string }[], } const server = createServer(async (request, response) => { @@ -88,6 +90,19 @@ const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => { }) vi.mock('@/lib/auth', () => authMock) +vi.mock('@/lib/mothership/request/lifecycle/controller-ownership', async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + claimRunController: async (...args: Parameters) => { + const claimed = await actual.claimRunController(...args) + const [{ runId, recoveryBackoff }] = args + if (claimed) worker.claims.set(runId, [...(worker.claims.get(runId) ?? []), recoveryBackoff]) + return claimed + }, + } +}) vi.mock('@/lib/mothership/request/session/buffer', async (importOriginal) => { const actual = await importOriginal() const { getRedisClient } = await import('@/lib/core/config/redis') @@ -330,6 +345,7 @@ async function reconnect( afterAll(async () => { await new Promise((resolve) => worker.server.close(() => resolve())) worker.runs.clear() + worker.claims.clear() for (const [key, value] of Object.entries(inheritedEnv)) { if (value === undefined) delete process.env[key] else process.env[key] = value @@ -428,9 +444,20 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () => const { turns, stops } = await reconnect(run, { tails, windowMs: 60_000 }) expect(turns).toHaveLength(MAX_RECOVERY_ATTEMPTS) - /** Each takeover waits at least the jittered floor of the one before it. */ - turns.slice(1).forEach((turn, i) => { - expect(turn.at - turns[i].at).toBeGreaterThanOrEqual(0.7 * 1_000 * 2 ** i) + /** + * Each takeover is planned no earlier than the jittered floor of the one before it, + * read from the backoff each claim persisted. Worker request arrival would add the + * variable work between a claim and its POST, which concurrent cases stretch. + */ + const claims = worker.claims.get(run.runId) ?? [] + expect(claims.map((claim) => claim.attempts)).toEqual( + Array.from({ length: MAX_RECOVERY_ATTEMPTS + 1 }, (_, i) => i + 1) + ) + claims.slice(1).forEach((claim, i) => { + expect(claim.claimedAt).toBeGreaterThanOrEqual(claims[i].notBefore) + expect(claims[i].notBefore - claims[i].claimedAt).toBeGreaterThanOrEqual( + 0.8 * 1_000 * 2 ** i + ) }) expect(stops).toHaveLength(1) expect(await storedRun(run.runId)).toMatchObject({ diff --git a/apps/sim/lib/mothership/tools/server/settings-access-requests.ts b/apps/sim/lib/mothership/tools/server/settings-access-requests.ts new file mode 100644 index 00000000000..8367ebb0d37 --- /dev/null +++ b/apps/sim/lib/mothership/tools/server/settings-access-requests.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { + ACCESS_REQUEST_STATUSES, + accessRequestParamsSchema, + listOrganizationAccessRequestsQuerySchema, +} from '@/lib/api/contracts/access-requests' +import { v2ResolveAccessRequestBodySchema } from '@/lib/api/contracts/v2/access-requests' +import type { SettingsContext } from '@/lib/mothership/application/settings-context' +import { + settingsOperation, + settingsOrganizationId, +} from '@/lib/mothership/tools/server/settings-operation' +import { listOrganizationAccessRequests } from '@/ee/access-requests/lib/application/requests' +import { + previewAccessRequest, + resolveAccessRequest, +} from '@/ee/access-requests/lib/application/review' +import type { ResolveAccessRequestDecision } from '@/ee/access-requests/lib/types' + +const requestTarget = { requestId: accessRequestParamsSchema.shape.requestId } +const [applyDecisionSchema, declineDecisionSchema] = v2ResolveAccessRequestBodySchema.options +const listQuerySchema = listOrganizationAccessRequestsQuerySchema.extend({ + status: z + .enum(ACCESS_REQUEST_STATUSES) + .default('pending') + .describe('Request status to list; defaults to pending.'), +}) + +/** The first page of pending requests, or the page `query` selects. */ +export function readSettingsAccessRequests( + context: SettingsContext, + query: z.output = listQuerySchema.parse({}) +) { + return listOrganizationAccessRequests.execute({ + principal: context.principal, + input: { ...query, organizationId: settingsOrganizationId(context) }, + }) +} + +async function resolve( + context: SettingsContext, + requestId: string, + decision: ResolveAccessRequestDecision +) { + const { request, changed } = await resolveAccessRequest.execute({ + principal: context.principal, + input: { organizationId: settingsOrganizationId(context), requestId, decision }, + }) + return { request, changed } +} + +/** Admin role, fingerprint freshness, grants, audit and the requester's notice stay in the use cases. */ +export const accessRequestSettingsActions = { + list: settingsOperation('read', listQuerySchema, readSettingsAccessRequests), + preview: settingsOperation('read', z.strictObject(requestTarget), (context, input) => + previewAccessRequest.execute({ + principal: context.principal, + input: { ...input, organizationId: settingsOrganizationId(context) }, + }) + ), + approve: settingsOperation( + 'write', + applyDecisionSchema.omit({ action: true }).extend(requestTarget).strict(), + (context, { requestId, ...decision }) => + resolve(context, requestId, { action: 'apply', ...decision }) + ), + deny: settingsOperation( + 'write', + declineDecisionSchema.omit({ action: true }).extend(requestTarget).strict(), + (context, { requestId, ...decision }) => + resolve(context, requestId, { action: 'decline', ...decision }) + ), +} diff --git a/apps/sim/lib/mothership/tools/server/settings-operations.ts b/apps/sim/lib/mothership/tools/server/settings-operations.ts index d863a971f99..ee51866369d 100644 --- a/apps/sim/lib/mothership/tools/server/settings-operations.ts +++ b/apps/sim/lib/mothership/tools/server/settings-operations.ts @@ -14,6 +14,7 @@ import { createPermissionGroupBodySchema, updatePermissionGroupBodySchema, } from '@/lib/api/contracts/permission-groups' +import { v2BulkAddPermissionGroupMembersBodySchema } from '@/lib/api/contracts/v2/permission-groups' import { updateWorkspacePermissionsBodySchema } from '@/lib/api/contracts/workspaces' import { getOrganizationUsageBreakdown } from '@/lib/billing/application/organization-usage/get-organization-usage-breakdown' import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary' @@ -47,6 +48,7 @@ import { removeSlackSearchInstallation, } from '@/lib/knowledge/application/slack-search/installations' import { slackSearchSettingsPatchSchema } from '@/lib/knowledge/application/slack-search/settings-projection' +import { accessRequestSettingsActions } from '@/lib/mothership/tools/server/settings-access-requests' import { memberUsageLimitSettingsActions, usageLimitSettingsActions, @@ -77,12 +79,15 @@ import { settingsWorkspaceId, } from '@/lib/mothership/tools/server/settings-operation' import { organizationDomainSettingsActions } from '@/lib/mothership/tools/server/settings-security' +import { simLinkSettingsActions } from '@/lib/mothership/tools/server/settings-sim-links' import { + previewOrganizationMemberRemoval, removeOrganizationMember, updateOrganizationMember, } from '@/lib/organizations/application/members' import { addPermissionGroupMember, + bulkAddPermissionGroupMembers, createPermissionGroup, deletePermissionGroup, getPermissionGroup, @@ -370,6 +375,22 @@ export const settingsOperations: Record { + const { added, skipped } = await bulkAddPermissionGroupMembers.execute({ + principal: context.principal, + input: { ...input, organizationId: settingsOrganizationId(context) }, + }) + return { added, skipped } + } + ), remove_member: settingsOperation( 'write', z.strictObject({ groupId: z.string().min(1), memberId: z.string().min(1) }), @@ -412,6 +433,15 @@ export const settingsOperations: Record + previewOrganizationMemberRemoval.execute({ + principal: context.principal, + input: { ...input, organizationId: settingsOrganizationId(context) }, + }) + ), remove: settingsOperation( 'write', z.strictObject({ userId: z.string().min(1) }), @@ -443,6 +473,8 @@ export const settingsOperations: Record = {} +) { + const page = await listSimLinks.execute({ + principal: context.principal, + input: { ...query, organizationId: settingsOrganizationId(context) }, + }) + return { links: page.links.map(presentSimLink), nextCursor: page.nextCursor } +} + +/** Per-link edit, unlist and restrict rules, audit and the rollout flag stay in the use cases. */ +export const simLinkSettingsActions = { + list: settingsOperation('read', listSimLinksQuerySchema, readSettingsSimLinks), + get: settingsOperation( + 'read', + z.union([ + z.strictObject({ linkId }), + z.strictObject({ + name: z + .string() + .trim() + .min(1) + .max(255) + .describe('The name as typed, with or without the sim/ prefix'), + }), + ]), + async (context, input) => + presentSimLink( + await getSimLinkView.execute({ + principal: context.principal, + input: { + organizationId: settingsOrganizationId(context), + ...('linkId' in input + ? { linkId: input.linkId } + : { name: input.name.replace(/^sim\//i, '') }), + }, + }) + ) + ), + create: settingsOperation('write', simLinkFieldsSchema, async (context, fields) => + presentSimLink( + await createSimLink.execute({ + principal: context.principal, + input: { ...fields, organizationId: settingsOrganizationId(context) }, + }) + ) + ), + update: settingsOperation( + 'write', + z.strictObject({ + linkId, + fields: simLinkFieldsSchema.describe( + 'Every field of the link as it should be saved; read it with get first' + ), + }), + async (context, input) => + presentSimLink( + await updateSimLink.execute({ + principal: context.principal, + input: { + ...input.fields, + linkId: input.linkId, + organizationId: settingsOrganizationId(context), + }, + }) + ) + ), + delete: settingsOperation('write', z.strictObject({ linkId }), async (context, input) => ({ + deleted: ( + await deleteSimLink.execute({ + principal: context.principal, + input: { ...input, organizationId: settingsOrganizationId(context) }, + }) + ).id, + })), +} diff --git a/apps/sim/lib/mothership/tools/server/settings.test.ts b/apps/sim/lib/mothership/tools/server/settings.test.ts index d1b7561df67..06297d12f15 100644 --- a/apps/sim/lib/mothership/tools/server/settings.test.ts +++ b/apps/sim/lib/mothership/tools/server/settings.test.ts @@ -1,4 +1,5 @@ import type { OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' import { knowledgeAvailabilityMock, knowledgeAvailabilityMockFns, @@ -11,6 +12,7 @@ vi.mock('@/lib/settings/application/organization-section-access', () => ({ authorizeOrganizationSettingsSection: sectionAccess, })) vi.mock('@/lib/knowledge/access/availability', () => knowledgeAvailabilityMock) +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) vi.mock('@/lib/mothership/application/settings-context', () => ({ resolveSettingsContext: resolve, })) @@ -98,6 +100,27 @@ describe('settings tool dispatch', () => { }) }) + it('offers Sim links only to an organization that has them enabled', async () => { + const simLinks = expect.objectContaining({ id: 'sim-links' }) + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) + expect( + await settingsServerTool.execute({ scope: 'organization', action: 'list' }) + ).not.toMatchObject({ sections: expect.arrayContaining([simLinks]) }) + for (const action of ['open', 'get'] as const) { + await expect( + settingsServerTool.execute({ scope: 'organization', action, section: 'sim-links' }) + ).rejects.toThrow('unavailable') + } + + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( + async (flag, context) => + flag === 'sim-links' && (context as { orgId?: string })?.orgId === 'org' + ) + expect( + await settingsServerTool.execute({ scope: 'organization', action: 'list' }) + ).toMatchObject({ sections: expect.arrayContaining([simLinks]) }) + }) + it('discovers names without eagerly reading every setting or dumping every schema', async () => { const read = vi.spyOn(readOrganizationSettings, 'execute') const result = await settingsServerTool.execute({ scope: 'organization', action: 'list' }) diff --git a/apps/sim/lib/mothership/tools/server/settings.ts b/apps/sim/lib/mothership/tools/server/settings.ts index ccd509b7189..e5b4e55c78a 100644 --- a/apps/sim/lib/mothership/tools/server/settings.ts +++ b/apps/sim/lib/mothership/tools/server/settings.ts @@ -34,6 +34,7 @@ import { import type { ResourceChange } from '@/lib/mothership/generated/resources' import { inboxSettingsPatchSchema } from '@/lib/mothership/inbox/settings-input' import type { BaseServerTool } from '@/lib/mothership/tools/server/base-tool' +import { readSettingsAccessRequests } from '@/lib/mothership/tools/server/settings-access-requests' import { readSettingsUsageLimit } from '@/lib/mothership/tools/server/settings-billing' import { readArchivedSettingsChats } from '@/lib/mothership/tools/server/settings-chats' import { @@ -46,6 +47,7 @@ import { } from '@/lib/mothership/tools/server/settings-operation' import { settingsOperations } from '@/lib/mothership/tools/server/settings-operations' import { readSettingsDomains } from '@/lib/mothership/tools/server/settings-security' +import { readSettingsSimLinks } from '@/lib/mothership/tools/server/settings-sim-links' import { organizationRoutes } from '@/lib/navigation/paths' import { getOrganizationDataRetention, @@ -62,6 +64,7 @@ import { } from '@/lib/organizations/application/settings' import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access' import { type SettingsSection, settingsSections } from '@/lib/settings/sections' +import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' import { delegatedAccountPreferencesSchema, updateCurrentUserPreferences, @@ -74,11 +77,18 @@ import { import { listCustomBlockSettings } from '@/lib/workflows/custom-blocks/application/settings' import { readInboxSettings, updateInboxSettings } from '@/lib/workspaces/application/inbox-settings' import { readWorkspacePermissions } from '@/lib/workspaces/application/manage-permissions' +import { + readWorkspaceIdentity, + updateWorkspaceIdentity, + workspaceIdentityPatchSchema, +} from '@/lib/workspaces/application/update-workspace' interface SettingAdapter { get(context: SettingsContext): Promise schema?: z.ZodType update?(context: SettingsContext, changes: Record): Promise + /** Whether the section exists for this target at all; a section that does not is never listed. */ + available?(context: SettingsContext): Promise } function organizationInput(context: SettingsContext) { @@ -88,6 +98,24 @@ function organizationInput(context: SettingsContext) { /** Closed adapters call the same application operations as Settings; no route proxy or metadata patch. */ const adapters: Record = { + 'workspace/general': { + schema: workspaceIdentityPatchSchema, + get: (context) => + readWorkspaceIdentity.execute({ + principal: context.principal, + input: { workspaceId: settingsWorkspaceId(context) }, + }), + update: async (context, changes) => + ( + await updateWorkspaceIdentity.execute({ + principal: context.principal, + input: { + workspaceId: settingsWorkspaceId(context), + patch: workspaceIdentityPatchSchema.parse(changes), + }, + }) + ).identity, + }, 'workspace/custom-blocks': { get: (context) => listCustomBlockSettings.execute({ @@ -228,6 +256,11 @@ const adapters: Record = { }), }, 'organization/members': { get: readSettingsRoster }, + 'organization/requests': { get: (context) => readSettingsAccessRequests(context) }, + 'organization/sim-links': { + available: (context) => isSimLinksEnabled(organizationInput(context).organizationId), + get: (context) => readSettingsSimLinks(context), + }, 'organization/whitelabeling': { schema: updateOrganizationWhitelabelBodySchema.strict(), get: async (context) => @@ -287,7 +320,14 @@ const adapters: Record = { } async function describeSection(context: SettingsContext, section: SettingsSection) { + if ( + context.scope === 'organization' && + section.id === 'sim-links' && + !(await isSimLinksEnabled(organizationInput(context).organizationId)) + ) + return null const adapter = adapters[`${context.scope}/${section.id}`] + if (adapter?.available && !(await adapter.available(context))) return null const uiSection = context.scope === 'organization' && section.id === 'connected-accounts' && diff --git a/apps/sim/lib/organizations/application/member-removal.test.ts b/apps/sim/lib/organizations/application/member-removal.test.ts index f89aa23366b..eecdfc7cbf7 100644 --- a/apps/sim/lib/organizations/application/member-removal.test.ts +++ b/apps/sim/lib/organizations/application/member-removal.test.ts @@ -31,7 +31,10 @@ vi.mock('@/lib/posthog/server', () => posthogServerMock) vi.mock('@sim/audit', () => auditMock) vi.mock('@/ee/scim/lib/managed-membership', () => ({ assertMembershipNotScimManaged: vi.fn() })) -import { removeOrganizationMember } from '@/lib/organizations/application/members' +import { + previewOrganizationMemberRemoval, + removeOrganizationMember, +} from '@/lib/organizations/application/members' import { DELETE } from '@/app/api/organizations/[id]/members/[memberId]/route' const mocks = { @@ -40,6 +43,7 @@ const mocks = { remove: organizationMembershipMockFns.mockRemoveUserFromOrganization, external: organizationMembershipMockFns.mockRemoveExternalUserFromOrganizationWorkspaces, audit: auditMockFns.mockRecordAudit, + impact: organizationMembershipMockFns.mockGetOrganizationTransferCredentialDependencies, } const principal = { @@ -147,3 +151,24 @@ describe('organization member removal', () => { expect(mocks.audit).not.toHaveBeenCalled() }) }) +describe('organization member removal preview', () => { + const credential = { id: 'cred', displayName: 'GitHub', type: 'oauth', workspaceId: 'ws' } + beforeEach(() => mocks.impact.mockReset().mockResolvedValue([credential])) + it("refuses a delegated member previewing somebody else's removal before reading credentials", async () => { + queueTableRows(member, [{ role: 'member' }]) + await expect( + previewOrganizationMemberRemoval.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.impact).not.toHaveBeenCalled() + }) + it('lets a delegated member preview leaving', async () => { + queueTableRows(member, [{ role: 'member' }]) + await expect( + previewOrganizationMemberRemoval.execute({ + principal, + input: { organizationId: 'org', userId: 'actor' }, + }) + ).resolves.toEqual({ credentials: [credential] }) + expect(mocks.impact).toHaveBeenCalledExactlyOnceWith('actor', 'org') + }) +}) diff --git a/apps/sim/lib/organizations/application/members.ts b/apps/sim/lib/organizations/application/members.ts index 2817d69a72c..7d2e41a2d4d 100644 --- a/apps/sim/lib/organizations/application/members.ts +++ b/apps/sim/lib/organizations/application/members.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership' import { defineAuthorizedOrganizationUseCase, type OrganizationUseCaseContext, @@ -79,3 +80,27 @@ export const removeOrganizationMember = defineAuthorizedOrganizationUseCase({ }, }), }) + +/** + * Identity-bound credentials the target user owns in this organization's workspaces: the set that + * stops working when their workspace access is revoked. Administrators preview removing anyone; + * members preview leaving. + */ +export const previewOrganizationMemberRemoval = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.previewMemberRemoval, + authorizeResource: ({ input, context }) => { + if (!isOrgAdminRole(context.role) && input.userId !== context.userId) + throw new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Forbidden - Admin access required' + ) + }, + execute: async ({ + input, + }: OrganizationUseCaseContext<{ organizationId: string; userId: string }>) => ({ + credentials: await getOrganizationTransferCredentialDependencies( + input.userId, + input.organizationId + ), + }), +}) diff --git a/apps/sim/lib/organizations/application/operations.ts b/apps/sim/lib/organizations/application/operations.ts index e79a5fc75d3..b03be01b5fb 100644 --- a/apps/sim/lib/organizations/application/operations.ts +++ b/apps/sim/lib/organizations/application/operations.ts @@ -67,6 +67,18 @@ export const organizationOperations = { delegatedServices: ['copilot'], oauthScope: 'api:write', }), + /** + * permission-group-exempt: the application allows administrators to preview any member's removal and members to preview leaving. + */ + previewMemberRemoval: defineOrganizationOperation({ + id: 'organizations.members.removal_impact.read', + contentDelivery: 'none', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session', 'organization_delegated'], + delegationAudience: 'sim:settings', + delegatedServices: ['copilot'], + }), /** * permission-group-exempt: administrators can inspect existing invitations even when new invitations are disabled. */ diff --git a/apps/sim/lib/settings/sections.ts b/apps/sim/lib/settings/sections.ts index 3cade5c6717..a60585ff2bc 100644 --- a/apps/sim/lib/settings/sections.ts +++ b/apps/sim/lib/settings/sections.ts @@ -53,6 +53,13 @@ export const settingsSections: Record featureFlagsMock) + +readTestDatabaseUrl() + +const ownerId = generateId() +const creatorId = generateId() +const organizationId = generateId() +const creatorMemberId = generateId() + +afterAll(async () => { + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [ownerId, creatorId])) +}) + +it('refuses a link whose creator is removed after authorization but before the insert', async () => { + await db.insert(user).values( + [ownerId, creatorId].map((id) => ({ + id, + name: 'Sim link fixture', + email: `${id}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Sim link organization', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values([ + { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() }, + { + id: creatorMemberId, + organizationId, + userId: creatorId, + role: 'member', + createdAt: new Date(), + }, + ]) + + const authorized = createDeferred() + const proceed = createDeferred() + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => { + if (flag !== 'sim-links') return false + authorized.resolve() + await proceed.promise + return true + }) + + const creation = createSimLink.execute({ + principal: createSessionPrincipal({ userId: creatorId }), + input: { + organizationId, + name: 'handbook', + url: 'https://example.test/handbook', + defaultUrl: null, + description: null, + unlisted: true, + restrictEdit: true, + }, + }) + const settled = creation.then( + () => null, + (error: unknown) => error + ) + await authorized.promise + expect( + ( + await removeUserFromOrganization({ + organizationId, + userId: creatorId, + memberId: creatorMemberId, + skipBillingLogic: true, + onError: 'throw', + }) + ).success + ).toBe(true) + proceed.resolve() + + expect(await settled).toMatchObject({ message: 'Organization not found' }) + expect( + await db + .select({ id: simLink.id }) + .from(simLink) + .where(eq(simLink.organizationId, organizationId)) + ).toEqual([]) +}) diff --git a/apps/sim/lib/sim-links/application/operations.ts b/apps/sim/lib/sim-links/application/operations.ts index 2ae93c4d499..81057857d2a 100644 --- a/apps/sim/lib/sim-links/application/operations.ts +++ b/apps/sim/lib/sim-links/application/operations.ts @@ -3,30 +3,45 @@ import { defineOrganizationOperation } from '@/lib/core/application/organization const policy = { minimumRole: 'member', principalKinds: ['session'] } as const +/** Chat curates links under the member's delegation through the generic settings tool. */ +const settingsPolicy = { + minimumRole: 'member', + principalKinds: ['session', 'organization_delegated'], + delegationAudience: 'sim:settings', + delegatedServices: ['copilot'], + contentDelivery: 'none', +} as const + /** Every member may follow and curate Sim links; per-link edit access is enforced by the use cases. */ export const simLinkOperations = { // permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability. list: defineOrganizationOperation({ id: 'organization.sim_links.list', - ...policy, + ...settingsPolicy, + capability: 'none', + }), + // permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability. + get: defineOrganizationOperation({ + id: 'organization.sim_links.get', + ...settingsPolicy, capability: 'none', }), // permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability. create: defineOrganizationOperation({ id: 'organization.sim_links.create', - ...policy, + ...settingsPolicy, capability: 'none', }), // permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability. update: defineOrganizationOperation({ id: 'organization.sim_links.update', - ...policy, + ...settingsPolicy, capability: 'none', }), // permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability. delete: defineOrganizationOperation({ id: 'organization.sim_links.delete', - ...policy, + ...settingsPolicy, capability: 'none', }), // permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability. diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index 8b68bb2dbd0..93d2c435327 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -9,7 +9,10 @@ import { type OrganizationUseCaseContext, } from '@/lib/core/application/authorized-organization-use-case' import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application/authorized-workspace-use-case' -import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' +import { + type OrganizationMembershipContext, + requireOrganizationSubjectMembership, +} from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import type { DbTransaction } from '@/lib/db/types' @@ -163,6 +166,29 @@ export const listSimLinks = defineAuthorizedOrganizationUseCase({ }, }) +/** + * One link by id or by name as typed. Hidden exactly as browsing hides it: an unlisted link reads + * as missing to anyone who cannot manage it. + */ +export const getSimLinkView = defineAuthorizedOrganizationUseCase({ + operation: simLinkOperations.get, + ...rollout, + async execute({ + input, + context, + }: OrganizationUseCaseContext) { + const viewer = viewerOf(context) + const linkId = + 'linkId' in input + ? input.linkId + : (await repository.findSimLinkDestination(input.organizationId, input.name))?.id + const record = linkId ? await repository.getSimLink(db, input.organizationId, linkId) : null + if (!record || (record.unlisted && !canManage(record.createdBy?.id ?? null, viewer))) + throw new OrchestrationError('not_found', 'Sim link not found') + return toView(record, viewer) + }, +}) + export const createSimLink = defineAuthorizedOrganizationUseCase({ operation: simLinkOperations.create, ...rollout, @@ -170,6 +196,18 @@ export const createSimLink = defineAuthorizedOrganizationUseCase({ const { organizationId, ...fields } = input return withUniqueName(organizationId, fields.name, () => db.transaction(async (tx) => { + /** A removal deletes this row before handing off the member's links, so it waits for this insert. */ + await requireOrganizationSubjectMembership( + context.userId, + organizationId, + 'member', + 'none', + undefined, + { + executor: tx, + forShare: true, + } + ) const linkId = await repository.insertSimLink(tx, organizationId, context.userId, fields) return readView(tx, organizationId, linkId, viewerOf(context)) }) diff --git a/apps/sim/lib/sim-links/repository.ts b/apps/sim/lib/sim-links/repository.ts index 17023e545d8..db8f945ed21 100644 --- a/apps/sim/lib/sim-links/repository.ts +++ b/apps/sim/lib/sim-links/repository.ts @@ -124,7 +124,7 @@ export async function listSimLinks( } export async function getSimLink( - tx: DbTransaction, + tx: DbOrTx, organizationId: string, id: string ): Promise { diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index 79234ad18ad..5b0ec536d83 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -19,7 +19,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' -import { formatQuotedNameList } from '@sim/utils/string' +import { compareStrings, formatQuotedNameList } from '@sim/utils/string' import { and, eq, gt, inArray, isNotNull, isNull, lte, ne, notExists, or, sql } from 'drizzle-orm' import type { AccountDeletionBlocker, @@ -34,6 +34,8 @@ import { } from '@/lib/billing/organizations/membership' import { findOrganizationSharedResourceSuccessor, + handoffLeftoverSimLinksTx, + listLeftoverSimLinkOrganizationIdsForUser, listSharedResourceOrganizationIdsForUser, reassignOrganizationSharedResourcesTx, } from '@/lib/billing/organizations/resource-handoff' @@ -812,7 +814,16 @@ export async function deleteUserAccount( await db.transaction(async (tx) => { /** Organization and Project gates precede workspace rows, which precede user FK barriers. */ const sharedOrganizationIds = await listSharedResourceOrganizationIdsForUser(tx, userId) - for (const organizationId of sharedOrganizationIds) { + const leftoverSimLinkOrganizationIds = await listLeftoverSimLinkOrganizationIdsForUser( + tx, + userId, + sharedOrganizationIds + ) + const lockedOrganizationIds = [ + ...sharedOrganizationIds, + ...leftoverSimLinkOrganizationIds, + ].sort(compareStrings) + for (const organizationId of lockedOrganizationIds) { await acquireOrganizationMutationLock(tx, organizationId) } const workspaceIds = new Set([ @@ -852,9 +863,15 @@ export async function deleteUserAccount( ...(await listSharedResourceWorkspaceIdsForUser(tx, userId)), ] const currentOrganizationIds = await listSharedResourceOrganizationIdsForUser(tx, userId) + const currentSimLinkOrganizationIds = await listLeftoverSimLinkOrganizationIdsForUser( + tx, + userId, + currentOrganizationIds + ) if ( currentWorkspaceIds.some((id) => !workspaceIds.has(id)) || - currentOrganizationIds.some((id) => !sharedOrganizationIds.includes(id)) + currentOrganizationIds.some((id) => !sharedOrganizationIds.includes(id)) || + currentSimLinkOrganizationIds.some((id) => !lockedOrganizationIds.includes(id)) ) { throw new AccountDeletionBlockedError([ { @@ -965,6 +982,7 @@ export async function deleteUserAccount( for (const organizationId of sharedOrganizationIds) { await reassignOrganizationSharedResourcesTx(tx, organizationId, userId) } + await handoffLeftoverSimLinksTx(tx, userId, leftoverSimLinkOrganizationIds) const sharedWorkspaceIds = await listSharedResourceWorkspaceIdsForUser(tx, userId) const { unresolved: resourceUnresolved } = diff --git a/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts b/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts index 1d5dfbfeefc..83d23a3dffc 100644 --- a/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts +++ b/apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts @@ -13,6 +13,7 @@ import { permissionGroup, permissions, project, + simLink, ssoProvider, user, userStats, @@ -1264,6 +1265,109 @@ describe('shared resource retention across workspace departure and account erasu await assertRetained(fixture) }) + it("hands a removed member's Sim links to the owner and leaves another organization's links", async () => { + const fixture = await seedResources(false, false) + const [organizationId, otherOrganizationId] = [generateId(), generateId()] + const memberId = generateId() + const [linkId, otherLinkId] = [generateId(), generateId()] + organizationIds.push(organizationId, otherOrganizationId) + await db.insert(organization).values( + [organizationId, otherOrganizationId].map((id) => ({ + id, + name: 'Sim link organization', + slug: id, + createdAt: new Date(), + })) + ) + await db.insert(member).values([ + { + id: generateId(), + organizationId, + userId: fixture.ownerId, + role: 'owner', + createdAt: new Date(), + }, + { + id: memberId, + organizationId, + userId: fixture.departingId, + role: 'member', + createdAt: new Date(), + }, + ]) + await db.insert(simLink).values( + [ + { id: linkId, organizationId }, + { id: otherLinkId, organizationId: otherOrganizationId }, + ].map(({ id, organizationId: linkOrganizationId }) => ({ + id, + organizationId: linkOrganizationId, + name: 'docs', + normalizedName: 'docs', + url: 'https://example.test/docs', + unlisted: true, + restrictEdit: true, + createdBy: fixture.departingId, + })) + ) + expect( + ( + await removeUserFromOrganization({ + organizationId, + userId: fixture.departingId, + memberId, + skipBillingLogic: true, + onError: 'throw', + }) + ).success + ).toBe(true) + expect( + await db + .select({ id: simLink.id, createdBy: simLink.createdBy }) + .from(simLink) + .where(inArray(simLink.id, [linkId, otherLinkId])) + ).toEqual( + expect.arrayContaining([ + { id: linkId, createdBy: fixture.ownerId }, + { id: otherLinkId, createdBy: fixture.departingId }, + ]) + ) + }) + + it("repairs a former member's leftover Sim link to the owner on account deletion", async () => { + const fixture = await seedResources(false, false) + const organizationId = generateId() + const linkId = generateId() + organizationIds.push(organizationId) + await db.insert(organization).values({ + id: organizationId, + name: 'Former organization', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values({ + id: generateId(), + organizationId, + userId: fixture.ownerId, + role: 'owner', + createdAt: new Date(), + }) + await db.insert(simLink).values({ + id: linkId, + organizationId, + name: 'handbook', + normalizedName: 'handbook', + url: 'https://example.test/handbook', + restrictEdit: true, + createdBy: fixture.departingId, + }) + await deleteUserAccount(fixture.departingId, { recordIdentity: false }) + expect( + await db.select({ createdBy: simLink.createdBy }).from(simLink).where(eq(simLink.id, linkId)) + ).toEqual([{ createdBy: fixture.ownerId }]) + await assertRetained(fixture) + }) + it('refuses organization-only retention without an owner successor', async () => { const fixture = await seedResources(false, false) const organizationId = generateId() diff --git a/apps/sim/lib/workspaces/application/update-workspace.integration.ts b/apps/sim/lib/workspaces/application/update-workspace.integration.ts new file mode 100644 index 00000000000..5f03bea5c63 --- /dev/null +++ b/apps/sim/lib/workspaces/application/update-workspace.integration.ts @@ -0,0 +1,322 @@ +/** + * Workspace updates against real Postgres: the session route keeps its legacy + * refusals, and Chat's settings tool renames a workspace only as an admin and + * never reaches billing or the personal-key policy. + */ +import { db } from '@sim/db' +import { auditLog, permissions, user, workspace } from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock' +import { generateId } from '@sim/utils/id' +import { toRecord } from '@sim/utils/object' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/auth', () => authMock) + +import { createTrustedCopilotPrincipal } from '@/lib/mothership/auth/application-delegation' +import { settingsServerTool } from '@/lib/mothership/tools/server/settings' +import { updateWorkspace } from '@/lib/workspaces/application/update-workspace' +import { PATCH } from '@/app/api/workspaces/[id]/route' + +const adminId = generateId() +const secondAdminId = generateId() +const writerId = generateId() +const outsiderId = generateId() +const workspaceId = generateId() +const archivedWorkspaceId = generateId() +const routeRaceWorkspaceId = generateId() +const chatRaceWorkspaceId = generateId() +const fixtureWorkspaceIds = [ + workspaceId, + archivedWorkspaceId, + routeRaceWorkspaceId, + chatRaceWorkspaceId, +] +const ORIGINAL_NAME = 'Workspace update fixture' +const ARCHIVED_NAME = 'Archived fixture' + +function signIn(userId: string) { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: userId, name: 'Fixture', email: `${userId}@workspace-update.test` }, + session: { id: generateId() }, + }) +} + +function patchWorkspace(id: string, body: Record) { + return PATCH( + new NextRequest(`http://localhost:3000/api/workspaces/${id}`, { + method: 'PATCH', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }), + { params: Promise.resolve({ id }) } + ) +} + +function chatSettings(userId: string, input: Record, targetId = workspaceId) { + return settingsServerTool.execute( + { scope: 'workspace', ...input } as Parameters[0], + { copilotToolExecution: true, userId, workspaceId: targetId, toolCallId: generateId() } + ) +} + +async function readWorkspace(id: string) { + const [row] = await db.select().from(workspace).where(eq(workspace.id, id)) + return row +} + +/** + * Holds the workspace row lock until `update` is queued behind it, then archives + * the row and commits, so the update's write runs after its own active-state reads + * passed but against an archived row. + */ +async function archiveWhileUpdateWaits(id: string, update: () => Promise) { + const held = createDeferred() + const release = createDeferred() + const archival = db.transaction(async (tx) => { + await tx.select({ id: workspace.id }).from(workspace).where(eq(workspace.id, id)).for('update') + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + held.resolve(connection.pid) + await release.promise + await tx.update(workspace).set({ archivedAt: new Date() }).where(eq(workspace.id, id)) + }) + void archival.catch((error: unknown) => held.reject(error)) + try { + const blockerPid = await held.promise + const pending = update() + void pending.catch(() => undefined) + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(waiting).toBeDefined() + }, + { timeout: 10_000, interval: 25 } + ) + release.resolve() + await archival + const [settled] = await Promise.allSettled([pending]) + return settled + } finally { + release.resolve() + await Promise.allSettled([archival]) + } +} + +describe('workspace update operations', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values( + [adminId, secondAdminId, writerId, outsiderId].map((id) => ({ + id, + name: 'Workspace update fixture', + email: `${id}@workspace-update.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await insertWorkspaceFixture(db, [ + { + id: workspaceId, + name: ORIGINAL_NAME, + ownerId: adminId, + billedAccountUserId: adminId, + allowPersonalApiKeys: true, + }, + { + id: archivedWorkspaceId, + name: ARCHIVED_NAME, + ownerId: adminId, + billedAccountUserId: adminId, + archivedAt: now, + }, + ...[routeRaceWorkspaceId, chatRaceWorkspaceId].map((id) => ({ + id, + name: ORIGINAL_NAME, + ownerId: adminId, + billedAccountUserId: adminId, + })), + ]) + await db.insert(permissions).values([ + ...fixtureWorkspaceIds.map((entityId) => ({ + id: generateId(), + entityType: 'workspace', + entityId, + userId: adminId, + permissionType: 'admin' as const, + })), + { + id: generateId(), + entityType: 'workspace', + entityId: workspaceId, + userId: secondAdminId, + permissionType: 'admin' as const, + }, + { + id: generateId(), + entityType: 'workspace', + entityId: workspaceId, + userId: writerId, + permissionType: 'write' as const, + }, + ]) + }, 30_000) + + afterAll(async () => { + await db.delete(auditLog).where(inArray(auditLog.workspaceId, fixtureWorkspaceIds)) + await db.delete(permissions).where(inArray(permissions.entityId, fixtureWorkspaceIds)) + await deleteWorkspaceFixture(db, inArray(workspace.id, fixtureWorkspaceIds)) + await db.delete(user).where(inArray(user.id, [adminId, secondAdminId, writerId, outsiderId])) + }) + + describe('session route', () => { + it.each([ + ['a write member', writerId, workspaceId, ORIGINAL_NAME], + ['a non-member', outsiderId, workspaceId, ORIGINAL_NAME], + ['an admin of an archived workspace', adminId, archivedWorkspaceId, ARCHIVED_NAME], + ])('refuses %s with the legacy 403 body', async (_label, userId, id, unchangedName) => { + signIn(userId) + const response = await patchWorkspace(id, { name: 'Renamed by someone else' }) + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: 'Insufficient permissions' }) + expect((await readWorkspace(id)).name).toBe(unchangedName) + }) + + it('answers 404 and leaves the row alone when the workspace is archived mid-update', async () => { + signIn(adminId) + const settled = await archiveWhileUpdateWaits(routeRaceWorkspaceId, () => + patchWorkspace(routeRaceWorkspaceId, { name: 'Renamed after archival' }) + ) + expect(settled.status).toBe('fulfilled') + const response = settled.status === 'fulfilled' ? settled.value : null + expect(response?.status).toBe(404) + expect(await response?.json()).toMatchObject({ error: 'Workspace not found' }) + const row = await readWorkspace(routeRaceWorkspaceId) + expect(row.archivedAt).not.toBeNull() + expect(row.name).toBe(ORIGINAL_NAME) + }) + + it('rejects an empty patch from an admin', async () => { + signIn(adminId) + const response = await patchWorkspace(workspaceId, {}) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'No updates provided' }) + }) + + it('refuses a billed-account change to a non-admin with the legacy message', async () => { + signIn(adminId) + const response = await patchWorkspace(workspaceId, { billedAccountUserId: writerId }) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: 'Billed account must be a workspace admin', + }) + expect((await readWorkspace(workspaceId)).billedAccountUserId).toBe(adminId) + }) + + it('updates as an admin and answers with the workspace and the admin role', async () => { + signIn(adminId) + const response = await patchWorkspace(workspaceId, { allowPersonalApiKeys: false }) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.workspace).toMatchObject({ + id: workspaceId, + allowPersonalApiKeys: false, + permissions: 'admin', + }) + expect((await readWorkspace(workspaceId)).allowPersonalApiKeys).toBe(false) + await db + .update(workspace) + .set({ allowPersonalApiKeys: true }) + .where(eq(workspace.id, workspaceId)) + }) + }) + + describe('Chat settings tool', () => { + it('refuses a write member renaming the workspace', async () => { + await expect( + chatSettings(writerId, { + action: 'update', + section: 'general', + changes: { name: 'Renamed by a writer' }, + }) + ).rejects.toThrow('Insufficient workspace permissions') + expect((await readWorkspace(workspaceId)).name).toBe(ORIGINAL_NAME) + }) + + it.each([ + ['billedAccountUserId', { billedAccountUserId: secondAdminId }], + ['allowPersonalApiKeys', { allowPersonalApiKeys: false }], + ])('cannot change %s', async (_field, changes) => { + await expect( + chatSettings(adminId, { action: 'update', section: 'general', changes }) + ).rejects.toThrow(/Unrecognized key/) + const row = await readWorkspace(workspaceId) + expect(row.billedAccountUserId).toBe(adminId) + expect(row.allowPersonalApiKeys).toBe(true) + }) + + it('refuses a rename when the workspace is archived mid-update', async () => { + const settled = await archiveWhileUpdateWaits(chatRaceWorkspaceId, () => + chatSettings( + adminId, + { action: 'update', section: 'general', changes: { name: 'Renamed after archival' } }, + chatRaceWorkspaceId + ) + ) + expect(settled.status).toBe('rejected') + expect(settled.status === 'rejected' ? settled.reason : null).toMatchObject({ + message: 'Workspace not found', + }) + const row = await readWorkspace(chatRaceWorkspaceId) + expect(row.archivedAt).not.toBeNull() + expect(row.name).toBe(ORIGINAL_NAME) + }) + + it('cannot reach the full workspace update as a delegated principal', async () => { + const principal = createTrustedCopilotPrincipal( + { userId: adminId, workspaceId, delegationId: generateId() }, + { audience: 'sim:settings', ttlMs: 60_000 } + ) + await expect( + updateWorkspace.execute({ + principal, + input: { workspaceId, patch: { billedAccountUserId: adminId } }, + }) + ).rejects.toThrow('cannot perform operation workspaces.update') + }) + + it('renames the workspace as an admin and audits the change as Chat acting for them', async () => { + const result = await chatSettings(adminId, { + action: 'update', + section: 'general', + changes: { name: ' Renamed by Chat ' }, + }) + expect(result).toMatchObject({ + status: 'updated', + value: { id: workspaceId, name: 'Renamed by Chat' }, + }) + expect((await readWorkspace(workspaceId)).name).toBe('Renamed by Chat') + await vi.waitFor(async () => { + const entries = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.workspaceId, workspaceId), eq(auditLog.actorId, adminId))) + const entry = entries.find( + (row) => toRecord(row.metadata).operation === 'workspaces.identity.update' + ) + expect(entry).toMatchObject({ action: 'workspace.updated', resourceId: workspaceId }) + expect(entry?.metadata).toMatchObject({ + operation: 'workspaces.identity.update', + changes: { name: { from: ORIGINAL_NAME, to: 'Renamed by Chat' } }, + }) + }) + }) + }) +}) diff --git a/apps/sim/lib/workspaces/application/update-workspace.ts b/apps/sim/lib/workspaces/application/update-workspace.ts new file mode 100644 index 00000000000..501efe92b3e --- /dev/null +++ b/apps/sim/lib/workspaces/application/update-workspace.ts @@ -0,0 +1,139 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { z } from 'zod' +import { updateWorkspaceBodySchema } from '@/lib/api/contracts/workspaces' +import { + defineAuthorizedWorkspaceUseCase, + NoWorkspaceAccessError, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + loadActiveWorkspaceApplicationContext, + resolveActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/context' +import { + applyWorkspaceUpdate, + readWorkspaceIdentityRecord, + type WorkspaceIdentity, + type WorkspaceUpdatePatch, + type WorkspaceUpdateResult, +} from '@/lib/workspaces/update' + +export const workspaceUpdateOperations = { + // permission-group-exempt: workspace administration has no module capability; the admin role is the gate, as it was before this operation existed + update: defineWorkspaceOperation({ + id: 'workspaces.update', + minimumRole: 'admin', + capability: 'none', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + // permission-group-exempt: reading the workspace's own name and logo, which every member already sees in the workspace switcher + readIdentity: defineWorkspaceOperation({ + id: 'workspaces.identity.read', + minimumRole: 'read', + capability: 'none', + contentDelivery: 'none', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + /** + * Name and logo only. Billing payer and the personal-key policy stay on + * {@link workspaceUpdateOperations.update}, which Copilot cannot reach. + */ + // permission-group-exempt: same admin-role gate as the full workspace update, narrowed to presentation fields + updateIdentity: defineWorkspaceOperation({ + id: 'workspaces.identity.update', + minimumRole: 'admin', + capability: 'none', + contentDelivery: 'none', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), +} as const + +export const workspaceIdentityPatchSchema = updateWorkspaceBodySchema + .pick({ name: true, logoUrl: true }) + .strict() +type WorkspaceIdentityPatch = z.output + +function projectUpdateAudit( + patch: WorkspaceUpdatePatch, + { previous, updated }: WorkspaceUpdateResult +): WorkspaceUseCaseAuditEntry { + const { name, logoUrl, allowPersonalApiKeys, billedAccountUserId } = patch + return { + action: AuditAction.WORKSPACE_UPDATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: updated.id, + resourceName: updated.name, + description: `Updated workspace "${updated.name}"`, + metadata: { + changes: { + ...(name !== undefined && { name: { from: previous.name, to: name } }), + ...(logoUrl !== undefined && { logoUrl: { from: previous.logoUrl, to: logoUrl } }), + ...(allowPersonalApiKeys !== undefined && { + allowPersonalApiKeys: { from: previous.allowPersonalApiKeys, to: allowPersonalApiKeys }, + }), + ...(billedAccountUserId !== undefined && { + billedAccountUserId: { from: previous.billedAccountUserId, to: billedAccountUserId }, + }), + }, + }, + } +} + +/** + * The full workspace settings update: name, logo, personal-key policy and + * billed account. An inactive or unreachable workspace is refused the same way + * as a non-admin, so the caller cannot tell the two apart. + */ +export const updateWorkspace = defineAuthorizedWorkspaceUseCase({ + operation: workspaceUpdateOperations.update, + resolveContext: async ({ + input, + }: { + input: { workspaceId: string; patch: WorkspaceUpdatePatch } + }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new NoWorkspaceAccessError() + return context + }, + authorizationOptions: {}, + execute: ({ context, input }) => applyWorkspaceUpdate(context.workspaceId, input.patch), + projectAudit: ({ input, result }) => projectUpdateAudit(input.patch, result), +}) + +const identityDelegation = { + delegation: { audience: 'sim:settings', isWithinScope: () => true }, +} + +function presentIdentity(row: WorkspaceIdentity): WorkspaceIdentity { + return { id: row.id, name: row.name, logoUrl: row.logoUrl } +} + +export const readWorkspaceIdentity = defineAuthorizedWorkspaceUseCase({ + operation: workspaceUpdateOperations.readIdentity, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: identityDelegation, + execute: ({ context }) => readWorkspaceIdentityRecord(context.workspaceId), +}) + +export const updateWorkspaceIdentity = defineAuthorizedWorkspaceUseCase({ + operation: workspaceUpdateOperations.updateIdentity, + resolveContext: ({ input }: { input: { workspaceId: string; patch: WorkspaceIdentityPatch } }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: identityDelegation, + execute: async ({ context, input }) => { + const parsed = workspaceIdentityPatchSchema.safeParse(input.patch) + if (!parsed.success) throw new OrchestrationError('validation', parsed.error.issues[0].message) + const patch = { name: parsed.data.name, logoUrl: parsed.data.logoUrl } + const result = await applyWorkspaceUpdate(context.workspaceId, patch) + return { ...result, patch, identity: presentIdentity(result.updated) } + }, + projectAudit: ({ result }) => projectUpdateAudit(result.patch, result), +}) diff --git a/apps/sim/lib/workspaces/organization/index.ts b/apps/sim/lib/workspaces/organization/index.ts index 20f39ac9f43..4ae911f56b7 100644 --- a/apps/sim/lib/workspaces/organization/index.ts +++ b/apps/sim/lib/workspaces/organization/index.ts @@ -1,8 +1,4 @@ // Export types export type { Member } from '@/lib/workspaces/organization/types' // Export utility functions -export { - generateSlug, - getUserRole, - isAdminOrOwner, -} from '@/lib/workspaces/organization/utils' +export { generateSlug } from '@/lib/workspaces/organization/utils' diff --git a/apps/sim/lib/workspaces/organization/types.ts b/apps/sim/lib/workspaces/organization/types.ts index ce817a5d5c9..58940e5a382 100644 --- a/apps/sim/lib/workspaces/organization/types.ts +++ b/apps/sim/lib/workspaces/organization/types.ts @@ -10,13 +10,3 @@ export interface Member { role: string user?: User } - -export interface Organization { - id: string - name: string - slug: string - logo?: string | null - members?: Member[] - createdAt: string | Date - [key: string]: unknown -} diff --git a/apps/sim/lib/workspaces/organization/utils.ts b/apps/sim/lib/workspaces/organization/utils.ts index f81470f3aec..7b64801e85b 100644 --- a/apps/sim/lib/workspaces/organization/utils.ts +++ b/apps/sim/lib/workspaces/organization/utils.ts @@ -3,34 +3,6 @@ * These are pure functions that compute values from organization data */ -import { isOrgAdminRole } from '@sim/platform-authz/predicates' -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) -} - /** * Generate a URL-friendly slug from a name */ diff --git a/apps/sim/lib/workspaces/update.ts b/apps/sim/lib/workspaces/update.ts new file mode 100644 index 00000000000..b45b4f80ac2 --- /dev/null +++ b/apps/sim/lib/workspaces/update.ts @@ -0,0 +1,128 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' + +type WorkspaceRow = typeof workspace.$inferSelect + +export interface WorkspaceUpdatePatch { + name?: string + logoUrl?: string | null + allowPersonalApiKeys?: boolean + billedAccountUserId?: string +} + +export interface WorkspaceIdentity { + id: string + name: string + logoUrl: string | null +} + +export interface WorkspaceUpdateResult { + previous: WorkspaceRow + updated: WorkspaceRow +} + +/** The name and logo of an active workspace. */ +export async function readWorkspaceIdentityRecord(workspaceId: string): Promise { + const [row] = await db + .select({ id: workspace.id, name: workspace.name, logoUrl: workspace.logoUrl }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + if (!row) throw new OrchestrationError('not_found', 'Workspace not found') + return row +} + +/** + * Applies a workspace metadata patch to an active workspace. A billed-account + * change moves the workspace's storage to the new payer in the same transaction. + */ +export async function applyWorkspaceUpdate( + workspaceId: string, + patch: WorkspaceUpdatePatch +): Promise { + const { name, logoUrl, allowPersonalApiKeys, billedAccountUserId } = patch + if ( + name === undefined && + logoUrl === undefined && + billedAccountUserId === undefined && + allowPersonalApiKeys === undefined + ) { + throw new OrchestrationError('validation', 'No updates provided') + } + + const [previous] = await db + .select() + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + if (!previous) throw new OrchestrationError('not_found', 'Workspace not found') + + const updateData: Partial = {} + if (name !== undefined) updateData.name = name + if (logoUrl !== undefined) updateData.logoUrl = logoUrl + if (allowPersonalApiKeys !== undefined) updateData.allowPersonalApiKeys = allowPersonalApiKeys + + if (billedAccountUserId !== undefined) { + if (previous.organizationId && previous.workspaceMode === 'organization') { + throw new OrchestrationError( + 'validation', + 'Organization workspaces use organization billing and cannot change billed account.' + ) + } + if (previous.workspaceMode === 'personal') { + throw new OrchestrationError( + 'validation', + 'Personal workspaces are always billed to their owner and cannot change billed account.' + ) + } + const candidatePermission = await getEffectiveWorkspacePermission(billedAccountUserId, previous) + if (candidatePermission !== 'admin') { + throw new OrchestrationError('validation', 'Billed account must be a workspace admin') + } + } + + updateData.updatedAt = new Date() + + const updated = + billedAccountUserId === undefined + ? await updateWorkspaceRow(db, workspaceId, updateData) + : await db.transaction(async (tx) => { + await changeWorkspaceStoragePayerInTx(tx, { + workspaceId, + organizationId: previous.organizationId, + billedAccountUserId, + expectedCurrentPayer: { + organizationId: previous.organizationId, + billedAccountUserId: previous.billedAccountUserId, + }, + }) + return updateWorkspaceRow(tx, workspaceId, updateData) + }) + + return { previous, updated } +} + +/** + * Writes the patch only while the workspace is still active: an archival that + * commits after the earlier reads leaves no row to update, which answers the + * same not-found as a workspace that was already gone (and rolls back a + * billed-account transfer). + */ +async function updateWorkspaceRow( + executor: DbOrTx, + workspaceId: string, + updateData: Partial +): Promise { + const [row] = await executor + .update(workspace) + .set(updateData) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .returning() + if (!row) throw new OrchestrationError('not_found', 'Workspace not found') + return row +} diff --git a/apps/sim/public/library/best-self-hosted-ai-workflow-automation-platforms-2026/cover.jpg b/apps/sim/public/library/best-self-hosted-ai-workflow-automation-platforms-2026/cover.jpg index b4c1791e2f3..06b77ac00b3 100644 Binary files a/apps/sim/public/library/best-self-hosted-ai-workflow-automation-platforms-2026/cover.jpg and b/apps/sim/public/library/best-self-hosted-ai-workflow-automation-platforms-2026/cover.jpg differ diff --git a/apps/sim/stores/modals/chat-plan/store.ts b/apps/sim/stores/modals/chat-plan/store.ts new file mode 100644 index 00000000000..06c92a1d994 --- /dev/null +++ b/apps/sim/stores/modals/chat-plan/store.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface ChatPlanModalState { + /** The workspace whose refused send opened the modal; another workspace never shows it. */ + workspaceId: string | null + open: (workspaceId: string | undefined) => void + close: () => void +} + +/** + * Which workspace's Chat plan modal is showing. A send the payer's plan refuses opens it from + * wherever the send started (the composer, a queued message, a handoff from another surface), and + * the workspace shell renders the one modal, only for the workspace that opened it. + */ +export const useChatPlanModalStore = create()( + devtools( + (set) => ({ + workspaceId: null, + open: (workspaceId) => set({ workspaceId: workspaceId ?? null }), + close: () => set({ workspaceId: null }), + }), + { name: 'chat-plan-modal-store' } + ) +) diff --git a/packages/db/dev-push.test.ts b/packages/db/dev-push.test.ts index cfef616d22e..8901148f33e 100644 --- a/packages/db/dev-push.test.ts +++ b/packages/db/dev-push.test.ts @@ -44,3 +44,23 @@ describe('dev push index options', () => { expect(statements[1]).toContain('WITH (m=16,ef_construction=64)') }) }) + +describe('dev push Project bootstrap switch', () => { + async function importWorkspaceProjectId() { + vi.resetModules() + const { workspace } = await import('@sim/db/schema') + return workspace.projectId + } + + it('keeps workspace.project_id required when only the bootstrap flag is set', async () => { + vi.stubEnv('SIM_DEV_DB_PUSH', undefined) + vi.stubEnv('SIM_DB_PUSH_PROJECT_BOOTSTRAP', '1') + expect((await importWorkspaceProjectId()).notNull).toBe(true) + }) + + it('pushes workspace.project_id nullable only during a development push bootstrap', async () => { + vi.stubEnv('SIM_DEV_DB_PUSH', '1') + vi.stubEnv('SIM_DB_PUSH_PROJECT_BOOTSTRAP', '1') + expect((await importWorkspaceProjectId()).notNull).toBe(false) + }) +}) diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 23aef61ba21..ead9081ec44 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -15,9 +15,14 @@ export default { dbCredentials: { url: process.env.DATABASE_URL!, }, - /** Runner-owned journals and resumable cleanup cursors must survive a development schema push. */ + /** + * Runner-owned journals, resumable cleanup cursors, and a Project bootstrap's connector and + * rollout marker (until its migration retires them) must survive a development schema push. + */ tablesFilter: [ '!script_migrations', + '!project_workspace', + '!project_membership_rollout', '!project_backfill_archive_repairs', '!search_embedding_cleanup_progress', '!search_embedding_cleanup_targets', diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 0ebce86d034..e2d5582dc81 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2171,6 +2171,21 @@ export const workspaceModeEnum = pgEnum('workspace_mode', [ export type WorkspaceMode = (typeof workspaceModeEnum.enumValues)[number] +/** + * A development push that bootstraps Projects first pushes this column nullable, so the columns + * `0032_project_membership` reads exist before it fills the column and enforces NOT NULL; the + * push that follows uses the required column. Every other push and generation reads it required, + * including one that inherits a stray bootstrap flag outside a development push. + */ +function workspaceProjectIdColumn() { + const column = text('project_id').references((): AnyPgColumn => project.id, { + onDelete: 'restrict', + }) + return process.env.SIM_DEV_DB_PUSH === '1' && process.env.SIM_DB_PUSH_PROJECT_BOOTSTRAP === '1' + ? (column as ReturnType) + : column.notNull() +} + export const workspace = pgTable( 'workspace', { @@ -2198,9 +2213,7 @@ export const workspace = pgTable( organizationScopeKey: text('organization_scope_key').generatedAlwaysAs( sql`CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END` ), - projectId: text('project_id') - .notNull() - .references((): AnyPgColumn => project.id, { onDelete: 'restrict' }), + projectId: workspaceProjectIdColumn(), workspaceMode: workspaceModeEnum('workspace_mode').notNull().default('grandfathered_shared'), billedAccountUserId: text('billed_account_user_id') .notNull() diff --git a/packages/db/scripts/project-push-bootstrap.integration.ts b/packages/db/scripts/project-push-bootstrap.integration.ts new file mode 100644 index 00000000000..042aebccdd3 --- /dev/null +++ b/packages/db/scripts/project-push-bootstrap.integration.ts @@ -0,0 +1,387 @@ +import { spawnSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import journal from '@sim/db/migrations/meta/_journal.json' +import { applyMigration } from '@sim/db/scripts/migration-fixture' +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { generateId } from '@sim/utils/id' +import postgres, { type Sql } from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const REFUSAL = + 'Existing Project databases must complete the Project script migration 0032_project_membership before schema push' +const MIGRATION_LOCK_KEY = 4_961_002_270n +const packageRoot = fileURLToPath(new URL('..', import.meta.url)) +const databaseUrl = readTestDatabaseUrl() + +function databaseUrlFor(name: string): string { + const url = new URL(databaseUrl) + url.pathname = `/${name}` + return url.toString() +} + +/** Runs the supported `db:push --force` entrypoint, fence included, against one database. */ +function runPush(url: string, devPush: boolean) { + const result = spawnSync('bun', ['--no-env-file', 'scripts/push.ts', '--force'], { + cwd: packageRoot, + env: { + ...process.env, + DATABASE_URL: url, + MIGRATION_DATABASE_URL: url, + /** The test runner's NODE_ENV silences the child loggers whose messages these tests read. */ + NODE_ENV: 'development', + SIM_DEV_DB_PUSH: devPush ? '1' : undefined, + }, + encoding: 'utf8', + timeout: 120_000, + }) + expect(result.error).toBeUndefined() + return { status: result.status, output: result.stdout + result.stderr } +} + +async function migrationSql(tag: string): Promise { + return readFile(new URL(`../migrations/${tag}.sql`, import.meta.url), 'utf8') +} + +/** Replays a versioned migration on one session, as the migrator does. */ +async function replayMigration(sql: Sql, tag: string): Promise { + const session = await sql.reserve() + try { + await applyMigration(session, await migrationSql(tag)) + } finally { + session.release() + } +} + +async function projectSchema(sql: Sql) { + const [state] = await sql`SELECT + to_regclass('public.project') IS NOT NULL AS project, + to_regclass('public.project_workspace') IS NOT NULL AS connector, + to_regclass('public.project_membership_rollout') IS NOT NULL AS rollout, + (SELECT attnotnull FROM pg_attribute WHERE attrelid = 'public.workspace'::regclass + AND attname = 'project_id' AND NOT attisdropped) AS required` + return state +} + +/** Everything a refused push must leave exactly as it found it. */ +async function retainedState(sql: Sql) { + const schema = await projectSchema(sql) + return { + schema, + workspaces: await sql`SELECT to_jsonb(w) AS row FROM workspace w ORDER BY id`, + folders: await sql`SELECT to_jsonb(f) AS row FROM folder f ORDER BY id`, + projects: schema.project ? await sql`SELECT to_jsonb(p) AS row FROM project p ORDER BY id` : [], + connector: schema.connector + ? await sql`SELECT * FROM project_workspace ORDER BY workspace_id` + : [], + } +} + +describe('development schema push of a database that predates Projects', () => { + const template = `project_push_template_test_${generateId().replaceAll('-', '')}` + let admin: Sql + + beforeAll(async () => { + admin = postgres(databaseUrl, { max: 1, onnotice: () => undefined }) + await admin.unsafe(`CREATE DATABASE "${template}"`) + const sql = postgres(databaseUrlFor(template), { max: 1, onnotice: () => undefined }) + try { + for (const extension of ['vector', 'btree_gin', 'pg_trgm']) { + await sql`CREATE EXTENSION IF NOT EXISTS ${sql(extension)}` + } + const fresh = runPush(databaseUrlFor(template), true) + expect(fresh.status, fresh.output).toBe(0) + /** Removes every Project object so the template matches a push-managed schema from before Projects. */ + await sql.unsafe(` + DROP TABLE project CASCADE; + ALTER TABLE workspace DROP COLUMN project_id CASCADE, DROP COLUMN organization_scope_key CASCADE; + ALTER TABLE folder DROP COLUMN project_id CASCADE; + ALTER TABLE workspace_files DROP COLUMN project_id CASCADE; + DELETE FROM script_migrations WHERE name = '0032_project_membership'; + `) + } finally { + await sql.end() + } + }, 180_000) + + afterAll(async () => { + if (!admin) return + await admin.unsafe(`DROP DATABASE IF EXISTS "${template}" WITH (FORCE)`) + await admin.end() + }) + + /** Copies the pre-Project template with seeded tenants into a database owned by one test. */ + async function preProjectDatabase(run: (sql: Sql, url: string) => Promise) { + const name = `project_push_test_${generateId().replaceAll('-', '')}` + await admin.unsafe(`CREATE DATABASE "${name}" TEMPLATE "${template}"`) + const url = databaseUrlFor(name) + const sql = postgres(url, { max: 2, onnotice: () => undefined }) + try { + await sql.unsafe(` + INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) VALUES + ('owner', 'Owner', 'owner@example.test', true, now(), now()), + ('member', 'Member', 'member@example.test', true, now(), now()); + INSERT INTO organization (id, name, slug) VALUES ('org', 'Org', 'org'); + INSERT INTO workspace (id, name, owner_id, billed_account_user_id, organization_id, forked_from_workspace_id, archived_at) VALUES + ('personal', 'Personal', 'owner', 'owner', NULL, NULL, NULL), + ('team', 'Team', 'owner', 'owner', 'org', NULL, NULL), + ('team-fork', 'Team fork', 'member', 'owner', 'org', 'team', NULL), + ('retired', 'Retired', 'member', 'member', NULL, NULL, '2026-01-01'); + INSERT INTO folder (id, resource_type, name, user_id, workspace_id) VALUES + ('folder', 'workflow', 'Folder', 'owner', 'team'); + `) + await run(sql, url) + } finally { + await sql.end({ timeout: 2 }) + await admin.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`) + } + } + + async function expectBootstrapped(sql: Sql) { + expect(await projectSchema(sql)).toEqual({ + project: true, + connector: false, + rollout: false, + required: true, + }) + const memberships = await sql<{ id: string; project_id: string; archived: boolean }[]>` + SELECT w.id, w.project_id, p.archived_at IS NOT NULL AS archived + FROM workspace w JOIN project p ON p.id = w.project_id ORDER BY w.id` + expect(memberships.map(({ id, archived }) => ({ id, archived }))).toEqual([ + { id: 'personal', archived: false }, + { id: 'retired', archived: true }, + { id: 'team', archived: false }, + { id: 'team-fork', archived: false }, + ]) + const projectOf = new Map(memberships.map((row) => [row.id, row.project_id])) + expect(projectOf.get('team-fork')).toBe(projectOf.get('team')) + expect(new Set(projectOf.values()).size).toBe(3) + expect( + await sql`SELECT conname, convalidated, condeferrable FROM pg_constraint + WHERE conrelid = 'workspace'::regclass AND conname IN ('workspace_project_id_project_id_fk', + 'workspace_project_organization_fk', 'workspace_fork_project_fk') + ORDER BY conname` + ).toEqual([ + { conname: 'workspace_fork_project_fk', convalidated: true, condeferrable: true }, + { conname: 'workspace_project_id_project_id_fk', convalidated: true, condeferrable: false }, + { conname: 'workspace_project_organization_fk', convalidated: true, condeferrable: true }, + ]) + expect( + await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'` + ).toEqual([{ name: '0032_project_membership' }]) + await expect( + sql`UPDATE workspace SET organization_id = NULL WHERE id = 'team-fork'` + ).rejects.toMatchObject({ code: '23503' }) + } + + it('bootstraps Projects, completes the push, and keeps memberships and constraints on replay', async () => { + await preProjectDatabase(async (sql, url) => { + const pushed = runPush(url, true) + expect(pushed.status, pushed.output).toBe(0) + await expectBootstrapped(sql) + const before = await sql`SELECT id, project_id FROM workspace ORDER BY id` + const replayed = runPush(url, true) + expect(replayed.status, replayed.output).toBe(0) + expect(await sql`SELECT id, project_id FROM workspace ORDER BY id`).toEqual(before) + await expectBootstrapped(sql) + }) + }, 240_000) + + it('pushes the columns the Project migration reads before running it', async () => { + await preProjectDatabase(async (sql, url) => { + await sql`ALTER TABLE workspace DROP COLUMN forked_from_workspace_id CASCADE` + const pushed = runPush(url, true) + expect(pushed.status, pushed.output).toBe(0) + expect(await projectSchema(sql)).toEqual({ + project: true, + connector: false, + rollout: false, + required: true, + }) + expect( + await sql`SELECT count(*)::int AS unassigned FROM workspace WHERE project_id IS NULL` + ).toEqual([{ unassigned: 0 }]) + expect( + await sql`SELECT count(*)::int AS restored FROM pg_attribute WHERE attrelid = 'workspace'::regclass + AND attname = 'forked_from_workspace_id' AND NOT attisdropped` + ).toEqual([{ restored: 1 }]) + expect( + await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'` + ).toEqual([{ name: '0032_project_membership' }]) + }) + }, 240_000) + + it('resumes after the Project migration reports conflicts', async () => { + await preProjectDatabase(async (sql, url) => { + await sql`INSERT INTO workspace (id, name, owner_id, billed_account_user_id, forked_from_workspace_id) + VALUES ('cycle-a', 'Cycle A', 'owner', 'owner', NULL), ('cycle-b', 'Cycle B', 'owner', 'owner', 'cycle-a')` + await sql`UPDATE workspace SET forked_from_workspace_id = 'cycle-b' WHERE id = 'cycle-a'` + const refused = runPush(url, true) + expect(refused.status, refused.output).toBe(1) + expect(refused.output).toContain('Project preparation requires operator remediation') + expect(await projectSchema(sql)).toEqual({ + project: true, + connector: true, + rollout: true, + required: false, + }) + expect(await sql`SELECT phase FROM project_membership_rollout`).toEqual([{ phase: 'column' }]) + expect( + await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'` + ).toEqual([]) + + await sql`UPDATE workspace SET forked_from_workspace_id = NULL WHERE id = 'cycle-a'` + const resumed = runPush(url, true) + expect(resumed.status, resumed.output).toBe(0) + await sql`DELETE FROM workspace WHERE id IN ('cycle-a', 'cycle-b')` + await expectBootstrapped(sql) + }) + }, 240_000) + + it('rolls the foundation back with the expansion when the workspace barrier is busy', async () => { + await preProjectDatabase(async (sql, url) => { + const holder = await sql.reserve() + try { + await holder`BEGIN` + await holder`SELECT 1 FROM workspace LIMIT 1` + const busy = runPush(url, true) + expect(busy.status, busy.output).toBe(1) + expect(busy.output).toContain('55P03') + await holder`ROLLBACK` + } finally { + holder.release() + } + expect(await projectSchema(sql)).toEqual({ + project: false, + connector: false, + rollout: false, + required: null, + }) + const pushed = runPush(url, true) + expect(pushed.status, pushed.output).toBe(0) + await expectBootstrapped(sql) + }) + }, 240_000) + + it('bootstraps into public when the connection search path lists another schema first', async () => { + await preProjectDatabase(async (sql, url) => { + await sql`CREATE SCHEMA shadow` + const shadowed = new URL(url) + shadowed.searchParams.set('options', '-c search_path=shadow,public') + const pushed = runPush(shadowed.toString(), true) + expect(pushed.status, pushed.output).toBe(0) + const shadowTables = await sql`SELECT tablename FROM pg_tables WHERE schemaname = 'shadow' + AND tablename IN ('project', 'project_workspace', 'project_membership_rollout')` + await expectBootstrapped(sql) + expect(shadowTables).toEqual([]) + }) + }, 240_000) + + it('leaves the database untouched outside development push mode', async () => { + await preProjectDatabase(async (sql, url) => { + const refused = runPush(url, false) + expect(refused.status, refused.output).toBe(1) + expect(refused.output).toContain(REFUSAL) + expect(await projectSchema(sql)).toEqual({ + project: false, + connector: false, + rollout: false, + required: null, + }) + expect( + await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'` + ).toEqual([]) + }) + }, 120_000) + + it('refuses to bootstrap while a migration holds the schema lock', async () => { + await preProjectDatabase(async (sql, url) => { + const migrator = await sql.reserve() + try { + await migrator`SELECT pg_advisory_lock(${MIGRATION_LOCK_KEY})` + const fenced = runPush(url, true) + expect(fenced.status, fenced.output).toBe(1) + expect(fenced.output).toContain('A migration is running') + await migrator`SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})` + } finally { + migrator.release() + } + expect((await projectSchema(sql)).project).toBe(false) + }) + }, 120_000) + + it.each([ + { + state: 'a connector with live memberships', + async build(sql: Sql) { + await sql.unsafe(await migrationSql('0394_project_foundation')) + await sql`INSERT INTO project (id, name, owner_id) VALUES ('legacy', 'Legacy', 'owner')` + await sql`INSERT INTO project_workspace (project_id, workspace_id) VALUES ('legacy', 'personal')` + }, + }, + { + state: 'an empty connector without the column expansion', + async build(sql: Sql) { + await sql.unsafe(await migrationSql('0394_project_foundation')) + }, + }, + { + state: 'Projects without a connector or required column', + async build(sql: Sql) { + await sql.unsafe(await migrationSql('0394_project_foundation')) + await sql`DROP TABLE project_workspace` + }, + }, + { + state: 'a workspace Project column without Projects', + async build(sql: Sql) { + await sql`ALTER TABLE workspace ADD COLUMN project_id text` + }, + }, + { + state: 'folder Project ownership without Projects', + async build(sql: Sql) { + await sql`ALTER TABLE folder ADD COLUMN project_id text` + await sql`UPDATE folder SET project_id = 'orphaned'` + }, + }, + { + state: 'a versioned expansion awaiting its script migration', + async build(sql: Sql) { + await sql.unsafe(await migrationSql('0394_project_foundation')) + await replayMigration(sql, '0406_workspace_project_column') + const expansion = journal.entries.find( + (entry) => entry.tag === '0406_workspace_project_column' + ) + await sql`CREATE SCHEMA drizzle` + await sql`CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint)` + await sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('fixture', ${expansion?.when ?? 0})` + }, + }, + { + state: 'an expansion whose connector holds memberships', + async build(sql: Sql) { + await sql.unsafe(await migrationSql('0394_project_foundation')) + await replayMigration(sql, '0406_workspace_project_column') + await sql`INSERT INTO project (id, name, owner_id) VALUES ('legacy', 'Legacy', 'owner')` + await sql`INSERT INTO project_workspace (project_id, workspace_id) VALUES ('legacy', 'personal')` + }, + }, + ])( + 'refuses development push of $state', + async ({ build }) => { + await preProjectDatabase(async (sql, url) => { + await build(sql) + const before = await retainedState(sql) + const refused = runPush(url, true) + expect(refused.status, refused.output).toBe(1) + expect(refused.output).toContain(REFUSAL) + expect(await retainedState(sql)).toEqual(before) + expect( + await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'` + ).toEqual([]) + }) + }, + 120_000 + ) +}) diff --git a/packages/db/scripts/push.integration.ts b/packages/db/scripts/push.integration.ts index 576e1afc878..dd20a82a87e 100644 --- a/packages/db/scripts/push.integration.ts +++ b/packages/db/scripts/push.integration.ts @@ -330,6 +330,25 @@ export const workflows = pgTable('workflow', { ).rejects.toMatchObject({ code: '23503' }) }, 60_000) + it('adds a composite unique constraint before the foreign keys that reference it', async () => { + await sql`CREATE TABLE folder (id text PRIMARY KEY, resource_type text NOT NULL, parent_id text)` + await sql`INSERT INTO folder VALUES ('parent', 'file', NULL), ('child', 'file', 'parent')` + await schema(`export const folders = pgTable('folder', { + id: text('id').primaryKey(), resourceType: text('resource_type').notNull(), + projectId: text('project_id'), parentId: text('parent_id'), +}, (t) => [ + unique('folder_project_owner_type_unique').on(t.id, t.projectId, t.resourceType), + foreignKey({ name: 'folder_project_parent_owner_fk', columns: [t.parentId, t.projectId, t.resourceType], foreignColumns: [t.id, t.projectId, t.resourceType] }), +])`) + const result = push() + expect(result.error).toBeUndefined() + expect(result.status, result.stdout + result.stderr).toBe(0) + await sql`UPDATE folder SET project_id = 'project' WHERE id = 'parent'` + await expect( + sql`UPDATE folder SET project_id = 'other' WHERE id = 'child'` + ).rejects.toMatchObject({ code: '23503' }) + }, 30_000) + it('retires the legacy size bridge without losing bigint or unbackfilled values', async () => { await sql`CREATE TABLE workspace_files (id text PRIMARY KEY, size integer NOT NULL, size_bytes bigint)` await sql`INSERT INTO workspace_files VALUES ('legacy', 123, NULL), ('large', 2147483647, 5000000000)` diff --git a/packages/db/scripts/push.ts b/packages/db/scripts/push.ts index e0417ce76c1..89a0492c089 100644 --- a/packages/db/scripts/push.ts +++ b/packages/db/scripts/push.ts @@ -17,8 +17,13 @@ const RECONCILIATION_COMMANDS = [ ['bun', '--env-file=.env', 'run', './script-migrations/0031_drop_keyword_tin.ts'], ] +/** Reads, on the fence session, whether `--prepare` left a Project bootstrap for this push to finish. */ +type ProjectBootstrapProbe = () => Promise + /** Fences preparation, Drizzle and reconcilers from a concurrent migration run. */ -async function withMigrationFence(run: (signal: AbortSignal) => Promise): Promise { +async function withMigrationFence( + run: (signal: AbortSignal, projectBootstrapPending: ProjectBootstrapProbe) => Promise +): Promise { const rawUrl = process.env.DATABASE_URL if (!rawUrl) { logger.error('DATABASE_URL is required for schema push') @@ -48,7 +53,11 @@ async function withMigrationFence(run: (signal: AbortSignal) => Promise) logger.error('A migration is running; retry schema push later') return 1 } - return run(abort.signal) + return run(abort.signal, async () => { + const [state] = await tx<{ pending: boolean }[]>` + SELECT to_regclass('public.project_membership_rollout') IS NOT NULL AS pending`.simple() + return state.pending + }) })) as number } catch (error) { logger.error( @@ -82,11 +91,21 @@ export async function runPush(args: string[]): Promise { return 1 } - async function runCommands(signal?: AbortSignal): Promise { - async function runCommand(command: string[]): Promise { + async function runCommands( + signal?: AbortSignal, + projectBootstrapPending?: ProjectBootstrapProbe + ): Promise { + async function runCommand( + command: string[], + env: Record = {} + ): Promise { signal?.throwIfAborted() const child = Bun.spawn(command, { - env: { ...process.env, SIM_DB_PUSH_RENAME_MODE: interactiveRenames ? 'prompt' : 'create' }, + env: { + ...process.env, + SIM_DB_PUSH_RENAME_MODE: interactiveRenames ? 'prompt' : 'create', + ...env, + }, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', @@ -119,13 +138,21 @@ export async function runPush(args: string[]): Promise { const pushArgs = args.filter((arg) => arg !== '--interactive-renames') // Launch the installed CLI directly so losing the fence also terminates its database connection. const cli = fileURLToPath(new URL('bin.cjs', import.meta.resolve('drizzle-kit'))) - const exitCode = await runCommand([ - 'bun', - cli, - 'push', - '--config=./drizzle.config.ts', - ...pushArgs, - ]) + const drizzlePush = ['bun', cli, 'push', '--config=./drizzle.config.ts', ...pushArgs] + if (!help && process.env.SIM_DEV_DB_PUSH === '1' && (await projectBootstrapPending?.())) { + /** The Project migration reads columns an older database gains only from this push. */ + const code = await runCommand(drizzlePush, { SIM_DB_PUSH_PROJECT_BOOTSTRAP: '1' }) + if (code !== 0) return code + const bootstrapCode = await runCommand([ + 'bun', + '--env-file=.env', + 'run', + './scripts/reconcile-project-membership.ts', + '--bootstrap', + ]) + if (bootstrapCode !== 0) return bootstrapCode + } + const exitCode = await runCommand(drizzlePush) if (exitCode !== 0 || help) return exitCode for (const command of RECONCILIATION_COMMANDS) { diff --git a/packages/db/scripts/reconcile-project-membership.ts b/packages/db/scripts/reconcile-project-membership.ts index cea52f5f292..495810d323a 100644 --- a/packages/db/scripts/reconcile-project-membership.ts +++ b/packages/db/scripts/reconcile-project-membership.ts @@ -1,4 +1,8 @@ +import { readFile } from 'node:fs/promises' import { enforceProjectMembership } from '@sim/db/maintenance/project-enforcement' +import journal from '@sim/db/migrations/meta/_journal.json' +import { runScriptMigrations } from '@sim/db/script-migrations' +import { projectMembershipMigration } from '@sim/db/script-migrations/0032_project_membership' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import postgres from 'postgres' @@ -7,25 +11,138 @@ const logger = createLogger('ProjectMembershipPush') const url = process.env.MIGRATION_DATABASE_URL || process.env.DATABASE_URL if (!url) throw new Error('A database URL is required for Project schema push reconciliation') const sql = postgres(url, { max: 1, prepare: false, connect_timeout: 10, max_lifetime: null }) -try { - const [state] = await sql< - { workspace: boolean; project: boolean; connector: boolean; required: boolean }[] - >` + +const REFUSAL = + 'Existing Project databases must complete the Project script migration 0032_project_membership before schema push. Use the phased migration path for retained data; schema push cannot bypass the writer-drain requirement' + +interface ProjectSchemaState { + workspace: boolean + project: boolean + connector: boolean + rollout: boolean + journal: boolean + /** Null when `workspace.project_id` does not exist; otherwise whether it is NOT NULL. */ + projectIdNotNull: boolean | null + /** Whether any public table carries a `project_id` column. */ + projectColumns: boolean +} + +async function readProjectSchemaState(): Promise { + const [state] = await sql` SELECT to_regclass('public.workspace') IS NOT NULL AS workspace, to_regclass('public.project') IS NOT NULL AS project, to_regclass('public.project_workspace') IS NOT NULL AS connector, - EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = to_regclass('public.workspace') - AND attname = 'project_id' AND NOT attisdropped AND attnotnull) AS required + to_regclass('public.project_membership_rollout') IS NOT NULL AS rollout, + to_regclass('drizzle.__drizzle_migrations') IS NOT NULL AS journal, + (SELECT attnotnull FROM pg_attribute WHERE attrelid = to_regclass('public.workspace') + AND attname = 'project_id' AND NOT attisdropped) AS "projectIdNotNull", + EXISTS (SELECT 1 FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relkind IN ('r', 'p') + AND a.attname = 'project_id' AND NOT a.attisdropped) AS "projectColumns" ` - if (state.workspace && (state.connector || !state.required || !state.project)) { - throw new Error( - 'Existing Project databases must complete the Project script migration 0032_project_membership before schema push. Use the phased migration path for retained data; schema push cannot bypass the writer-drain requirement' - ) + return state +} + +/** + * Classifies a development push database that has no connector writer to drain: either it + * predates Projects entirely (no Project table, connector, marker, or `project_id` column + * anywhere), or an earlier bootstrap committed the expansion and has not finished. Only the + * versioned 0406 creates the rollout marker, so a marker that the migration journal does not + * account for, beside a connector nothing has written, is a bootstrap in progress. Every other + * shape returns null and keeps the writer-drain refusal. + */ +async function classifyProjectBootstrap( + state: ProjectSchemaState +): Promise<'fresh' | 'resume' | null> { + if (process.env.SIM_DEV_DB_PUSH !== '1' || !state.workspace) return null + if (!state.project && !state.connector && !state.rollout && !state.projectColumns) return 'fresh' + if (!state.project || !state.connector || !state.rollout || state.projectIdNotNull !== false) + return null + const [connector] = await sql<{ populated: boolean }[]>` + SELECT EXISTS (SELECT 1 FROM public.project_workspace) AS populated` + if (connector.populated) return null + if (state.journal) { + const expansion = journal.entries.find((entry) => entry.tag === '0406_workspace_project_column') + if (!expansion) + throw new Error('Project column expansion is missing from the migration journal') + const [applied] = await sql<{ versioned: boolean }[]>` + SELECT EXISTS (SELECT 1 FROM drizzle.__drizzle_migrations + WHERE created_at = ${expansion.when}) AS versioned` + if (applied.versioned) return null + } + return 'resume' +} + +async function migrationStatements(tag: string): Promise { + const source = await readFile(new URL(`../migrations/${tag}.sql`, import.meta.url), 'utf8') + return source.split('--> statement-breakpoint').filter((statement) => statement.trim()) +} + +/** + * Replays the versioned Project expansion. 0406 opens by committing the migrator's batch; leaving + * that out commits the 0394 foundation together with the rollout marker, so an interrupted + * expansion leaves either no Project schema or one the next push resumes. + */ +async function expandProjects(mode: 'fresh' | 'resume'): Promise { + logger.info('Expanding Projects for a development push database without them', { mode }) + const foundation = mode === 'fresh' ? await migrationStatements('0394_project_foundation') : [] + const [batchCommit, ...expansion] = await migrationStatements('0406_workspace_project_column') + if (batchCommit.trim() !== 'COMMIT;') { + throw new Error('Project column expansion no longer opens with the migrator batch commit') + } + await sql.unsafe('BEGIN') + try { + for (const statement of [...foundation, ...expansion]) await sql.unsafe(statement) + } catch (error) { + await sql.unsafe('ROLLBACK').catch(() => undefined) + throw error + } +} + +/** Migration SQL and the script runner name tables unqualified; pin them to `public`, as the migrator's default session does. */ +async function inPublicSchema(run: () => Promise): Promise { + const [settings] = await sql<{ search_path: string }[]>`SHOW search_path` + await sql`SELECT set_config('search_path', 'public, pg_temp', false)` + try { + await run() + } finally { + await sql`SELECT set_config('search_path', ${settings.search_path}, false)` } - if (!process.argv.includes('--prepare') && state.workspace && state.project) { - /** Drizzle cannot express deferred FK timing; fresh push shares the migration finalizer. */ - await enforceProjectMembership(sql) - logger.info('Project membership validation and native constraints completed') +} + +function assertProjectMembershipComplete(state: ProjectSchemaState): void { + if (state.workspace && (state.connector || !state.projectIdNotNull || !state.project)) { + throw new Error(REFUSAL) + } +} + +/** + * `--prepare` runs before the first Drizzle push and expands an eligible bootstrap. `push.ts` + * then pushes the schema with `workspace.project_id` still nullable, so the columns the Project + * migration reads exist, and calls `--bootstrap` to run the registered migration on this + * single-session connection, recorded as the migration runner records it. Without a flag, the + * post-push pass installs the native constraints. + */ +try { + let state = await readProjectSchemaState() + if (process.argv.includes('--prepare')) { + const bootstrap = await classifyProjectBootstrap(state) + if (bootstrap) { + await inPublicSchema(() => expandProjects(bootstrap)) + state = await readProjectSchemaState() + } + if ((await classifyProjectBootstrap(state)) !== 'resume') assertProjectMembershipComplete(state) + } else if (process.argv.includes('--bootstrap')) { + if ((await classifyProjectBootstrap(state)) !== 'resume') throw new Error(REFUSAL) + await inPublicSchema(() => runScriptMigrations(sql, [projectMembershipMigration])) + assertProjectMembershipComplete(await readProjectSchemaState()) + } else { + assertProjectMembershipComplete(state) + if (state.workspace && state.project) { + /** Drizzle cannot express deferred FK timing; fresh push shares the migration finalizer. */ + await enforceProjectMembership(sql) + logger.info('Project membership validation and native constraints completed') + } } } catch (error) { logger.error('Project schema push stopped; resolve the error and retry', { diff --git a/patches/README.md b/patches/README.md index c5bc00eff79..b97ad405fda 100644 --- a/patches/README.md +++ b/patches/README.md @@ -60,11 +60,21 @@ chooser and requires a terminal. Normal migration generation keeps its rename prompts. Data-loss confirmations, table filters, introspection, and generated SQL remain owned by Drizzle; `--force` still controls data-loss approval. +The same policy answers Drizzle's prompt to truncate a populated table before +adding a unique constraint with its non-destructive choice: the constraint is +added without truncating, and duplicate rows fail the push. Drizzle also adds +PostgreSQL unique constraints after the foreign keys of the same diff, so a +composite foreign key that references a new unique constraint on an existing +table (the folder ownership keys) always fails. The patch adds unique +constraints right after new columns, before every foreign key, for push and +generated migrations alike; a unique constraint never depends on a foreign key. + `packages/db/scripts/push.test.ts` covers argument forwarding and stopping before reconciliation on failure. `packages/db/scripts/push.integration.ts` exercises the installed CLI against PostgreSQL, including multiple column changes, table and enum replacement, schema replacement, preservation of the excluded script -ledger, and database errors. Set `TEST_DATABASE_URL` to a disposable local +ledger, database errors, and a composite foreign key on a new unique constraint of +a populated table. Set `TEST_DATABASE_URL` to a disposable local PostgreSQL database and run these tests with `bun run --cwd packages/db test --mode integration`. diff --git a/patches/drizzle-kit@0.31.10.patch b/patches/drizzle-kit@0.31.10.patch index 67c42f08929..841c851274b 100644 --- a/patches/drizzle-kit@0.31.10.patch +++ b/patches/drizzle-kit@0.31.10.patch @@ -13,6 +13,16 @@ diff --git a/bin.cjs b/bin.cjs + WHERE tc.table_name = '${tableName}' and constraint_schema = '${tableSchema}' + ORDER BY constraint_name, kcu.ordinal_position;` ); +@@ -29040,8 +29041,8 @@ + jsonStatements.push(...jsonAddColumnsStatemets); ++ jsonStatements.push(...jsonAddedUniqueConstraints); + jsonStatements.push(...jsonCreateReferencesForCreatedTables); + jsonStatements.push(...jsonCreateIndexesForCreatedTables); + jsonStatements.push(...jsonCreatedReferencesForAlteredTables); + jsonStatements.push(...jsonCreateIndexesFoAlteredTables); + jsonStatements.push(...jsonDropColumnsStatemets); + jsonStatements.push(...jsonAlteredCompositePKs); +- jsonStatements.push(...jsonAddedUniqueConstraints); @@ -32694,3 +32695,3 @@ promptColumnsConflicts = async (tableName, newColumns, missingColumns) => { - if (newColumns.length === 0 || missingColumns.length === 0) { @@ -33,6 +43,11 @@ diff --git a/bin.cjs b/bin.cjs - if (missingSchemas.length === 0 || newSchemas.length === 0) { + if (process.env.SIM_DB_PUSH_RENAME_MODE === "create" || missingSchemas.length === 0 || newSchemas.length === 0) { return { created: newSchemas, renamed: [], deleted: missingSchemas }; +@@ -80441,3 +80442,3 @@ + const count = Number(res[0].count); +- if (count > 0) { ++ if (count > 0 && process.env.SIM_DB_PUSH_RENAME_MODE !== "create") { + const unsquashedUnique = PgSquasher.unsquashUnique(statement.data); @@ -82741,3 +82742,3 @@ (0, import_hanji13.render)(`[${source_default.red("x")}] All changes were aborted`); - process.exit(0); diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index e4448b682b2..826770493e5 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -1005,7 +1005,6 @@ "apps/sim/hooks/queries/kb/connectors.ts#ViewerConnectorMembership", "apps/sim/hooks/queries/kb/connectors.ts#connectorDocumentKeys", "apps/sim/hooks/queries/kb/connectors.ts#searchIndexKeys", - "apps/sim/hooks/queries/kb/connectors.ts#useStartConnectorMemberEnrollment", "apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_CHUNK_LIST_STALE_TIME", "apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_CHUNK_SEARCH_STALE_TIME", "apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME", @@ -1167,7 +1166,6 @@ "apps/sim/lib/api-key/orchestration/index.ts#ApiKeyOrchestrationErrorCode", "apps/sim/lib/api-key/orchestration/index.ts#CreatedApiKey", "apps/sim/lib/api/contracts/access-requests.ts#ACCESS_REQUEST_MAX_PAGE_SIZE", - "apps/sim/lib/api/contracts/access-requests.ts#ACCESS_REQUEST_PAGE_SIZE", "apps/sim/lib/api/contracts/access-requests.ts#AccessRequestDecision", "apps/sim/lib/api/contracts/access-requests.ts#AccessRequestListResponse", "apps/sim/lib/api/contracts/access-requests.ts#AccessRequestParams", @@ -1192,7 +1190,6 @@ "apps/sim/lib/api/contracts/access-requests.ts#discoverAccessRequestsQuerySchema", "apps/sim/lib/api/contracts/access-requests.ts#discoverAccessRequestsResponseSchema", "apps/sim/lib/api/contracts/access-requests.ts#listMyAccessRequestsQuerySchema", - "apps/sim/lib/api/contracts/access-requests.ts#listOrganizationAccessRequestsQuerySchema", "apps/sim/lib/api/contracts/access-requests.ts#organizationAccessRequestDetailParamsSchema", "apps/sim/lib/api/contracts/access-requests.ts#organizationAccessRequestParamsSchema", "apps/sim/lib/api/contracts/admin.ts#adminDeleteWorkspaceMemberQuerySchema", @@ -1705,7 +1702,6 @@ "apps/sim/lib/api/contracts/oauth-provider.ts#RegisterOAuthClientBody", "apps/sim/lib/api/contracts/oauth-provider.ts#RegisterOAuthClientResponse", "apps/sim/lib/api/contracts/organization-accounts.ts#OrganizationAccountPeoplePage", - "apps/sim/lib/api/contracts/organization-accounts.ts#PersonalOrganizationAccount", "apps/sim/lib/api/contracts/organization-accounts.ts#ReconnectPersonalOrganizationAccountQuery", "apps/sim/lib/api/contracts/organization-accounts.ts#WorkspaceOrganizationAccounts", "apps/sim/lib/api/contracts/organization-accounts.ts#organizationAccountConnectionResponseSchema", @@ -3079,7 +3075,6 @@ "apps/sim/lib/api/contracts/v2/access-requests.ts#v2ListOrganizationAccessRequestsQuerySchema", "apps/sim/lib/api/contracts/v2/access-requests.ts#v2OrganizationAccessRequestDetailParamsSchema", "apps/sim/lib/api/contracts/v2/access-requests.ts#v2OrganizationAccessRequestParamsSchema", - "apps/sim/lib/api/contracts/v2/access-requests.ts#v2ResolveAccessRequestBodySchema", "apps/sim/lib/api/contracts/v2/access-requests.ts#v2WorkspaceAccessRequestDetailParamsSchema", "apps/sim/lib/api/contracts/v2/access-requests.ts#v2WorkspaceAccessRequestParamsSchema", "apps/sim/lib/api/contracts/v2/audit-logs.ts#V2AuditLogEntry", @@ -3964,7 +3959,6 @@ "apps/sim/lib/api/contracts/workspaces.ts#createWorkspaceBodySchema", "apps/sim/lib/api/contracts/workspaces.ts#deploymentFeaturesSchema", "apps/sim/lib/api/contracts/workspaces.ts#deploymentShapeSchema", - "apps/sim/lib/api/contracts/workspaces.ts#updateWorkspaceBodySchema", "apps/sim/lib/api/contracts/workspaces.ts#workspaceCreationPolicySchema", "apps/sim/lib/api/contracts/workspaces.ts#workspaceCreditAvailabilitySchema", "apps/sim/lib/api/contracts/workspaces.ts#workspaceHostContextSchema", @@ -4257,7 +4251,6 @@ "apps/sim/lib/credential-groups/enrollments.ts#getPublicCredentialGroupEnrollment", "apps/sim/lib/credential-groups/limits.ts#ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT", "apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts#MANAGED_MCP_CONNECTOR_ICONS", - "apps/sim/lib/credential-groups/managed-mcp-connectors.ts#isManagedMcpConnectorId", "apps/sim/lib/credential-groups/provider-adapter.ts#PreparedCredentialGroupAuthorization", "apps/sim/lib/credential-groups/provider-configuration.ts#SlackAppCredentialGroupConfiguration", "apps/sim/lib/credential-groups/slack-managed-users.ts#getSlackManagedUsersRedirectUri", @@ -4702,7 +4695,6 @@ "apps/sim/lib/messaging/email/mailer.ts#sendBatchEmails", "apps/sim/lib/messaging/email/types.ts#EmailAttachment", "apps/sim/lib/messaging/email/utils.ts#extractEmailFromAddress", - "apps/sim/lib/monitoring/metrics.ts#flushHostedKeyMetrics", "apps/sim/lib/mothership/agent-cli/types.ts#AgentCliClient", "apps/sim/lib/mothership/application/execute-knowledge-use-case.ts#requireCopilotKnowledgeWorkspaceId", "apps/sim/lib/mothership/application/execute-knowledge-use-case.ts#resolveCopilotKnowledgePrincipal", @@ -5235,8 +5227,6 @@ "apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationIssue", "apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationKind", "apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationStatus", - "apps/sim/lib/workspaces/organization/index.ts#getUserRole", - "apps/sim/lib/workspaces/organization/utils.ts#getUserRole", "apps/sim/lib/workspaces/permissions/utils.ts#MemberRoleSource", "apps/sim/lib/workspaces/policy.ts#CONTACT_OWNER_TO_UPGRADE_REASON", "apps/sim/lib/workspaces/policy.ts#UPGRADE_TO_INVITE_REASON",