From 60bea92354f10d2a51221247353f268f6d46159d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Jul 2026 19:00:53 -0700 Subject: [PATCH 1/3] fix(deps): install xlsx from @e965/xlsx npm mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency was pinned to a direct tarball on cdn.sheetjs.com, which now returns 403 (Cloudflare bot-challenge) to automated clients, breaking bun install in CI. npm's own xlsx is frozen at 0.18.5, so switch to the @e965/xlsx mirror which republishes the identical 0.20.3 CDN build to the npm registry. No code changes needed — all imports use bare 'xlsx'. --- apps/sim/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index dd211bc8798..4a18b86ceb7 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -212,7 +212,7 @@ "twilio": "5.9.0", "undici": "7.28.0", "unpdf": "1.4.0", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "xlsx": "npm:@e965/xlsx@0.20.3", "zod": "4.3.6", "zustand": "^5.0.13" }, diff --git a/bun.lock b/bun.lock index 6bdcb6cb463..d64e241065b 100644 --- a/bun.lock +++ b/bun.lock @@ -280,7 +280,7 @@ "twilio": "5.9.0", "undici": "7.28.0", "unpdf": "1.4.0", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "xlsx": "npm:@e965/xlsx@0.20.3", "zod": "4.3.6", "zustand": "^5.0.13", }, @@ -4000,7 +4000,7 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }], + "xlsx": ["@e965/xlsx@0.20.3", "", { "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w=="], "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], From e6ad905bcd19d232c11739be96fb87941525583f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 8 Jul 2026 18:18:46 -0700 Subject: [PATCH 2/3] feat(blocks): add block visibility gating (preview blocks + AppConfig reveals) --- .claude/commands/gate-block.md | 60 +++++++ .cursor/commands/gate-block.md | 60 +++++++ .../app/api/blocks/visibility/route.test.ts | 84 ++++++++++ apps/sim/app/api/blocks/visibility/route.ts | 46 +++++ .../app/workspace/[workspaceId]/layout.tsx | 2 + .../providers/block-visibility-loader.tsx | 33 ++++ .../user-input/hooks/use-mention-data.ts | 6 +- .../panel/components/toolbar/toolbar.tsx | 42 ++++- apps/sim/blocks/custom/client-overlay.ts | 15 +- apps/sim/blocks/integration-matcher.ts | 13 ++ apps/sim/blocks/registry.ts | 68 +++++++- apps/sim/blocks/types.ts | 10 ++ apps/sim/blocks/visibility/client.test.ts | 55 ++++++ apps/sim/blocks/visibility/client.ts | 46 +++++ apps/sim/blocks/visibility/context.ts | 73 ++++++++ .../blocks/visibility/registry-inert.test.ts | 60 +++++++ apps/sim/blocks/visibility/server-context.ts | 25 +++ apps/sim/blocks/visibility/visibility.test.ts | 149 +++++++++++++++++ .../components/group-detail.tsx | 27 ++- .../utils/permission-check.test.ts | 13 ++ apps/sim/hooks/queries/block-visibility.ts | 31 ++++ .../sim/lib/api/contracts/block-visibility.ts | 37 +++++ apps/sim/lib/copilot/block-visibility.ts | 58 +++++++ apps/sim/lib/copilot/chat/payload.test.ts | 10 ++ apps/sim/lib/copilot/chat/payload.ts | 31 +++- apps/sim/lib/copilot/integration-tools.ts | 61 +++++-- .../tools/handlers/integration-tools.ts | 13 +- apps/sim/lib/copilot/tools/handlers/vfs.ts | 19 ++- .../server/blocks/get-blocks-metadata-tool.ts | 10 ++ apps/sim/lib/copilot/tools/server/router.ts | 37 ++++- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 73 ++++++-- .../lib/core/config/appconfig-rules.test.ts | 69 ++++++++ apps/sim/lib/core/config/appconfig-rules.ts | 80 +++++++++ .../lib/core/config/block-visibility.test.ts | 157 ++++++++++++++++++ apps/sim/lib/core/config/block-visibility.ts | 110 ++++++++++++ apps/sim/lib/core/config/env-flags.ts | 13 ++ apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 60 ++----- apps/sim/lib/search/tool-operations.ts | 9 +- apps/sim/scripts/check-block-registry.ts | 7 +- scripts/check-api-validation-contracts.ts | 4 +- scripts/generate-docs.ts | 32 +++- 42 files changed, 1684 insertions(+), 125 deletions(-) create mode 100644 .claude/commands/gate-block.md create mode 100644 .cursor/commands/gate-block.md create mode 100644 apps/sim/app/api/blocks/visibility/route.test.ts create mode 100644 apps/sim/app/api/blocks/visibility/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/block-visibility-loader.tsx create mode 100644 apps/sim/blocks/visibility/client.test.ts create mode 100644 apps/sim/blocks/visibility/client.ts create mode 100644 apps/sim/blocks/visibility/context.ts create mode 100644 apps/sim/blocks/visibility/registry-inert.test.ts create mode 100644 apps/sim/blocks/visibility/server-context.ts create mode 100644 apps/sim/blocks/visibility/visibility.test.ts create mode 100644 apps/sim/hooks/queries/block-visibility.ts create mode 100644 apps/sim/lib/api/contracts/block-visibility.ts create mode 100644 apps/sim/lib/copilot/block-visibility.ts create mode 100644 apps/sim/lib/core/config/appconfig-rules.test.ts create mode 100644 apps/sim/lib/core/config/appconfig-rules.ts create mode 100644 apps/sim/lib/core/config/block-visibility.test.ts create mode 100644 apps/sim/lib/core/config/block-visibility.ts diff --git a/.claude/commands/gate-block.md b/.claude/commands/gate-block.md new file mode 100644 index 00000000000..8b7b41113d5 --- /dev/null +++ b/.claude/commands/gate-block.md @@ -0,0 +1,60 @@ +--- +description: Gate a block's visibility — ship an unreleased block as a preview (hidden until revealed via AppConfig/env), reveal it to admins/orgs, GA it, or kill-switch a shipped block +argument-hint: +--- + +# Gate Block Skill + +You manage **block visibility gating** in Sim — hiding blocks from every discovery surface (toolbar, cmd+K search, copilot @-mentions, agent tool picker, mothership VFS/metadata/tools, Access Control list, public docs/catalog) while **never** gating execution of already-placed instances. + +## The model + +Three levers, evaluated in `apps/sim/lib/core/config/block-visibility.ts` and folded into the registry accessors (`apps/sim/blocks/registry.ts`): + +1. **`preview: true`** on the `BlockConfig` (static, in code) — the block is default-hidden EVERYWHERE (hosted, self-hosted, dev, SSR) until revealed. Fail-closed. +2. **The hosted `block-visibility` AppConfig document** — per-block rule keyed by the existing block type: + + ```jsonc + { + "": { + "enabled": false, // required. true = GA (visible to everyone) + "orgIds": ["org_..."], // optional allowlist clauses (any match reveals) + "userIds": ["user_..."], + "adminEnabled": true // platform admins (user.role === 'admin') + } + } + ``` + +3. **`PREVIEW_BLOCKS` env** (comma-separated block types) — the off-AppConfig reveal path for self-hosters and local dev. + +A revealed block that is not globally GA (`enabled !== true`, or env-revealed) renders with a **" (Preview)"** name suffix on discovery surfaces. `getBlock()` stays pure, so placed instances keep their canonical name and always execute. + +## Lifecycle of a preview block + +1. **Author** the block normally (`/add-block` etc.) and set `preview: true` on its `BlockConfig`. **Ship no `BlockMeta` and no docs until GA** — `check-block-registry` deliberately skips preview blocks in meta coverage, and `generate-docs` skips them at every gate. +2. **Local dev:** set `PREVIEW_BLOCKS=` in your env to see it (with the suffix). +3. **Merge/deploy.** The block's code is live everywhere but visible nowhere — no AppConfig rule exists and self-hosters have no env entry. +4. **Hosted preview:** add a rule to the `block-visibility` AppConfig document and start a deployment (no code deploy): + - Admins only: `{ "enabled": false, "adminEnabled": true }` + - Design-partner org: `{ "enabled": false, "orgIds": ["org_123"] }` + - GA via config (code cleanup pending): `{ "enabled": true }` — suffix disappears everywhere within ~30s (AppConfig TTL) + client refetch. + + Same runbook as `feature-flags`: edit the hosted document, `aws appconfig start-deployment` with the `sim--fast` strategy (see the infra README). +5. **GA cleanup:** delete `preview: true` from the block (now visible to self-hosters on their next upgrade), add its `BlockMeta` + regen docs, and drop the AppConfig entry. For a v2 upgrade, this is also when v1 gets `hideFromToolbar: true` (the superseded-version paradigm). + +## Kill switch (shipped blocks) + +To pull an already-GA block from discovery surfaces on hosted (incident, deprecation): add `{ "": { "enabled": false } }` to the document. Allowlist clauses can carve out exceptions. **Execution is NOT stopped** — workflows already using the block keep running; the kill switch only prevents new placement/discovery. + +## Invariants (do not violate) + +- **Execution is never gated.** The executor, serializer, drop-naming, and `isBlockTypeAccessControlExempt` resolve via pure `getBlock`. Do not add visibility checks to execution paths. +- **Clone-not-remove:** gated blocks stay in `getAllBlocks()` output as clones with `hideFromToolbar: true` — `.find`-by-type consumers rely on this. Never filter them out. +- **Keys are registry block types.** Never `custom_block_*` (parse drops them — custom blocks have their own enabled/disabled lifecycle). +- **The shared hidden-predicate is `isHiddenUnder`** (`apps/sim/blocks/visibility/context.ts`). Never restate the preview/disabled rule inline at a new consumer. +- **Process-global caches stay ungated.** `getStaticComponentFiles` (VFS) and `getExposedIntegrationTools` build the ungated universe; per-viewer filtering happens at stamp/consumer time. Never move gating into a shared builder. +- Gating is **surface hiding, not secrecy** — the full config ships in the client JS bundle. Anything truly secret cannot be a registered block. + +## Tests + +Evaluation semantics: `apps/sim/lib/core/config/block-visibility.test.ts`. Registry projection: `apps/sim/blocks/visibility/visibility.test.ts`. When gating behavior changes, extend those — mock `isPlatformAdmin` for the admin clause; use the local `withAppConfig` harness. diff --git a/.cursor/commands/gate-block.md b/.cursor/commands/gate-block.md new file mode 100644 index 00000000000..8b7b41113d5 --- /dev/null +++ b/.cursor/commands/gate-block.md @@ -0,0 +1,60 @@ +--- +description: Gate a block's visibility — ship an unreleased block as a preview (hidden until revealed via AppConfig/env), reveal it to admins/orgs, GA it, or kill-switch a shipped block +argument-hint: +--- + +# Gate Block Skill + +You manage **block visibility gating** in Sim — hiding blocks from every discovery surface (toolbar, cmd+K search, copilot @-mentions, agent tool picker, mothership VFS/metadata/tools, Access Control list, public docs/catalog) while **never** gating execution of already-placed instances. + +## The model + +Three levers, evaluated in `apps/sim/lib/core/config/block-visibility.ts` and folded into the registry accessors (`apps/sim/blocks/registry.ts`): + +1. **`preview: true`** on the `BlockConfig` (static, in code) — the block is default-hidden EVERYWHERE (hosted, self-hosted, dev, SSR) until revealed. Fail-closed. +2. **The hosted `block-visibility` AppConfig document** — per-block rule keyed by the existing block type: + + ```jsonc + { + "": { + "enabled": false, // required. true = GA (visible to everyone) + "orgIds": ["org_..."], // optional allowlist clauses (any match reveals) + "userIds": ["user_..."], + "adminEnabled": true // platform admins (user.role === 'admin') + } + } + ``` + +3. **`PREVIEW_BLOCKS` env** (comma-separated block types) — the off-AppConfig reveal path for self-hosters and local dev. + +A revealed block that is not globally GA (`enabled !== true`, or env-revealed) renders with a **" (Preview)"** name suffix on discovery surfaces. `getBlock()` stays pure, so placed instances keep their canonical name and always execute. + +## Lifecycle of a preview block + +1. **Author** the block normally (`/add-block` etc.) and set `preview: true` on its `BlockConfig`. **Ship no `BlockMeta` and no docs until GA** — `check-block-registry` deliberately skips preview blocks in meta coverage, and `generate-docs` skips them at every gate. +2. **Local dev:** set `PREVIEW_BLOCKS=` in your env to see it (with the suffix). +3. **Merge/deploy.** The block's code is live everywhere but visible nowhere — no AppConfig rule exists and self-hosters have no env entry. +4. **Hosted preview:** add a rule to the `block-visibility` AppConfig document and start a deployment (no code deploy): + - Admins only: `{ "enabled": false, "adminEnabled": true }` + - Design-partner org: `{ "enabled": false, "orgIds": ["org_123"] }` + - GA via config (code cleanup pending): `{ "enabled": true }` — suffix disappears everywhere within ~30s (AppConfig TTL) + client refetch. + + Same runbook as `feature-flags`: edit the hosted document, `aws appconfig start-deployment` with the `sim--fast` strategy (see the infra README). +5. **GA cleanup:** delete `preview: true` from the block (now visible to self-hosters on their next upgrade), add its `BlockMeta` + regen docs, and drop the AppConfig entry. For a v2 upgrade, this is also when v1 gets `hideFromToolbar: true` (the superseded-version paradigm). + +## Kill switch (shipped blocks) + +To pull an already-GA block from discovery surfaces on hosted (incident, deprecation): add `{ "": { "enabled": false } }` to the document. Allowlist clauses can carve out exceptions. **Execution is NOT stopped** — workflows already using the block keep running; the kill switch only prevents new placement/discovery. + +## Invariants (do not violate) + +- **Execution is never gated.** The executor, serializer, drop-naming, and `isBlockTypeAccessControlExempt` resolve via pure `getBlock`. Do not add visibility checks to execution paths. +- **Clone-not-remove:** gated blocks stay in `getAllBlocks()` output as clones with `hideFromToolbar: true` — `.find`-by-type consumers rely on this. Never filter them out. +- **Keys are registry block types.** Never `custom_block_*` (parse drops them — custom blocks have their own enabled/disabled lifecycle). +- **The shared hidden-predicate is `isHiddenUnder`** (`apps/sim/blocks/visibility/context.ts`). Never restate the preview/disabled rule inline at a new consumer. +- **Process-global caches stay ungated.** `getStaticComponentFiles` (VFS) and `getExposedIntegrationTools` build the ungated universe; per-viewer filtering happens at stamp/consumer time. Never move gating into a shared builder. +- Gating is **surface hiding, not secrecy** — the full config ships in the client JS bundle. Anything truly secret cannot be a registered block. + +## Tests + +Evaluation semantics: `apps/sim/lib/core/config/block-visibility.test.ts`. Registry projection: `apps/sim/blocks/visibility/visibility.test.ts`. When gating behavior changes, extend those — mock `isPlatformAdmin` for the admin clause; use the local `withAppConfig` harness. diff --git a/apps/sim/app/api/blocks/visibility/route.test.ts b/apps/sim/app/api/blocks/visibility/route.test.ts new file mode 100644 index 00000000000..1a59df82aa5 --- /dev/null +++ b/apps/sim/app/api/blocks/visibility/route.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockCheckWorkspaceAccess, mockIsPlatformAdmin, mockGetBlockVisibility } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockIsPlatformAdmin: vi.fn(), + mockGetBlockVisibility: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/permissions/super-user', () => ({ + isPlatformAdmin: mockIsPlatformAdmin, +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mockGetBlockVisibility, +})) + +import { GET } from '@/app/api/blocks/visibility/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +function request(workspaceId = WORKSPACE_ID) { + return new NextRequest(`http://localhost/api/blocks/visibility?workspaceId=${workspaceId}`) +} + +describe('GET /api/blocks/visibility', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + workspace: { organizationId: 'org-1' }, + }) + mockIsPlatformAdmin.mockResolvedValue(false) + mockGetBlockVisibility.mockResolvedValue({ + revealed: new Set(['gmail_v2']), + disabled: new Set(['slack']), + previewTagged: new Set(['gmail_v2']), + }) + }) + + it('returns 401 without a session', async () => { + mockGetSession.mockResolvedValue(null) + const response = await GET(request()) + expect(response.status).toBe(401) + }) + + it('returns 403 without workspace access', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: false, workspace: null }) + const response = await GET(request()) + expect(response.status).toBe(403) + expect(mockGetBlockVisibility).not.toHaveBeenCalled() + }) + + it('evaluates visibility for the session user, workspace org, and pre-resolved admin', async () => { + mockIsPlatformAdmin.mockResolvedValue(true) + const response = await GET(request()) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + revealed: ['gmail_v2'], + disabled: ['slack'], + previewTagged: ['gmail_v2'], + }) + expect(mockGetBlockVisibility).toHaveBeenCalledWith({ + userId: 'user-1', + orgId: 'org-1', + isAdmin: true, + }) + }) +}) diff --git a/apps/sim/app/api/blocks/visibility/route.ts b/apps/sim/app/api/blocks/visibility/route.ts new file mode 100644 index 00000000000..8aea5ad77b3 --- /dev/null +++ b/apps/sim/app/api/blocks/visibility/route.ts @@ -0,0 +1,46 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getBlockVisibilityContract } from '@/lib/api/contracts/block-visibility' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isPlatformAdmin } from '@/lib/permissions/super-user' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +/** + * Evaluates the viewer's block-visibility projection for a workspace: which + * preview blocks are revealed (and preview-tagged) and which shipped blocks are + * kill-switched. Consumed by the client visibility overlay + * (`BlockVisibilityLoader`); discovery-only — execution is never gated. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getBlockVisibilityContract, request, {}) + if (!parsed.success) return parsed.response + + const userId = session.user.id + const { workspaceId } = parsed.data.query + + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const isAdmin = await isPlatformAdmin(userId) + const vis = await getBlockVisibility({ + userId, + orgId: access.workspace?.organizationId, + isAdmin, + }) + + return NextResponse.json({ + revealed: [...vis.revealed], + disabled: [...vis.disabled], + previewTagged: [...vis.previewTagged], + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 4fdea0c52d6..b770cd4f076 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -7,6 +7,7 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch' +import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' @@ -45,6 +46,7 @@ export default async function WorkspaceLayout({ +
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/block-visibility-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/block-visibility-loader.tsx new file mode 100644 index 00000000000..1f8228d1317 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/block-visibility-loader.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useEffect } from 'react' +import { useParams } from 'next/navigation' +import { hydrateBlockVisibility } from '@/blocks/visibility/client' +import { useBlockVisibility } from '@/hooks/queries/block-visibility' + +/** + * Hydrates the client block-visibility overlay for the active workspace so the + * registry accessors project the viewer's revealed/disabled preview blocks. + * Mounted once in the workspace layout, next to `CustomBlocksLoader`. + * + * First paint needs no prefetch: `preview: true` blocks are fail-closed until + * this hydrate lands, so the fetch only ever reveals (benign pop-in) or applies + * a kill switch to an already-public block. Identical refetches are absorbed by + * the deep-equal guard inside `hydrateBlockVisibility`. + */ +export function BlockVisibilityLoader() { + const params = useParams() + const workspaceId = params?.workspaceId as string | undefined + const { data } = useBlockVisibility(workspaceId) + + useEffect(() => { + if (!data) return + hydrateBlockVisibility({ + revealed: new Set(data.revealed), + disabled: new Set(data.disabled), + previewTagged: new Set(data.previewTagged), + }) + }, [data]) + + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts index 95508ac4102..d0d0a1a43a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -7,6 +7,7 @@ import { requestJson } from '@/lib/api/client/request' import { listCopilotChatsContract } from '@/lib/api/contracts/copilot' import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge/base' import { listLogsContract } from '@/lib/api/contracts/logs' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { type IntegrationDescriptor, listIntegrations } from '@/blocks/integration-matcher' import { useWorkflows } from '@/hooks/queries/workflows' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -130,9 +131,12 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn { const [blocksList, setBlocksList] = useState([]) const [isLoadingBlocks, setIsLoadingBlocks] = useState(false) + // Reset on permission changes and on block-overlay bumps (custom-block or + // block-visibility hydrate) so late preview reveals refresh the folder. + const blockOverlayVersion = useCustomBlockOverlayVersion() useEffect(() => { setBlocksList([]) - }, [config.allowedIntegrations]) + }, [config.allowedIntegrations, blockOverlayVersion]) const [logsList, setLogsList] = useState([]) const [isLoadingLogs, setIsLoadingLogs] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index e051f58379f..9118eef024a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -35,6 +35,7 @@ import { CUSTOM_BLOCK_TILE_COLOR, isCustomBlockType, } from '@/blocks/custom/build-config' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' import { getTileIconColorClass } from '@/blocks/icon-color' import { getCanonicalBlocksByCategory } from '@/blocks/registry' @@ -154,11 +155,29 @@ const ToolbarItem = memo(function ToolbarItem({ let cachedTriggers: BlockItem[] | null = null /** - * Gets triggers data, computing it once and caching for subsequent calls. - * Non-integration triggers (Start, Schedule, Webhook) are prioritized first, - * followed by all other triggers sorted alphabetically. + * Block-overlay version the caches below were built against. The registry's + * output is no longer static — a block-visibility hydrate (preview reveal / + * kill switch) bumps the shared overlay version — so the caches are keyed to + * it and dropped when it moves. -1 = never built. */ -function getTriggers(): BlockItem[] { +let cachedAtOverlayVersion = -1 + +/** Drop all three caches when the overlay version moved since they were built. */ +function syncCachesToOverlayVersion(version: number) { + if (cachedAtOverlayVersion === version) return + cachedAtOverlayVersion = version + cachedTriggers = null + cachedBlocks = null + cachedTools = null +} + +/** + * Gets triggers data, computing it once per overlay version and caching for + * subsequent calls. Non-integration triggers (Start, Schedule, Webhook) are + * prioritized first, followed by all other triggers sorted alphabetically. + */ +function getTriggers(overlayVersion: number): BlockItem[] { + syncCachesToOverlayVersion(overlayVersion) if (cachedTriggers === null) { const allTriggers = getTriggersForSidebar() const priorityOrder = ['Start', 'Schedule', 'Webhook'] @@ -250,12 +269,14 @@ function ensureBlockCaches() { cachedTools = toolItems } -function getBlocks(): BlockItem[] { +function getBlocks(overlayVersion: number): BlockItem[] { + syncCachesToOverlayVersion(overlayVersion) ensureBlockCaches() return cachedBlocks as BlockItem[] } -function getTools(): BlockItem[] { +function getTools(overlayVersion: number): BlockItem[] { + syncCachesToOverlayVersion(overlayVersion) ensureBlockCaches() return cachedTools as BlockItem[] } @@ -455,9 +476,12 @@ export const Toolbar = memo( const { data: whitelabel } = useWhitelabelSettings(customBlocksData?.[0]?.organizationId) const fallbackIconUrl = whitelabel?.logoUrl ?? null - const allTriggers = getTriggers() - const allBlocks = getBlocks() - const allTools = getTools() + // Re-read the block lists whenever the overlay version bumps (custom-block + // or block-visibility hydrate) — the module caches are keyed to it. + const blockOverlayVersion = useCustomBlockOverlayVersion() + const allTriggers = getTriggers(blockOverlayVersion) + const allBlocks = getBlocks(blockOverlayVersion) + const allTools = getTools(blockOverlayVersion) // Published custom blocks are their own section. Exclude disabled blocks (still // resolvable so placed instances survive, but not offered for new placement) and diff --git a/apps/sim/blocks/custom/client-overlay.ts b/apps/sim/blocks/custom/client-overlay.ts index cc1bbff01e1..4dfab2aac94 100644 --- a/apps/sim/blocks/custom/client-overlay.ts +++ b/apps/sim/blocks/custom/client-overlay.ts @@ -24,11 +24,22 @@ registerBlockOverlayResolver({ all: () => [...map.values()], }) +/** + * Bump the overlay version and notify subscribers that block-registry-derived + * data changed. Shared signal for BOTH custom-block hydration and the + * block-visibility hydrate path — subscribers treat the version as an opaque + * "re-read `getAllBlocks()`" token, never as "custom blocks specifically + * changed". + */ +export function notifyBlockOverlayChanged(): void { + version += 1 + for (const listener of listeners) listener() +} + /** Replace the in-scope custom blocks and notify subscribers. */ export function hydrateClientCustomBlocks(configs: BlockConfig[]): void { map = new Map(configs.map((config) => [config.type, config])) - version += 1 - for (const listener of listeners) listener() + notifyBlockOverlayChanged() } function subscribe(listener: () => void): () => void { diff --git a/apps/sim/blocks/integration-matcher.ts b/apps/sim/blocks/integration-matcher.ts index 148d5cc6a3d..172d07e9be2 100644 --- a/apps/sim/blocks/integration-matcher.ts +++ b/apps/sim/blocks/integration-matcher.ts @@ -1,6 +1,7 @@ import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' import { getCanonicalBlocksByCategory } from '@/blocks/registry' import type { BlockIcon } from '@/blocks/types' +import { registerBlockCacheInvalidator } from '@/blocks/visibility/context' /** * Public descriptor for a single integration block, exposed to UI surfaces @@ -46,6 +47,18 @@ function normalizeDisplayName(name: string): string { let cachedMatcher: IntegrationMatcher | null = null let cachedList: readonly IntegrationDescriptor[] | null = null +/** + * Drops the memoized matcher/list. Registered with the block cache-invalidator + * seam so a client block-visibility change (preview reveal / kill switch) + * rebuilds the matcher against the new canonical block set. + */ +function clearIntegrationMatcherCache(): void { + cachedMatcher = null + cachedList = null +} + +registerBlockCacheInvalidator(clearIntegrationMatcherCache) + function buildMatcher(): IntegrationMatcher { const byName = new Map() const names: string[] = [] diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index e7760a87974..d0019e28aaf 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -1,4 +1,5 @@ import { stripVersionSuffix } from '@sim/utils/string' +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { overlayBlocks, resolveOverlayBlock } from '@/blocks/custom/overlay' import { BLOCK_META_REGISTRY, BLOCK_REGISTRY } from '@/blocks/registry-maps' import type { @@ -8,6 +9,7 @@ import type { BlockTemplate, SuggestedSkill, } from '@/blocks/types' +import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' /** * Normalize an external block type to its registry key form: dashes become @@ -22,9 +24,58 @@ export function getBlock(type: string): BlockConfig | undefined { return BLOCK_REGISTRY[type] ?? BLOCK_REGISTRY[normalizeType(type)] ?? resolveOverlayBlock(type) } -/** All block configs, including any in-scope custom blocks from the overlay. */ +/** Whether any registered block is an unreleased `preview` block. Static — computed once. */ +const HAS_PREVIEW_BLOCKS = Object.values(BLOCK_REGISTRY).some((block) => block.preview) + +/** + * True when the visibility projection cannot change any block, so accessors can + * return raw arrays untouched: no `preview` blocks exist (they must be hidden + * even with a null state) and no kill-switch entries apply. + */ +function visibilityInert(vis: BlockVisibilityState | null): boolean { + if (HAS_PREVIEW_BLOCKS) return false + return vis === null || vis.disabled.size === 0 +} + +/** + * Effective hidden state for discovery surfaces: static `hideFromToolbar` + * (superseded versions, disabled custom blocks) plus the per-viewer visibility + * predicate ({@link isHiddenUnder}: unrevealed `preview` blocks — fail-closed + * even without a context — and kill-switched types). + */ +function effectiveHidden(block: BlockConfig, vis: BlockVisibilityState | null): boolean { + if (block.hideFromToolbar) return true + return isHiddenUnder(vis, block) +} + +/** + * Project a block through the viewer's visibility: gated blocks become shallow + * clones with `hideFromToolbar: true` (CLONE-NOT-REMOVE — gated blocks must stay + * in `getAllBlocks()` output because `.find`-by-type consumers rely on it), and + * revealed-but-not-GA preview blocks get a display " (Preview)" name suffix. + * The `!block.hideFromToolbar` guard keeps already-hidden blocks (including + * disabled custom blocks) un-cloned and never suffixed. + */ +function projectBlock(block: BlockConfig, vis: BlockVisibilityState | null): BlockConfig { + if (effectiveHidden(block, vis) && !block.hideFromToolbar) { + return { ...block, hideFromToolbar: true } + } + if (block.preview && vis?.previewTagged.has(block.type)) { + return { ...block, name: `${block.name} (Preview)` } + } + return block +} + +/** + * All block configs, including any in-scope custom blocks from the overlay, + * projected through the viewer's block visibility. Execution paths are + * unaffected: they resolve via the pure {@link getBlock}. + */ export function getAllBlocks(): BlockConfig[] { - return [...Object.values(BLOCK_REGISTRY), ...overlayBlocks()] + const all = [...Object.values(BLOCK_REGISTRY), ...overlayBlocks()] + const vis = overlayVisibility() + if (visibilityInert(vis)) return all + return all.map((block) => projectBlock(block, vis)) } /** Find the block whose `tools.access` contains the given tool id. */ @@ -77,14 +128,17 @@ export function getBlocksByCategory(category: BlockCategory): BlockConfig[] { * category. This is the single source of truth shared by every surface that * extracts blocks for presentation — the toolbar, the search/mention engine, * and the integrations catalog. A block is included when its `category` - * matches and it is not hidden from the toolbar (i.e. it is the latest - * version under the upgrade paradigm, since superseded versions set - * `hideFromToolbar: true`). + * matches and it is not effectively hidden: not `hideFromToolbar` (superseded + * versions), not an unrevealed `preview` block, and not kill-switched for the + * viewer. Visible blocks are projected so revealed preview blocks carry their + * " (Preview)" display suffix. */ export function getCanonicalBlocksByCategory(category: BlockCategory): BlockConfig[] { - return [...Object.values(BLOCK_REGISTRY), ...overlayBlocks()].filter( - (block) => block.category === category && !block.hideFromToolbar + const vis = overlayVisibility() + const blocks = [...Object.values(BLOCK_REGISTRY), ...overlayBlocks()].filter( + (block) => block.category === category && !effectiveHidden(block, vis) ) + return visibilityInert(vis) ? blocks : blocks.map((block) => projectBlock(block, vis)) } /** All registered block type identifiers. */ diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 7e3f9636bef..6d9f63188ab 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -462,6 +462,16 @@ export interface BlockConfig { } } hideFromToolbar?: boolean + /** + * Marks an unreleased block. Preview blocks are hidden from every discovery + * surface (toolbar, search, mentions, copilot/VFS, docs) in every environment — + * hosted, self-hosted, dev, and SSR — until revealed via the hosted + * `block-visibility` AppConfig document or the `PREVIEW_BLOCKS` env allowlist. + * Fail-closed by design; distinct from {@link hideFromToolbar} (permanently + * hidden superseded versions). Execution of already-placed instances is never + * gated. Remove at GA. + */ + preview?: boolean triggers?: { enabled: boolean available: string[] // List of trigger IDs this block supports diff --git a/apps/sim/blocks/visibility/client.test.ts b/apps/sim/blocks/visibility/client.test.ts new file mode 100644 index 00000000000..ead91b97c9d --- /dev/null +++ b/apps/sim/blocks/visibility/client.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockNotify } = vi.hoisted(() => ({ mockNotify: vi.fn() })) + +vi.mock('@/blocks/custom/client-overlay', () => ({ + notifyBlockOverlayChanged: mockNotify, +})) + +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +// client-boundary-allow: vitest ignores the 'use client' directive; this node-env test exercises the module directly +import { hydrateBlockVisibility } from '@/blocks/visibility/client' +import { overlayVisibility, registerBlockCacheInvalidator } from '@/blocks/visibility/context' + +function state(revealed: string[], disabled: string[] = []): BlockVisibilityState { + return { + revealed: new Set(revealed), + disabled: new Set(disabled), + previewTagged: new Set(revealed), + } +} + +describe('hydrateBlockVisibility', () => { + beforeEach(() => vi.clearAllMocks()) + + it('applies state, fires invalidators, and bumps the overlay version', () => { + const invalidator = vi.fn() + const unregister = registerBlockCacheInvalidator(invalidator) + + hydrateBlockVisibility(state(['gmail_v2'])) + expect(overlayVisibility()?.revealed.has('gmail_v2')).toBe(true) + expect(invalidator).toHaveBeenCalledTimes(1) + expect(mockNotify).toHaveBeenCalledTimes(1) + + unregister() + }) + + it('no-ops on a deep-equal state (fresh objects, same content)', () => { + const invalidator = vi.fn() + const unregister = registerBlockCacheInvalidator(invalidator) + + hydrateBlockVisibility(state(['gmail_v2'], ['slack'])) + hydrateBlockVisibility(state(['gmail_v2'], ['slack'])) + expect(invalidator).toHaveBeenCalledTimes(1) + expect(mockNotify).toHaveBeenCalledTimes(1) + + hydrateBlockVisibility(state(['gmail_v2', 'notion_v3'], ['slack'])) + expect(invalidator).toHaveBeenCalledTimes(2) + expect(mockNotify).toHaveBeenCalledTimes(2) + + unregister() + }) +}) diff --git a/apps/sim/blocks/visibility/client.ts b/apps/sim/blocks/visibility/client.ts new file mode 100644 index 00000000000..720d1af8a2e --- /dev/null +++ b/apps/sim/blocks/visibility/client.ts @@ -0,0 +1,46 @@ +'use client' + +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay' +import { invalidateBlockCaches, registerBlockVisibilityResolver } from '@/blocks/visibility/context' + +/** + * Client-side visibility state, hydrated from `useBlockVisibility` by + * `BlockVisibilityLoader`. Registered at module load with `null` state so the + * very first render — including the SSR pass — is fail-closed for `preview` + * blocks; the post-mount fetch only ever reveals (benign pop-in) or applies a + * kill switch to an already-public block. + */ +let state: BlockVisibilityState | null = null + +registerBlockVisibilityResolver({ current: () => state }) + +function setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) return false + for (const value of a) if (!b.has(value)) return false + return true +} + +/** + * Replace the in-scope visibility state, reset registered module caches, and + * bump the shared block-overlay version so every subscribed consumer re-reads + * `getAllBlocks()`. + * + * No-ops when the incoming state is deep-equal to the current one — React + * Query refetches deliver fresh-but-identical objects on every poll, and + * without this guard each poll would thundering-rebuild the toolbar, search, + * and matcher caches for nothing. + */ +export function hydrateBlockVisibility(next: BlockVisibilityState): void { + if ( + state && + setsEqual(state.revealed, next.revealed) && + setsEqual(state.disabled, next.disabled) && + setsEqual(state.previewTagged, next.previewTagged) + ) { + return + } + state = next + invalidateBlockCaches() + notifyBlockOverlayChanged() +} diff --git a/apps/sim/blocks/visibility/context.ts b/apps/sim/blocks/visibility/context.ts new file mode 100644 index 00000000000..7c191603035 --- /dev/null +++ b/apps/sim/blocks/visibility/context.ts @@ -0,0 +1,73 @@ +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +import type { BlockConfig } from '@/blocks/types' + +/** + * Resolver for the per-viewer block-visibility projection, mirroring the + * custom-block overlay seam (`@/blocks/custom/overlay`) but deliberately + * independent of it: visibility is a discovery concern with its own lifecycle, + * and a separate AsyncLocalStorage composes with `withCustomBlockOverlay` + * without `store.run` clobbering. + * + * Two environment-specific resolvers register here: + * - client: module state hydrated from `useBlockVisibility` (see `client.ts`) + * - server: an AsyncLocalStorage scoped per request (see `server-context.ts`) + * + * This module is isomorphic (no `'use client'`, no `node:` imports) so + * `@/blocks/registry` stays importable on both sides. When NO resolver state is + * active, `preview` blocks are still hidden ({@link isHiddenUnder} treats a null + * state as "nothing revealed") — fail-closed is the default everywhere, + * including SSR and server paths outside `withBlockVisibility`. + */ +export interface BlockVisibilityResolver { + current(): BlockVisibilityState | null +} + +let resolver: BlockVisibilityResolver | null = null + +/** Register (or clear with `null`) the active visibility resolver for this environment. */ +export function registerBlockVisibilityResolver(next: BlockVisibilityResolver | null): void { + resolver = next +} + +/** The visibility state in scope, or `null` when none (= nothing revealed, nothing disabled). */ +export function overlayVisibility(): BlockVisibilityState | null { + return resolver?.current() ?? null +} + +/** + * THE single hidden-predicate for block gating — every surface that hides + * blocks (registry projection, VFS stamp filter, exposed-integration-tools + * filter, `get_blocks_metadata`) calls this; never restate the rule inline. + * + * A block is hidden when it is an unrevealed `preview` block (fail-closed even + * with a null state) or when the kill switch (`disabled`) names it. Static + * `hideFromToolbar` is deliberately NOT part of this predicate — callers that + * need it check it separately. + */ +export function isHiddenUnder( + vis: BlockVisibilityState | null, + block: Pick +): boolean { + if (block.preview && !vis?.revealed.has(block.type)) return true + if (vis?.disabled.has(block.type)) return true + return false +} + +/** + * Registry of non-React module-cache resets (e.g. the tool-operations search + * index, the integration matcher) fired when the client visibility state + * changes. Lives here — not in the `'use client'` module — so plain `lib/` + * modules can register at load time on either side of the server boundary. + */ +const cacheInvalidators = new Set<() => void>() + +/** Register a cache reset to run on visibility changes. Returns an unregister fn. */ +export function registerBlockCacheInvalidator(fn: () => void): () => void { + cacheInvalidators.add(fn) + return () => cacheInvalidators.delete(fn) +} + +/** Fire every registered cache reset (called by the client hydrate path). */ +export function invalidateBlockCaches(): void { + for (const fn of cacheInvalidators) fn() +} diff --git a/apps/sim/blocks/visibility/registry-inert.test.ts b/apps/sim/blocks/visibility/registry-inert.test.ts new file mode 100644 index 00000000000..ddb7551737b --- /dev/null +++ b/apps/sim/blocks/visibility/registry-inert.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('@/blocks/registry') + +const { synthRegistry } = vi.hoisted(() => ({ + synthRegistry: { + slack: { + type: 'slack', + name: 'Slack', + description: '', + category: 'tools', + bgColor: '#000', + icon: () => null, + subBlocks: [], + tools: { access: [] }, + inputs: {}, + outputs: {}, + }, + }, +})) + +vi.mock('@/blocks/registry-maps', () => ({ + BLOCK_REGISTRY: synthRegistry, + BLOCK_META_REGISTRY: {}, +})) + +import { getAllBlocks } from '@/blocks/registry' +import { registerBlockVisibilityResolver } from '@/blocks/visibility/context' + +afterEach(() => registerBlockVisibilityResolver(null)) + +describe('registry fast path (no preview blocks registered)', () => { + it('returns raw references with no context', () => { + expect(getAllBlocks()[0]).toBe(synthRegistry.slack) + }) + + it('returns raw references when the active state has no kill-switch entries', () => { + const state = { + revealed: new Set(['whatever']), + disabled: new Set(), + previewTagged: new Set(['whatever']), + } + registerBlockVisibilityResolver({ current: () => state }) + expect(getAllBlocks()[0]).toBe(synthRegistry.slack) + }) + + it('still projects when a kill-switch entry applies', () => { + const state = { + revealed: new Set(), + disabled: new Set(['slack']), + previewTagged: new Set(), + } + registerBlockVisibilityResolver({ current: () => state }) + expect(getAllBlocks()[0]?.hideFromToolbar).toBe(true) + expect(synthRegistry.slack).not.toHaveProperty('hideFromToolbar', true) + }) +}) diff --git a/apps/sim/blocks/visibility/server-context.ts b/apps/sim/blocks/visibility/server-context.ts new file mode 100644 index 00000000000..e713779c43c --- /dev/null +++ b/apps/sim/blocks/visibility/server-context.ts @@ -0,0 +1,25 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +import { registerBlockVisibilityResolver } from '@/blocks/visibility/context' + +/** + * Server-side visibility context: a per-request AsyncLocalStorage, independent + * of the custom-block overlay's ALS so `withBlockVisibility` and + * `withCustomBlockOverlay` nest in either order without clobbering each other. + * + * Only copilot/mothership discovery paths establish this scope. Execution entry + * points (execute route, trigger.dev tasks, schedules/webhooks) never do — so + * placed preview blocks always serialize and run, and `preview` blocks stay + * hidden on unscoped discovery reads purely via the static fail-closed default. + */ +const store = new AsyncLocalStorage() + +registerBlockVisibilityResolver({ current: () => store.getStore() ?? null }) + +/** Run `fn` with the given visibility state resolvable via the registry accessors. */ +export function withBlockVisibility( + vis: BlockVisibilityState, + fn: () => Promise +): Promise { + return store.run(vis, fn) +} diff --git a/apps/sim/blocks/visibility/visibility.test.ts b/apps/sim/blocks/visibility/visibility.test.ts new file mode 100644 index 00000000000..6cd95f22435 --- /dev/null +++ b/apps/sim/blocks/visibility/visibility.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('@/blocks/registry') + +const { synthRegistry } = vi.hoisted(() => { + const block = (type: string, extra: Record = {}) => ({ + type, + name: type.toUpperCase(), + description: '', + category: 'tools', + bgColor: '#000', + icon: () => null, + subBlocks: [], + tools: { access: [] }, + inputs: {}, + outputs: {}, + ...extra, + }) + return { + synthRegistry: { + slack: block('slack'), + gmail_v2: block('gmail_v2', { preview: true }), + old_v1: block('old_v1', { hideFromToolbar: true }), + }, + } +}) + +vi.mock('@/blocks/registry-maps', () => ({ + BLOCK_REGISTRY: synthRegistry, + BLOCK_META_REGISTRY: {}, +})) + +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +import { registerBlockOverlayResolver } from '@/blocks/custom/overlay' +import { getAllBlocks, getBlock, getCanonicalBlocksByCategory } from '@/blocks/registry' +import type { BlockConfig } from '@/blocks/types' +import { isHiddenUnder, registerBlockVisibilityResolver } from '@/blocks/visibility/context' + +function vis(partial: Partial = {}): BlockVisibilityState { + return { + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + ...partial, + } +} + +function withVisibility(state: BlockVisibilityState | null) { + registerBlockVisibilityResolver(state ? { current: () => state } : null) +} + +const byType = (blocks: BlockConfig[], type: string) => blocks.find((b) => b.type === type) + +afterEach(() => { + registerBlockVisibilityResolver(null) + registerBlockOverlayResolver(null) +}) + +describe('isHiddenUnder', () => { + it('hides unrevealed preview blocks even with a null state (fail-closed)', () => { + expect(isHiddenUnder(null, { type: 'gmail_v2', preview: true })).toBe(true) + expect(isHiddenUnder(null, { type: 'slack' })).toBe(false) + }) + + it('reveals preview blocks named in revealed', () => { + const state = vis({ revealed: new Set(['gmail_v2']) }) + expect(isHiddenUnder(state, { type: 'gmail_v2', preview: true })).toBe(false) + }) + + it('hides kill-switched types only with an active state', () => { + const state = vis({ disabled: new Set(['slack']) }) + expect(isHiddenUnder(state, { type: 'slack' })).toBe(true) + expect(isHiddenUnder(null, { type: 'slack' })).toBe(false) + }) +}) + +describe('registry projection', () => { + it('hides preview blocks without any context: clone-not-remove', () => { + withVisibility(null) + const all = getAllBlocks() + const gmail = byType(all, 'gmail_v2') + expect(gmail).toBeDefined() + expect(gmail?.hideFromToolbar).toBe(true) + // the underlying registry entry is untouched + expect(getBlock('gmail_v2')?.hideFromToolbar).toBeUndefined() + // canonical (filtered) set excludes it + expect(byType(getCanonicalBlocksByCategory('tools'), 'gmail_v2')).toBeUndefined() + }) + + it('reveals a preview block with a " (Preview)" display suffix when tagged', () => { + withVisibility(vis({ revealed: new Set(['gmail_v2']), previewTagged: new Set(['gmail_v2']) })) + expect(byType(getAllBlocks(), 'gmail_v2')?.name).toBe('GMAIL_V2 (Preview)') + const canonical = byType(getCanonicalBlocksByCategory('tools'), 'gmail_v2') + expect(canonical?.name).toBe('GMAIL_V2 (Preview)') + expect(canonical?.hideFromToolbar).toBeUndefined() + }) + + it('reveals a config-GA preview block without a suffix', () => { + withVisibility(vis({ revealed: new Set(['gmail_v2']) })) + expect(byType(getAllBlocks(), 'gmail_v2')?.name).toBe('GMAIL_V2') + }) + + it('kill-switches a shipped block only when a context is active', () => { + withVisibility(vis({ disabled: new Set(['slack']) })) + expect(byType(getAllBlocks(), 'slack')?.hideFromToolbar).toBe(true) + expect(byType(getCanonicalBlocksByCategory('tools'), 'slack')).toBeUndefined() + + withVisibility(null) + expect(byType(getAllBlocks(), 'slack')?.hideFromToolbar).toBeUndefined() + }) + + it('keeps getBlock pure regardless of visibility', () => { + withVisibility( + vis({ + revealed: new Set(['gmail_v2']), + previewTagged: new Set(['gmail_v2']), + disabled: new Set(['slack']), + }) + ) + expect(getBlock('gmail_v2')?.name).toBe('GMAIL_V2') + expect(getBlock('slack')?.hideFromToolbar).toBeUndefined() + }) + + it('returns untouched references for unaffected blocks', () => { + withVisibility(vis({ revealed: new Set(['gmail_v2']) })) + expect(byType(getAllBlocks(), 'slack')).toBe(synthRegistry.slack) + expect(byType(getAllBlocks(), 'old_v1')).toBe(synthRegistry.old_v1) + }) + + it('never re-clones or suffixes already-hidden custom blocks', () => { + const disabledCustom = { + ...synthRegistry.slack, + type: 'custom_block_abc', + name: 'My Custom', + hideFromToolbar: true, + } as BlockConfig + registerBlockOverlayResolver({ + get: (t) => (t === disabledCustom.type ? disabledCustom : undefined), + all: () => [disabledCustom], + }) + withVisibility(vis({ revealed: new Set(['gmail_v2']) })) + const projected = byType(getAllBlocks(), 'custom_block_abc') + expect(projected).toBe(disabledCustom) + expect(projected?.name).toBe('My Custom') + }) +}) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index bcb06776da8..7bf146ec4ac 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -615,8 +615,19 @@ export function GroupDetail({ const { data: roster } = useOrganizationRoster(organizationId) const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: true }) - // Recompute when custom (deploy-as-block) blocks hydrate into the overlay. + // Recompute when custom (deploy-as-block) blocks or the viewer's block + // visibility hydrate into the overlay. const customBlockOverlayVersion = useCustomBlockOverlayVersion() + + /** + * The allowlist UNIVERSE: every access-controllable block, INCLUDING blocks + * gated for this viewer (they arrive as clones with `hideFromToolbar: true`, + * clone-not-remove). Materialization and the collapse-to-null comparison in + * `toggleIntegration`/`setBlocksAllowed` must use this viewer-independent set — + * otherwise a null→partial transition by a non-revealed admin would silently + * drop a preview block from the stored allowlist and deny it to revealed + * users already running it. + */ const allBlocks = useMemo(() => { const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type)) return blocks.sort((a, b) => { @@ -628,6 +639,14 @@ export function GroupDetail({ }) }, [customBlockOverlayVersion]) + /** + * The RENDERED list: hides blocks gated for this viewer by reading the + * registry projection's effective flag off the clone (the single source of + * truth — never re-derive visibility here). Revealed viewers see preview + * blocks (with their " (Preview)" suffix) and can toggle them explicitly. + */ + const visibleBlocks = useMemo(() => allBlocks.filter((b) => !b.hideFromToolbar), [allBlocks]) + const allProviderIds = useMemo(() => { const allIds = getAllProviderIds() const blacklist = blacklistedProvidersData?.blacklistedProviders ?? [] @@ -830,10 +849,10 @@ export function GroupDetail({ }, [allProviderIds, providerSearchTerm]) const filteredBlocks = useMemo(() => { - if (!integrationSearchTerm.trim()) return allBlocks + if (!integrationSearchTerm.trim()) return visibleBlocks const query = integrationSearchTerm.toLowerCase() - return allBlocks.filter((b) => b.name.toLowerCase().includes(query)) - }, [allBlocks, integrationSearchTerm]) + return visibleBlocks.filter((b) => b.name.toLowerCase().includes(query)) + }, [visibleBlocks, integrationSearchTerm]) const filteredCoreBlocks = useMemo( () => filteredBlocks.filter((block) => block.category === 'blocks'), diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index be436ca0019..13352b9fb00 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -379,6 +379,19 @@ describe('validateBlockType', () => { await validateBlockType(undefined, undefined, 'notion') }) + it('does NOT treat preview blocks as exempt — preview is not legacy', async () => { + // A `preview: true` block has static hideFromToolbar unset, so it is a + // normal access-controlled block: visibility gating (discovery) and + // permission-group enforcement (execution) are deliberately independent. + mockGetBlock.mockImplementation((type) => + type === 'gmail_v2' ? ({ preview: true } as { hideFromToolbar?: boolean }) : undefined + ) + + await expect(validateBlockType(undefined, undefined, 'gmail_v2')).rejects.toThrow( + IntegrationNotAllowedError + ) + }) + it('matches case-insensitively', async () => { await validateBlockType(undefined, undefined, 'Slack') await validateBlockType(undefined, undefined, 'GOOGLE_DRIVE') diff --git a/apps/sim/hooks/queries/block-visibility.ts b/apps/sim/hooks/queries/block-visibility.ts new file mode 100644 index 00000000000..2f073cb08d4 --- /dev/null +++ b/apps/sim/hooks/queries/block-visibility.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type BlockVisibilityResponse, + getBlockVisibilityContract, +} from '@/lib/api/contracts/block-visibility' + +export const BLOCK_VISIBILITY_STALE_TIME = 60 * 1000 + +export const blockVisibilityKeys = { + all: ['block-visibility'] as const, + lists: () => [...blockVisibilityKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...blockVisibilityKeys.lists(), workspaceId ?? ''] as const, +} + +async function fetchBlockVisibility( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(getBlockVisibilityContract, { query: { workspaceId }, signal }) +} + +/** The viewer's block-visibility projection for a workspace (revealed/disabled/preview-tagged types). */ +export function useBlockVisibility(workspaceId?: string) { + return useQuery({ + queryKey: blockVisibilityKeys.list(workspaceId), + queryFn: ({ signal }) => fetchBlockVisibility(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: BLOCK_VISIBILITY_STALE_TIME, + }) +} diff --git a/apps/sim/lib/api/contracts/block-visibility.ts b/apps/sim/lib/api/contracts/block-visibility.ts new file mode 100644 index 00000000000..505eec694c6 --- /dev/null +++ b/apps/sim/lib/api/contracts/block-visibility.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' + +/** + * Per-viewer block visibility projection (see + * `@/lib/core/config/block-visibility`): which `preview: true` block types this + * viewer may see, which types are kill-switched, and which revealed types carry + * the " (Preview)" display tag. + */ + +const getBlockVisibilityQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type GetBlockVisibilityQuery = z.input + +const blockVisibilityResponseSchema = z.object({ + /** Preview block types revealed to this viewer. */ + revealed: z.array(z.string()), + /** Block types kill-switched (hidden from discovery) for this viewer. */ + disabled: z.array(z.string()), + /** Revealed types not globally GA — displayed with a " (Preview)" suffix. */ + previewTagged: z.array(z.string()), +}) + +export type BlockVisibilityResponse = z.output + +export const getBlockVisibilityContract = defineRouteContract({ + method: 'GET', + path: '/api/blocks/visibility', + query: getBlockVisibilityQuerySchema, + response: { + mode: 'json', + schema: blockVisibilityResponseSchema, + }, +}) diff --git a/apps/sim/lib/copilot/block-visibility.ts b/apps/sim/lib/copilot/block-visibility.ts new file mode 100644 index 00000000000..90e3c04cea7 --- /dev/null +++ b/apps/sim/lib/copilot/block-visibility.ts @@ -0,0 +1,58 @@ +import { LRUCache } from 'lru-cache' +import { type BlockVisibilityState, getBlockVisibility } from '@/lib/core/config/block-visibility' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' + +/** + * Copilot-side resolver for the viewer's block-visibility projection. + * + * A single mothership turn fans out into many Go→Sim tool callbacks; resolving + * visibility per callback would repeat the workspace→org lookup (and, for + * admin-gated rules, a replica read) N times. This memoizes the resolved state + * per (userId, workspaceId) for a short TTL matching the AppConfig cache + * cadence, so a turn costs at most one resolution. + */ +const VISIBILITY_CACHE_TTL_MS = 30_000 + +const visibilityCache = new LRUCache>({ + max: 1000, + ttl: VISIBILITY_CACHE_TTL_MS, +}) + +async function resolveVisibility( + userId: string, + workspaceId?: string +): Promise { + const orgId = workspaceId + ? (await getWorkspaceWithOwner(workspaceId, { includeArchived: true }))?.organizationId + : undefined + return getBlockVisibility({ userId, orgId }) +} + +/** The viewer's visibility state, memoized per (userId, workspaceId) for ~30s. */ +export function getBlockVisibilityForCopilot( + userId: string, + workspaceId?: string +): Promise { + const key = `${userId}:${workspaceId ?? ''}` + let promise = visibilityCache.get(key) + if (!promise) { + promise = resolveVisibility(userId, workspaceId).catch((error) => { + visibilityCache.delete(key) + throw error + }) + visibilityCache.set(key, promise) + } + return promise +} + +/** + * Stable signature of a visibility state, for keying caches whose contents + * depend on the gated projection (e.g. the integration tool-schema LRU). + */ +export function visibilitySignature(vis: BlockVisibilityState): string { + return JSON.stringify([ + [...vis.revealed].sort(), + [...vis.disabled].sort(), + [...vis.previewTagged].sort(), + ]) +} diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 1afbfbbaac3..88d6abbf689 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -53,7 +53,17 @@ vi.mock('@/tools/utils', () => ({ stripVersionSuffix: vi.fn((toolId: string) => toolId), })) +vi.mock('@/lib/copilot/block-visibility', () => ({ + getBlockVisibilityForCopilot: vi.fn(async () => ({ + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + })), + visibilitySignature: vi.fn(() => 'vis:none'), +})) + vi.mock('@/lib/copilot/integration-tools', () => ({ + filterExposedIntegrationTools: vi.fn((tools: unknown[]) => tools), getExposedIntegrationTools: vi.fn(() => [ { toolId: 'gmail_send', diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index e718b33cce3..97943990f09 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -3,11 +3,16 @@ import { toError } from '@sim/utils/errors' import { LRUCache } from 'lru-cache' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPaid } from '@/lib/billing/plan-helpers' +import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility' import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' -import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' +import { + filterExposedIntegrationTools, + getExposedIntegrationTools, +} from '@/lib/copilot/integration-tools' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' import { buildUserSkillTool } from '@/lib/mothership/skills' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -73,9 +78,12 @@ const integrationToolSchemaCache = new LRUCache { const schemaSurface = options.schemaSurface ?? 'copilot' - const cacheKey = getIntegrationToolSchemaCacheKey(userId, workspaceId, schemaSurface) + const vis = await getBlockVisibilityForCopilot(userId, workspaceId) + const cacheKey = getIntegrationToolSchemaCacheKey( + userId, + workspaceId, + schemaSurface, + visibilitySignature(vis) + ) const cached = integrationToolSchemaCache.get(cacheKey) if (cached) { return cloneToolSchemas(await cached.promise) @@ -120,7 +134,8 @@ export async function buildIntegrationToolSchemas( userId, messageId, { schemaSurface }, - workspaceId + workspaceId, + vis ).catch((error) => { integrationToolSchemaCache.delete(cacheKey) throw error @@ -137,7 +152,8 @@ async function buildIntegrationToolSchemasUncached( userId: string, messageId: string | undefined, options: Required, - workspaceId?: string + workspaceId?: string, + vis: BlockVisibilityState | null = null ): Promise { const reqLogger = logger.withMetadata({ messageId }) const integrationTools: ToolSchema[] = [] @@ -186,7 +202,8 @@ async function buildIntegrationToolSchemasUncached( } } - for (const { toolId, config: toolConfig, service } of getExposedIntegrationTools()) { + const exposedTools = filterExposedIntegrationTools(getExposedIntegrationTools(), vis) + for (const { toolId, config: toolConfig, service } of exposedTools) { try { if (allowedIntegrations && toolIdToBlockType) { const owningBlock = toolIdToBlockType.get(stripVersionSuffix(toolId)) diff --git a/apps/sim/lib/copilot/integration-tools.ts b/apps/sim/lib/copilot/integration-tools.ts index c1c93cc536e..77559d78784 100644 --- a/apps/sim/lib/copilot/integration-tools.ts +++ b/apps/sim/lib/copilot/integration-tools.ts @@ -1,4 +1,6 @@ -import { getAllBlocks } from '@/blocks/registry' +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' +import { BLOCK_REGISTRY } from '@/blocks/registry-maps' +import { isHiddenUnder } from '@/blocks/visibility/context' import { tools as toolRegistry } from '@/tools/registry' import type { ToolConfig } from '@/tools/types' import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' @@ -15,14 +17,24 @@ export interface ExposedIntegrationTool { service: string /** Operation stem within the service (used for the VFS path filename), e.g. "read". */ operation: string + /** Owning block's registry type — the key block-visibility rules gate on. */ + blockType: string + /** Owning block's static `preview` marker, for the per-viewer filter. */ + preview?: boolean } let cached: ExposedIntegrationTool[] | null = null /** - * Returns the canonical set of integration tools exposed to the copilot agent: - * the latest version of each operation owned by a visible (non-hideFromToolbar) - * block. + * Returns the UNGATED universe of integration tools exposable to the copilot + * agent: the latest version of each operation owned by a non-`hideFromToolbar` + * block — INCLUDING unreleased `preview` blocks. + * + * Deliberately sourced from the raw `BLOCK_REGISTRY` (never the visibility- + * projected `getAllBlocks`) so this process-global memo is deterministic and + * can never be poisoned by whichever viewer's gated projection ran first. + * Every per-viewer consumer MUST apply {@link filterExposedIntegrationTools} + * before exposing the set. * * This is the single source of truth shared by VFS discovery * (components/integrations/**) and the deferred callable-tool payload, so the @@ -33,15 +45,16 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] { if (cached) return cached // Map the tool ids each visible block exposes (both the raw id and its - // version-stripped base name) to that block's service directory. - const toolToService = new Map() - for (const block of getAllBlocks()) { + // version-stripped base name) to that block's service directory + type. + const toolToBlock = new Map() + for (const block of Object.values(BLOCK_REGISTRY)) { if (block.hideFromToolbar) continue if (!block.tools?.access) continue const service = stripVersionSuffix(block.type) + const owner = { service, blockType: block.type, preview: block.preview } for (const toolId of block.tools.access) { - toolToService.set(toolId, service) - toolToService.set(stripVersionSuffix(toolId), service) + toolToBlock.set(toolId, owner) + toolToBlock.set(stripVersionSuffix(toolId), owner) } } @@ -49,19 +62,41 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] { const seen = new Set() for (const [toolId, config] of Object.entries(getLatestVersionTools(toolRegistry))) { const baseName = stripVersionSuffix(toolId) - const service = toolToService.get(toolId) ?? toolToService.get(baseName) - if (!service) continue + const owner = toolToBlock.get(toolId) ?? toolToBlock.get(baseName) + if (!owner) continue if (seen.has(baseName)) continue seen.add(baseName) - const prefix = `${service}_` + const prefix = `${owner.service}_` const operation = baseName.startsWith(prefix) ? baseName.slice(prefix.length) : baseName - exposed.push({ toolId, config, service, operation }) + exposed.push({ + toolId, + config, + service: owner.service, + operation, + blockType: owner.blockType, + preview: owner.preview, + }) } cached = exposed return exposed } +/** + * Per-viewer projection of the exposed set: drops tools whose owning block is + * hidden under `vis` (unrevealed preview blocks — including with a null state — + * and kill-switched types). Apply at every surface that hands the set to a + * viewer: VFS stamping, the deferred tool payload, `list_integration_tools`. + */ +export function filterExposedIntegrationTools( + tools: ExposedIntegrationTool[], + vis: BlockVisibilityState | null +): ExposedIntegrationTool[] { + return tools.filter( + (tool) => !isHiddenUnder(vis, { type: tool.blockType, preview: tool.preview }) + ) +} + /** Test-only: clears the memoized set so registry changes are picked up. */ export function resetExposedIntegrationToolsCache(): void { cached = null diff --git a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts index a3e307bac8a..465da57bc33 100644 --- a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts +++ b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts @@ -1,17 +1,24 @@ -import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' +import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' +import { + filterExposedIntegrationTools, + getExposedIntegrationTools, +} from '@/lib/copilot/integration-tools' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { stripVersionSuffix } from '@/tools/utils' export async function executeListIntegrationTools( params: Record, - _context: ExecutionContext + context: ExecutionContext ): Promise { const raw = typeof params.integration === 'string' ? params.integration.trim() : '' if (!raw) { return { success: false, error: "Missing required parameter 'integration'" } } - const all = getExposedIntegrationTools() + // The exposed set is the ungated universe — project it for this viewer so + // gated (preview / kill-switched) integrations stay undiscoverable. + const vis = await getBlockVisibilityForCopilot(context.userId, context.workspaceId) + const all = filterExposedIntegrationTools(getExposedIntegrationTools(), vis) const service = stripVersionSuffix(raw.toLowerCase()) const matches = all.filter((tool) => tool.service === service) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index ca1902692e1..fd4a515a8af 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -1,15 +1,28 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import { withBlockVisibility } from '@/blocks/visibility/server-context' import { grepChatUpload, listChatUploads, readChatUpload } from './upload-file-reader' const logger = createLogger('VfsTools') +/** + * Materialize the workspace VFS inside the viewer's block-visibility context so + * the static component files stamped into it exclude blocks gated for this + * viewer (unrevealed previews, kill-switched types). Visibility is memoized per + * (userId, workspaceId), so repeated tool calls in one turn resolve once. + */ +async function getGatedVFS(workspaceId: string, userId: string) { + const vis = await getBlockVisibilityForCopilot(userId, workspaceId) + return withBlockVisibility(vis, () => getOrMaterializeVFS(workspaceId, userId)) +} + /** * Encode a chat-upload display name as a single canonical VFS path segment so * `uploads/` paths follow the same percent-encoded convention as `files/`. @@ -119,7 +132,7 @@ export async function executeVfsGrep( } result = await grepChatUpload(filename, context.chatId, pattern, grepOptions) } else { - const vfs = await getOrMaterializeVFS(workspaceId, context.userId) + const vfs = await getGatedVFS(workspaceId, context.userId) result = isWorkspaceFileGrepPath(rawPath) ? await vfs.grepFile(rawPath, pattern, grepOptions) : await vfs.grep(pattern, rawPath, grepOptions) @@ -176,7 +189,7 @@ export async function executeVfsGlob( } try { - const vfs = await getOrMaterializeVFS(workspaceId, context.userId) + const vfs = await getGatedVFS(workspaceId, context.userId) let files = vfs.glob(pattern) if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) { @@ -281,7 +294,7 @@ export async function executeVfsRead( } } - const vfs = await getOrMaterializeVFS(workspaceId, context.userId) + const vfs = await getGatedVFS(workspaceId, context.userId) // Plain canonical file leaves are metadata resources. Dynamic file content // and inspection paths use explicit suffixes like /content, /style, diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 6dd2579711a..7828f89ec98 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -10,6 +10,7 @@ import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isCustomBlockType } from '@/blocks/custom/build-config' import { getBlock } from '@/blocks/registry' import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' +import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { PROVIDER_DEFINITIONS } from '@/providers/models' import { tools as toolsRegistry } from '@/tools/registry' @@ -165,6 +166,15 @@ export const getBlocksMetadataServerTool: BaseServerTool< continue } + // getBlock is pure, so the viewer's visibility must be checked + // explicitly: unrevealed preview blocks and kill-switched types stay + // out of the agent's metadata (the router wraps this tool in + // withBlockVisibility). + if (isHiddenUnder(overlayVisibility(), blockConfig)) { + logger.debug('Skipping block gated by visibility', { blockId }) + continue + } + if (isCustomBlockType(blockId)) { // Custom (deploy-as-block) blocks run a bound workflow via an internal // `workflow_executor`; the agent never configures a workflowId/inputMapping. diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 6754920bca4..9dd8c737dde 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' +import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { CreateFile, CreateFileFolder, @@ -60,6 +61,7 @@ import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit import { queryLogsServerTool } from '@/lib/copilot/tools/server/workflow/query-logs' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' +import { withBlockVisibility } from '@/blocks/visibility/server-context' export type ExecuteResponseSuccess = z.output @@ -76,6 +78,16 @@ const logger = createLogger('ServerToolRouter') */ const CUSTOM_BLOCK_OVERLAY_TOOLS = new Set(['edit_workflow', 'get_blocks_metadata']) +/** + * DISCOVERY tools that must run inside the viewer's block-visibility context so + * gated (preview / kill-switched) blocks disappear from what the agent can + * list. Deliberately a DIFFERENT set from {@link CUSTOM_BLOCK_OVERLAY_TOOLS}: + * `edit_workflow` is excluded because its registry use is functional + * (find-by-type over clones, never a discovery listing) and gating it would + * only risk leaking display projections into persisted state. + */ +const VISIBILITY_GATED_TOOLS = new Set(['get_blocks_metadata', 'get_trigger_blocks']) + const WRITE_ACTIONS: Record = { [KnowledgeBase.id]: [ 'create', @@ -243,15 +255,22 @@ export async function routeExecution( // Execute. The registry-dependent tools resolve blocks via getBlock/getAllBlocks; // wrap them in the custom-block overlay for the workspace's org so `custom_block_*` // types resolve (metadata lookup + edit-workflow validation) instead of being - // rejected as unknown. Other tools skip the extra query. - const runTool = () => tool.execute(args, context) - const result = - CUSTOM_BLOCK_OVERLAY_TOOLS.has(toolName) && context?.workspaceId - ? await withCustomBlockOverlay( - await listCustomBlocksWithInputsForWorkspace(context.workspaceId), - runTool - ) - : await runTool() + // rejected as unknown, and wrap discovery tools in the viewer's block-visibility + // context so gated blocks stay hidden. The two ALS scopes are independent and + // nest in either order. Other tools skip the extra queries. + let run = () => tool.execute(args, context) + if (VISIBILITY_GATED_TOOLS.has(toolName) && context?.userId) { + // Memoized per (userId, workspaceId) ~30s — a multi-tool turn resolves once. + const vis = await getBlockVisibilityForCopilot(context.userId, context.workspaceId) + const inner = run + run = () => withBlockVisibility(vis, inner) + } + if (CUSTOM_BLOCK_OVERLAY_TOOLS.has(toolName) && context?.workspaceId) { + const rows = await listCustomBlocksWithInputsForWorkspace(context.workspaceId) + const inner = run + run = () => withCustomBlockOverlay(rows, inner) + } + const result = await run() // Validate output if tool declares a schema; otherwise fall back to the // generated JSON schema contract emitted from Go. diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index abfc3ad9508..f40def73f7e 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -88,6 +88,7 @@ import { workspacePlanBackingPath, workspacePlansBackingFolderPath, } from '@/lib/copilot/vfs/workflow-aliases' +import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { @@ -124,8 +125,9 @@ import { } from '@/lib/workspaces/permissions/utils' import { computeNeedsRedeployment } from '@/app/api/workflows/utils' import { buildCustomBlockConfig, isCustomBlockType } from '@/blocks/custom/build-config' -import { getAllBlocks } from '@/blocks/registry' -import type { BlockIcon } from '@/blocks/types' +import { BLOCK_REGISTRY } from '@/blocks/registry-maps' +import type { BlockConfig, BlockIcon } from '@/blocks/types' +import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' @@ -137,9 +139,39 @@ const logger = createLogger('WorkspaceVFS') const PLACEHOLDER_BLOCK_ICON = (() => null) as unknown as BlockIcon const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024 -/** Static component files, computed once and shared across all VFS instances */ +/** + * Static component files, computed once and shared across all VFS instances. + * Built from the UNGATED registry universe (preview blocks included) so this + * process-global cache can never be poisoned by one viewer's gated projection; + * per-viewer gating is applied when the map is stamped into each fresh VFS + * (see {@link isStaticFileHidden}). + */ let staticComponentFiles: Map | null = null +/** + * Owning block for each `components/integrations/**` file, recorded at build + * time. Block/trigger schema files carry their owning type as the path + * basename, but integration paths use the version-stripped service name — so + * their owners need this lookup for the stamp-time visibility filter. + */ +const integrationPathOwners = new Map>() + +/** + * Per-request visibility filter for the shared static files: hides files whose + * owning block is gated for this viewer (unrevealed preview blocks — the + * default with no context — and kill-switched types). Non-registry paths + * (loop/parallel, connectors, overviews) are always visible. + */ +function isStaticFileHidden(path: string, vis: BlockVisibilityState | null): boolean { + const blockMatch = path.match(/^components\/(?:blocks|triggers\/sim)\/([^/]+)\.json$/) + if (blockMatch) { + const config = BLOCK_REGISTRY[blockMatch[1]!] + return config ? isHiddenUnder(vis, config) : false + } + const owner = integrationPathOwners.get(path) + return owner ? isHiddenUnder(vis, owner) : false +} + // On-the-fly doc reads (render/extract) download the binary into the Sim process // and base64-stage it to E2B, so bound the input like the compile path's staging // caps — otherwise an authenticated member could OOM the worker with a multi-GB @@ -170,7 +202,12 @@ function getStaticComponentFiles(): Map { const files = new Map() - const allBlocks = getAllBlocks() + // Raw registry, never the visibility-projected getAllBlocks: this map is a + // process-global shared cache, so it must hold the deterministic ungated + // universe. Preview blocks get schema files here (path-filterable at stamp + // time for revealed viewers) but are EXCLUDED from the shared aggregate + // files (overviews, oauth/api-key summaries) that all viewers receive. + const allBlocks = Object.values(BLOCK_REGISTRY) const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar) let blocksFiltered = 0 @@ -188,11 +225,17 @@ function getStaticComponentFiles(): Map { // Integration tools come from the shared exposed-tool set (latest version of // each operation owned by a visible block), the same set used to build the // deferred callable tools — so discovery and execution can never drift. - for (const { config: tool, service, operation } of getExposedIntegrationTools()) { + for (const exposedTool of getExposedIntegrationTools()) { + const { config: tool, service, operation, blockType, preview } = exposedTool const path = `components/integrations/${service}/${operation}.json` files.set(path, serializeIntegrationSchema(tool)) + integrationPathOwners.set(path, { type: blockType, preview }) integrationCount++ + // Preview-owned tools stay out of the shared oauth/api-key aggregates — + // those files are identical for every viewer. + if (preview) continue + if (tool.oauth?.required) { const existing = oauthServices.get(service) if (existing) { @@ -320,12 +363,16 @@ function getStaticComponentFiles(): Map { files.set( 'components/triggers/triggers.md', serializeTriggerOverview( - builtinTriggerBlocks.map((b) => ({ - id: b.type, - name: b.name, - provider: 'sim', - description: b.description, - })), + // The overview is a shared file — preview trigger blocks stay out of it + // (their per-type schema file remains discoverable for revealed viewers). + builtinTriggerBlocks + .filter((b) => !b.preview) + .map((b) => ({ + id: b.type, + name: b.name, + provider: 'sim', + description: b.description, + })), Object.entries(TRIGGER_REGISTRY).map(([id, t]) => ({ id, name: t.name, @@ -642,7 +689,11 @@ export class WorkspaceVFS { await timed('recently_deleted', this.materializeRecentlyDeleted(workspaceId, userId)) + // Per-viewer gating happens HERE, not in the shared builder: files + // owned by blocks hidden for this viewer are skipped at stamp time. + const blockVisibility = overlayVisibility() for (const [path, content] of getStaticComponentFiles()) { + if (isStaticFileHidden(path, blockVisibility)) continue this.files.set(path, content) } diff --git a/apps/sim/lib/core/config/appconfig-rules.test.ts b/apps/sim/lib/core/config/appconfig-rules.test.ts new file mode 100644 index 00000000000..ab79359000d --- /dev/null +++ b/apps/sim/lib/core/config/appconfig-rules.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { matchesRule, normalizeRule, parseGateConfig } from '@/lib/core/config/appconfig-rules' + +describe('normalizeRule', () => { + it('returns null for non-object values', () => { + expect(normalizeRule('nope')).toBeNull() + expect(normalizeRule(null)).toBeNull() + expect(normalizeRule(42)).toBeNull() + }) + + it('keeps only boolean enabled/adminEnabled', () => { + expect(normalizeRule({ enabled: 'true', adminEnabled: 1 })).toEqual({}) + expect(normalizeRule({ enabled: true, adminEnabled: false })).toEqual({ + enabled: true, + adminEnabled: false, + }) + }) + + it('trims, dedupes, and drops empty ids', () => { + expect(normalizeRule({ orgIds: ['Org_1', ' org_1 ', '', 'org_2'], userIds: 'nope' })).toEqual({ + orgIds: ['Org_1', 'org_1', 'org_2'], + }) + }) +}) + +describe('parseGateConfig', () => { + it('drops malformed entries and coerces the rest', () => { + const rules = parseGateConfig({ + a: { enabled: true }, + b: 'not-an-object', + c: { userIds: ['u1'] }, + }) + expect(rules.a).toEqual({ enabled: true }) + expect(rules.b).toBeUndefined() + expect(rules.c).toEqual({ userIds: ['u1'] }) + }) + + it('degrades to an empty map on a malformed document', () => { + expect(parseGateConfig('not-an-object')).toEqual({}) + expect(parseGateConfig(null)).toEqual({}) + }) +}) + +describe('matchesRule', () => { + it('returns false for a missing rule', () => { + expect(matchesRule(undefined, { userId: 'u1' }, true)).toBe(false) + }) + + it('matches the global enabled clause', () => { + expect(matchesRule({ enabled: true }, {}, false)).toBe(true) + expect(matchesRule({ enabled: false }, {}, false)).toBe(false) + }) + + it('matches the userId and orgId allowlists', () => { + expect(matchesRule({ userIds: ['u1'] }, { userId: 'u1' }, false)).toBe(true) + expect(matchesRule({ userIds: ['u1'] }, { userId: 'u2' }, false)).toBe(false) + expect(matchesRule({ orgIds: ['o1'] }, { orgId: 'o1' }, false)).toBe(true) + expect(matchesRule({ orgIds: ['o1'] }, {}, false)).toBe(false) + }) + + it('matches the admin clause only with the supplied isAdmin', () => { + expect(matchesRule({ adminEnabled: true }, { userId: 'u1' }, true)).toBe(true) + expect(matchesRule({ adminEnabled: true }, { userId: 'u1' }, false)).toBe(false) + expect(matchesRule({ enabled: false }, { userId: 'u1' }, true)).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/config/appconfig-rules.ts b/apps/sim/lib/core/config/appconfig-rules.ts new file mode 100644 index 00000000000..34ceec5173a --- /dev/null +++ b/apps/sim/lib/core/config/appconfig-rules.ts @@ -0,0 +1,80 @@ +/** + * Shared parsing and clause evaluation for AppConfig gating documents + * (`feature-flags`, `block-visibility`). Both documents are maps of key → + * gate rule with identical rule shapes; this module is the single copy of the + * security-sensitive normalization that prevents a malformed document from + * granting access. Admin-resolution *scheduling* deliberately stays with the + * callers (feature-flags resolves lazily per rule; block-visibility resolves + * once per document), so {@link matchesRule} takes an explicit `isAdmin`. + */ + +/** + * A single gating rule. A gate is open for a context when ANY clause matches: + * the global `enabled` default, the org/user allowlists, or `adminEnabled` for + * platform admins. An absent clause never matches. + */ +export interface AppConfigGateRule { + enabled?: boolean + orgIds?: string[] + userIds?: string[] + adminEnabled?: boolean +} + +/** + * Per-request evaluation context. Pass only the ids you have — a missing id + * skips its clause. `isAdmin` is a fast-path override for callers that already + * resolved platform-admin status. + */ +export interface AppConfigGateContext { + userId?: string | null + orgId?: string | null + isAdmin?: boolean +} + +function normalizeIds(values: unknown): string[] | undefined { + if (!Array.isArray(values)) return undefined + const ids = Array.from(new Set(values.map((v) => String(v).trim()).filter(Boolean))) + return ids.length > 0 ? ids : undefined +} + +/** Coerce a single arbitrary JSON value into a rule, or `null` when malformed. */ +export function normalizeRule(value: unknown): AppConfigGateRule | null { + if (!value || typeof value !== 'object') return null + const obj = value as Record + const rule: AppConfigGateRule = {} + if (typeof obj.enabled === 'boolean') rule.enabled = obj.enabled + if (typeof obj.adminEnabled === 'boolean') rule.adminEnabled = obj.adminEnabled + const orgIds = normalizeIds(obj.orgIds) + if (orgIds) rule.orgIds = orgIds + const userIds = normalizeIds(obj.userIds) + if (userIds) rule.userIds = userIds + return rule +} + +/** Coerce an arbitrary AppConfig/JSON document into a rule map, dropping malformed entries. */ +export function parseGateConfig(json: unknown): Record { + const obj = (json && typeof json === 'object' ? json : {}) as Record + const rules: Record = {} + for (const [key, value] of Object.entries(obj)) { + const rule = normalizeRule(value) + if (rule) rules[key] = rule + } + return rules +} + +/** + * Pure OR-of-clauses check. The caller supplies `isAdmin` — pass `false` to + * evaluate only the non-admin clauses (for lazy admin resolution). + */ +export function matchesRule( + rule: AppConfigGateRule | undefined, + ctx: AppConfigGateContext, + isAdmin: boolean +): boolean { + if (!rule) return false + if (rule.enabled) return true + if (ctx.userId && rule.userIds?.includes(ctx.userId)) return true + if (ctx.orgId && rule.orgIds?.includes(ctx.orgId)) return true + if (rule.adminEnabled && isAdmin) return true + return false +} diff --git a/apps/sim/lib/core/config/block-visibility.test.ts b/apps/sim/lib/core/config/block-visibility.test.ts new file mode 100644 index 00000000000..ef7e44c1006 --- /dev/null +++ b/apps/sim/lib/core/config/block-visibility.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockIsPlatformAdmin: vi.fn(), + envRef: { + APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, + APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, + }, + flagRef: { isAppConfigEnabled: false, previewBlocks: [] as string[] }, +})) + +vi.mock('@/lib/core/config/appconfig', () => ({ + fetchAppConfigProfile: mockFetch, +})) + +vi.mock('@/lib/core/config/env', () => ({ + get env() { + return envRef + }, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + get isAppConfigEnabled() { + return flagRef.isAppConfigEnabled + }, + getPreviewBlocksFromEnv: () => flagRef.previewBlocks, +})) + +vi.mock('@/lib/permissions/super-user', () => ({ + isPlatformAdmin: mockIsPlatformAdmin, +})) + +import { getBlockVisibility } from '@/lib/core/config/block-visibility' + +/** Make `getBlockVisibility` resolve `doc` via the AppConfig path (also exercises parsing). */ +function withAppConfig(doc: unknown) { + flagRef.isAppConfigEnabled = true + mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc))) +} + +describe('getBlockVisibility', () => { + beforeEach(() => { + vi.clearAllMocks() + flagRef.isAppConfigEnabled = false + flagRef.previewBlocks = [] + }) + + describe('off-AppConfig (env fallback)', () => { + it('reveals and preview-tags the PREVIEW_BLOCKS types without fetching', async () => { + flagRef.previewBlocks = ['gmail_v2', 'notion_v3'] + const vis = await getBlockVisibility({ userId: 'u1' }) + expect(vis.revealed).toEqual(new Set(['gmail_v2', 'notion_v3'])) + expect(vis.previewTagged).toEqual(new Set(['gmail_v2', 'notion_v3'])) + expect(vis.disabled.size).toBe(0) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('returns empty state when PREVIEW_BLOCKS is unset', async () => { + const vis = await getBlockVisibility() + expect(vis.revealed.size).toBe(0) + expect(vis.disabled.size).toBe(0) + expect(vis.previewTagged.size).toBe(0) + }) + }) + + it('fetches the block-visibility profile', async () => { + withAppConfig({}) + await getBlockVisibility() + expect(mockFetch).toHaveBeenCalledWith( + { application: 'sim-staging', environment: 'staging', profile: 'block-visibility' }, + expect.any(Function) + ) + }) + + it('GA rule (enabled: true) reveals without a preview tag', async () => { + withAppConfig({ gmail_v2: { enabled: true } }) + const vis = await getBlockVisibility({ userId: 'u1' }) + expect(vis.revealed.has('gmail_v2')).toBe(true) + expect(vis.previewTagged.has('gmail_v2')).toBe(false) + expect(vis.disabled.has('gmail_v2')).toBe(false) + }) + + it('allowlist rule reveals with a preview tag; non-matching viewers get disabled', async () => { + withAppConfig({ gmail_v2: { enabled: false, orgIds: ['o1'], userIds: ['u9'] } }) + + const allowedOrg = await getBlockVisibility({ orgId: 'o1' }) + expect(allowedOrg.revealed.has('gmail_v2')).toBe(true) + expect(allowedOrg.previewTagged.has('gmail_v2')).toBe(true) + + const allowedUser = await getBlockVisibility({ userId: 'u9' }) + expect(allowedUser.revealed.has('gmail_v2')).toBe(true) + + const denied = await getBlockVisibility({ userId: 'u1', orgId: 'o2' }) + expect(denied.revealed.has('gmail_v2')).toBe(false) + expect(denied.disabled.has('gmail_v2')).toBe(true) + }) + + it('kill switch (enabled: false, no allowlists) disables for everyone', async () => { + withAppConfig({ slack: { enabled: false } }) + const vis = await getBlockVisibility({ userId: 'u1', orgId: 'o1' }) + expect(vis.disabled.has('slack')).toBe(true) + expect(vis.revealed.has('slack')).toBe(false) + }) + + it('drops custom_block_* keys so custom blocks can never be gated', async () => { + withAppConfig({ custom_block_abc123: { enabled: false }, gmail_v2: { enabled: true } }) + const vis = await getBlockVisibility({ userId: 'u1' }) + expect(vis.disabled.has('custom_block_abc123')).toBe(false) + expect(vis.revealed.has('custom_block_abc123')).toBe(false) + expect(vis.revealed.has('gmail_v2')).toBe(true) + }) + + it('drops malformed entries', async () => { + withAppConfig({ a: 'nope', b: { enabled: false, orgIds: [' o1 ', ''] } }) + const vis = await getBlockVisibility({ orgId: 'o1' }) + expect(vis.disabled.has('a')).toBe(false) + expect(vis.revealed.has('b')).toBe(true) + }) + + describe('admin resolution (once per call)', () => { + it('resolves admin exactly once for a document with multiple adminEnabled rules', async () => { + withAppConfig({ + a: { enabled: false, adminEnabled: true }, + b: { enabled: false, adminEnabled: true }, + c: { enabled: false }, + }) + mockIsPlatformAdmin.mockResolvedValue(true) + const vis = await getBlockVisibility({ userId: 'u1' }) + expect(mockIsPlatformAdmin).toHaveBeenCalledTimes(1) + expect(vis.revealed).toEqual(new Set(['a', 'b'])) + expect(vis.previewTagged).toEqual(new Set(['a', 'b'])) + expect(vis.disabled).toEqual(new Set(['c'])) + }) + + it('uses the isAdmin fast-path without querying', async () => { + withAppConfig({ a: { enabled: false, adminEnabled: true } }) + const vis = await getBlockVisibility({ userId: 'u1', isAdmin: true }) + expect(vis.revealed.has('a')).toBe(true) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + + it('does not query when no rule has adminEnabled or when userId is absent', async () => { + withAppConfig({ a: { enabled: false, orgIds: ['o1'] } }) + await getBlockVisibility({ userId: 'u1' }) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + + withAppConfig({ a: { enabled: false, adminEnabled: true } }) + const vis = await getBlockVisibility({ orgId: 'o1' }) + expect(vis.disabled.has('a')).toBe(true) + expect(mockIsPlatformAdmin).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/core/config/block-visibility.ts b/apps/sim/lib/core/config/block-visibility.ts new file mode 100644 index 00000000000..3a8cb96447a --- /dev/null +++ b/apps/sim/lib/core/config/block-visibility.ts @@ -0,0 +1,110 @@ +import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' +import type { AppConfigGateContext, AppConfigGateRule } from '@/lib/core/config/appconfig-rules' +import { matchesRule, parseGateConfig } from '@/lib/core/config/appconfig-rules' +import { env } from '@/lib/core/config/env' +import { getPreviewBlocksFromEnv, isAppConfigEnabled } from '@/lib/core/config/env-flags' + +/** + * Name of the AppConfig configuration profile holding per-block visibility rules. + * Cross-repo contract: must match the `CfnConfigurationProfile` name created by + * the infra stack (`BLOCK_VISIBILITY_PROFILE_NAME`). + */ +const BLOCK_VISIBILITY_PROFILE = 'block-visibility' + +/** + * Custom (deploy-as-block) block types are org-scoped and managed by their own + * enabled/disabled lifecycle — the visibility document must never gate them. + * Literal mirrors `CUSTOM_BLOCK_TYPE_PREFIX` in `@/blocks/custom/build-config`, + * not imported to keep the blocks graph out of this config module. + */ +const CUSTOM_BLOCK_KEY_PREFIX = 'custom_block_' + +/** Per-request evaluation context; same shape as the feature-flag context. */ +export type BlockVisibilityContext = AppConfigGateContext + +/** + * The evaluated per-viewer visibility projection. + * + * - `revealed` — preview block types this viewer may see. + * - `disabled` — types whose rule exists but matched no clause; hides + * non-preview (shipped) blocks from discovery surfaces (the kill switch). + * - `previewTagged` — revealed types not globally GA (`enabled !== true`); + * the registry appends " (Preview)" to their names. + * + * All three are needed: `revealed \ previewTagged` is the "GA'd via config while + * `preview: true` is still in code" window, and `disabled` targets a disjoint + * (non-preview) population. + */ +export interface BlockVisibilityState { + revealed: Set + disabled: Set + previewTagged: Set +} + +function parseVisibilityConfig(json: unknown): Record { + const rules = parseGateConfig(json) + for (const key of Object.keys(rules)) { + if (key.startsWith(CUSTOM_BLOCK_KEY_PREFIX)) delete rules[key] + } + return rules +} + +/** + * Resolve platform-admin status lazily. Dynamically imported so the DB-backed + * helper (and `@sim/db`) stay out of this config module's load graph for callers + * that never reach an admin-gated rule. + */ +async function resolveAdmin(userId: string): Promise { + const { isPlatformAdmin } = await import('@/lib/permissions/super-user') + return isPlatformAdmin(userId) +} + +/** + * Evaluate the block-visibility document for a viewer. + * + * On hosted deployments the rules come from the AppConfig profile (cached, + * ~30s TTL); off-AppConfig the `PREVIEW_BLOCKS` env allowlist is the only + * reveal path and nothing is disabled. + * + * Unlike feature-flags (one rule per call, admin resolved lazily per rule), + * this evaluates the whole document, so platform-admin status is resolved at + * most ONCE per call — and only when some rule actually has `adminEnabled` and + * the caller didn't already supply `ctx.isAdmin`. + */ +export async function getBlockVisibility( + ctx: BlockVisibilityContext = {} +): Promise { + if (!isAppConfigEnabled) { + const revealed = new Set(getPreviewBlocksFromEnv()) + return { revealed, disabled: new Set(), previewTagged: new Set(revealed) } + } + + const rules = + (await fetchAppConfigProfile( + { + application: env.APPCONFIG_APPLICATION as string, + environment: env.APPCONFIG_ENVIRONMENT as string, + profile: BLOCK_VISIBILITY_PROFILE, + }, + parseVisibilityConfig + )) ?? {} + + const needsAdmin = + ctx.isAdmin === undefined && + Boolean(ctx.userId) && + Object.values(rules).some((rule) => rule.adminEnabled) + const isAdmin = ctx.isAdmin ?? (needsAdmin ? await resolveAdmin(ctx.userId as string) : false) + + const revealed = new Set() + const disabled = new Set() + const previewTagged = new Set() + for (const [type, rule] of Object.entries(rules)) { + if (matchesRule(rule, ctx, isAdmin)) { + revealed.add(type) + if (rule.enabled !== true) previewTagged.add(type) + } else { + disabled.add(type) + } + } + return { revealed, disabled, previewTagged } +} diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index b9f3478159f..92b7b26219c 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -293,6 +293,19 @@ export function getAllowedIntegrationsFromEnv(): string[] | null { return parsed.length > 0 ? parsed : null } +/** + * Returns the preview block types revealed via the environment variable — the + * off-AppConfig reveal path for self-hosters and local dev. If not set or empty, + * returns an empty array (all `preview: true` blocks stay hidden). Block types + * are already lowercase snake_case, so entries are trimmed but not lowercased. + */ +export function getPreviewBlocksFromEnv(): string[] { + if (!env.PREVIEW_BLOCKS) return [] + return env.PREVIEW_BLOCKS.split(',') + .map((t) => t.trim()) + .filter(Boolean) +} + /** * Returns the list of blacklisted provider IDs from the environment variable. * If not set or empty, returns an empty array (meaning no providers are blacklisted). diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index d7903b9a380..990ab7679d3 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -163,6 +163,7 @@ export const env = createEnv({ BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. ALLOWED_INTEGRATIONS: z.string().optional(), // Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed. + PREVIEW_BLOCKS: z.string().optional(), // Comma-separated preview block types to reveal off-AppConfig (e.g., "gmail_v2,notion_v3"). Empty = all preview blocks hidden. // Azure Configuration - Shared credentials with feature-specific models AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 8a44624ba78..558707fa587 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -1,4 +1,6 @@ import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' +import type { AppConfigGateContext, AppConfigGateRule } from '@/lib/core/config/appconfig-rules' +import { matchesRule, parseGateConfig } from '@/lib/core/config/appconfig-rules' import { env, isTruthy } from '@/lib/core/config/env' import { isAppConfigEnabled } from '@/lib/core/config/env-flags' @@ -11,15 +13,11 @@ const FEATURE_FLAGS_PROFILE = 'feature-flags' /** * A single flag's gating rule. A flag is ON for a context when ANY clause matches: - * the global `enabled` default, the org/user allowlists, or `admins` for platform - * admins. An absent clause never matches. + * the global `enabled` default, the org/user allowlists, or `adminEnabled` for + * platform admins. An absent clause never matches. Shape shared with the other + * AppConfig gating documents via {@link AppConfigGateRule}. */ -export interface FeatureFlagRule { - enabled?: boolean - orgIds?: string[] - userIds?: string[] - adminEnabled?: boolean -} +export type FeatureFlagRule = AppConfigGateRule export type FeatureFlagsConfig = Record @@ -28,11 +26,7 @@ export type FeatureFlagsConfig = Record * its clause. Admin status is resolved internally from `userId`; `isAdmin` is an * optional fast-path override for callers that already know it (e.g. admin routes). */ -export interface FeatureFlagContext { - userId?: string | null - orgId?: string | null - isAdmin?: boolean -} +export type FeatureFlagContext = AppConfigGateContext /** * Registry of known feature flags. Each maps to the secret consulted ONLY when @@ -137,36 +131,6 @@ function fallbackFlags(): FeatureFlagsConfig { return flags } -function normalizeIds(values: unknown): string[] | undefined { - if (!Array.isArray(values)) return undefined - const ids = Array.from(new Set(values.map((v) => String(v).trim()).filter(Boolean))) - return ids.length > 0 ? ids : undefined -} - -function normalizeRule(value: unknown): FeatureFlagRule | null { - if (!value || typeof value !== 'object') return null - const obj = value as Record - const rule: FeatureFlagRule = {} - if (typeof obj.enabled === 'boolean') rule.enabled = obj.enabled - if (typeof obj.adminEnabled === 'boolean') rule.adminEnabled = obj.adminEnabled - const orgIds = normalizeIds(obj.orgIds) - if (orgIds) rule.orgIds = orgIds - const userIds = normalizeIds(obj.userIds) - if (userIds) rule.userIds = userIds - return rule -} - -/** Coerce an arbitrary AppConfig/JSON value into a config, dropping malformed entries. */ -function parseConfig(json: unknown): FeatureFlagsConfig { - const obj = (json && typeof json === 'object' ? json : {}) as Record - const flags: FeatureFlagsConfig = {} - for (const [name, value] of Object.entries(obj)) { - const rule = normalizeRule(value) - if (rule) flags[name] = rule - } - return flags -} - /** * Resolve platform-admin status lazily. Dynamically imported so the DB-backed * helper (and `@sim/db`) stay out of this config module's load graph for callers @@ -179,17 +143,15 @@ async function resolveAdmin(userId: string): Promise { /** * The admin clause is resolved last and lazily: a global/userId/orgId match - * short-circuits before any DB read, a rule without `admins` never queries, and a - * missing `userId` resolves to `false` without a query. + * short-circuits before any DB read, a rule without `adminEnabled` never queries, + * and a missing `userId` resolves to `false` without a query. */ async function evaluate( rule: FeatureFlagRule | undefined, ctx: FeatureFlagContext ): Promise { if (!rule) return false - if (rule.enabled) return true - if (ctx.userId && rule.userIds?.includes(ctx.userId)) return true - if (ctx.orgId && rule.orgIds?.includes(ctx.orgId)) return true + if (matchesRule(rule, ctx, false)) return true if (rule.adminEnabled) { const admin = ctx.isAdmin ?? (ctx.userId ? await resolveAdmin(ctx.userId) : false) if (admin) return true @@ -211,7 +173,7 @@ export async function getFeatureFlags(): Promise { environment: env.APPCONFIG_ENVIRONMENT as string, profile: FEATURE_FLAGS_PROFILE, }, - parseConfig + parseGateConfig ) return value ?? fallbackFlags() diff --git a/apps/sim/lib/search/tool-operations.ts b/apps/sim/lib/search/tool-operations.ts index 25640935deb..c4c2cfa4fd9 100644 --- a/apps/sim/lib/search/tool-operations.ts +++ b/apps/sim/lib/search/tool-operations.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react' import { getAllBlocks } from '@/blocks' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import { registerBlockCacheInvalidator } from '@/blocks/visibility/context' /** * Represents a searchable tool operation extracted from block configurations. @@ -169,13 +170,17 @@ export function buildToolOperationsIndex(): ToolOperationItem[] { /** * Cached operations index to avoid rebuilding on every search. - * The index is built lazily on first access. + * The index is built lazily on first access and reset when the client + * block-visibility state changes (a preview reveal / kill switch alters the + * `hideFromToolbar` projection this index is filtered on). */ let cachedOperations: ToolOperationItem[] | null = null +registerBlockCacheInvalidator(() => clearToolOperationsCache()) + /** * Returns the tool operations index, building it if necessary. - * The index is cached after first build since block registry is static. + * The index is cached after first build and dropped on visibility changes. */ export function getToolOperationsIndex(): ToolOperationItem[] { if (!cachedOperations) { diff --git a/apps/sim/scripts/check-block-registry.ts b/apps/sim/scripts/check-block-registry.ts index a1a36ed9bae..13089512f14 100644 --- a/apps/sim/scripts/check-block-registry.ts +++ b/apps/sim/scripts/check-block-registry.ts @@ -255,7 +255,12 @@ function checkIntegrationMetaCoverage(): CheckResult { const errors: string[] = [] for (const block of getAllBlocks()) { - const isCatalogIntegration = block.category === 'tools' && !block.hideFromToolbar + // Unreleased preview blocks ship no BlockMeta until GA (they are absent + // from every catalog surface), so meta coverage must not force one. The + // registry projection already hides them here (no visibility context in a + // script), but the explicit check keeps this true regardless. + const isCatalogIntegration = + block.category === 'tools' && !block.hideFromToolbar && !block.preview if (!isCatalogIntegration) continue if (!getBlockMeta(block.type)) { diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1aa4dc0495d..a3523233a05 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 919, - zodRoutes: 919, + totalRoutes: 920, + zodRoutes: 920, nonZodRoutes: 0, } as const diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 3ad1c9e3938..984dd89a76e 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -165,6 +165,17 @@ interface BlockConfig { [key: string]: any } +/** + * True when a block's source text marks it as an unreleased `preview: true` + * block. THE single preview gate for this script — every surface it emits + * (docs .mdx, integrations.json, icon mapping) must consult this, because a + * missed gate publishes an unreleased block to docs.sim.ai, the catalog, the + * sitemap, and OG images. Mirrors the `hideFromToolbar` source-text checks. + */ +function isPreviewSource(blockContent: string): boolean { + return /preview\s*:\s*true/.test(blockContent) +} + /** * Find the position after the matching close delimiter for an opening delimiter. * Assumes `content[openPos]` is the opening char (e.g. `{` or `[`). @@ -298,6 +309,11 @@ async function generateIconMapping(options: { // Check hideFromToolbar - skip hidden blocks for docs but NOT for icon mapping const hideFromToolbar = /hideFromToolbar\s*:\s*true/.test(blockContent) + // Unreleased preview blocks never reach any public surface, icon map included. + if (isPreviewSource(blockContent)) { + continue + } + // Get block type const blockType = extractStringPropertyFromContent(blockContent, 'type') || blockName.toLowerCase() @@ -983,6 +999,14 @@ function extractAllBlockConfigs(fileContent: string): BlockConfig[] { continue } + // Unreleased preview blocks stay out of every generated surface: docs + // .mdx pages, integrations.json (landing + workspace catalog + sitemap + + // OG images), and the icon mapping. + if (isPreviewSource(blockContent)) { + console.log(`Skipping ${blockName}Block - preview is true`) + continue + } + // Pass fileContent to enable spread inheritance resolution const config = extractBlockConfigFromContent(blockContent, blockName, fileContent) if (config) { @@ -1139,8 +1163,12 @@ function extractBlockConfigFromContent( * also naturally selects the canonical version. Recategorizing a block to * `'blocks'` or `'triggers'` removes it from all integration surfaces. */ -function isIntegrationBlock(config: { category?: string; hideFromToolbar?: boolean }): boolean { - return config.category === 'tools' && !config.hideFromToolbar +function isIntegrationBlock(config: { + category?: string + hideFromToolbar?: boolean + preview?: boolean +}): boolean { + return config.category === 'tools' && !config.hideFromToolbar && !config.preview } /** From c61616c8c60e366fba92bc3651109e22cb82155a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 8 Jul 2026 18:30:53 -0700 Subject: [PATCH 3/3] fix(blocks): reset visibility to fail-closed empty state on workspace switch --- .../providers/block-visibility-loader.tsx | 12 ++++--- apps/sim/blocks/visibility/client.test.ts | 31 +++++++++++++++++++ apps/sim/blocks/visibility/client.ts | 15 ++++++--- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/block-visibility-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/block-visibility-loader.tsx index 1f8228d1317..e3a25c8f961 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/block-visibility-loader.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/block-visibility-loader.tsx @@ -21,11 +21,15 @@ export function BlockVisibilityLoader() { const { data } = useBlockVisibility(workspaceId) useEffect(() => { - if (!data) return + // On a workspace switch the query key changes and `data` is undefined while + // the new projection loads — hydrate an EMPTY (fail-closed) state so the + // previous workspace's reveals/kill-switches never linger across orgs. + // The empty-while-null guard inside hydrateBlockVisibility makes the first + // mount free. hydrateBlockVisibility({ - revealed: new Set(data.revealed), - disabled: new Set(data.disabled), - previewTagged: new Set(data.previewTagged), + revealed: new Set(data?.revealed), + disabled: new Set(data?.disabled), + previewTagged: new Set(data?.previewTagged), }) }, [data]) diff --git a/apps/sim/blocks/visibility/client.test.ts b/apps/sim/blocks/visibility/client.test.ts index ead91b97c9d..c0e19e8dfa2 100644 --- a/apps/sim/blocks/visibility/client.test.ts +++ b/apps/sim/blocks/visibility/client.test.ts @@ -25,6 +25,19 @@ function state(revealed: string[], disabled: string[] = []): BlockVisibilityStat describe('hydrateBlockVisibility', () => { beforeEach(() => vi.clearAllMocks()) + // Must run FIRST: module state starts null and persists across tests. + it('treats an empty state while none is set as a no-op (null ≡ empty)', () => { + const invalidator = vi.fn() + const unregister = registerBlockCacheInvalidator(invalidator) + + hydrateBlockVisibility(state([])) + expect(overlayVisibility()).toBeNull() + expect(invalidator).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + + unregister() + }) + it('applies state, fires invalidators, and bumps the overlay version', () => { const invalidator = vi.fn() const unregister = registerBlockCacheInvalidator(invalidator) @@ -37,6 +50,24 @@ describe('hydrateBlockVisibility', () => { unregister() }) + it('resets to a fail-closed empty state on workspace switch', () => { + const invalidator = vi.fn() + const unregister = registerBlockCacheInvalidator(invalidator) + + // Reveal in workspace A, then switch (loader hydrates empty while loading). + // (notion_v3: distinct from prior tests' state — module state persists.) + hydrateBlockVisibility(state(['notion_v3'])) + hydrateBlockVisibility(state([])) + expect(overlayVisibility()?.revealed.size).toBe(0) + expect(invalidator).toHaveBeenCalledTimes(2) + + // Repeated empties are no-ops (empty deep-equals empty). + hydrateBlockVisibility(state([])) + expect(invalidator).toHaveBeenCalledTimes(2) + + unregister() + }) + it('no-ops on a deep-equal state (fresh objects, same content)', () => { const invalidator = vi.fn() const unregister = registerBlockCacheInvalidator(invalidator) diff --git a/apps/sim/blocks/visibility/client.ts b/apps/sim/blocks/visibility/client.ts index 720d1af8a2e..79320eb10b4 100644 --- a/apps/sim/blocks/visibility/client.ts +++ b/apps/sim/blocks/visibility/client.ts @@ -21,17 +21,24 @@ function setsEqual(a: Set, b: Set): boolean { return true } +function isEmptyState(vis: BlockVisibilityState): boolean { + return vis.revealed.size === 0 && vis.disabled.size === 0 && vis.previewTagged.size === 0 +} + /** * Replace the in-scope visibility state, reset registered module caches, and * bump the shared block-overlay version so every subscribed consumer re-reads * `getAllBlocks()`. * - * No-ops when the incoming state is deep-equal to the current one — React - * Query refetches deliver fresh-but-identical objects on every poll, and - * without this guard each poll would thundering-rebuild the toolbar, search, - * and matcher caches for nothing. + * No-ops when the change cannot alter the projection: an incoming state + * deep-equal to the current one (React Query refetches deliver + * fresh-but-identical objects on every poll — without this guard each poll + * would thundering-rebuild the toolbar, search, and matcher caches), or an + * empty state while none is set (`null` and empty are equivalent for + * `isHiddenUnder`, so the fail-closed reset on first mount is free). */ export function hydrateBlockVisibility(next: BlockVisibilityState): void { + if (state === null && isEmptyState(next)) return if ( state && setsEqual(state.revealed, next.revealed) &&