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,255 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { removeWorkspaceMemberContract } from '@/lib/api/contracts/invitations'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { captureServerEvent } from '@/lib/posthog/server'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
const logger = createLogger('WorkspaceMemberAPI')
// DELETE /api/workspaces/members/[id] - Remove a member from a workspace
export const DELETE = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(removeWorkspaceMemberContract, req, context)
if (!parsed.success) return parsed.response
const { id: userId } = parsed.data.params
const { workspaceId } = parsed.data.body
const workspaceRow = await db
.select({
ownerId: workspace.ownerId,
billedAccountUserId: workspace.billedAccountUserId,
organizationId: workspace.organizationId,
})
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (workspaceRow[0].billedAccountUserId === userId) {
return NextResponse.json(
{ error: 'Cannot remove the workspace billing account. Please reassign billing first.' },
{ status: 400 }
)
}
// Check if the user to be removed actually has permissions for this workspace
const userPermission = await db
.select()
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.then((rows) => rows[0])
const isRemovingWorkspaceOwner = workspaceRow[0].ownerId === userId
const isOwnerOnlyRemoval = isRemovingWorkspaceOwner && !userPermission
if (!userPermission && !isOwnerOnlyRemoval) {
return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 })
}
// Check if current user has admin access to this workspace
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
const isSelf = userId === session.user.id
if (!hasAdminAccess && !isSelf) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
// Removing the workspace owner is allowed for any admin: ownership transfers
// to the billing account in the transaction below. The billing account itself
// stays protected by the guard above (and personal workspaces, where owner ==
// billing account, are blocked there).
// Prevent removing yourself if you're the last admin
if (isSelf && userPermission?.permissionType === 'admin' && !isRemovingWorkspaceOwner) {
const otherAdmins = await db
.select()
.from(permissions)
.where(
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId),
eq(permissions.permissionType, 'admin')
)
)
.then((rows) => rows.filter((row) => row.userId !== session.user.id))
if (otherAdmins.length === 0) {
return NextResponse.json(
{ error: 'Cannot remove the last admin from a workspace' },
{ status: 400 }
)
}
}
const organizationId = workspaceRow[0].organizationId
const { ownershipTransferred, workflowOwnershipReassignment } = await db.transaction(
async (tx) => {
const didTransferOwnership =
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(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
return { ownershipTransferred: didTransferOwnership, workflowOwnershipReassignment }
}
)
/**
* Seats are tied to organization membership (one per member), so a
* single-workspace removal only drops a seat when it leaves the member
* with no access to any of the org's workspaces — at which point their
* org membership is removed too. Members still in other org workspaces
* keep their membership and seat.
*/
let organizationRemoval = false
let seatReduction: Awaited<ReturnType<typeof reconcileOrganizationSeats>> | null = null
if (organizationId && userId !== workspaceRow[0].billedAccountUserId) {
const [orgMembership] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userId)))
.limit(1)
if (orgMembership) {
/**
* Remove the org membership + seat only when this is the member's last
* access to any org workspace. The remaining-access check and the
* deletion happen atomically under a `(user, org)` advisory lock inside
* `removeUserFromOrganization` (`requireNoOrgWorkspaceAccess`), so a
* concurrent invite acceptance can't be raced into a "workspace access
* but no org membership" state.
*/
const removal = await removeUserFromOrganization({
userId,
organizationId,
memberId: orgMembership.id,
requireNoOrgWorkspaceAccess: true,
})
if (removal.success && removal.removed) {
organizationRemoval = true
try {
seatReduction = await reconcileOrganizationSeats({
organizationId,
reason: 'member-removed',
actorId: session.user.id,
})
} catch (seatError) {
logger.error('Failed to reduce seats after workspace member removal', {
organizationId,
workspaceId,
removedUserId: userId,
error: seatError,
})
}
} else if (!removal.success) {
logger.error('Failed to remove org membership after last workspace removal', {
organizationId,
workspaceId,
removedUserId: userId,
error: removal.error,
})
}
}
}
captureServerEvent(
session.user.id,
'workspace_member_removed',
{ workspace_id: workspaceId, is_self_removal: isSelf },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.MEMBER_REMOVED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: isSelf
? organizationRemoval
? 'Left the organization'
: 'Left the workspace'
: organizationRemoval
? `Removed member ${userId} from the organization`
: `Removed member ${userId} from the workspace`,
metadata: {
removedUserId: userId,
removedUserRole: userPermission?.permissionType ?? 'owner',
selfRemoval: isSelf,
ownershipTransferred,
workflowOwnershipReassignment,
organizationRemoval,
seatReduction,
},
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Error removing workspace member:', error)
return NextResponse.json({ error: 'Failed to remove workspace member' }, { status: 500 })
}
}
)