Skip to content

Commit 5a31568

Browse files
committed
Merge remote-tracking branch 'origin/staging' into chore/sync-dev-with-staging-2026-11-03b
2 parents 8f6a420 + e137d82 commit 5a31568

110 files changed

Lines changed: 4878 additions & 850 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/api/credentials/[id]/members/route.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe('/api/credentials/[id]/members compatibility', () => {
7575
joinedAt: new Date('2026-08-02T00:00:00.000Z'),
7676
userName: 'Member',
7777
userEmail: 'member@example.com',
78+
explicitAdmin: false,
7879
},
7980
])
8081
})
Lines changed: 21 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,25 @@
1-
import { createLogger } from '@sim/logger'
2-
import { type NextRequest, NextResponse } from 'next/server'
31
import { getMemberRemovalImpactContract } from '@/lib/api/contracts/organization'
4-
import { parseRequest } from '@/lib/api/server'
5-
import { getSession } from '@/lib/auth'
6-
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
7-
import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership'
8-
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9-
10-
const logger = createLogger('OrganizationRemovalImpactAPI')
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations'
8+
import { previewOrganizationMemberRemoval } from '@/lib/organizations/application/members'
119

1210
/**
13-
* Identity-bound credentials the target user owns in this organization's
14-
* workspaces — the set that stops working when their workspace access is
15-
* revoked. Readable by org admins (removing someone) and by the user
16-
* themself (leaving).
11+
* Identity-bound credentials the target user owns in this organization's workspaces. Readable by
12+
* org admins (removing someone) and by the user themself (leaving).
1713
*/
18-
export const GET = withRouteHandler(
19-
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
20-
const session = await getSession()
21-
if (!session?.user?.id) {
22-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23-
}
24-
25-
const parsed = await parseRequest(getMemberRemovalImpactContract, request, context)
26-
if (!parsed.success) return parsed.response
27-
28-
const { id: organizationId } = parsed.data.params
29-
const { userId: targetUserId } = parsed.data.query
30-
31-
try {
32-
const isSelf = targetUserId === session.user.id
33-
if (!isSelf && !(await isOrganizationOwnerOrAdmin(session.user.id, organizationId))) {
34-
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
35-
}
36-
37-
const credentials = await getOrganizationTransferCredentialDependencies(
38-
targetUserId,
39-
organizationId
40-
)
41-
42-
return NextResponse.json({ credentials })
43-
} catch (error) {
44-
logger.error('Failed to compute member removal impact', {
45-
organizationId,
46-
targetUserId,
47-
error,
48-
})
49-
return NextResponse.json({ error: 'Failed to compute removal impact' }, { status: 500 })
50-
}
51-
}
52-
)
14+
export const GET = defineInternalJsonRoute({
15+
contract: getMemberRemovalImpactContract,
16+
auth: internalSessionAuth,
17+
operation: previewOrganizationMemberRemoval.operation,
18+
rateLimit: internalRateLimits.none({
19+
reason: 'Preserve existing organization member administration behavior.',
20+
}),
21+
errorPolicy: internalOrganizationErrorPolicy,
22+
mapInput: ({ params, query }) => ({ organizationId: params.id, userId: query.userId }),
23+
useCase: previewOrganizationMemberRemoval,
24+
present: ({ credentials }) => ({ credentials }),
25+
})

‎apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,21 @@ export const POST = withRouteHandler(
149149
userId: session.user.id,
150150
organizationId,
151151
memberId: selfMember.id,
152+
actorUserId: session.user.id,
152153
spareSessionToken: session.session.token,
153154
})
154155
deliverRunStops(removeResult.stoppedRuns)
155156

