Skip to content

Commit 7962236

Browse files
improvement(custom-blocks): hardened delete with usage count + per-input required option (#5575)
* improvement(custom-blocks): usage visibility + type-to-confirm delete * feat(custom-blocks): per-input required option * improvement(custom-blocks): replace usage tab with delete-confirmation usage count * fix(custom-blocks): escape LIKE wildcards in usage scan + fresh count on delete modal * fix(custom-blocks): explicit ESCAPE clause on usage-scan LIKE prefilter
1 parent 8e7e2db commit 7962236

14 files changed

Lines changed: 527 additions & 80 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextResponse } from 'next/server'
2+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
3+
import { getCustomBlockManageContext } from '@/lib/workflows/custom-blocks/operations'
4+
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
5+
6+
export type ManageContext = NonNullable<Awaited<ReturnType<typeof getCustomBlockManageContext>>>
7+
8+
/**
9+
* Confirm the caller can manage (edit/delete) the block: admin of the block's
10+
* SOURCE workflow's workspace — matching who could publish it. Org admins/owners
11+
* hold admin on every org workspace, so they pass too; a workspace admin from a
12+
* different workspace does not, so they cannot alter another workspace's block or
13+
* its exposed outputs.
14+
*/
15+
export async function authorizeManage(
16+
userId: string,
17+
id: string
18+
): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> {
19+
const ctx = await getCustomBlockManageContext(id)
20+
if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }
21+
22+
if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
23+
return {
24+
error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
25+
ctx: null,
26+
}
27+
}
28+
if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
29+
return {
30+
error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
31+
ctx: null,
32+
}
33+
}
34+
return { error: null, ctx }
35+
}

apps/sim/app/api/custom-blocks/[id]/route.ts

