diff --git a/apps/sim/app/api/secrets/references/route.test.ts b/apps/sim/app/api/secrets/references/route.test.ts
new file mode 100644
index 00000000000..b15871f789a
--- /dev/null
+++ b/apps/sim/app/api/secrets/references/route.test.ts
@@ -0,0 +1,122 @@
+/**
+ * @vitest-environment node
+ */
+import { authMockFns, createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ listReferences: vi.fn() }))
+
+vi.mock('@/lib/secrets/application/use-cases', () => ({
+ listSecretReferencesUseCase: {
+ operation: { id: 'secrets.references' },
+ execute: mocks.listReferences,
+ },
+}))
+
+import { GET } from '@/app/api/secrets/references/route'
+
+const url = 'http://localhost/api/secrets/references?workspaceId=workspace-1&name=API_KEY'
+
+describe('GET /api/secrets/references', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: 'admin-1' },
+ session: { id: 'session-1' },
+ })
+ })
+
+ it('returns the workflows, blocks, and resources a secret is wired into', async () => {
+ mocks.listReferences.mockResolvedValue({
+ workflows: [
+ {
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ blocks: [
+ { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' },
+ ],
+ },
+ ],
+ resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }],
+ truncated: false,
+ })
+
+ const response = await GET(createMockRequest('GET', undefined, {}, url))
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({
+ workflows: [
+ {
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ blocks: [
+ { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' },
+ ],
+ },
+ ],
+ resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }],
+ truncated: false,
+ })
+ })
+
+ it('returns empty lists for a secret referenced nowhere', async () => {
+ mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false })
+
+ const response = await GET(createMockRequest('GET', undefined, {}, url))
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ workflows: [], resources: [], truncated: false })
+ })
+
+ it('rejects a request that names no secret', async () => {
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost/api/secrets/references?workspaceId=workspace-1'
+ )
+ )
+
+ expect(response.status).toBe(400)
+ expect(mocks.listReferences).not.toHaveBeenCalled()
+ })
+
+ /**
+ * The contract carries no `scope`. It used to, and because a reference scan is name-based and
+ * never narrowed by scope, asserting `personal` skipped the admin gate outright — a member
+ * could read the reference map for any workspace secret. A stray `scope` must therefore reach
+ * neither the gate nor the scan.
+ */
+ it('ignores a scope the caller tries to assert', async () => {
+ mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false })
+
+ const response = await GET(createMockRequest('GET', undefined, {}, `${url}&scope=personal`))
+
+ expect(response.status).toBe(200)
+ expect(mocks.listReferences).toHaveBeenCalledTimes(1)
+ expect(mocks.listReferences.mock.calls[0]?.[0]?.input).toEqual({
+ workspaceId: 'workspace-1',
+ name: 'API_KEY',
+ })
+ })
+
+ /**
+ * The use case gates the read behind the same permission that reveals the value. A refusal
+ * has to reach the client as a refusal — surfacing it as an empty list would read as
+ * "referenced nowhere" and invite deleting a live key.
+ */
+ it('surfaces the use case refusal rather than an empty list', async () => {
+ const { ForbiddenOperationError } = await import('@/lib/core/application/forbidden')
+ mocks.listReferences.mockRejectedValue(
+ new ForbiddenOperationError(
+ 'SECRET_ADMIN_ACCESS_REQUIRED',
+ 'Credential admin permission required to view this secret usage'
+ )
+ )
+
+ const response = await GET(createMockRequest('GET', undefined, {}, url))
+
+ expect(response.status).toBe(403)
+ })
+})
diff --git a/apps/sim/app/api/secrets/references/route.ts b/apps/sim/app/api/secrets/references/route.ts
new file mode 100644
index 00000000000..24cf45f638e
--- /dev/null
+++ b/apps/sim/app/api/secrets/references/route.ts
@@ -0,0 +1,25 @@
+import { getSecretReferencesContract } from '@/lib/api/contracts/secrets'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { secretOperations } from '@/lib/secrets/application/operations'
+import { listSecretReferencesUseCase } from '@/lib/secrets/application/use-cases'
+
+/** GET /api/secrets/references — where one secret is wired in, for the credential detail panel. */
+export const GET = defineInternalJsonRoute({
+ contract: getSecretReferencesContract,
+ auth: internalSessionAuth,
+ operation: secretOperations.references,
+ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ query }) => ({
+ workspaceId: query.workspaceId,
+ name: query.name,
+ }),
+ useCase: listSecretReferencesUseCase,
+ /** The scan's shape is already the wire shape — nothing to project or serialize. */
+ present: (scan) => scan,
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx
index 8efb140820d..b19f6aaea86 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx
@@ -7,6 +7,13 @@ interface CredentialDetailLayoutProps {
back: ReactNode
/** Optional controls grouped at the end of the action bar. */
actions?: ReactNode
+ /**
+ * Page title, for a view whose subject is the view itself rather than a resource — the same
+ * slot `SettingsPanel` fills for a detail sub-view like the Forks "Activity" page. A surface
+ * that leads with a resource uses {@link CredentialDetailHeading} instead; the two are
+ * alternatives, not a pair.
+ */
+ title?: ReactNode
children: ReactNode
}
@@ -16,7 +23,12 @@ interface CredentialDetailLayoutProps {
* supply the slots and body sections; all layout chrome lives here so callsites
* stay free of bespoke styling.
*/
-export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) {
+export function CredentialDetailLayout({
+ back,
+ actions,
+ title,
+ children,
+}: CredentialDetailLayoutProps) {
return (
@@ -24,7 +36,11 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe
{actions ?
{actions}
: null}
-
{children}
+
+ {/* Same element, class and column position the settings shell gives its page title. */}
+ {title ?
{title}
: null}
+ {children}
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts
new file mode 100644
index 00000000000..c9da881f044
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts
@@ -0,0 +1 @@
+export { SecretReferencesPanel } from './secret-references-panel'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx
new file mode 100644
index 00000000000..7f602da7e00
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx
@@ -0,0 +1,187 @@
+'use client'
+
+import { Wrench } from '@sim/emcn/icons'
+import { McpIcon } from '@/components/icons'
+import type { SecretReferenceResourcePayload } from '@/lib/api/contracts'
+import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
+import {
+ customToolIdParam,
+ mcpServerIdParam,
+} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
+import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import {
+ RESOURCE_LIST_STACK,
+ SettingsResourceRow,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import { focusBlockParam } from '@/app/workspace/[workspaceId]/w/[workflowId]/search-params'
+import { getBlock } from '@/blocks/registry'
+import { useSecretReferences } from '@/hooks/queries/credentials'
+
+interface SecretReferencesPanelProps {
+ workspaceId: string
+ secretName: string
+ /**
+ * This personal secret is overridden by a workspace variable of the same name. References are
+ * name-based, so every `{{name}}` in the workspace resolves to the workspace variable — whose
+ * reference map is admin-gated. The owner may still read their own Logs, which is why the view
+ * is offered at all, so this tab explains the shadowing instead of asking the API for a map it
+ * will refuse.
+ */
+ shadowed: boolean
+}
+
+/**
+ * Shown when a capped scan produced nothing to list. Distinct from "not referenced": the scan
+ * stopped early, so silence here is absence of evidence, and saying otherwise would invite
+ * deleting a key that four blocks past the cap still depend on.
+ */
+const TRUNCATED_NOTE = 'This secret is referenced in more places than can be listed here.'
+
+/** A custom tool and an MCP server can each carry the key more than once, so `id` alone is not a key. */
+function resourceKey(resource: SecretReferenceResourcePayload): string {
+ return `${resource.kind}:${resource.id}:${resource.field}`
+}
+
+/**
+ * The field's own label from the block's config — "Tools", "API Key" — rather than the storage
+ * id the scanner reports. The id is how the value is keyed, not what the block calls it, and a
+ * reader looking for the field on the canvas is looking for the label.
+ *
+ * Falls back to the raw id when the block or field is unregistered, which is honest: an id the
+ * config cannot name is still better than naming nothing.
+ */
+function fieldLabel(blockType: string, field: string): string {
+ return getBlock(blockType)?.subBlocks?.find((subBlock) => subBlock.id === field)?.title ?? field
+}
+
+/**
+ * The workflow, pointed at the block that carries the reference, so the canvas lands on it
+ * rather than on its default framing. The target rides in the link itself so it survives a
+ * middle-click or a reload, which an in-memory handoff could not.
+ */
+function blockHref(workspaceId: string, workflowId: string, blockId: string): string {
+ return `/workspace/${workspaceId}/w/${workflowId}?${focusBlockParam.key}=${encodeURIComponent(blockId)}`
+}
+
+/**
+ * The settings page that owns the resource, deep-linked to its detail through the same param
+ * that page reads — so a cascade row navigates like a block row instead of dead-ending.
+ */
+function resourceHref(workspaceId: string, resource: SecretReferenceResourcePayload): string {
+ const settings = `/workspace/${workspaceId}/settings`
+ return resource.kind === 'mcp-server'
+ ? `${settings}/mcp?${mcpServerIdParam.key}=${encodeURIComponent(resource.id)}`
+ : `${settings}/custom-tools?${customToolIdParam.key}=${encodeURIComponent(resource.id)}`
+}
+
+/**
+ * Where one secret is wired in: the blocks that name it as `{{KEY}}`, grouped under their
+ * workflow, then the custom tools and MCP servers whose own bodies carry it.
+ *
+ * The companion to the Logs tab, and the half of the question runs cannot answer — a secret
+ * four blocks depend on but nothing has executed yet has an empty trail and a full list here.
+ */
+export function SecretReferencesPanel({
+ workspaceId,
+ secretName,
+ shadowed,
+}: SecretReferencesPanelProps) {
+ const { data, isPending, isError } = useSecretReferences(
+ { workspaceId, name: secretName },
+ !shadowed
+ )
+
+ if (shadowed) {
+ return (
+
+ Overridden by a workspace variable, so every reference to this name resolves to that
+ variable instead.
+
+ )
+ }
+
+ if (isError) {
+ return (
+
+ Could not load references.
+
+ )
+ }
+
+ if (isPending) {
+ return Loading…
+ }
+
+ if (data.workflows.length === 0 && data.resources.length === 0) {
+ return (
+
+ {data.truncated ? TRUNCATED_NOTE : 'This secret is not referenced in any workflow.'}
+
+ )
+ }
+
+ return (
+
+ {data.workflows.map((workflow) => (
+
+
+ {workflow.blocks.map((block) => {
+ const BlockIcon = getBlock(block.blockType)?.icon
+ return (
+
+ ) : undefined
+ }
+ title={block.blockName}
+ description={fieldLabel(block.blockType, block.field)}
+ href={blockHref(workspaceId, workflow.workflowId, block.blockId)}
+ clickLabel={`Open ${block.blockName} in ${workflow.workflowName}`}
+ navigable
+ />
+ )
+ })}
+
+
+ ))}
+
+ {data.resources.length > 0 && (
+
+
+ {data.resources.map((resource) => (
+
+ ) : (
+
+ )
+ }
+ iconFilled
+ title={resource.name}
+ description={resource.field}
+ href={resourceHref(workspaceId, resource)}
+ clickLabel={`Open ${resource.name}`}
+ navigable
+ />
+ ))}
+
+
+ )}
+
+ {data.truncated &&
{TRUNCATED_NOTE}
}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts
index d7585860cc6..c657b225780 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts
@@ -15,3 +15,19 @@ export const secretDetailViewUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const
+
+/**
+ * Active tab inside the usage view, so a shared `secret-view=usage` link can land on either
+ * reading. Defaults to `logs`, which is what the header's "See usage" opened before References
+ * existed — the action's name still promises the trail.
+ */
+export const secretUsageTabParam = {
+ key: 'usage-tab',
+ parser: parseAsStringLiteral(['references', 'logs'] as const).withDefault('logs'),
+} as const
+
+/** Tab view-state: clean URLs, no back-stack churn. */
+export const secretUsageTabUrlKeys = {
+ history: 'replace',
+ clearOnDefault: true,
+} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
index 989411cd199..73eac2f7c7b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { Chip, ChipCopyInput, ChipLink, ChipTextarea } from '@sim/emcn'
+import { Chip, ChipCopyInput, ChipLink, ChipModalTabs, ChipTextarea } from '@sim/emcn'
import { ArrowLeft, Clock, Key, Send } from '@sim/emcn/icons'
import { useQueryState } from 'nuqs'
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
@@ -20,10 +20,13 @@ import {
import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field'
import { useSecretValue } from '@/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { SecretReferencesPanel } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel'
import { SecretUsagePanel } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel'
import {
secretDetailViewParam,
secretDetailViewUrlKeys,
+ secretUsageTabParam,
+ secretUsageTabUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
@@ -32,6 +35,14 @@ interface SecretDetailProps {
credentialId: string
}
+type SecretUsageTab = 'references' | 'logs'
+
+/** Logs leads: it is the default tab, and what "See usage" opened before References existed. */
+const SECRET_USAGE_TABS = [
+ { value: 'logs', label: 'Logs' },
+ { value: 'references', label: 'References' },
+] as const
+
export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
const secretsHref = `/workspace/${workspaceId}/settings/secrets`
@@ -44,6 +55,10 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
...secretDetailViewParam.parser,
...secretDetailViewUrlKeys,
})
+ const [usageTab, setUsageTab] = useQueryState(secretUsageTabParam.key, {
+ ...secretUsageTabParam.parser,
+ ...secretUsageTabUrlKeys,
+ })
const valueField = useSecretValue({ workspaceId, credential })
@@ -159,28 +174,44 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
/**
* Usage is a destination reached from the header, the same shape as the Forks tab's
* "See activity" — it replaces the secret rather than expanding inside it, so the two
- * readings never compete for the same column. Back returns with `replace`, since opening
- * already pushed.
+ * readings never compete for the same column. It leads with a plain page title like that
+ * view does: the back chip already names the secret, so a resource heading would say it
+ * twice. Back returns with `replace`, since opening already pushed, and clears the tab in
+ * the same batched write so no `?usage-tab=` lingers on the secret's own URL.
*/
if (canViewUsage && view === 'usage') {
+ const secretName = credential.envKey || ''
+ const scope = isPersonal ? 'personal' : 'workspace'
return (
void setView(null, { history: 'replace' })}>
+ {
+ void setView(null, { history: 'replace' })
+ void setUsageTab(null, { history: 'replace' })
+ }}
+ >
{credential.envKey || credential.displayName}
}
+ title='Usage'
>
- }
- title='Usage'
- subtitle={credential.envKey || credential.displayName}
- />
- void setUsageTab(value as SecretUsageTab)}
+ aria-label='Secret usage views'
/>
+ {usageTab === 'references' ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/focus-block-deep-link.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/focus-block-deep-link.tsx
new file mode 100644
index 00000000000..c898425ed67
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/focus-block-deep-link.tsx
@@ -0,0 +1,54 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+import { useQueryState } from 'nuqs'
+import { focusBlockParam } from '@/app/workspace/[workspaceId]/w/[workflowId]/search-params'
+
+interface FocusBlockDeepLinkProps {
+ /** Called once with the inbound target. The canvas owns what "focus" means. */
+ onTarget: (blockId: string) => void
+}
+
+/**
+ * Reads the inbound `?block=` target and hands it to the canvas exactly once.
+ *
+ * Exists as its own component for one structural reason: `useQueryState` reads
+ * `useSearchParams`, which Next requires under a Suspense boundary. Confining that read here
+ * lets the editor keep its current mount path — no boundary around `Workflow` itself, no
+ * `loading.tsx` — and an inner boundary with `fallback={null}` is the sanctioned shape for a
+ * suspending leaf that renders nothing.
+ *
+ * Read-then-strip, behind a ref latch: the target is an instruction, not view-state, so once the
+ * canvas has it the param is cleared with `replace` so it neither lingers on the URL nor
+ * re-fires when the reader re-renders. Stripping is also what lets a second visit to the same
+ * block re-assert the camera rather than being swallowed as "already applied".
+ */
+export function FocusBlockDeepLink({ onTarget }: FocusBlockDeepLinkProps) {
+ const [blockId, setBlockId] = useQueryState(focusBlockParam.key, focusBlockParam.parser)
+
+ /* Ref, so consuming the target does not depend on the caller memoizing `onTarget`. */
+ const onTargetRef = useRef(onTarget)
+ useEffect(() => {
+ onTargetRef.current = onTarget
+ }, [onTarget])
+
+ /**
+ * Latched while a target is present and released when the param clears — the same shape the
+ * canvas's note reveal uses. A latch that only ever set would swallow the second visit to a
+ * block, since stripping returns the param to null and arriving again re-supplies the very
+ * same id.
+ */
+ const appliedRef = useRef(false)
+ useEffect(() => {
+ if (!blockId) {
+ appliedRef.current = false
+ return
+ }
+ if (appliedRef.current) return
+ appliedRef.current = true
+ onTargetRef.current(blockId)
+ void setBlockId(null, { history: 'replace', scroll: false })
+ }, [blockId, setBlockId])
+
+ return null
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts
new file mode 100644
index 00000000000..d7e2577acc3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts
@@ -0,0 +1 @@
+export { FocusBlockDeepLink } from './focus-block-deep-link'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts
new file mode 100644
index 00000000000..15a2ca55ce7
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts
@@ -0,0 +1,19 @@
+import { parseAsString } from 'nuqs/server'
+
+/**
+ * `block` points an inbound link at one block, so a surface that knows where something lives —
+ * the secret References tab, naming the block that carries a `{{KEY}}` — can land the reader on
+ * it instead of on the workflow's default framing.
+ *
+ * The lone param on the editor route, and deliberately so: `.claude/rules/sim-url-state.md` keeps
+ * the canvas's own view-state (pan, zoom, selection, drag) in Zustand because it is
+ * socket-synced, high-frequency, or a persisted preference. This is none of those. It is a
+ * read-once navigation signal, consumed on arrival and stripped, in the same family as
+ * integrations' `?connect=` — not canvas state that lives in the URL.
+ *
+ * Nullable with no default: absent means "no target", which is the overwhelmingly common case.
+ */
+export const focusBlockParam = {
+ key: 'block',
+ parser: parseAsString,
+} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index aa839056345..14bb7d539c5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -66,6 +66,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
+import { FocusBlockDeepLink } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link'
import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace'
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
import {
@@ -4675,6 +4676,61 @@ const WorkflowContent = React.memo(
focusBlockInView(node)
}, [blocks, displayNodes, embedded, focusBlockInView, searchMatchBlockId, searchMatchId])
+ /**
+ * Inbound `?block=` target, held until the canvas can act on it. State rather than a ref
+ * because the effect below has to re-run on the commit that finally mounts the node.
+ */
+ const [deepLinkBlockId, setDeepLinkBlockId] = useState(null)
+
+ useEffect(() => {
+ if (embedded || !deepLinkBlockId) return
+
+ /* Same reason as the note reveal above: read from `displayNodes` so a target that lands
+ before its node mounts is retried on the mounting commit instead of dropped. */
+ const node = displayNodes.find((candidate) => candidate.id === deepLinkBlockId)
+ if (!node) {
+ /* Absent is only conclusive once THIS workflow's graph is the one loaded. `isWorkflowReady`
+ is that test — it pins `hydration.workflowId` and `activeWorkflowId` to the id in the
+ URL — where a count of mounted nodes is not: arriving from another workflow, the store
+ still holds that graph, so nodes are present while the linked workflow is still
+ hydrating and a valid target would be thrown away.
+
+ Releasing it matters because the param is already stripped: a target held forever would
+ re-check on every canvas update and shadow a later link to the same block. Dropping it
+ leaves the default framing, which is what the link did before it carried a target. */
+ if (isWorkflowReady) setDeepLinkBlockId(null)
+ return
+ }
+
+ setDeepLinkBlockId(null)
+
+ /* Claim the framing before the canvas can re-init over it. `onInit` re-reads this ref
+ inside its own `requestAnimationFrame`, so setting it here suppresses the initial
+ `fitView` whenever this effect wins the race — and is harmless when it does not, since
+ `focusBlockInView` animates from wherever the fit left the camera. */
+ userFocusedWorkflowIdRef.current = activeWorkflowId ?? workflowIdParam
+
+ setDisplayNodes((currentNodes) =>
+ resolveSelectionConflicts(
+ currentNodes.map((currentNode) => ({
+ ...currentNode,
+ selected: currentNode.id === node.id,
+ })),
+ blocks
+ )
+ )
+ focusBlockInView(node)
+ }, [
+ activeWorkflowId,
+ blocks,
+ deepLinkBlockId,
+ displayNodes,
+ embedded,
+ focusBlockInView,
+ isWorkflowReady,
+ workflowIdParam,
+ ])
+
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
const onEdgeClick = useCallback(
(event: React.MouseEvent, edge: any) => {
@@ -5141,6 +5197,10 @@ const WorkflowContent = React.memo(
{!embedded && (
<>
+ {/* Renders nothing; the boundary is what `useSearchParams` needs. */}
+
+
+
diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts
index ae181b450e4..6f64d56213f 100644
--- a/apps/sim/hooks/queries/credentials.ts
+++ b/apps/sim/hooks/queries/credentials.ts
@@ -8,6 +8,7 @@ import {
createCredentialDraftContract,
createWorkspaceCredentialContract,
deleteWorkspaceCredentialContract,
+ getSecretReferencesContract,
getSecretUsageContract,
getWorkspaceCredentialContract,
listWorkspaceCredentialMembersContract,
@@ -335,3 +336,33 @@ export function useSecretUsage({ workspaceId, name, scope }: SecretUsageParams,
staleTime: SECRET_USAGE_STALE_TIME,
})
}
+
+/**
+ * References only move when someone edits a workflow, a custom tool, or an MCP server — far
+ * less often than the usage trail, which every run appends to. A longer window keeps the scan
+ * (which reads every candidate block in the workspace) off the wire on tab switches.
+ */
+export const SECRET_REFERENCES_STALE_TIME = 5 * 60 * 1000
+
+interface SecretReferencesParams {
+ workspaceId?: string
+ name?: string
+}
+
+/**
+ * Reads where one secret is wired in. Takes no scope: a reference names a key, not a scope, and
+ * the server authorizes against what the name resolves to. Only credential admins of a workspace
+ * secret — or the owner of a personal one — are authorized server-side.
+ */
+export function useSecretReferences({ workspaceId, name }: SecretReferencesParams, enabled = true) {
+ return useQuery({
+ queryKey: workspaceCredentialKeys.references(workspaceId, name),
+ queryFn: ({ signal }) =>
+ requestJson(getSecretReferencesContract, {
+ query: { workspaceId: workspaceId as string, name: name as string },
+ signal,
+ }),
+ enabled: Boolean(workspaceId && name) && enabled,
+ staleTime: SECRET_REFERENCES_STALE_TIME,
+ })
+}
diff --git a/apps/sim/hooks/queries/utils/credential-keys.ts b/apps/sim/hooks/queries/utils/credential-keys.ts
index c81b662fcab..db7c276b29f 100644
--- a/apps/sim/hooks/queries/utils/credential-keys.ts
+++ b/apps/sim/hooks/queries/utils/credential-keys.ts
@@ -33,4 +33,10 @@ export const workspaceCredentialKeys = {
scope ?? 'all',
name ?? '',
] as const,
+ /**
+ * Keyed by name alone — references are found by name, so neither the credential id nor a
+ * scope narrows the result, and adding either would split one answer across cache entries.
+ */
+ references: (workspaceId?: string, name?: string) =>
+ [...workspaceCredentialKeys.all, 'references', workspaceId ?? 'none', name ?? ''] as const,
}
diff --git a/apps/sim/lib/api/contracts/secrets.ts b/apps/sim/lib/api/contracts/secrets.ts
index 2aaea797f2a..d4b94bebaf0 100644
--- a/apps/sim/lib/api/contracts/secrets.ts
+++ b/apps/sim/lib/api/contracts/secrets.ts
@@ -43,6 +43,70 @@ export const getSecretUsageContract = defineRouteContract({
},
})
+/**
+ * Ceilings on one scan's payload. These match the caps `lib/secrets/references/scan.ts` stops
+ * at — in particular `resources` is capped on EMITTED entries there, not on rows read, because
+ * one MCP server expands to an entry per matching header. Raising a bound here without raising
+ * the scanner's cap is harmless; lowering one below it makes the route reject its own response.
+ */
+const SECRET_REFERENCE_MAX_WORKFLOWS = 2000
+const SECRET_REFERENCE_MAX_BLOCKS = 2000
+const SECRET_REFERENCE_MAX_RESOURCES = 400
+
+/**
+ * No `scope`, unlike the usage query. A `{{KEY}}` reference names a key and not a scope, so the
+ * scan is name-based and the use case authorizes against what the name resolves to. A scope here
+ * would be a caller-controlled assertion that nothing narrows by — a bypass, not an input.
+ */
+export const secretReferencesQuerySchema = z.object({
+ workspaceId: z.string().min(1, 'workspaceId is required'),
+ name: z.string().min(1, 'Secret name is required'),
+})
+
+export const secretReferenceBlockSchema = z.object({
+ blockId: z.string().min(1, 'blockId cannot be empty'),
+ blockName: z.string(),
+ blockType: z.string().min(1, 'blockType cannot be empty'),
+ /** A sub-block key carrying the reference — one per block, not necessarily the only one. */
+ field: z.string().min(1, 'field cannot be empty'),
+})
+
+export const secretReferenceWorkflowSchema = z.object({
+ workflowId: z.string().min(1, 'workflowId cannot be empty'),
+ workflowName: z.string(),
+ blocks: z
+ .array(secretReferenceBlockSchema)
+ .min(1, 'A referencing workflow must name at least one block')
+ .max(SECRET_REFERENCE_MAX_BLOCKS),
+})
+
+export const secretReferenceResourceSchema = z.object({
+ id: z.string().min(1, 'resource id cannot be empty'),
+ kind: z.enum(['custom-tool', 'mcp-server']),
+ name: z.string(),
+ /** Where inside the resource the reference lives — `code`, `url`, or `header: X`. */
+ field: z.string().min(1, 'field cannot be empty'),
+})
+
+export const getSecretReferencesContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/secrets/references',
+ query: secretReferencesQuerySchema,
+ response: {
+ mode: 'json',
+ schema: z.object({
+ workflows: z.array(secretReferenceWorkflowSchema).max(SECRET_REFERENCE_MAX_WORKFLOWS),
+ resources: z.array(secretReferenceResourceSchema).max(SECRET_REFERENCE_MAX_RESOURCES),
+ /** True when a scan cap was hit, so the lists are a prefix rather than the whole set. */
+ truncated: z.boolean(),
+ }),
+ },
+})
+
export type SecretUsageScope = z.output
export type SecretUsageQuery = z.input
export type SecretUsageEntryPayload = z.output
+export type SecretReferencesQuery = z.input
+export type SecretReferenceWorkflowPayload = z.output
+export type SecretReferenceBlockPayload = z.output
+export type SecretReferenceResourcePayload = z.output
diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts
index c43e1c2ba55..d9a8f6ff919 100644
--- a/apps/sim/lib/credentials/environment.ts
+++ b/apps/sim/lib/credentials/environment.ts
@@ -1,5 +1,11 @@
import { db } from '@sim/db'
-import { credential, credentialMember, permissions, workspace } from '@sim/db/schema'
+import {
+ credential,
+ credentialMember,
+ permissions,
+ workspace,
+ workspaceEnvironment,
+} from '@sim/db/schema'
import { permissionSatisfies } from '@sim/platform-authz/workspace'
import { chunkArray } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
@@ -169,6 +175,32 @@ export async function getPersonalEnvKeyRawAccess(params: {
return { ownedKeys, adminKeys }
}
+/**
+ * Whether the workspace holds a value under this env key, read from the authoritative
+ * `workspace_environment.variables` map.
+ *
+ * Deliberately NOT {@link getWorkspaceEnvKeyAdminAccess}'s `knownKeys`, which answers the
+ * narrower "does an `env_workspace` credential row exist". A legacy value written before the
+ * credential ACL existed has no such row yet still wins at run time, so a gate that reads
+ * `knownKeys` as "there is no workspace secret here" would let a non-admin through on exactly
+ * the keys that predate the ACL. Callers deciding whether a NAME belongs to the workspace must
+ * ask this; callers deciding who may administer an ACL keep asking `knownKeys`.
+ */
+export async function hasWorkspaceEnvValue(params: {
+ workspaceId: string
+ envKey: string
+}): Promise {
+ const [row] = await db
+ .select({ variables: workspaceEnvironment.variables })
+ .from(workspaceEnvironment)
+ .where(eq(workspaceEnvironment.workspaceId, params.workspaceId))
+ .limit(1)
+
+ const variables = row?.variables
+ if (!variables || typeof variables !== 'object') return false
+ return Object.hasOwn(variables as Record, params.envKey)
+}
+
/**
* For a set of workspace env keys, resolves which the caller may administer
* (active `credential_member` with role `admin`) and which already have an
diff --git a/apps/sim/lib/secrets/application/operations.ts b/apps/sim/lib/secrets/application/operations.ts
index 67c113dee8d..00f408fd84e 100644
--- a/apps/sim/lib/secrets/application/operations.ts
+++ b/apps/sim/lib/secrets/application/operations.ts
@@ -31,6 +31,17 @@ export const secretOperations = {
workspaceApiKey: 'deny',
principalKinds: HUMAN_API_PRINCIPAL_KINDS,
}),
+ /**
+ * Reading where a secret is wired in names workflows, blocks, and the tools and servers that
+ * carry it — the same shape of disclosure as {@link usage}, so it takes the same floor and
+ * the same narrowing in the use case.
+ */
+ references: defineWorkspaceOperation({
+ id: 'secrets.references',
+ minimumRole: 'read',
+ workspaceApiKey: 'deny',
+ principalKinds: HUMAN_API_PRINCIPAL_KINDS,
+ }),
} as const
export type SecretOperation = (typeof secretOperations)[keyof typeof secretOperations]
diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts
index 6bdccfc65a9..f0c10ea4a2b 100644
--- a/apps/sim/lib/secrets/application/use-cases.test.ts
+++ b/apps/sim/lib/secrets/application/use-cases.test.ts
@@ -21,6 +21,8 @@ const { mocks } = vi.hoisted(() => ({
deletePersonal: vi.fn(),
listCredentials: vi.fn(),
secretUsage: vi.fn(),
+ workspaceEnvValue: vi.fn(),
+ scanReferences: vi.fn(),
audit: vi.fn(),
},
}))
@@ -47,6 +49,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
vi.mock('@/lib/credentials/environment', () => ({
getWorkspaceEnvKeyAdminAccess: mocks.keyAccess,
getPersonalEnvCredentialMetadata: mocks.personalMetadata,
+ hasWorkspaceEnvValue: mocks.workspaceEnvValue,
+}))
+vi.mock('@/lib/secrets/references/scan', () => ({
+ scanSecretReferences: mocks.scanReferences,
}))
vi.mock('@/lib/credentials/queries', () => ({
listVisibleWorkspaceCredentials: mocks.listCredentials,
@@ -63,6 +69,8 @@ vi.mock('@/lib/credentials/secret-values', () => ({
import {
deleteSecretUseCase,
+ type ListSecretReferencesInput,
+ listSecretReferencesUseCase,
listSecretUsageUseCase,
setSecretUseCase,
} from '@/lib/secrets/application/use-cases'
@@ -399,3 +407,105 @@ describe('listSecretUsageUseCase', () => {
expect(mocks.keyAccess).not.toHaveBeenCalled()
})
})
+
+describe('listSecretReferencesUseCase', () => {
+ const execute = listSecretReferencesUseCase.execute as (args: {
+ principal: Principal
+ input: ListSecretReferencesInput
+ }) => Promise
+
+ const input: ListSecretReferencesInput = {
+ workspaceId: workspace.workspaceId,
+ name: 'STRIPE_API_KEY',
+ }
+ const scan = { workflows: [], resources: [], truncated: false }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.loadContext.mockResolvedValue(workspace)
+ mocks.resolvePermission.mockResolvedValue('write')
+ mocks.scanReferences.mockResolvedValue(scan)
+ })
+
+ /**
+ * The map names workflows, blocks, tools and servers. A Member who may use the secret but not
+ * read it has no claim on it, so this must fail the same way the usage trail does.
+ */
+ it('denies a member who is not an admin of a workspace key', async () => {
+ mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false })
+ mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() })
+ mocks.workspaceEnvValue.mockResolvedValue(true)
+
+ await expect(execute({ principal: session, input })).rejects.toThrow(
+ 'Credential admin permission required to view this secret usage'
+ )
+ expect(mocks.scanReferences).not.toHaveBeenCalled()
+ })
+
+ /**
+ * The gate reads the authoritative variables map, not `knownKeys`. A legacy value predating
+ * the credential ACL has no `env_workspace` row yet still wins at run time, so treating an
+ * empty `knownKeys` as "no workspace secret" would hand a non-admin exactly the oldest keys.
+ */
+ it('denies a personal owner when a legacy workspace value shares the name', async () => {
+ mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false })
+ mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() })
+ mocks.workspaceEnvValue.mockResolvedValue(true)
+ mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' })
+
+ await expect(execute({ principal: session, input })).rejects.toThrow(
+ 'Credential admin permission required to view this secret usage'
+ )
+ expect(mocks.scanReferences).not.toHaveBeenCalled()
+ })
+
+ it('denies a caller who holds no secret of that name', async () => {
+ mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false })
+ mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() })
+ mocks.workspaceEnvValue.mockResolvedValue(false)
+ mocks.personalMetadata.mockResolvedValue(null)
+
+ await expect(execute({ principal: session, input })).rejects.toThrow(
+ 'Credential admin permission required to view this secret usage'
+ )
+ expect(mocks.scanReferences).not.toHaveBeenCalled()
+ })
+
+ it('allows the owner of a personal secret that no workspace value shadows', async () => {
+ mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false })
+ mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() })
+ mocks.workspaceEnvValue.mockResolvedValue(false)
+ mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' })
+
+ await expect(execute({ principal: session, input })).resolves.toEqual(scan)
+ })
+
+ it('allows a credential admin of that key', async () => {
+ mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false })
+ mocks.keyAccess.mockResolvedValue({
+ knownKeys: new Set(['STRIPE_API_KEY']),
+ adminKeys: new Set(['STRIPE_API_KEY']),
+ })
+
+ await expect(execute({ principal: session, input })).resolves.toEqual(scan)
+ // An admin stays authorized however the name resolves, so the volatile input is never read.
+ expect(mocks.workspaceEnvValue).not.toHaveBeenCalled()
+ })
+
+ /**
+ * A `personal` grant rests on no workspace value existing under the name. If one is created
+ * between the check and the scan, the map now in hand is admin-gated — so the condition is
+ * re-read after the scan and the request fails closed rather than returning it.
+ */
+ it('refuses when a workspace value appears between the check and the scan', async () => {
+ mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false })
+ mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() })
+ mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' })
+ mocks.workspaceEnvValue.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
+
+ await expect(execute({ principal: session, input })).rejects.toThrow(
+ 'Credential admin permission required to view this secret usage'
+ )
+ expect(mocks.workspaceEnvValue).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts
index c4d948f47c6..188aa60e664 100644
--- a/apps/sim/lib/secrets/application/use-cases.ts
+++ b/apps/sim/lib/secrets/application/use-cases.ts
@@ -7,6 +7,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
getPersonalEnvCredentialMetadata,
getWorkspaceEnvKeyAdminAccess,
+ hasWorkspaceEnvValue,
} from '@/lib/credentials/environment'
import {
listVisibleWorkspaceCredentials,
@@ -19,6 +20,7 @@ import {
setWorkspaceSecret,
} from '@/lib/credentials/secret-values'
import { secretOperations } from '@/lib/secrets/application/operations'
+import { scanSecretReferences } from '@/lib/secrets/references/scan'
import { getSecretUsage } from '@/lib/secrets/usage/queries'
import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -379,6 +381,12 @@ async function requireSecretUsageReadAccess(params: {
scope: SecretScope
userId: string
}): Promise {
+ /**
+ * Safe to trust the asserted scope here, and only here: the read below is NARROWED by it to
+ * `secretOwnerUserId`, so asserting `personal` for a workspace key returns the caller's own
+ * (empty) trail rather than the workspace one. A read that is not scope-narrowed must not
+ * reuse this — see {@link requireSecretReferencesReadAccess}.
+ */
if (params.scope === 'personal') return
const [workspaceAccess, keyAccess] = await Promise.all([
@@ -427,3 +435,122 @@ export const listSecretUsageUseCase = defineAuthorizedWorkspaceUseCase({
})
},
})
+
+/**
+ * Deliberately carries no `scope`. A reference is found by name, so a scope here could only be
+ * an assertion the caller controls and the read never narrows by — exactly the shape that made
+ * the first cut of this operation bypassable. The name alone decides both access and result.
+ */
+export interface ListSecretReferencesInput {
+ workspaceId: string
+ name: string
+}
+
+/**
+ * Gates the reference scan on what the NAME resolves to, never on the scope the caller asserts.
+ *
+ * The usage trail can trust `scope` because it narrows the read by it — a personal request is
+ * filtered to `secretOwnerUserId`, so asserting `personal` for a workspace key returns nothing.
+ * A reference scan cannot: `{{KEY}}` names a key and not a scope, so the scan is name-based and
+ * workspace-wide by construction. Reusing the trail's gate therefore made `scope=personal` a
+ * bypass — it returns before any check, and a member could read the admin-gated map for any
+ * workspace secret by naming it under personal scope.
+ *
+ * So the asserted scope is discarded here and the canonical name decides:
+ * - a workspace secret exists under this name → only its admins (or a workspace admin) may look,
+ * which is the same predicate that reveals its value;
+ * - no workspace secret exists → the caller must actually hold a personal secret of that name,
+ * which stops a member enumerating arbitrary names for a map they have no claim to.
+ */
+/**
+ * Which branch authorized the read. `personal` depends on no workspace value existing under the
+ * name — a condition another request can change — so only that branch needs re-checking after
+ * the scan. An `admin` caller stays authorized however the name resolves, and pays nothing.
+ */
+type SecretReferencesGrant = 'admin' | 'personal'
+
+async function requireSecretReferencesReadAccess(params: {
+ workspaceId: string
+ name: string
+ userId: string
+}): Promise {
+ const [workspaceAccess, keyAccess] = await Promise.all([
+ checkWorkspaceAccess(params.workspaceId, params.userId),
+ getWorkspaceEnvKeyAdminAccess({
+ workspaceId: params.workspaceId,
+ envKeys: [params.name],
+ userId: params.userId,
+ }),
+ ])
+ if (workspaceAccess.canAdmin || keyAccess.adminKeys.has(params.name)) return 'admin'
+
+ const forbidden = new ForbiddenOperationError(
+ 'SECRET_ADMIN_ACCESS_REQUIRED',
+ 'Credential admin permission required to view this secret usage'
+ )
+
+ /**
+ * A workspace value under this name is admin-gated outright — a personal secret the caller
+ * happens to hold under the same name does not unlock the workspace one's reference map,
+ * and at run time the workspace value is the one that wins anyway.
+ *
+ * Read from the authoritative variables map rather than `keyAccess.knownKeys`: that set only
+ * covers names with an `env_workspace` credential row, so a legacy value written before the
+ * ACL existed would look like "no workspace secret here" and fall through to the personal
+ * branch — handing a non-admin the map for exactly the oldest keys.
+ */
+ if (await hasWorkspaceEnvValue({ workspaceId: params.workspaceId, envKey: params.name })) {
+ throw forbidden
+ }
+
+ const owned = await getPersonalEnvCredentialMetadata({
+ userId: params.userId,
+ envKey: params.name,
+ })
+ if (!owned) throw forbidden
+ return 'personal'
+}
+
+export const listSecretReferencesUseCase = defineAuthorizedWorkspaceUseCase({
+ operation: secretOperations.references,
+ resolveContext: ({ input }: { input: ListSecretReferencesInput }) =>
+ resolveWorkspaceContext(input.workspaceId),
+ authorizationOptions,
+ async execute({ principal, input, context }) {
+ const userId = principalUserId(principal)
+ const grant = await requireSecretReferencesReadAccess({
+ workspaceId: context.workspaceId,
+ name: input.name,
+ userId,
+ })
+
+ /**
+ * Name-based, not scope-narrowed: the same reference sites answer for a workspace secret and
+ * for the personal one it shadows. Narrowing by scope would report a personal secret as
+ * unreferenced the moment a workspace variable of the same name existed.
+ */
+ const scan = await scanSecretReferences({
+ workspaceId: context.workspaceId,
+ name: input.name,
+ })
+
+ /**
+ * Re-check the one input that can change under us. A `personal` grant rests on no workspace
+ * value existing under this name; a workspace secret created between the check and the scan
+ * would make the very map now in hand admin-gated. The read is cheap and skipped entirely
+ * for an `admin` grant, and failing closed here costs a non-admin nothing they were entitled
+ * to keep — the request is simply refused the way it would have been a moment later.
+ */
+ if (
+ grant === 'personal' &&
+ (await hasWorkspaceEnvValue({ workspaceId: context.workspaceId, envKey: input.name }))
+ ) {
+ throw new ForbiddenOperationError(
+ 'SECRET_ADMIN_ACCESS_REQUIRED',
+ 'Credential admin permission required to view this secret usage'
+ )
+ }
+
+ return scan
+ },
+})
diff --git a/apps/sim/lib/secrets/references/scan.test.ts b/apps/sim/lib/secrets/references/scan.test.ts
new file mode 100644
index 00000000000..89f0345bf14
--- /dev/null
+++ b/apps/sim/lib/secrets/references/scan.test.ts
@@ -0,0 +1,389 @@
+/**
+ * @vitest-environment node
+ */
+import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { scanSecretReferences } from '@/lib/secrets/references/scan'
+
+/** A stored block row as the scan reads it, with one short-input sub-block. */
+function blockRow(overrides: {
+ blockId: string
+ blockName: string
+ workflowId: string
+ workflowName: string
+ subBlocks: Record
+ blockType?: string
+}) {
+ return {
+ blockType: 'agent',
+ data: {},
+ ...overrides,
+ }
+}
+
+function shortInput(key: string, value: unknown) {
+ return { [key]: { id: key, type: 'short-input', value } }
+}
+
+describe('scanSecretReferences', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ })
+
+ it('groups referencing blocks under their workflow', async () => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Fetch orders',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('apiKey', '{{API_KEY}}'),
+ }),
+ blockRow({
+ blockId: 'block-2',
+ blockName: 'Post results',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('headers', 'Bearer {{API_KEY}}'),
+ }),
+ blockRow({
+ blockId: 'block-3',
+ blockName: 'Notify',
+ workflowId: 'workflow-2',
+ workflowName: 'Alerting',
+ subBlocks: shortInput('token', '{{API_KEY}}'),
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows).toEqual([
+ {
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ blocks: [
+ { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'agent', field: 'apiKey' },
+ { blockId: 'block-2', blockName: 'Post results', blockType: 'agent', field: 'headers' },
+ ],
+ },
+ {
+ workflowId: 'workflow-2',
+ workflowName: 'Alerting',
+ blocks: [{ blockId: 'block-3', blockName: 'Notify', blockType: 'agent', field: 'token' }],
+ },
+ ])
+ expect(scan.truncated).toBe(false)
+ })
+
+ /**
+ * The SQL prefilter matches the reference syntax, so these never reach the scanner in
+ * production — but the scanner stays the authority, and these pin that it agrees. Reporting
+ * them would send someone rotating one key to edit blocks that never touch it.
+ */
+ it('drops a block whose reference only shares a prefix with the name', async () => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Staging call',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('apiKey', '{{API_KEY_TEST}}'),
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows).toEqual([])
+ })
+
+ /** A prefilter hit with no `{{ }}` around the name is prose, not a reference. */
+ it('drops a block that only names the secret in free text', async () => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Docs',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('systemPrompt', 'Ask the admin for the API_KEY value.'),
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows).toEqual([])
+ })
+
+ /**
+ * The fork remapper collapses a block's references to one per `(kind, sourceId)`, so a block
+ * naming the secret twice yields one entry, not two. Pinned here because the panel renders
+ * `field` as the row's whole description — if this ever became a list, the row would need to
+ * say so rather than silently naming one of several.
+ */
+ it('reports one entry for a block that references the secret in two fields', async () => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Fetch orders',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: {
+ ...shortInput('apiKey', '{{API_KEY}}'),
+ ...shortInput('headers', 'Bearer {{API_KEY}}'),
+ ...shortInput('url', 'https://example.com'),
+ },
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ const blocks = scan.workflows[0]?.blocks ?? []
+ expect(blocks).toHaveLength(1)
+ expect(['apiKey', 'headers']).toContain(blocks[0]?.field)
+ })
+
+ /**
+ * `{subBlockId}-tool-{index}-{paramId}` keys are a client-only projection of the canonical
+ * `tool-input` value that older rows persisted anyway. Reporting one puts an internal key
+ * where the reader expects a field name, so the canonical sub-block has to win.
+ */
+ it('reports the canonical field rather than a persisted tool mirror', async () => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Agent 1',
+ workflowId: 'workflow-1',
+ workflowName: 'Exa Tool Demo',
+ subBlocks: {
+ ...shortInput('tools', [{ params: { code: 'const k = "{{API_KEY}}"' } }]),
+ ...shortInput('tools-tool-0-code', 'const k = "{{API_KEY}}"'),
+ },
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows[0]?.blocks[0]?.field).toBe('tools')
+ })
+
+ it('finds a reference nested inside a sub-block value', async () => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Call API',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('params', [{ name: 'auth', value: '{{API_KEY}}' }]),
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows[0]?.blocks[0]?.field).toBe('params')
+ })
+
+ /**
+ * `ENV_REF_PATTERN`'s `\s` spans more than ASCII, so a value pasted with a non-breaking or
+ * ideographic space inside the braces is a reference the executor resolves. Reporting it as
+ * unreferenced is the one failure direction this feature must never take.
+ */
+ it.each([
+ ['ascii space', '{{ API_KEY }}'],
+ ['tab', '{{\tAPI_KEY\t}}'],
+ ['newline', '{{\nAPI_KEY\n}}'],
+ ['non-breaking space', '{{ API_KEY }}'],
+ ['narrow non-breaking space', '{{ API_KEY }}'],
+ ['ideographic space', '{{ API_KEY }}'],
+ ])('tolerates %s inside the reference braces', async (_label, value) => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Call API',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('apiKey', value),
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows[0]?.blocks[0]?.field).toBe('apiKey')
+ })
+
+ /**
+ * One unreadable block must not blank the whole tab — the other blocks are still the honest
+ * answer, and a reference reported without the field that carries it is worse than omitted.
+ */
+ it('skips a block whose sub-blocks cannot be scanned', async () => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Corrupt',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: null as unknown as Record,
+ }),
+ blockRow({
+ blockId: 'block-2',
+ blockName: 'Fetch orders',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('apiKey', '{{API_KEY}}'),
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows[0]?.blocks.map((block) => block.blockId)).toEqual(['block-2'])
+ })
+
+ it('reports custom tools and MCP servers that carry the secret', async () => {
+ queueTableRows(schemaMock.customTools, [
+ { id: 'tool-1', title: 'Order lookup', code: 'fetch(url, { key: "{{API_KEY}}" })' },
+ { id: 'tool-2', title: 'Unrelated', code: 'const label = "API_KEY"' },
+ ])
+ queueTableRows(schemaMock.mcpServers, [
+ {
+ id: 'server-1',
+ name: 'Billing',
+ url: 'https://example.com?token={{API_KEY}}',
+ headers: { Authorization: 'Bearer {{API_KEY}}', 'X-Trace': 'on' },
+ },
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.resources).toEqual([
+ { id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' },
+ { id: 'server-1', kind: 'mcp-server', name: 'Billing', field: 'url' },
+ { id: 'server-1', kind: 'mcp-server', name: 'Billing', field: 'header: Authorization' },
+ ])
+ })
+
+ it('flags a scan that hit the block cap', async () => {
+ queueTableRows(
+ schemaMock.workflowBlocks,
+ Array.from({ length: 2001 }, (_, index) =>
+ blockRow({
+ blockId: `block-${index}`,
+ blockName: `Block ${index}`,
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('apiKey', '{{API_KEY}}'),
+ })
+ )
+ )
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.truncated).toBe(true)
+ expect(scan.workflows[0]?.blocks).toHaveLength(2000)
+ })
+
+ /**
+ * Landing exactly on a bound is a complete scan, not a truncated one. Claiming truncation
+ * there tells the reader references may be missing when every one was returned — the same
+ * "absence of evidence" note, on a scan that has none.
+ */
+ it('does not claim truncation for a scan that ends exactly on the result limit', async () => {
+ queueTableRows(
+ schemaMock.workflowBlocks,
+ Array.from({ length: 2000 }, (_, index) =>
+ blockRow({
+ blockId: `block-${index}`,
+ blockName: `Block ${index}`,
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('apiKey', '{{API_KEY}}'),
+ })
+ )
+ )
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows[0]?.blocks).toHaveLength(2000)
+ expect(scan.truncated).toBe(false)
+ })
+
+ /**
+ * The cap counts what is reported, not what is read. The prefilter judges syntax only, so a
+ * block naming the secret in prose is a genuine candidate that the scanner then rejects — and
+ * when the cap counted candidates, enough of those sorted earlier pushed real references out of
+ * the answer entirely.
+ */
+ it('does not let filtered candidates displace real references', async () => {
+ const noise = Array.from({ length: 2500 }, (_, index) =>
+ blockRow({
+ blockId: `noise-${index}`,
+ blockName: `Noise ${index}`,
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('systemPrompt', 'the API_KEY is configured elsewhere'),
+ })
+ )
+ const real = blockRow({
+ blockId: 'block-real',
+ blockName: 'Fetch orders',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('apiKey', '{{API_KEY}}'),
+ })
+ queueTableRows(schemaMock.workflowBlocks, [...noise, real])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.workflows[0]?.blocks.map((block) => block.blockId)).toEqual(['block-real'])
+ })
+
+ it('returns nothing for a secret referenced nowhere', async () => {
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan).toEqual({ workflows: [], resources: [], truncated: false })
+ })
+
+ /**
+ * A name outside the env-key charset can never sit inside `{{ }}`, so the scan short-circuits
+ * before touching the database — which is also what makes it safe to inline the name into the
+ * SQL regex without escaping.
+ */
+ it('scans nothing for a name that cannot be an env reference', async () => {
+ queueTableRows(schemaMock.workflowBlocks, [
+ blockRow({
+ blockId: 'block-1',
+ blockName: 'Fetch orders',
+ workflowId: 'workflow-1',
+ workflowName: 'Nightly sync',
+ subBlocks: shortInput('apiKey', '{{API_KEY}}'),
+ }),
+ ])
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API.*KEY' })
+
+ expect(scan).toEqual({ workflows: [], resources: [], truncated: false })
+ expect(dbChainMockFns.select).not.toHaveBeenCalled()
+ })
+
+ /**
+ * The row caps bound what is READ; one MCP server expands to an entry per matching header, so
+ * the emitted total is what has to stay inside the contract's array bound. Without this the
+ * route rejects its own response and a successful scan surfaces as a 500.
+ */
+ it('caps emitted resources so the response bound holds', async () => {
+ const headers: Record = {}
+ for (let index = 0; index < 300; index++) headers[`X-Key-${index}`] = 'Bearer {{API_KEY}}'
+ queueTableRows(
+ schemaMock.mcpServers,
+ Array.from({ length: 3 }, (_, index) => ({
+ id: `server-${index}`,
+ name: `Server ${index}`,
+ url: 'https://example.com',
+ headers,
+ }))
+ )
+
+ const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
+
+ expect(scan.resources).toHaveLength(400)
+ expect(scan.truncated).toBe(true)
+ })
+})
diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts
new file mode 100644
index 00000000000..3bbcfc93b6b
--- /dev/null
+++ b/apps/sim/lib/secrets/references/scan.ts
@@ -0,0 +1,399 @@
+import { db } from '@sim/db'
+import { customTools, mcpServers, workflow, workflowBlocks } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, asc, eq, isNull, sql } from 'drizzle-orm'
+import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
+import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
+import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks'
+import { ENV_REF_PATTERN, remapSubBlocks } from '@/ee/workspace-forking/lib/remap/remap-references'
+
+const logger = createLogger('SecretReferenceScan')
+
+/**
+ * Cap on blocks REPORTED — confirmed references, not rows read.
+ *
+ * Capping candidates instead made the answer depend on how many irrelevant rows happened to sort
+ * first. The prefilter can only judge syntax, while `remapSubBlocks` additionally drops dormant
+ * canonical members and condition-hidden fields — semantics no SQL predicate can replicate — so a
+ * block whose only `{{name}}` sits in a hidden field is a real candidate that yields nothing, and
+ * enough of them sorted earlier displaced active references out of the result.
+ */
+const BLOCK_RESULT_LIMIT = 2000
+
+/**
+ * Ceiling on candidate rows read, so a scan cannot walk an unbounded set.
+ *
+ * Sits above {@link BLOCK_RESULT_LIMIT} so semantically filtered rows — dormant members and
+ * condition-hidden fields, which the SQL cannot judge — are absorbed as extra reads rather than
+ * displacing results. The headroom is deliberately modest: every candidate carries its block's
+ * `sub_blocks`, so this is the memory bound as much as the work bound.
+ *
+ * It is also the irreducible one. Bounded work and guaranteed completeness cannot both hold, so
+ * the only real choices are where the bound sits and whether it counts something the reader can
+ * see. It counts results.
+ */
+const BLOCK_CANDIDATE_CEILING = 4000
+
+/** Matching cap for each cascade table, which are far smaller than the block table. */
+const RESOURCE_SCAN_LIMIT = 200
+
+/**
+ * Ceiling on EMITTED resource entries, matching `secretReferenceResourceSchema`'s array bound in
+ * the secrets contract. Capping rows alone is not enough: one MCP server yields an entry per
+ * matching header plus one for its url, so 200 server rows can expand past the declared bound and
+ * make the route reject its own response. The producer stops at the bound instead.
+ */
+const RESOURCE_EMIT_LIMIT = 400
+
+/**
+ * The env-key charset `ENV_REF_PATTERN` accepts. A name outside it can never appear inside
+ * `{{ }}`, so the scan short-circuits — which also means the name is safe to inline into the
+ * SQL regex below without escaping, since it cannot carry a metacharacter.
+ */
+const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
+
+/**
+ * Drops the `{subBlockId}-tool-{index}-{paramId}` mirrors a tool row renders with.
+ *
+ * Those ids are documented as an ephemeral, client-only projection of the value held canonically
+ * at `tool.params[paramId]` inside the aggregate `tool-input` sub-block — they are not supposed
+ * to be persisted at all, but rows predating that rule still carry them. Left in, the scanner
+ * reports whichever the record happens to yield last, which surfaced an internal key like
+ * `tools-tool-0-code` where the reader expects a field.
+ *
+ * Removing them is right even when the two disagree: the canonical `tool.params` is what
+ * executes, so a mirror the canonical no longer matches describes a reference that no longer
+ * runs.
+ */
+function withoutToolMirrors(subBlocks: SubBlockRecord): SubBlockRecord {
+ const canonical: SubBlockRecord = {}
+ for (const [key, value] of Object.entries(subBlocks)) {
+ if (isSyntheticToolSubBlockId(key)) continue
+ canonical[key] = value
+ }
+ return canonical
+}
+
+export interface SecretReferenceBlock {
+ blockId: string
+ blockName: string
+ blockType: string
+ /**
+ * A sub-block key on this block whose value carries the reference — not necessarily the
+ * only one. The fork remapper collapses a block's references to one entry per
+ * `(kind, sourceId)`, so a block naming the secret in two fields reports one of them. The
+ * block is the unit the reader acts on; the field is there to locate it inside the block.
+ */
+ field: string
+}
+
+export interface SecretReferenceWorkflow {
+ workflowId: string
+ workflowName: string
+ blocks: SecretReferenceBlock[]
+}
+
+/**
+ * One reference site inside a resource a workflow reaches through rather than a block field.
+ * An MCP server carrying the key in two headers yields two entries, so `id` alone is not
+ * unique — `(kind, id, field)` is.
+ */
+export interface SecretReferenceResource {
+ id: string
+ kind: 'custom-tool' | 'mcp-server'
+ name: string
+ /** Where inside the resource the reference lives — `code`, `url`, or `header: X`. */
+ field: string
+}
+
+export interface SecretReferenceScan {
+ workflows: SecretReferenceWorkflow[]
+ resources: SecretReferenceResource[]
+ /** True when a scan cap was hit, so the lists are a prefix rather than the whole set. */
+ truncated: boolean
+}
+
+interface ScanSecretReferencesParams {
+ workspaceId: string
+ name: string
+}
+
+/** Whether `text` carries a `{{name}}` reference, using the fork remapper's own pattern. */
+function referencesEnvKey(text: string, name: string): boolean {
+ for (const match of text.matchAll(ENV_REF_PATTERN)) {
+ if (match[1] === name) return true
+ }
+ return false
+}
+
+/**
+ * Code points JS `\s` matches beyond ASCII, as inclusive ranges. Spelled out as numbers and
+ * rendered to `\uXXXX` below rather than written literally, so the source stays readable ASCII
+ * instead of carrying a run of invisible characters no reviewer could check.
+ */
+const UNICODE_SPACE_RANGES: ReadonlyArray = [
+ [0x00a0, 0x00a0],
+ [0x1680, 0x1680],
+ [0x2000, 0x200a],
+ [0x2028, 0x2029],
+ [0x202f, 0x202f],
+ [0x205f, 0x205f],
+ [0x3000, 0x3000],
+ [0xfeff, 0xfeff],
+]
+
+/** A code point as the `\uXXXX` escape a Postgres regex understands. */
+function toPgEscape(codePoint: number): string {
+ return `\\u${codePoint.toString(16).padStart(4, '0')}`
+}
+
+const UNICODE_SPACE_CLASS = UNICODE_SPACE_RANGES.map(([low, high]) =>
+ low === high ? toPgEscape(low) : `${toPgEscape(low)}-${toPgEscape(high)}`
+).join('')
+
+/**
+ * One unit of what may sit between `{{` and the name: exactly the whitespace
+ * `ENV_REF_PATTERN` accepts, in each encoding it can arrive in.
+ *
+ * The prefilter reads a `::text` rendering of a JSON column, and the two regex engines
+ * disagree about whitespace, so all three forms are spelled out: `[[:space:]]` for raw ASCII;
+ * the JSON escapes, since `jsonb::text` renders a real tab as the literal pair `\\` `t` and a
+ * vertical tab as `\\u000b`; and the Unicode class above, which Postgres emits verbatim and
+ * `[[:space:]]` does not match though JS `\\s` does.
+ *
+ * Enumerating whitespace rather than excluding word characters costs a list to keep in step
+ * with `\\s`, and buys a prefilter exactly as tight as the authority. A looser gap admitted
+ * `{{-NAME-}}` and `{{"NAME"}}`, and those are not free: each occupies a row under
+ * {@link BLOCK_SCAN_LIMIT}, so a workspace with enough of them sorted earlier would exhaust
+ * the cap before a genuine reference was read — reporting a live key as unused.
+ */
+const REFERENCE_GAP = `([[:space:]]|\\\\[tnrf]|\\\\u000[bB]|[${UNICODE_SPACE_CLASS}])`
+
+/**
+ * Matches the name sitting inside `{{ }}` with only {@link REFERENCE_GAP} units between.
+ *
+ * Deliberately not `LIKE '%name%'`: `_` is a LIKE single-character wildcard and nearly every env
+ * key contains one, so `SB_ACTION_ROUTER_SECRET` would match text it does not occur in. And
+ * deliberately not a bare `strpos` either: that matched the name in prose and as a prefix of a
+ * longer key (`API_KEY` inside `{{API_KEY_TEST}}`), and those false positives counted against the
+ * row cap — so on a workspace with enough of them, genuine references sorted later were never
+ * read at all.
+ *
+ * The gap accepts exactly the whitespace `ENV_REF_PATTERN` does, so a candidate row is always a
+ * real occurrence and the cap counts only references. The scanners below still re-check each one
+ * and remain the authority; this decides what is worth reading.
+ */
+function referencesKey(column: unknown, envKey: string) {
+ return sql`${column} ~ ${`\\{\\{${REFERENCE_GAP}*${envKey}${REFERENCE_GAP}*\\}\\}`}`
+}
+
+/**
+ * Every place in a workspace that names one secret: the blocks that reference it as
+ * `{{KEY}}`, plus the custom tools and MCP servers whose own bodies carry it.
+ *
+ * Detection is the workspace-fork remapper's — {@link remapSubBlocks} already walks nested
+ * `tool-input` params, resolves canonical basic/advanced pairs, and skips dormant and
+ * condition-hidden members. Only the aggregation is new: `scanWorkflowReferences` collapses
+ * its output to unique `(kind, sourceId)` pairs, which is right for building a mapping table
+ * and wrong for answering "where is this wired in".
+ *
+ * The scan is name-based and therefore identical for a workspace and a personal secret — a
+ * `{{KEY}}` in a workflow names a key, not a scope, and resolves to whichever slice wins at
+ * run time. The detail page already reports shadowing separately.
+ */
+export async function scanSecretReferences({
+ workspaceId,
+ name,
+}: ScanSecretReferencesParams): Promise {
+ // A name outside the env-key charset cannot appear inside `{{ }}`, so nothing can reference it.
+ if (!ENV_KEY_PATTERN.test(name)) return { workflows: [], resources: [], truncated: false }
+
+ /**
+ * All candidates in one read, up to the ceiling.
+ *
+ * Deliberately not paged. Paging bought headroom but paid for it with drift: `OFFSET` is
+ * positional, so a block renamed, inserted or deleted between pages shifts the result set and
+ * the scan silently skips a live reference or reports one twice. One statement is one snapshot,
+ * so neither can happen — and the ceiling is what bounds the read instead.
+ *
+ * `blockId` still closes the ordering, so repeated scans of an unchanged workspace return the
+ * same list in the same order rather than an arbitrary one among ties.
+ */
+ const readCandidates = () =>
+ db
+ .select({
+ blockId: workflowBlocks.id,
+ blockName: workflowBlocks.name,
+ blockType: workflowBlocks.type,
+ subBlocks: workflowBlocks.subBlocks,
+ data: workflowBlocks.data,
+ workflowId: workflow.id,
+ workflowName: workflow.name,
+ })
+ .from(workflowBlocks)
+ .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId))
+ .where(
+ and(
+ eq(workflow.workspaceId, workspaceId),
+ isNull(workflow.archivedAt),
+ referencesKey(sql`${workflowBlocks.subBlocks}::text`, name)
+ )
+ )
+ .orderBy(
+ asc(workflow.name),
+ asc(workflow.id),
+ asc(workflowBlocks.name),
+ asc(workflowBlocks.id)
+ )
+ // Limit-plus-one, like the resource reads: the extra row is how "there were more" is known
+ // without claiming truncation on a set that ended exactly on the bound.
+ .limit(BLOCK_CANDIDATE_CEILING + 1)
+
+ const [candidates, tools, servers] = await Promise.all([
+ readCandidates(),
+ db
+ .select({ id: customTools.id, title: customTools.title, code: customTools.code })
+ .from(customTools)
+ .where(and(eq(customTools.workspaceId, workspaceId), referencesKey(customTools.code, name)))
+ .orderBy(asc(customTools.title))
+ .limit(RESOURCE_SCAN_LIMIT + 1),
+ db
+ .select({
+ id: mcpServers.id,
+ name: mcpServers.name,
+ url: mcpServers.url,
+ headers: mcpServers.headers,
+ })
+ .from(mcpServers)
+ .where(
+ and(
+ eq(mcpServers.workspaceId, workspaceId),
+ isNull(mcpServers.deletedAt),
+ // `headers` is a `json` column, so `::text` is the only safe read here — a jsonb
+ // operator would raise 42883 and abort the statement.
+ sql`(${referencesKey(mcpServers.url, name)} OR ${referencesKey(sql`${mcpServers.headers}::text`, name)})`
+ )
+ )
+ .orderBy(asc(mcpServers.name))
+ .limit(RESOURCE_SCAN_LIMIT + 1),
+ ])
+
+ /** Every bound is `> limit` on a limit-plus-one read, so landing exactly on one is not truncation. */
+ let truncated =
+ tools.length > RESOURCE_SCAN_LIMIT ||
+ servers.length > RESOURCE_SCAN_LIMIT ||
+ candidates.length > BLOCK_CANDIDATE_CEILING
+
+ const workflows: SecretReferenceWorkflow[] = []
+ const workflowIndex = new Map()
+ let reported = 0
+
+ for (const row of candidates.slice(0, BLOCK_CANDIDATE_CEILING)) {
+ /* Reporting stops at the result limit, but the read does not: the remaining candidates are
+ still worth walking only insofar as they cannot add results, so stop here and say so. */
+ if (reported >= BLOCK_RESULT_LIMIT) {
+ truncated = true
+ break
+ }
+ scanCandidate(row)
+ }
+
+ function scanCandidate(row: {
+ blockId: string
+ blockName: string
+ blockType: string
+ subBlocks: unknown
+ data: unknown
+ workflowId: string
+ workflowName: string
+ }): void {
+ let field: string | undefined
+ try {
+ const { references } = remapSubBlocks(
+ withoutToolMirrors(row.subBlocks as SubBlockRecord),
+ () => null,
+ {
+ blockId: row.blockId,
+ blockName: row.blockName,
+ blockType: row.blockType,
+ canonicalModes: (row.data as { canonicalModes?: CanonicalModeOverrides } | null)
+ ?.canonicalModes,
+ }
+ )
+ field = references.find(
+ (reference) => reference.kind === 'env-var' && reference.sourceId === name
+ )?.subBlockKey
+ } catch (error) {
+ // One malformed block must not blank the whole tab. The block is dropped rather than
+ // reported without the field that carries the reference, which would read as a
+ // reference we cannot locate — and the log names it so the shape can be fixed.
+ logger.error('Failed to scan block for secret references', {
+ blockId: row.blockId,
+ workflowId: row.workflowId,
+ error,
+ })
+ return
+ }
+ if (!field) return
+
+ let entry = workflowIndex.get(row.workflowId)
+ if (!entry) {
+ entry = { workflowId: row.workflowId, workflowName: row.workflowName, blocks: [] }
+ workflowIndex.set(row.workflowId, entry)
+ workflows.push(entry)
+ }
+ entry.blocks.push({
+ blockId: row.blockId,
+ blockName: row.blockName,
+ blockType: row.blockType,
+ field,
+ })
+ reported += 1
+ }
+
+ const resources: SecretReferenceResource[] = []
+
+ /**
+ * Stops at {@link RESOURCE_EMIT_LIMIT} rather than trusting the row caps to bound the output.
+ * One server expands to an entry per matching header, so the emitted total is what has to be
+ * checked against the contract's array bound — exceeding it would make the route reject its
+ * own response and turn a successful scan into a 500.
+ */
+ const emitResource = (resource: SecretReferenceResource): boolean => {
+ if (resources.length >= RESOURCE_EMIT_LIMIT) {
+ truncated = true
+ return false
+ }
+ resources.push(resource)
+ return true
+ }
+
+ for (const tool of tools.slice(0, RESOURCE_SCAN_LIMIT)) {
+ if (!referencesEnvKey(tool.code ?? '', name)) continue
+ if (!emitResource({ id: tool.id, kind: 'custom-tool', name: tool.title, field: 'code' })) break
+ }
+
+ for (const server of servers.slice(0, RESOURCE_SCAN_LIMIT)) {
+ if (resources.length >= RESOURCE_EMIT_LIMIT) {
+ truncated = true
+ break
+ }
+ if (server.url && referencesEnvKey(server.url, name)) {
+ emitResource({ id: server.id, kind: 'mcp-server', name: server.name, field: 'url' })
+ }
+ const headers = (server.headers ?? {}) as Record
+ for (const [headerName, headerValue] of Object.entries(headers)) {
+ if (typeof headerValue !== 'string') continue
+ if (!referencesEnvKey(headerValue, name)) continue
+ const emitted = emitResource({
+ id: server.id,
+ kind: 'mcp-server',
+ name: server.name,
+ field: `header: ${headerName}`,
+ })
+ if (!emitted) break
+ }
+ }
+
+ return { workflows, resources, truncated }
+}
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index 2b92482ad92..3f66bbc8a2f 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: 1161,
- zodRoutes: 1161,
+ totalRoutes: 1162,
+ zodRoutes: 1162,
nonZodRoutes: 0,
} as const