Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
395a394
fix(test): assert recovery backoff on the claims, not worker request …
waleedlatif1 Nov 3, 2026
86ad456
docs(library): update best-ai-agent-evaluation-platforms-2026 (#9594)
waleedlatif1 Nov 3, 2026
260de05
improvement(media): record a hosted-key auth failure when fal refuses…
waleedlatif1 Nov 3, 2026
5f4ea51
docs(library): update best-zapier-alternatives (#9592)
waleedlatif1 Nov 3, 2026
3fa33cf
improvement(sandbox): return a timed-out workbench run's output and t…
waleedlatif1 Nov 3, 2026
7b72680
docs(library): update best-self-hosted-ai-workflow-automation-platfor…
waleedlatif1 Nov 3, 2026
b3a3dda
fix(ui): disable MCP URL edits and table rename for roles the server …
waleedlatif1 Nov 3, 2026
fa10efb
improvement(sandbox): verify the workbench CLI inside each command's …
waleedlatif1 Nov 3, 2026
1ea6c7f
improvement(chat): let Chat manage sim links, bulk-add permission gro…
waleedlatif1 Nov 3, 2026
194423a
fix(settings): let people revoke the accounts they connected for an o…
waleedlatif1 Nov 3, 2026
c3501be
fix(organizations): let admins rename their organization (#9603)
waleedlatif1 Nov 3, 2026
9680032
fix(credentials): let members leave a shared credential (#9605)
waleedlatif1 Nov 3, 2026
eacc3a2
improvement(workspaces): move workspace updates behind an application…
waleedlatif1 Nov 3, 2026
1b72781
fix(organizations): let members leave their organization; remove sign…
waleedlatif1 Nov 3, 2026
133c671
fix(knowledge): let members connect their own account to a member-acc…
waleedlatif1 Nov 3, 2026
0bf55bc
improvement(billing): open a Chat plan popup on send instead of a sta…
waleedlatif1 Nov 3, 2026
6989ce8
fix(sim-links): hand off a departing member's links to the successor …
waleedlatif1 Nov 3, 2026
eede06c
improvement(chat): let Chat review organization access requests (#9610)
waleedlatif1 Nov 3, 2026
0a2970f
fix(organizations): refuse self-leave for directory-managed members a…
waleedlatif1 Nov 3, 2026
e137d82
fix(db): bootstrap Projects for development push databases that preda…
waleedlatif1 Nov 3, 2026
848c876
fix(chat): hide the Sim links settings section where Sim links are of…
waleedlatif1 Nov 3, 2026
0ba2186
fix(billing): close the Chat plan popup when its shell unmounts and n…
waleedlatif1 Nov 3, 2026
a2495e3
fix(release): report short workbench timeouts, gate Sim links in Chat…
waleedlatif1 Nov 3, 2026
32af8bc
chore(billing): drop the unreachable organization branch from the pla…
waleedlatif1 Nov 3, 2026
eaec7be
chore(release): merge staging into the v0.9.42 release
waleedlatif1 Nov 3, 2026
2ec4e89
fix(sandbox,db): keep a short workbench run's output on timeout; requ…
waleedlatif1 Nov 3, 2026
3919c12
chore(release): merge staging into the v0.9.42 release
waleedlatif1 Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/api/credentials/[id]/members/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
])
})
Expand Down
69 changes: 21 additions & 48 deletions apps/sim/app/api/organizations/[id]/removal-impact/route.ts
Original file line number Diff line number Diff line change
@@ -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 }),
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Expand Down
238 changes: 57 additions & 181 deletions apps/sim/app/api/workspaces/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 }> }) => {
Expand Down Expand Up @@ -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<string, unknown> = {}

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 }> }) => {
Expand Down Expand Up @@ -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,
})
2 changes: 2 additions & 0 deletions apps/sim/app/extension/[workspaceId]/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -65,6 +66,7 @@ export function ExtensionWorkspace({ workspaceId }: ExtensionWorkspaceProps) {
>
<ExtensionChat workspaceId={workspaceId} workspaceName={data.workspace.name} />
</PermissionAccessBoundary>
<ChatPlanModal />
</WorkspacePermissionsProvider>
</BrandingProvider>
</WorkspaceHostProvider>
Expand Down
Loading
Loading