156157
if (!removeResult.success) {
157-
logger.error('Transfer succeeded but self-removal failed', {
158+
const outcome = {
158159
organizationId,
159160
userId: session.user.id,
160161
error: removeResult.error,
161-
})
162+
refusalCode: removeResult.refusalCode,
163+
}
164+
if (removeResult.refusalCode)
165+
logger.info('Transfer succeeded; policy kept the previous owner as admin', outcome)
166+
else logger.error('Transfer succeeded but self-removal failed', outcome)
162167
return NextResponse.json(
163168
{
164169
success: true,

‎apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const GET = defineV2JsonRoute({
3939
}),
4040
useCase: listCredentialMembersUseCase,
4141
present: ({ members, nextCursorKeys }, { params, query }) => ({
42-
data: members.map((member) => ({
42+
data: members.map(({ explicitAdmin, ...member }) => ({
4343
...member,
4444
joinedAt: member.joinedAt?.toISOString() ?? null,
4545
})),

‎apps/sim/app/api/workspaces/[id]/route.ts‎

Lines changed: 57 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,31 @@ import { workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, isNull } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6-
import { deleteWorkspaceBodySchema, updateWorkspaceContract } from '@/lib/api/contracts'
7-
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
6+
import {
7+
deleteWorkspaceBodySchema,
8+
updateWorkspaceContract,
9+
updateWorkspacePutContract,
10+
} from '@/lib/api/contracts'
11+
import { validationErrorResponse } from '@/lib/api/server'
812
import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response'
13+
import {
14+
defineInternalJsonRoute,
15+
type InternalErrorPolicy,
16+
internalErrorResponse,
17+
internalOrchestrationErrorPolicy,
18+
internalRateLimits,
19+
internalSessionAuth,
20+
} from '@/lib/api/server/routes'
921
import { getSession } from '@/lib/auth'
10-
import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer'
22+
import {
23+
InsufficientWorkspacePermissionsError,
24+
NoWorkspaceAccessError,
25+
} from '@/lib/core/application'
1126
import { captureServerEvent } from '@/lib/posthog/server'
27+
import {
28+
updateWorkspace,
29+
workspaceUpdateOperations,
30+
} from '@/lib/workspaces/application/update-workspace'
1231
import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
1332
import { presentWorkspace } from '@/lib/workspaces/response'
1433

@@ -18,10 +37,8 @@ import { db } from '@sim/db'
1837
import { permissions, workspace } from '@sim/db/schema'
1938
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2039
import { deliverRunStops } from '@/lib/mothership/request/application/stop-delivery'
21-
import {
22-
getEffectiveWorkspacePermission,
23-
getUserEntityPermissions,
24-
} from '@/lib/workspaces/permissions/utils'
40+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
41+
import type { WorkspaceUpdatePatch, WorkspaceUpdateResult } from '@/lib/workspaces/update'
2542

2643
export const GET = withRouteHandler(
2744
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
@@ -60,174 +77,35 @@ export const GET = withRouteHandler(
6077
}
6178
)
6279

63-
export const PATCH = withRouteHandler(
64-
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
65-
const session = await getSession()
66-
67-
if (!session?.user?.id) {
68-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
69-
}
70-
71-
const parsed = await parseRequest(updateWorkspaceContract, request, context)
72-
if (!parsed.success) return parsed.response
73-
74-
const workspaceId = parsed.data.params.id
75-
76-
// Check if user has admin permissions to update workspace
77-
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
78-
if (userPermission !== 'admin') {
79-
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
80-
}
81-
82-
try {
83-
const body = parsed.data.body
84-
const { name, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body
85-
86-
if (
87-
name === undefined &&
88-
logoUrl === undefined &&
89-
billedAccountUserId === undefined &&
90-
allowPersonalApiKeys === undefined
91-
) {
92-
return NextResponse.json({ error: 'No updates provided' }, { status: 400 })
93-
}
94-
95-
const existingWorkspace = await db
96-
.select()
97-
.from(workspace)
98-
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
99-
.then((rows) => rows[0])
100-
101-
if (!existingWorkspace) {
102-
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
103-
}
104-
105-
const updateData: Record<string, unknown> = {}
106-
107-
if (name !== undefined) {
108-
updateData.name = name
109-
}
110-
111-
if (logoUrl !== undefined) {
112-
updateData.logoUrl = logoUrl
113-
}
114-
115-
if (allowPersonalApiKeys !== undefined) {
116-
updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys)
117-
}
118-
119-
if (billedAccountUserId !== undefined) {
120-
if (
121-
existingWorkspace.organizationId &&
122-
existingWorkspace.workspaceMode === 'organization'
123-
) {
124-
return NextResponse.json(
125-
{
126-
error:
127-
'Organization workspaces use organization billing and cannot change billed account.',
128-
},
129-
{ status: 400 }
130-
)
131-
}
132-
133-
if (existingWorkspace.workspaceMode === 'personal') {
134-
return NextResponse.json(
135-
{
136-
error:
137-
'Personal workspaces are always billed to their owner and cannot change billed account.',
138-
},
139-
{ status: 400 }
140-
)
141-
}
142-
143-
const candidateId = billedAccountUserId
144-
145-
const candidatePermission = await getEffectiveWorkspacePermission(
146-
candidateId,
147-
existingWorkspace
148-
)
149-
if (candidatePermission !== 'admin') {
150-
return NextResponse.json(
151-
{ error: 'Billed account must be a workspace admin' },
152-
{ status: 400 }
153-
)
154-
}
155-
}
156-
157-
if (Object.keys(updateData).length === 0 && billedAccountUserId === undefined) {
158-
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })
159-
}
160-
161-
updateData.updatedAt = new Date()
162-
163-
if (billedAccountUserId !== undefined) {
164-
await db.transaction(async (tx) => {
165-
await changeWorkspaceStoragePayerInTx(tx, {
166-
workspaceId,
167-
organizationId: existingWorkspace.organizationId,
168-
billedAccountUserId,
169-
expectedCurrentPayer: {
170-
organizationId: existingWorkspace.organizationId,
171-
billedAccountUserId: existingWorkspace.billedAccountUserId,
172-
},
173-
})
174-
await tx.update(workspace).set(updateData).where(eq(workspace.id, workspaceId))
175-
})
176-
} else {
177-
await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId))
178-
}
179-
180-
const updatedWorkspace = await db
181-
.select()
182-
.from(workspace)
183-
.where(eq(workspace.id, workspaceId))
184-
.then((rows) => rows[0])
185-
186-
recordAudit({
187-
workspaceId,
188-
actorId: session.user.id,
189-
actorName: session.user.name,
190-
actorEmail: session.user.email,
191-
action: AuditAction.WORKSPACE_UPDATED,
192-
resourceType: AuditResourceType.WORKSPACE,
193-
resourceId: workspaceId,
194-
resourceName: updatedWorkspace?.name ?? existingWorkspace.name,
195-
description: `Updated workspace "${updatedWorkspace?.name ?? existingWorkspace.name}"`,
196-
metadata: {
197-
changes: {
198-
...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }),
199-
...(logoUrl !== undefined && {
200-
logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl },
201-
}),
202-
...(allowPersonalApiKeys !== undefined && {
203-
allowPersonalApiKeys: {
204-
from: existingWorkspace.allowPersonalApiKeys,
205-
to: allowPersonalApiKeys,
206-
},
207-
}),
208-
...(billedAccountUserId !== undefined && {
209-
billedAccountUserId: {
210-
from: existingWorkspace.billedAccountUserId,
211-
to: billedAccountUserId,
212-
},
213-
}),
214-
},
215-
},
216-
request,
217-
})
218-
219-
return NextResponse.json({
220-
workspace: {
221-
...(updatedWorkspace ? presentWorkspace(updatedWorkspace) : {}),
222-
permissions: userPermission,
223-
},
224-
})
225-
} catch (error) {
226-
logger.error('Error updating workspace:', error)
227-
return NextResponse.json({ error: 'Failed to update workspace' }, { status: 500 })
228-
}
229-
}
230-
)
80+
const updateWorkspaceErrorPolicy: InternalErrorPolicy = {
81+
project: (error) =>
82+
error instanceof NoWorkspaceAccessError ||
83+
error instanceof InsufficientWorkspacePermissionsError
84+
? internalErrorResponse(403, { error: 'Insufficient permissions' })
85+
: internalOrchestrationErrorPolicy.project(error),
86+
unhandled: () => internalErrorResponse(500, { error: 'Failed to update workspace' }),
87+
}
88+
89+
const updateWorkspaceRoute = {
90+
auth: internalSessionAuth,
91+
operation: workspaceUpdateOperations.update,
92+
rateLimit: internalRateLimits.none({ reason: 'Preserve existing workspace update admission' }),
93+
errorPolicy: updateWorkspaceErrorPolicy,
94+
mapInput: ({ params, body }: { params: { id: string }; body: WorkspaceUpdatePatch }) => ({
95+
workspaceId: params.id,
96+
patch: body,
97+
}),
98+
useCase: updateWorkspace,
99+
/** Only a workspace admin reaches a successful update, so the caller's role is known. */
100+
present: ({ updated }: WorkspaceUpdateResult) => ({
101+
workspace: { ...presentWorkspace(updated), permissions: 'admin' as const },
102+
}),
103+
}
104+
105+
export const PATCH = defineInternalJsonRoute({
106+
contract: updateWorkspaceContract,
107+
...updateWorkspaceRoute,
108+
})
231109

