fix(custom-blocks): restore self-host entitlement gate (#7098)

* fix(custom-blocks): restore self-host entitlement gate

* chore(helm): bump chart version
This commit is contained in:
Theodore Li
2026-08-25 20:45:00 -07:00
committed by GitHub
parent 9da342eec2
commit d1786e92e6
23 changed files with 189 additions and 49 deletions
@@ -12,7 +12,7 @@ On Sim Cloud, enterprise features are unlocked by an Enterprise subscription. Se
There are two parts to getting this right, and skipping the second is the most common reason features appear to do nothing:
1. **Enable the features** with `ENTERPRISE_ENABLED`.
2. **Give them an organization to apply to.** Whitelabeling, PII redaction, permission groups, data drains, and audit scoping all read their settings from the organization that owns a workspace. A deployment where everyone works in personal workspaces has no organization for those settings to come from.
2. **Give them an organization to apply to.** Whitelabeling, PII redaction, permission groups, custom blocks, data drains, and audit scoping all read their settings from the organization that owns a workspace. A deployment where everyone works in personal workspaces has no organization for those settings to come from.
## Enable the feature set
@@ -24,7 +24,7 @@ NEXT_PUBLIC_ENTERPRISE_ENABLED=true
```
That turns on organizations, permission groups, SSO, whitelabeling, audit logs,
session policies, data retention, data drains, workspace forks, the Sandbox
custom blocks, session policies, data retention, data drains, workspace forks, the Sandbox
entitlement, and the inbox. Sandboxes remain unavailable until their remote
provider and dedicated Function base are configured.
@@ -49,6 +49,7 @@ The individual flags also work on their own if you would rather opt in one at a
| SAML and OIDC sign-in | `SSO_ENABLED` | `NEXT_PUBLIC_SSO_ENABLED` |
| Custom branding | `WHITELABELING_ENABLED` | `NEXT_PUBLIC_WHITELABELING_ENABLED` |
| Audit logs | `AUDIT_LOGS_ENABLED` | `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` |
| Custom blocks | `CUSTOM_BLOCKS_ENABLED` | `NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED` |
| Session policies | `SESSION_POLICIES_ENABLED` | `NEXT_PUBLIC_SESSION_POLICIES_ENABLED` |
| Data retention deletion | `DATA_RETENTION_ENABLED` | `NEXT_PUBLIC_DATA_RETENTION_ENABLED` |
| Data drains | `DATA_DRAINS_ENABLED` | `NEXT_PUBLIC_DATA_DRAINS_ENABLED` |
+3 -2
View File
@@ -182,8 +182,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
# Enterprise Features (Optional - self-hosted). One switch enables organizations, SSO,
# permission groups, audit logs, whitelabeling, session policies, data retention, data
# drains, forks, and the inbox. Set both — the server value grants access, the
# permission groups, audit logs, custom blocks, whitelabeling, session policies, data
# retention, data drains, forks, and the inbox. Set both — the server value grants access, the
# NEXT_PUBLIC_ value decides what the settings UI shows.
# Docs: https://docs.sim.ai/platform/enterprise/self-hosted
# ENTERPRISE_ENABLED=true
@@ -195,6 +195,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# SSO_ENABLED= / NEXT_PUBLIC_SSO_ENABLED= # SAML and OIDC sign-in
# WHITELABELING_ENABLED= / NEXT_PUBLIC_WHITELABELING_ENABLED= # Custom branding
# AUDIT_LOGS_ENABLED= / NEXT_PUBLIC_AUDIT_LOGS_ENABLED= # Audit logging
# CUSTOM_BLOCKS_ENABLED= / NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED= # Reusable org-wide blocks
# SESSION_POLICIES_ENABLED= / NEXT_PUBLIC_SESSION_POLICIES_ENABLED=
# DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
@@ -1,5 +1,8 @@
import { NextResponse } from 'next/server'
import { getCustomBlockManageContext } from '@/lib/workflows/custom-blocks/operations'
import {
getCustomBlockManageContext,
isCustomBlocksDeploymentEnabled,
} from '@/lib/workflows/custom-blocks/operations'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
export type ManageContext = NonNullable<Awaited<ReturnType<typeof getCustomBlockManageContext>>>
@@ -18,6 +21,16 @@ export async function authorizeManage(
const ctx = await getCustomBlockManageContext(id)
if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }
if (!isCustomBlocksDeploymentEnabled()) {
return {
error: NextResponse.json(
{ error: 'Custom blocks are not enabled for this organization' },
{ status: 403 }
),
ctx: null,
}
}
if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
return {
error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
@@ -9,6 +9,7 @@ const { mockHasWorkspaceAdminAccess, mockOperations } = vi.hoisted(() => ({
mockOperations: {
getCustomBlockManageContext: vi.fn(),
getCustomBlockUsageCounts: vi.fn(),
isCustomBlocksDeploymentEnabled: vi.fn(),
},
}))
@@ -42,6 +43,7 @@ describe('GET /api/custom-blocks/[id]/usages', () => {
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
mockOperations.getCustomBlockManageContext.mockResolvedValue(MANAGE_CONTEXT)
mockOperations.getCustomBlockUsageCounts.mockResolvedValue(USAGE_COUNTS)
mockOperations.isCustomBlocksDeploymentEnabled.mockReturnValue(true)
})
it('returns 401 without a session', async () => {
@@ -63,6 +65,15 @@ describe('GET /api/custom-blocks/[id]/usages', () => {
expect(mockOperations.getCustomBlockUsageCounts).not.toHaveBeenCalled()
})
it('returns 403 when Custom Blocks is disabled for the deployment', async () => {
mockOperations.isCustomBlocksDeploymentEnabled.mockReturnValue(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)
+4 -4
View File
@@ -9,11 +9,11 @@ import {
} from '@/lib/api/contracts/custom-blocks'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
CustomBlockValidationError,
type CustomBlockWithInputs,
isCustomBlocksEligibleForOrganization,
listCustomBlocksWithInputs,
publishCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
@@ -63,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ enabled: false, customBlocks: [] })
}
const enabled = await isOrganizationOnEnterprisePlan(organizationId)
const enabled = await isCustomBlocksEligibleForOrganization(organizationId)
const blocks = enabled ? await listCustomBlocksWithInputs(organizationId) : []
return NextResponse.json({ enabled, customBlocks: blocks.map(toWire) })
})
@@ -102,9 +102,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
if (!(await isCustomBlocksEligibleForOrganization(organizationId))) {
return NextResponse.json(
{ error: 'Deploy as block requires an enterprise plan' },
{ error: 'Custom blocks are not enabled for this organization' },
{ status: 403 }
)
}
@@ -8,6 +8,7 @@ const {
mockGetSession,
mockGetWorkspaceHostContext,
mockIsForkingAvailable,
mockIsCustomBlocksEligibleForOrganization,
mockIsOrganizationOnEnterprisePlan,
mockIsOrganizationSettingsSectionAvailable,
mockNotFound,
@@ -19,6 +20,7 @@ const {
mockGetSession: vi.fn(),
mockGetWorkspaceHostContext: vi.fn(),
mockIsForkingAvailable: vi.fn(),
mockIsCustomBlocksEligibleForOrganization: vi.fn(),
mockIsOrganizationOnEnterprisePlan: vi.fn(),
mockIsOrganizationSettingsSectionAvailable: vi.fn(),
mockNotFound: vi.fn(() => {
@@ -73,6 +75,10 @@ vi.mock('@/lib/permissions/super-user', () => ({
isPlatformAdmin: vi.fn(() => false),
}))
vi.mock('@/lib/workflows/custom-blocks/operations', () => ({
isCustomBlocksEligibleForOrganization: mockIsCustomBlocksEligibleForOrganization,
}))
vi.mock('@/lib/workspaces/host-context', () => ({
getWorkspaceHostContextForViewer: mockGetWorkspaceHostContext,
}))
@@ -87,7 +93,15 @@ const { mockGetQueryClient, mockSectionPrefetch } = vi.hoisted(() => ({
}))
const { mockSections, mockAliases } = vi.hoisted(() => ({
mockSections: ['general', 'billing', 'secrets', 'sessions', 'admin', 'teammates'],
mockSections: [
'general',
'billing',
'secrets',
'sessions',
'admin',
'teammates',
'custom-blocks',
],
/** Mirrors the real alias table so a legacy segment behaves here as it does in production. */
mockAliases: {
subscription: 'billing',
@@ -174,6 +188,7 @@ describe('WorkspaceSettingsSectionPage unavailable sections', () => {
mockResolveWorkspaceNavigation.mockReturnValue([])
mockResolveWorkspaceGroup.mockResolvedValue(null)
mockIsForkingAvailable.mockResolvedValue(false)
mockIsCustomBlocksEligibleForOrganization.mockResolvedValue(false)
mockCanOpenOrganizationSettingsSection.mockResolvedValue(false)
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
mockIsOrganizationSettingsSectionAvailable.mockReturnValue(true)
@@ -192,6 +207,29 @@ describe('WorkspaceSettingsSectionPage unavailable sections', () => {
)
})
it('redirects Custom Blocks for a personal workspace without resolving an org entitlement', async () => {
mockResolveWorkspaceNavigation.mockImplementation(({ entitlements }) =>
entitlements.customBlocks ? [{ id: 'custom-blocks' }] : []
)
await expect(WorkspaceSettingsSectionPage(pageProps('custom-blocks'))).rejects.toThrow(
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
)
expect(mockIsCustomBlocksEligibleForOrganization).not.toHaveBeenCalled()
})
it('uses the shared Custom Blocks entitlement for an organization workspace', async () => {
mockGetWorkspaceHostContext.mockResolvedValue(ORGANIZATION_HOST_CONTEXT)
mockIsCustomBlocksEligibleForOrganization.mockResolvedValue(true)
mockResolveWorkspaceNavigation.mockImplementation(({ entitlements }) =>
entitlements.customBlocks ? [{ id: 'custom-blocks' }] : []
)
await WorkspaceSettingsSectionPage(pageProps('custom-blocks'))
expect(mockIsCustomBlocksEligibleForOrganization).toHaveBeenCalledWith('organization-b')
})
it('redirects an organization section when the destination has no organization', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('sessions'))).rejects.toThrow(
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
@@ -15,6 +15,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import {
@@ -132,7 +133,7 @@ export default async function WorkspaceSettingsSectionPage({
* Every other section is independent of that config, so resolving the viewer's group for it
* can never change this gate's answer.
*/
const [permissionGroup, forksAvailable] = await Promise.all([
const [permissionGroup, forksAvailable, customBlocksAvailable] = await Promise.all([
hostContext.hostOrganizationId &&
hostContext.ownerBilling.isEnterprise &&
workspaceSectionUsesPermissionConfig(workspaceSection)
@@ -141,8 +142,10 @@ export default async function WorkspaceSettingsSectionPage({
workspaceSection === 'forks'
? isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id)
: Promise.resolve(false),
workspaceSection === 'custom-blocks' && hostContext.hostOrganizationId
? isCustomBlocksEligibleForOrganization(hostContext.hostOrganizationId)
: Promise.resolve(false),
])
const customBlocksAvailable = !isHosted || hostContext.ownerBilling.isEnterprise
const navigation = resolveWorkspaceNavigation({
permission: hostContext.viewer.permission,
permissionConfig: permissionGroup?.config ?? {},
@@ -191,6 +191,9 @@ export function SettingsSidebar({
) {
return false
}
if (item.id === 'custom-blocks' && !hostContext.hostOrganizationId) {
return false
}
if (item.selfHostedOverride && !isHosted) {
/**
@@ -37,6 +37,12 @@ afterAll(() => {
})
describe('settings navigation boundaries', () => {
it('keeps Custom Blocks opt-in on self-hosted deployments', () => {
expect(
buildUnifiedSettingsNavigation().find(({ id }) => id === 'custom-blocks')?.selfHostedOverride
).toBe(false)
})
it('preserves the order of all four settings catalogs', () => {
expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toEqual([
'general',
+2 -1
View File
@@ -32,6 +32,7 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import {
isAccessControlEnabled,
isAuditLogsEnabled,
isCustomBlocksEnabled,
isDataDrainsEnabled,
isDataRetentionEnabled,
isHosted,
@@ -212,7 +213,7 @@ export interface SettingsSectionRegistryEntry {
const SETTINGS_SELF_HOSTED_OVERRIDES = {
accessControl: isAccessControlEnabled,
auditLogs: isAuditLogsEnabled,
customBlocks: true,
customBlocks: isCustomBlocksEnabled,
dataDrains: isDataDrainsEnabled,
dataRetention: isDataRetentionEnabled,
inbox: isInboxEnabled,
@@ -9,7 +9,8 @@ import type { ExecutionContext } from '@/lib/copilot/request/types'
const {
ensureWorkflowAccessMock,
getWorkspaceWithOwnerMock,
isOrganizationOnEnterprisePlanMock,
isCustomBlocksDeploymentEnabledMock,
isCustomBlocksEligibleForOrganizationMock,
publishCustomBlockMock,
updateCustomBlockMock,
deleteCustomBlockMock,
@@ -20,7 +21,8 @@ const {
} = vi.hoisted(() => ({
ensureWorkflowAccessMock: vi.fn(),
getWorkspaceWithOwnerMock: vi.fn(),
isOrganizationOnEnterprisePlanMock: vi.fn(),
isCustomBlocksDeploymentEnabledMock: vi.fn(),
isCustomBlocksEligibleForOrganizationMock: vi.fn(),
publishCustomBlockMock: vi.fn(),
updateCustomBlockMock: vi.fn(),
deleteCustomBlockMock: vi.fn(),
@@ -41,10 +43,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
getWorkspaceWithOwner: getWorkspaceWithOwnerMock,
}))
vi.mock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock,
}))
vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({
resolveCopilotWorkspaceFileReference: resolveWorkspaceFileReferenceMock,
executeCopilotFileUseCase: vi.fn(
@@ -75,6 +73,8 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => {
updateCustomBlock: updateCustomBlockMock,
deleteCustomBlock: deleteCustomBlockMock,
getCustomBlockWithInputsByWorkflowId: getCustomBlockWithInputsByWorkflowIdMock,
isCustomBlocksDeploymentEnabled: isCustomBlocksDeploymentEnabledMock,
isCustomBlocksEligibleForOrganization: isCustomBlocksEligibleForOrganizationMock,
}
})
@@ -111,7 +111,8 @@ describe('executeDeployCustomBlock', () => {
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: true },
})
getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: 'org-1' })
isOrganizationOnEnterprisePlanMock.mockResolvedValue(true)
isCustomBlocksDeploymentEnabledMock.mockReturnValue(true)
isCustomBlocksEligibleForOrganizationMock.mockResolvedValue(true)
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(null)
})
@@ -238,8 +239,8 @@ describe('executeDeployCustomBlock', () => {
})
})
it('updates an existing block without requiring the enterprise plan', async () => {
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
it('updates an existing block after organization eligibility lapses', async () => {
isCustomBlocksEligibleForOrganizationMock.mockResolvedValue(false)
getCustomBlockWithInputsByWorkflowIdMock
.mockResolvedValueOnce(publishedBlock)
.mockResolvedValueOnce(publishedBlock)
@@ -251,8 +252,8 @@ describe('executeDeployCustomBlock', () => {
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
it('undeploys without requiring the enterprise plan', async () => {
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
it('undeploys after organization eligibility lapses', async () => {
isCustomBlocksEligibleForOrganizationMock.mockResolvedValue(false)
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock)
const result = await executeDeployCustomBlock({ action: 'undeploy' }, context)
@@ -331,13 +332,26 @@ describe('executeDeployCustomBlock', () => {
expect(deleteCustomBlockMock).not.toHaveBeenCalled()
})
it('fails when the org is not on the enterprise plan', async () => {
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
it('fails when custom blocks are not enabled for the organization', async () => {
isCustomBlocksEligibleForOrganizationMock.mockResolvedValue(false)
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('enterprise')
expect(result.error).toContain('not enabled')
})
it('blocks existing custom blocks when the deployment entitlement is disabled', async () => {
isCustomBlocksDeploymentEnabledMock.mockReturnValue(false)
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock)
const result = await executeDeployCustomBlock({ action: 'undeploy' }, context)
expect(result).toEqual({
success: false,
error: 'Custom blocks are not enabled for this organization',
})
expect(deleteCustomBlockMock).not.toHaveBeenCalled()
})
it('ingests a workspace-file icon into public icon storage', async () => {
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import {
executeCopilotFileUseCase,
resolveCopilotWorkspaceFileReference,
@@ -19,6 +18,8 @@ import {
type CustomBlockWithInputs,
deleteCustomBlock,
getCustomBlockWithInputsByWorkflowId,
isCustomBlocksDeploymentEnabled,
isCustomBlocksEligibleForOrganization,
publishCustomBlock,
updateCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
@@ -165,6 +166,9 @@ export async function executeDeployCustomBlock(
error: 'Publishing a block requires the workspace to belong to an organization',
}
}
if (!isCustomBlocksDeploymentEnabled()) {
return { success: false, error: 'Custom blocks are not enabled for this organization' }
}
const existing = await getCustomBlockWithInputsByWorkflowId(workflowId)
if (action === 'undeploy') {
@@ -248,8 +252,8 @@ export async function executeDeployCustomBlock(
return { success: true, output: { ...customBlockOutput(updated, 'deploy'), updated: true } }
}
if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
return { success: false, error: 'Custom blocks require an enterprise plan' }
if (!(await isCustomBlocksEligibleForOrganization(organizationId))) {
return { success: false, error: 'Custom blocks are not enabled for this organization' }
}
if (!name) {
return { success: false, error: 'name is required when publishing a new custom block' }
@@ -109,6 +109,7 @@ describe('resolveEnterpriseEntitlement', () => {
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.dataDrains).toBe(false)
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.forking).toBe(false)
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.accessControl).toBe(false)
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.customBlocks).toBe(false)
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.organizations).toBe(false)
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.sso).toBe(false)
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.sandboxes).toBe(false)
@@ -28,6 +28,7 @@
export type EnterpriseFeature =
| 'accessControl'
| 'auditLogs'
| 'customBlocks'
| 'dataDrains'
| 'dataRetention'
| 'forking'
@@ -70,6 +71,7 @@ export type EnterpriseFeature =
export const ENTERPRISE_FEATURE_LEGACY_DEFAULTS: Readonly<Record<EnterpriseFeature, boolean>> = {
accessControl: false,
auditLogs: false,
customBlocks: false,
dataDrains: false,
dataRetention: false,
forking: false,
+6
View File
@@ -361,6 +361,12 @@ export const isAuditLogsEnabled = enterpriseFeatureEnabled(
'NEXT_PUBLIC_AUDIT_LOGS_ENABLED'
)
export const isCustomBlocksEnabled = enterpriseFeatureEnabled(
'customBlocks',
env.CUSTOM_BLOCKS_ENABLED,
'NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED'
)
/**
* Is retention *deletion* enabled.
*
+3
View File
@@ -584,6 +584,7 @@ export const env = createEnv({
// Enterprise Feature Overrides - for self-hosted deployments
WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks on self-hosted (bypasses hosted requirements)
DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings and retention deletion on self-hosted (bypasses hosted requirements)
DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements)
@@ -696,6 +697,7 @@ export const env = createEnv({
NEXT_PUBLIC_SLACK_EXTENDED_SCOPES: z.boolean().optional(), // Client twin of SLACK_EXTENDED_SCOPES — set both together
NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements)
@@ -739,6 +741,7 @@ export const env = createEnv({
NEXT_PUBLIC_SLACK_EXTENDED_SCOPES: process.env.NEXT_PUBLIC_SLACK_EXTENDED_SCOPES,
NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED,
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED,
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,
NEXT_PUBLIC_DATA_DRAINS_ENABLED: process.env.NEXT_PUBLIC_DATA_DRAINS_ENABLED,
NEXT_PUBLIC_SESSION_POLICIES_ENABLED: process.env.NEXT_PUBLIC_SESSION_POLICIES_ENABLED,
@@ -4,13 +4,15 @@
import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { extractInputFieldsFromBlocks, loadDeployedWorkflowState } = vi.hoisted(() => ({
extractInputFieldsFromBlocks: vi.fn(),
loadDeployedWorkflowState: vi.fn(),
}))
const { extractInputFieldsFromBlocks, loadDeployedWorkflowState, isOrganizationFeatureEntitled } =
vi.hoisted(() => ({
extractInputFieldsFromBlocks: vi.fn(),
loadDeployedWorkflowState: vi.fn(),
isOrganizationFeatureEntitled: vi.fn(),
}))
vi.mock('@/lib/billing/core/subscription', () => ({
isOrganizationOnEnterprisePlan: vi.fn(),
isOrganizationFeatureEntitled,
}))
vi.mock('@/lib/workflows/input-format', () => ({
@@ -27,6 +29,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
import {
CustomBlockValidationError,
isCustomBlocksEligibleForOrganization,
listCustomBlocksWithInputs,
publishCustomBlock,
updateCustomBlock,
@@ -46,6 +49,15 @@ beforeEach(() => {
resetDbChainMock()
})
describe('custom block entitlement', () => {
it('uses the shared organization feature resolver', async () => {
isOrganizationFeatureEntitled.mockResolvedValue(true)
await expect(isCustomBlocksEligibleForOrganization('org-1')).resolves.toBe(true)
expect(isOrganizationFeatureEntitled).toHaveBeenCalledWith('org-1', false)
})
})
describe('custom block input hydration', () => {
it('passes the joined source workspace to deployed-state loading', async () => {
const block = {
@@ -9,7 +9,8 @@ import {
import { createLogger } from '@sim/logger'
import { generateId, generateShortId } from '@sim/utils/id'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
import { isBillingEnabled, isCustomBlocksEnabled } from '@/lib/core/config/env-flags'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
@@ -20,16 +21,28 @@ import { CUSTOM_BLOCK_TYPE_PREFIX, isReservedOutputName } from '@/blocks/custom/
const logger = createLogger('CustomBlocksOperations')
const CUSTOM_BLOCK_HYDRATION_CONCURRENCY = 10
/** Whether the deployment permits Custom Blocks surfaces independent of an organization's plan. */
export function isCustomBlocksDeploymentEnabled(): boolean {
return isBillingEnabled || isCustomBlocksEnabled
}
/** Whether an organization may publish, list, and execute custom blocks. */
export async function isCustomBlocksEligibleForOrganization(
organizationId: string
): Promise<boolean> {
return isOrganizationFeatureEntitled(organizationId, isCustomBlocksEnabled)
}
/**
* Resolve a workspace's organization only when it is eligible for custom blocks.
* Applying the Enterprise-plan check in every org-scoped resolver keeps execution,
* the Copilot VFS, and workspace context from surfacing blocks the API withholds
* after an organization loses eligibility. Returns `null` when ineligible.
* Applying the shared entitlement in every org-scoped resolver keeps execution,
* the Copilot VFS, and workspace context from surfacing blocks the API withholds.
* Returns `null` when ineligible.
*/
async function eligibleOrgForWorkspace(workspaceId: string): Promise<string | null> {
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
if (!ws?.organizationId) return null
if (!(await isOrganizationOnEnterprisePlan(ws.organizationId))) return null
if (!(await isCustomBlocksEligibleForOrganization(ws.organizationId))) return null
return ws.organizationId
}
@@ -320,11 +333,8 @@ export async function getCustomBlockAuthority(
// key, so without the org filter a `custom_block_*` type smuggled in from another
// org's serialized workflow could resolve and run that org's block.
if (!consumerWorkspaceId) return null
// Match `getCustomBlockRowsForWorkspace` (which builds the overlay) — include
// archived so a workspace that can serialize a custom block can also execute it,
// instead of failing mid-run with "no longer available".
const consumerWs = await getWorkspaceWithOwner(consumerWorkspaceId, { includeArchived: true })
if (!consumerWs?.organizationId) return null
const organizationId = await eligibleOrgForWorkspace(consumerWorkspaceId)
if (!organizationId) return null
const [row] = await db
.select({
@@ -338,9 +348,7 @@ export async function getCustomBlockAuthority(
})
.from(customBlock)
.innerJoin(workflow, eq(workflow.id, customBlock.workflowId))
.where(
and(eq(customBlock.type, type), eq(customBlock.organizationId, consumerWs.organizationId))
)
.where(and(eq(customBlock.type, type), eq(customBlock.organizationId, organizationId)))
.limit(1)
if (!row || !row.enabled) return null
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v2
name: sim
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
type: application
version: 1.6.1
version: 1.6.2
appVersion: "v0.7.44"
kubeVersion: ">=1.25.0-0"
home: https://sim.ai
+2
View File
@@ -253,6 +253,8 @@ app:
NEXT_PUBLIC_WHITELABELING_ENABLED: "" # Show whitelabeling settings page ("true" to enable)
AUDIT_LOGS_ENABLED: "" # Enable audit logs on self-hosted ("true" to enable)
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: "" # Show audit logs settings page ("true" to enable)
CUSTOM_BLOCKS_ENABLED: "" # Enable custom blocks on self-hosted ("true" to enable)
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: "" # Show custom blocks settings page ("true" to enable)
DATA_DRAINS_ENABLED: "" # Enable data drains on self-hosted ("true" to enable)
NEXT_PUBLIC_DATA_DRAINS_ENABLED: "" # Show data drains settings page ("true" to enable)
+7
View File
@@ -8,4 +8,11 @@ describe('setup environment twins', () => {
client: 'NEXT_PUBLIC_SLACK_EXTENDED_SCOPES',
})
})
it('keeps the Custom Blocks server and browser entitlement values coherent', () => {
expect(FLAG_TWINS).toContainEqual({
server: 'CUSTOM_BLOCKS_ENABLED',
client: 'NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED',
})
})
})
+2
View File
@@ -12,6 +12,7 @@ export const FLAG_TWINS: ReadonlyArray<{ server: string; client: string }> = [
{ server: 'ORGANIZATIONS_ENABLED', client: 'NEXT_PUBLIC_ORGANIZATIONS_ENABLED' },
{ server: 'WHITELABELING_ENABLED', client: 'NEXT_PUBLIC_WHITELABELING_ENABLED' },
{ server: 'AUDIT_LOGS_ENABLED', client: 'NEXT_PUBLIC_AUDIT_LOGS_ENABLED' },
{ server: 'CUSTOM_BLOCKS_ENABLED', client: 'NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED' },
{ server: 'DATA_RETENTION_ENABLED', client: 'NEXT_PUBLIC_DATA_RETENTION_ENABLED' },
{ server: 'SESSION_POLICIES_ENABLED', client: 'NEXT_PUBLIC_SESSION_POLICIES_ENABLED' },
{ server: 'DATA_DRAINS_ENABLED', client: 'NEXT_PUBLIC_DATA_DRAINS_ENABLED' },
@@ -39,6 +40,7 @@ export const SELF_HOST_UNLOCKS: ReadonlyArray<{ server: string; label: string; h
},
{ server: 'ORGANIZATIONS_ENABLED', label: 'Organizations', hint: 'multi-workspace orgs' },
{ server: 'AUDIT_LOGS_ENABLED', label: 'Audit logs', hint: '' },
{ server: 'CUSTOM_BLOCKS_ENABLED', label: 'Custom blocks', hint: 'reusable org-wide blocks' },
{ server: 'DATA_RETENTION_ENABLED', label: 'Data retention', hint: 'deletes expired data' },
{ server: 'SESSION_POLICIES_ENABLED', label: 'Session policies', hint: 'session lifetime caps' },
{ server: 'DATA_DRAINS_ENABLED', label: 'Data drains', hint: 'export streams' },
@@ -36,6 +36,7 @@ export interface EnvFlagsMockState {
isSandboxesEnabled: boolean
isWhitelabelingEnabled: boolean
isAuditLogsEnabled: boolean
isCustomBlocksEnabled: boolean
isDataRetentionEnabled: boolean
isDataDrainsEnabled: boolean
isSessionPoliciesEnabled: boolean
@@ -89,6 +90,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = {
isWhitelabelingEnabled: true,
isSessionPoliciesEnabled: true,
isAuditLogsEnabled: false,
isCustomBlocksEnabled: false,
isDataRetentionEnabled: false,
isDataDrainsEnabled: false,
isForkingEnabled: false,