Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { getCustomBlockManageContext } from '@/lib/workflows/custom-blocks/operations'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'

export type ManageContext = NonNullable<Awaited<ReturnType<typeof getCustomBlockManageContext>>>

/**
* Confirm the caller can manage (edit/delete) the block: admin of the block's
* SOURCE workflow's workspace — matching who could publish it. Org admins/owners
* hold admin on every org workspace, so they pass too; a workspace admin from a
* different workspace does not, so they cannot alter another workspace's block or
* its exposed outputs.
*/
export async function authorizeManage(
userId: string,
id: string
): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> {
const ctx = await getCustomBlockManageContext(id)
if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }

if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
return {
error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
ctx: null,
}
}
if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
return {
error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
ctx: null,
}
}
return { error: null, ctx }
}
44 changes: 9 additions & 35 deletions apps/sim/app/api/custom-blocks/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,51 +9,19 @@ import {
} from '@/lib/api/contracts/custom-blocks'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
CustomBlockValidationError,
deleteCustomBlock,
getCustomBlockManageContext,
getCustomBlockUsageCounts,
updateCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage'

const logger = createLogger('CustomBlockAPI')

type RouteContext = { params: Promise<{ id: string }> }

/**
* Confirm the caller can manage (edit/delete) the block: admin of the block's
* SOURCE workflow's workspace — matching who could publish it. Org admins/owners
* hold admin on every org workspace, so they pass too; a workspace admin from a
* different workspace does not, so they cannot alter another workspace's block or
* its exposed outputs.
*/
type ManageContext = NonNullable<Awaited<ReturnType<typeof getCustomBlockManageContext>>>

async function authorizeManage(
userId: string,
id: string
): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> {
const ctx = await getCustomBlockManageContext(id)
if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }

if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
return {
error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
ctx: null,
}
}
if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
return {
error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
ctx: null,
}
}
return { error: null, ctx }
}

export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const session = await getSession()
if (!session?.user?.id) {
Expand Down Expand Up @@ -115,6 +83,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
if (authz.error) return authz.error
const { ctx } = authz

const usageCounts = await getCustomBlockUsageCounts(ctx.organizationId, ctx.type)
await deleteCustomBlock(id)
recordAudit({
workspaceId: ctx.sourceWorkspaceId,
Expand All @@ -126,7 +95,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
resourceId: id,
resourceName: ctx.name,
description: `Unpublished custom block "${ctx.name}"`,
metadata: { organizationId: ctx.organizationId, type: ctx.type },
metadata: {
organizationId: ctx.organizationId,
type: ctx.type,
usageCount: usageCounts.usageCount,
deployedUsageCount: usageCounts.deployedUsageCount,
},
request,
})
return NextResponse.json({ success: true as const })
Expand Down
91 changes: 91 additions & 0 deletions apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } =
vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockIsFeatureEnabled: vi.fn(),
mockHasWorkspaceAdminAccess: vi.fn(),
mockOperations: {
getCustomBlockManageContext: vi.fn(),
getCustomBlockUsageCounts: vi.fn(),
},
}))

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))

vi.mock('@/lib/core/config/feature-flags', () => ({
isFeatureEnabled: mockIsFeatureEnabled,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess,
}))

vi.mock('@/lib/workflows/custom-blocks/operations', () => mockOperations)

import { GET } from '@/app/api/custom-blocks/[id]/usages/route'

const MANAGE_CONTEXT = {
organizationId: 'org-1',
sourceWorkspaceId: 'ws-1',
type: 'custom_block_abc123',
name: 'Invoice Parser',
}

const USAGE_COUNTS = { usageCount: 3, deployedUsageCount: 2 }

function callRoute(id = 'cb-1') {
return GET(createMockRequest('GET'), { params: Promise.resolve({ id }) })
}

describe('GET /api/custom-blocks/[id]/usages', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockIsFeatureEnabled.mockResolvedValue(true)
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
mockOperations.getCustomBlockManageContext.mockResolvedValue(MANAGE_CONTEXT)
mockOperations.getCustomBlockUsageCounts.mockResolvedValue(USAGE_COUNTS)
})

it('returns 401 without a session', async () => {
mockGetSession.mockResolvedValue(null)
const response = await callRoute()
expect(response.status).toBe(401)
})

it('returns 404 for an unknown block', async () => {
mockOperations.getCustomBlockManageContext.mockResolvedValue(null)
const response = await callRoute()
expect(response.status).toBe(404)
})

it('returns 403 when the feature flag is off', async () => {
mockIsFeatureEnabled.mockResolvedValue(false)
const response = await callRoute()
expect(response.status).toBe(403)
})

it('returns 403 for a non-admin of the source workspace', async () => {
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
const response = await callRoute()
expect(response.status).toBe(403)
expect(mockOperations.getCustomBlockUsageCounts).not.toHaveBeenCalled()
})

it('returns the org-scoped usage counts for the block type', async () => {
const response = await callRoute()
expect(response.status).toBe(200)
expect(await response.json()).toEqual(USAGE_COUNTS)
expect(mockOperations.getCustomBlockUsageCounts).toHaveBeenCalledWith(
'org-1',
'custom_block_abc123'
)
})
})
26 changes: 26 additions & 0 deletions apps/sim/app/api/custom-blocks/[id]/usages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getCustomBlockUsageCountsContract } from '@/lib/api/contracts/custom-blocks'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCustomBlockUsageCounts } from '@/lib/workflows/custom-blocks/operations'
import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage'

type RouteContext = { params: Promise<{ id: string }> }

export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(getCustomBlockUsageCountsContract, request, context)
if (!parsed.success) return parsed.response

const authz = await authorizeManage(session.user.id, parsed.data.params.id)
if (authz.error) return authz.error

const counts = await getCustomBlockUsageCounts(authz.ctx.organizationId, authz.ctx.type)
return NextResponse.json(counts)
})
4 changes: 4 additions & 0 deletions apps/sim/blocks/custom/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface CustomBlockInput {
type: string
placeholder?: string
description?: string
required?: boolean
}

/**
Expand Down Expand Up @@ -108,6 +109,9 @@ export function buildCustomBlockConfig(
type,
description: field.description,
placeholder: field.placeholder,
// Serializer Loop-B (required subBlocks not covered by tool params) and the
// editor asterisk both read this — same enforcement path as regular blocks.
required: field.required === true,
}
if (field.type === 'object' || field.type === 'array') sub.language = 'json'
if (field.type === 'file[]') sub.multiple = true
Expand Down
Loading
Loading