232110
export const DELETE = withRouteHandler(
233111
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
@@ -344,9 +222,7 @@ export const DELETE = withRouteHandler(
344222
}
345223
)
346224

347-
export const PUT = withRouteHandler(
348-
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
349-
// Reuse the PATCH handler implementation for PUT requests
350-
return PATCH(request, { params })
351-
}
352-
)
225+
export const PUT = defineInternalJsonRoute({
226+
contract: updateWorkspacePutContract,
227+
...updateWorkspaceRoute,
228+
})

‎apps/sim/app/extension/[workspaceId]/workspace.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isApiClientError } from '@/lib/api/client/errors'
55
import { ExtensionChat } from '@/app/extension/[workspaceId]/chat'
66
import ExtensionLoading from '@/app/extension/loading'
77
import { ExtensionSignIn } from '@/app/extension/sign-in'
8+
import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal'
89
import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader'
910
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
1011
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
@@ -65,6 +66,7 @@ export function ExtensionWorkspace({ workspaceId }: ExtensionWorkspaceProps) {
6566
>
6667
<ExtensionChat workspaceId={workspaceId} workspaceName={data.workspace.name} />
6768
</PermissionAccessBoundary>
69+
<ChatPlanModal />
6870
</WorkspacePermissionsProvider>
6971
</BrandingProvider>
7072
</WorkspaceHostProvider>

0 commit comments

Comments
 (0)