chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,192 @@
/**
* GET /api/v1/admin/workspaces/[id]/export
*
* Export an entire workspace as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip)
* - JSON: WorkspaceExportPayload
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { encodeFilenameForHeader } from '@/app/api/files/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type FolderExportPayload,
parseWorkflowVariables,
type WorkflowExportState,
type WorkspaceExportPayload,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceExportAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ExportWorkspaceContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { format } = parsed.data.query
try {
const [workspaceData] = await db
.select({ id: workspace.id, name: workspace.name })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const workflows = await db
.select()
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const folders = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
const workflowExports: Array<{
workflow: WorkspaceExportPayload['workflows'][number]['workflow']
state: WorkflowExportState
}> = []
for (const wf of workflows) {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(wf.id)
if (!normalizedData) {
logger.warn(`Skipping workflow ${wf.id} - no normalized data found`)
continue
}
const variables = parseWorkflowVariables(wf.variables)
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: wf.name,
description: wf.description ?? undefined,
exportedAt: new Date().toISOString(),
},
variables,
}
workflowExports.push({
workflow: {
id: wf.id,
name: wf.name,
description: wf.description,
workspaceId: wf.workspaceId,
folderId: wf.folderId,
},
state,
})
} catch (error) {
logger.error(`Failed to load workflow ${wf.id}:`, { error })
}
}
const folderExports: FolderExportPayload[] = folders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId,
}))
logger.info(
`Admin API: Exporting workspace ${workspaceId} with ${workflowExports.length} workflows and ${folderExports.length} folders`
)
const auditExport = () =>
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.WORKSPACE_EXPORTED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
resourceName: workspaceData.name,
description: `Admin API exported workspace "${workspaceData.name}"`,
metadata: {
format,
workflowCount: workflowExports.length,
folderCount: folderExports.length,
},
request,
})
if (format === 'json') {
auditExport()
const exportPayload: WorkspaceExportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
workspace: {
id: workspaceData.id,
name: workspaceData.name,
},
workflows: workflowExports,
folders: folderExports,
}
return singleResponse(exportPayload)
}
const zipWorkflows = workflowExports.map((wf) => ({
workflow: {
id: wf.workflow.id,
name: wf.workflow.name,
description: wf.workflow.description ?? undefined,
folderId: wf.workflow.folderId,
},
state: wf.state,
variables: wf.state.variables,
}))
const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports)
const arrayBuffer = await zipBlob.arrayBuffer()
const sanitizedName = sanitizePathSegment(workspaceData.name)
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
auditExport()
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
})
} catch (error) {
logger.error('Admin API: Failed to export workspace', { error, workspaceId })
return internalErrorResponse('Failed to export workspace')
}
})
)
@@ -0,0 +1,77 @@
/**
* GET /api/v1/admin/workspaces/[id]/folders
*
* List all folders in a workspace with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminFolder>
*/
import { db } from '@sim/db'
import { workflowFolder, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
import { type AdminFolder, createPaginationMeta, toAdminFolder } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceFoldersAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ListWorkspaceFoldersContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { limit, offset } = parsed.data.query
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [countResult, folders] = await Promise.all([
db
.select({ total: count() })
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId)),
db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
.orderBy(workflowFolder.sortOrder, workflowFolder.name)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminFolder[] = folders.map(toAdminFolder)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(
`Admin API: Listed ${data.length} folders in workspace ${workspaceId} (total: ${total})`
)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspace folders', { error, workspaceId })
return internalErrorResponse('Failed to list folders')
}
})
)
@@ -0,0 +1,316 @@
/**
* POST /api/v1/admin/workspaces/[id]/import
*
* Import workflows into a workspace from a ZIP file or JSON.
*
* Content-Type:
* - application/zip or multipart/form-data (with 'file' field) for ZIP upload
* - application/json for JSON payload
*
* JSON Body:
* {
* workflows: Array<{
* content: string | object, // Workflow JSON
* name?: string, // Override name
* folderPath?: string[] // Folder path to create
* }>
* }
*
* Query Parameters:
* - createFolders: 'true' (default) or 'false'
* - rootFolderName: optional name for root import folder
*
* Response: WorkspaceImportResponse
*/
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import {
adminV1ImportWorkspaceContract,
adminV1WorkspaceImportBodySchema,
} from '@/lib/api/contracts/v1/admin'
import { parseJsonBody, parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
extractWorkflowName,
extractWorkflowsFromZip,
parseWorkflowJson,
} from '@/lib/workflows/operations/import-export'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
} from '@/app/api/v1/admin/responses'
import type {
ImportResult,
WorkflowVariable,
WorkspaceImportRequest,
WorkspaceImportResponse,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceImportAPI')
/**
* Body cap for admin bulk workflow imports, which can carry many serialized
* workflows and legitimately exceed the default contract-route limit.
*/
const ADMIN_IMPORT_MAX_BODY_BYTES = 100 * 1024 * 1024
interface RouteParams {
id: string
}
interface ParsedWorkflow {
content: string
name: string
folderPath: string[]
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ImportWorkspaceContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { createFolders, rootFolderName } = parsed.data.query
try {
const workspaceData = await getWorkspaceWithOwner(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const contentType = request.headers.get('content-type') || ''
let workflowsToImport: ParsedWorkflow[] = []
if (contentType.includes('application/json')) {
const rawBody = await parseJsonBody(request, 'response', ADMIN_IMPORT_MAX_BODY_BYTES)
if (!rawBody.success) {
// Preserve the 413 for an oversized body; only invalid JSON maps to 400.
return rawBody.reason === 'too_large'
? rawBody.response
: badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
}
const validation = adminV1WorkspaceImportBodySchema.safeParse(rawBody.data)
if (!validation.success) {
return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
}
const body = validation.data as WorkspaceImportRequest
workflowsToImport = body.workflows.map((w) => ({
content: typeof w.content === 'string' ? w.content : JSON.stringify(w.content),
name: w.name || 'Imported Workflow',
folderPath: w.folderPath || [],
}))
} else if (
contentType.includes('application/zip') ||
contentType.includes('multipart/form-data')
) {
let zipBuffer: ArrayBuffer
if (contentType.includes('multipart/form-data')) {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return badRequestResponse('No file provided in form data. Use field name "file".')
}
zipBuffer = await file.arrayBuffer()
} else {
zipBuffer = await request.arrayBuffer()
}
const blob = new Blob([zipBuffer], { type: 'application/zip' })
const file = new File([blob], 'import.zip', { type: 'application/zip' })
const { workflows } = await extractWorkflowsFromZip(file)
workflowsToImport = workflows
} else {
return badRequestResponse(
'Unsupported Content-Type. Use application/json or application/zip.'
)
}
if (workflowsToImport.length === 0) {
return badRequestResponse('No workflows found to import')
}
let rootFolderId: string | undefined
if (rootFolderName && createFolders) {
rootFolderId = generateId()
await db.insert(workflowFolder).values({
id: rootFolderId,
name: rootFolderName,
userId: workspaceData.ownerId,
workspaceId,
parentId: null,
createdAt: new Date(),
updatedAt: new Date(),
})
}
const folderMap = new Map<string, string>()
const results: ImportResult[] = []
for (const wf of workflowsToImport) {
const result = await importSingleWorkflow(
wf,
workspaceId,
workspaceData.ownerId,
createFolders,
rootFolderId,
folderMap
)
results.push(result)
if (result.success) {
logger.info(`Admin API: Imported workflow ${result.workflowId} (${result.name})`)
} else {
logger.warn(`Admin API: Failed to import workflow ${result.name}: ${result.error}`)
}
}
const imported = results.filter((r) => r.success).length
const failed = results.filter((r) => !r.success).length
logger.info(`Admin API: Import complete - ${imported} succeeded, ${failed} failed`)
const response: WorkspaceImportResponse = { imported, failed, results }
return NextResponse.json(response)
} catch (error) {
logger.error('Admin API: Failed to import into workspace', { error, workspaceId })
return internalErrorResponse('Failed to import workflows')
}
})
)
async function importSingleWorkflow(
wf: ParsedWorkflow,
workspaceId: string,
ownerId: string,
createFolders: boolean,
rootFolderId: string | undefined,
folderMap: Map<string, string>
): Promise<ImportResult> {
try {
const { data: workflowData, errors } = parseWorkflowJson(wf.content)
if (!workflowData || errors.length > 0) {
return {
workflowId: '',
name: wf.name,
success: false,
error: `Parse error: ${errors.join(', ')}`,
}
}
const workflowName = extractWorkflowName(wf.content, wf.name)
let targetFolderId: string | null = rootFolderId || null
if (createFolders && wf.folderPath.length > 0) {
let parentId = rootFolderId || null
for (let i = 0; i < wf.folderPath.length; i++) {
const pathSegment = wf.folderPath.slice(0, i + 1).join('/')
const fullPath = rootFolderId ? `root/${pathSegment}` : pathSegment
if (!folderMap.has(fullPath)) {
const folderId = generateId()
await db.insert(workflowFolder).values({
id: folderId,
name: wf.folderPath[i],
userId: ownerId,
workspaceId,
parentId,
createdAt: new Date(),
updatedAt: new Date(),
})
folderMap.set(fullPath, folderId)
parentId = folderId
} else {
parentId = folderMap.get(fullPath)!
}
}
const fullFolderPath = rootFolderId
? `root/${wf.folderPath.join('/')}`
: wf.folderPath.join('/')
targetFolderId = folderMap.get(fullFolderPath) || parentId
}
const workflowId = generateId()
const now = new Date()
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId)
await db.insert(workflow).values({
id: workflowId,
userId: ownerId,
workspaceId,
folderId: targetFolderId,
name: dedupedName,
description: workflowData.metadata?.description || 'Imported via Admin API',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
if (!saveResult.success) {
await db.delete(workflow).where(eq(workflow.id, workflowId))
return {
workflowId: '',
name: dedupedName,
success: false,
error: `Failed to save state: ${saveResult.error}`,
}
}
if (workflowData.variables && Array.isArray(workflowData.variables)) {
const variablesRecord: Record<string, WorkflowVariable> = {}
workflowData.variables.forEach((v) => {
const varId = v.id || generateId()
variablesRecord[varId] = {
id: varId,
name: v.name,
type: v.type || 'string',
value: v.value,
}
})
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
}
return {
workflowId,
name: dedupedName,
success: true,
}
} catch (error) {
return {
workflowId: '',
name: wf.name,
success: false,
error: getErrorMessage(error, 'Unknown error'),
}
}
}
@@ -0,0 +1,320 @@
/**
* GET /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Get workspace member details.
*
* Response: AdminSingleResponse<AdminWorkspaceMember>
*
* PATCH /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Update member permissions.
*
* Body:
* - permissions: 'admin' | 'write' | 'read' - New permission level
*
* Response: AdminSingleResponse<AdminWorkspaceMember>
*
* DELETE /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Remove member from workspace.
*
* Response: AdminSingleResponse<{ removed: true, memberId: string, userId: string }>
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import {
adminV1GetWorkspaceMemberContract,
adminV1RemoveWorkspaceMemberContract,
adminV1UpdateWorkspaceMemberContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminWorkspaceMember } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceMemberDetailAPI')
interface RouteParams {
id: string
memberId: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, memberId } = parsed.data.params
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [memberData] = await db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!memberData) {
return notFoundResponse('Workspace member')
}
const data: AdminWorkspaceMember = {
id: memberData.id,
workspaceId,
userId: memberData.userId,
permissions: memberData.permissionType,
createdAt: memberData.createdAt.toISOString(),
updatedAt: memberData.updatedAt.toISOString(),
userName: memberData.userName,
userEmail: memberData.userEmail,
userImage: memberData.userImage,
}
logger.info(`Admin API: Retrieved member ${memberId} from workspace ${workspaceId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to get workspace member')
}
})
)
export const PATCH = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1UpdateWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, memberId } = parsed.data.params
const { permissions: permissionLevel } = parsed.data.body
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [existingMember] = await db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
})
.from(permissions)
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingMember) {
return notFoundResponse('Workspace member')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (
workspaceBilling?.billedAccountUserId === existingMember.userId &&
permissionLevel !== 'admin'
) {
return badRequestResponse('Workspace billing account must retain admin permissions')
}
const now = new Date()
await db
.update(permissions)
.set({ permissionType: permissionLevel, updatedAt: now })
.where(eq(permissions.id, memberId))
const [userData] = await db
.select({ name: user.name, email: user.email, image: user.image })
.from(user)
.where(eq(user.id, existingMember.userId))
.limit(1)
const data: AdminWorkspaceMember = {
id: existingMember.id,
workspaceId,
userId: existingMember.userId,
permissions: permissionLevel,
createdAt: existingMember.createdAt.toISOString(),
updatedAt: now.toISOString(),
userName: userData?.name ?? '',
userEmail: userData?.email ?? '',
userImage: userData?.image ?? null,
}
logger.info(`Admin API: Updated member ${memberId} permissions to ${permissionLevel}`, {
workspaceId,
previousPermissions: existingMember.permissionType,
})
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: `Admin API changed workspace member permissions to ${permissionLevel}`,
metadata: {
memberId,
targetUserId: existingMember.userId,
previousPermissions: existingMember.permissionType,
permissions: permissionLevel,
},
request,
})
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to update workspace member')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1RemoveWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, memberId } = parsed.data.params
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [existingMember] = await db
.select({
id: permissions.id,
userId: permissions.userId,
})
.from(permissions)
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingMember) {
return notFoundResponse('Workspace member')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceBilling?.billedAccountUserId === existingMember.userId) {
return badRequestResponse(
'Cannot remove the workspace billing account. Please reassign billing first.'
)
}
await db.transaction(async (tx) => {
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId: existingMember.userId,
})
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds: [workspaceId],
departingUserId: existingMember.userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx.delete(permissions).where(eq(permissions.id, memberId))
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, existingMember.userId)
})
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
userId: existingMember.userId,
})
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_REMOVED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: 'Admin API removed member from workspace',
metadata: { memberId, targetUserId: existingMember.userId },
request,
})
return singleResponse({
removed: true,
memberId,
userId: existingMember.userId,
workspaceId,
})
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to remove workspace member')
}
})
)
@@ -0,0 +1,402 @@
/**
* GET /api/v1/admin/workspaces/[id]/members
*
* List all members of a workspace with their permission details.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminWorkspaceMember>
*
* POST /api/v1/admin/workspaces/[id]/members
*
* Add a user to a workspace with a specific permission level.
* If the user already has permissions, updates their permission level.
*
* Body:
* - userId: string - User ID to add
* - permissions: 'admin' | 'write' | 'read' - Permission level
*
* Response: AdminSingleResponse<AdminWorkspaceMember & { action: 'created' | 'updated' }>
*
* DELETE /api/v1/admin/workspaces/[id]/members
*
* Remove a user from a workspace.
*
* Query Parameters:
* - userId: string - User ID to remove
*
* Response: AdminSingleResponse<{ removed: true }>
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, count, eq } from 'drizzle-orm'
import {
adminV1CreateWorkspaceMemberContract,
adminV1DeleteWorkspaceMemberContract,
adminV1ListWorkspaceMembersContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { type AdminWorkspaceMember, createPaginationMeta } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceMembersAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ListWorkspaceMembersContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { limit, offset } = parsed.data.query
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [countResult, membersData] = await Promise.all([
db
.select({ count: count() })
.from(permissions)
.where(
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
),
db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
)
.orderBy(permissions.createdAt)
.limit(limit)
.offset(offset),
])
const total = countResult[0].count
const data: AdminWorkspaceMember[] = membersData.map((m) => ({
id: m.id,
workspaceId,
userId: m.userId,
permissions: m.permissionType,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
userName: m.userName,
userEmail: m.userEmail,
userImage: m.userImage,
}))
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} members for workspace ${workspaceId}`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspace members', { error, workspaceId })
return internalErrorResponse('Failed to list workspace members')
}
})
)
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1CreateWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { userId, permissions: permissionLevel } = parsed.data.body
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceBilling?.billedAccountUserId === userId && permissionLevel !== 'admin') {
return badRequestResponse('Workspace billing account must retain admin permissions')
}
const [userData] = await db
.select({ id: user.id, name: user.name, email: user.email, image: user.image })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [existingPermission] = await db
.select({
id: permissions.id,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
})
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (existingPermission) {
if (existingPermission.permissionType !== permissionLevel) {
const now = new Date()
await db
.update(permissions)
.set({ permissionType: permissionLevel, updatedAt: now })
.where(eq(permissions.id, existingPermission.id))
logger.info(`Admin API: Updated user ${userId} permissions in workspace ${workspaceId}`, {
previousPermissions: existingPermission.permissionType,
newPermissions: permissionLevel,
})
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: `Admin API changed workspace member permissions to ${permissionLevel}`,
metadata: {
targetUserId: userId,
previousPermissions: existingPermission.permissionType,
permissions: permissionLevel,
},
request,
})
return singleResponse({
id: existingPermission.id,
workspaceId,
userId,
permissions: permissionLevel,
createdAt: existingPermission.createdAt.toISOString(),
updatedAt: now.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'updated' as const,
})
}
return singleResponse({
id: existingPermission.id,
workspaceId,
userId,
permissions: existingPermission.permissionType,
createdAt: existingPermission.createdAt.toISOString(),
updatedAt: existingPermission.updatedAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'already_member' as const,
})
}
const now = new Date()
const permissionId = generateId()
await db.insert(permissions).values({
id: permissionId,
userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: permissionLevel,
createdAt: now,
updatedAt: now,
})
logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
permissions: permissionLevel,
permissionId,
})
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_ADDED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: `Admin API added member to workspace with ${permissionLevel} permissions`,
metadata: { targetUserId: userId, permissions: permissionLevel },
request,
})
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: userId,
})
}
return singleResponse({
id: permissionId,
workspaceId,
userId,
permissions: permissionLevel,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'created' as const,
})
} catch (error) {
logger.error('Admin API: Failed to add workspace member', { error, workspaceId })
return internalErrorResponse('Failed to add workspace member')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1DeleteWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { userId } = parsed.data.query
let targetUserId: string | undefined
try {
targetUserId = userId
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceBilling?.billedAccountUserId === userId) {
return badRequestResponse(
'Cannot remove the workspace billing account. Please reassign billing first.'
)
}
const [existingPermission] = await db
.select({ id: permissions.id })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingPermission) {
return notFoundResponse('Workspace member')
}
await db.transaction(async (tx) => {
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId: userId,
})
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds: [workspaceId],
departingUserId: userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx.delete(permissions).where(eq(permissions.id, existingPermission.id))
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
})
logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`)
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_REMOVED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: 'Admin API removed member from workspace',
metadata: { targetUserId: userId },
request,
})
return singleResponse({ removed: true, userId, workspaceId })
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to remove workspace member', {
error,
workspaceId,
userId: targetUserId,
})
return internalErrorResponse('Failed to remove workspace member')
}
})
)
@@ -0,0 +1,70 @@
/**
* GET /api/v1/admin/workspaces/[id]
*
* Get workspace details including workflow and folder counts.
*
* Response: AdminSingleResponse<AdminWorkspaceDetail>
*/
import { db } from '@sim/db'
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { adminV1GetWorkspaceContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { type AdminWorkspaceDetail, toAdminWorkspace } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceDetailAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetWorkspaceContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
try {
const [workspaceData] = await db
.select()
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [workflowCountResult, folderCountResult] = await Promise.all([
db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)),
db
.select({ count: count() })
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId)),
])
const data: AdminWorkspaceDetail = {
...toAdminWorkspace(workspaceData),
workflowCount: workflowCountResult[0].count,
folderCount: folderCountResult[0].count,
}
logger.info(`Admin API: Retrieved workspace ${workspaceId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get workspace', { error, workspaceId })
return internalErrorResponse('Failed to get workspace')
}
})
)
@@ -0,0 +1,141 @@
/**
* GET /api/v1/admin/workspaces/[id]/workflows
*
* List all workflows in a workspace with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminWorkflow>
*
* DELETE /api/v1/admin/workspaces/[id]/workflows
*
* Delete all workflows in a workspace (clean slate for reimport).
*
* Response: { success: true, deleted: number }
*/
import { db } from '@sim/db'
import { workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import {
adminV1DeleteWorkspaceWorkflowsContract,
adminV1ListWorkspaceWorkflowsContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
import { type AdminWorkflow, createPaginationMeta, toAdminWorkflow } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceWorkflowsAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ListWorkspaceWorkflowsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { limit, offset } = parsed.data.query
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [countResult, workflows] = await Promise.all([
db
.select({ total: count() })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))),
db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
workspaceId: workflow.workspaceId,
folderId: workflow.folderId,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
runCount: workflow.runCount,
lastRunAt: workflow.lastRunAt,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
})
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
.orderBy(workflow.name)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminWorkflow[] = workflows.map(toAdminWorkflow)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(
`Admin API: Listed ${data.length} workflows in workspace ${workspaceId} (total: ${total})`
)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspace workflows', { error, workspaceId })
return internalErrorResponse('Failed to list workflows')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1DeleteWorkspaceWorkflowsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const workflowsToDelete = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
if (workflowsToDelete.length === 0) {
return NextResponse.json({ success: true, deleted: 0 })
}
const deletedCount = await archiveWorkflowsForWorkspace(workspaceId, {
requestId: `admin-workspace-${workspaceId}`,
})
logger.info(`Admin API: Deleted ${deletedCount} workflows from workspace ${workspaceId}`)
return NextResponse.json({ success: true, deleted: deletedCount })
} catch (error) {
logger.error('Admin API: Failed to delete workspace workflows', { error, workspaceId })
return internalErrorResponse('Failed to delete workflows')
}
})
)
@@ -0,0 +1,55 @@
/**
* GET /api/v1/admin/workspaces
*
* List all workspaces with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminWorkspace>
*/
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count } from 'drizzle-orm'
import { adminV1ListWorkspacesContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import {
type AdminWorkspace,
createPaginationMeta,
toAdminWorkspace,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspacesAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(adminV1ListWorkspacesContract, request, {})
if (!parsed.success) return parsed.response
const { limit, offset } = parsed.data.query
try {
const [countResult, workspaces] = await Promise.all([
db.select({ total: count() }).from(workspace),
db.select().from(workspace).orderBy(workspace.name).limit(limit).offset(offset),
])
const total = countResult[0].total
const data: AdminWorkspace[] = workspaces.map(toAdminWorkspace)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} workspaces (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspaces', { error })
return internalErrorResponse('Failed to list workspaces')
}
})
)