mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
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:
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? {},
|
||||
|
||||
+3
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user