Lines changed: 9 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,51 +9,19 @@ import {
99
} from '@/lib/api/contracts/custom-blocks'
1010
import { parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
12-
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
1312
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1413
import {
1514
CustomBlockValidationError,
1615
deleteCustomBlock,
17-
getCustomBlockManageContext,
16+
getCustomBlockUsageCounts,
1817
updateCustomBlock,
1918
} from '@/lib/workflows/custom-blocks/operations'
20-
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
19+
import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage'
2120

2221
const logger = createLogger('CustomBlockAPI')
2322

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

26-
/**
27-
* Confirm the caller can manage (edit/delete) the block: admin of the block's
28-
* SOURCE workflow's workspace — matching who could publish it. Org admins/owners
29-
* hold admin on every org workspace, so they pass too; a workspace admin from a
30-
* different workspace does not, so they cannot alter another workspace's block or
31-
* its exposed outputs.
32-
*/
33-
type ManageContext = NonNullable<Awaited<ReturnType<typeof getCustomBlockManageContext>>>
34-
35-
async function authorizeManage(
36-
userId: string,
37-
id: string
38-
): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> {
39-
const ctx = await getCustomBlockManageContext(id)
40-
if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }
41-
42-
if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
43-
return {
44-
error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
45-
ctx: null,
46-
}
47-
}
48-
if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
49-
return {
50-
error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
51-
ctx: null,
52-
}
53-
}
54-
return { error: null, ctx }
55-
}
56-
5725
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
5826
const session = await getSession()
5927
if (!session?.user?.id) {
@@ -115,6 +83,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
11583
if (authz.error) return authz.error
11684
const { ctx } = authz
11785

86+
const usageCounts = await getCustomBlockUsageCounts(ctx.organizationId, ctx.type)
11887
await deleteCustomBlock(id)
11988
recordAudit({
12089
workspaceId: ctx.sourceWorkspaceId,
@@ -126,7 +95,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
12695
resourceId: id,
12796
resourceName: ctx.name,
12897
description: `Unpublished custom block "${ctx.name}"`,
129-
metadata: { organizationId: ctx.organizationId, type: ctx.type },
98+
metadata: {
99+
organizationId: ctx.organizationId,
100+
type: ctx.type,
101+
usageCount: usageCounts.usageCount,
102+
deployedUsageCount: usageCounts.deployedUsageCount,
103+
},
130104
request,
131105
})
132106
return NextResponse.json({ success: true as const })
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } =
8+
vi.hoisted(() => ({
9+
mockGetSession: vi.fn(),
10+
mockIsFeatureEnabled: vi.fn(),
11+
mockHasWorkspaceAdminAccess: vi.fn(),
12+
mockOperations: {
13+
getCustomBlockManageContext: vi.fn(),
14+
getCustomBlockUsageCounts: vi.fn(),
15+
},
16+
}))
17+
18+
vi.mock('@/lib/auth', () => ({
19+
getSession: mockGetSession,
20+
}))
21+
22+
vi.mock('@/lib/core/config/feature-flags', () => ({
23+
isFeatureEnabled: mockIsFeatureEnabled,
24+
}))
25+
26+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
27+
hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess,
28+
}))
29+
30+
vi.mock('@/lib/workflows/custom-blocks/operations', () => mockOperations)
31+
32+
import { GET } from '@/app/api/custom-blocks/[id]/usages/route'
33+
34+
const MANAGE_CONTEXT = {
35+
organizationId: 'org-1',
36+
sourceWorkspaceId: 'ws-1',
37+
type: 'custom_block_abc123',
38+
name: 'Invoice Parser',
39+
}
40+
41+
const USAGE_COUNTS = { usageCount: 3, deployedUsageCount: 2 }
42+
43+
function callRoute(id = 'cb-1') {
44+
return GET(createMockRequest('GET'), { params: Promise.resolve({ id }) })
45+
}
46+
47+
describe('GET /api/custom-blocks/[id]/usages', () => {
48+
beforeEach(() => {
49+
vi.clearAllMocks()
50+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
51+
mockIsFeatureEnabled.mockResolvedValue(true)
52+
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
53+
mockOperations.getCustomBlockManageContext.mockResolvedValue(MANAGE_CONTEXT)
54+
mockOperations.getCustomBlockUsageCounts.mockResolvedValue(USAGE_COUNTS)
55+
})
56+
57+
it('returns 401 without a session', async () => {
58+
mockGetSession.mockResolvedValue(null)
59+
const response = await callRoute()
60+
expect(response.status).toBe(401)
61+
})
62+
63+
it('returns 404 for an unknown block', async () => {
64+
mockOperations.getCustomBlockManageContext.mockResolvedValue(null)
65+
const response = await callRoute()
66+
expect(response.status).toBe(404)
67+
})
68+
69+
it('returns 403 when the feature flag is off', async () => {
70+
mockIsFeatureEnabled.mockResolvedValue(false)
71+
const response = await callRoute()
72+
expect(response.status).toBe(403)
73+
})
74+
75+
it('returns 403 for a non-admin of the source workspace', async () => {
76+
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
77+
const response = await callRoute()
78+
expect(response.status).toBe(403)
79+
expect(mockOperations.getCustomBlockUsageCounts).not.toHaveBeenCalled()
80+
})
81+
82+
it('returns the org-scoped usage counts for the block type', async () => {
83+
const response = await callRoute()
84+
expect(response.status).toBe(200)
85+
expect(await response.json()).toEqual(USAGE_COUNTS)
86+
expect(mockOperations.getCustomBlockUsageCounts).toHaveBeenCalledWith(
87+
'org-1',
88+
'custom_block_abc123'
89+
)
90+
})
91+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
import { getCustomBlockUsageCountsContract } from '@/lib/api/contracts/custom-blocks'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { getSession } from '@/lib/auth'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { getCustomBlockUsageCounts } from '@/lib/workflows/custom-blocks/operations'
8+
import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage'
9+
10+
type RouteContext = { params: Promise<{ id: string }> }
11+
12+
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
13+
const session = await getSession()
14+
if (!session?.user?.id) {
15+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
16+
}
17+
18+
const parsed = await parseRequest(getCustomBlockUsageCountsContract, request, context)
19+
if (!parsed.success) return parsed.response
20+
21+
const authz = await authorizeManage(session.user.id, parsed.data.params.id)
22+
if (authz.error) return authz.error
23+
24+
const counts = await getCustomBlockUsageCounts(authz.ctx.organizationId, authz.ctx.type)
25+
return NextResponse.json(counts)
26+
})

apps/sim/blocks/custom/build-config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface CustomBlockInput {
3333
type: string
3434
placeholder?: string
3535
description?: string
36+
required?: boolean
3637
}
3738

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

0 commit comments

Comments
 (0)