Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Combobox as EditableCombobox } from '@sim/emcn'
import { X } from '@sim/emcn/icons'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
Expand All @@ -14,6 +15,7 @@ import {
useSelectorOptionMap,
useSelectorOptions,
} from '@/hooks/selectors/use-selector-query'
import { useDebounce } from '@/hooks/use-debounce'

interface SelectorComboboxProps {
blockId: string
Expand Down Expand Up @@ -63,14 +65,26 @@ export function SelectorCombobox({
const [searchTerm, setSearchTerm] = useState('')
const [isEditing, setIsEditing] = useState(false)
const [multiInput, setMultiInput] = useState('')
/**
* The search reaches the provider, so it is debounced before it enters the query key
* rather than on every keystroke — several of these selectors are rate-limited by the
* provider. Only the query sees the debounced value; the input stays on `searchTerm`.
*
* Clearing is not debounced: a multi-select pick resets the term so the next choice comes
* from the full list, and waiting out the delay would leave the previous filtered results
* on screen. This mirrors the shared debounced-search setter, which also flushes empty.
*/
const trimmedSearch = searchTerm.trim()
const debouncedSearch = useDebounce(trimmedSearch, SEARCH_DEBOUNCE_MS)
const activeSearch = trimmedSearch === '' ? '' : debouncedSearch
const {
data: options = [],
isLoading,
hasMore,
error,
} = useSelectorOptions(selectorKey, {
context: selectorContext,
search: allowSearch ? searchTerm : undefined,
search: allowSearch ? activeSearch : undefined,
})
const { data: detailOption } = useSelectorOptionDetail(selectorKey, {
context: selectorContext,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2543,6 +2543,7 @@ export class AgentBlockHandler implements BlockHandler {
credentialId: providerRequest.vertexCredential,
actingUserId: ctx.userId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
callerLabel: 'vertex-agent',
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({

vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)

vi.mock('@/executor/utils/credential-token', () => ({
fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
}))

vi.mock('@/lib/credentials/access', () => ({
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
Expand Down
1 change: 1 addition & 0 deletions apps/sim/executor/handlers/evaluator/evaluator-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
credentialId: evaluatorConfig.vertexCredential,
actingUserId: ctx.userId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
callerLabel: 'vertex-evaluator',
})
}
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/executor/handlers/router/router-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)

vi.mock('@/executor/utils/credential-token', () => ({
fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
}))

vi.mock('@/lib/credentials/access', () => ({
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/executor/handlers/router/router-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class RouterBlockHandler implements BlockHandler {
credentialId: routerConfig.vertexCredential,
actingUserId: ctx.userId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
callerLabel: 'vertex-router',
})
}
Expand Down Expand Up @@ -279,6 +280,7 @@ export class RouterBlockHandler implements BlockHandler {
credentialId: routerConfig.vertexCredential,
actingUserId: ctx.userId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
callerLabel: 'vertex-router',
})
}
Expand Down
67 changes: 67 additions & 0 deletions apps/sim/executor/utils/credential-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createLogger } from '@sim/logger'
import { generateInternalToken } from '@/lib/auth/internal'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'

const logger = createLogger('ExecutorCredentialToken')

/**
* Fetches a credential's access token from the app rather than resolving it here.
*
* Refreshing an OAuth token needs the provider's client id and secret, read through
* `requireOAuthClientCapability`, which THROWS when they are absent. Only the app
* container loads those (from `SIM_ENV_SECRET_ID`); workflow execution runs in a
* Trigger.dev worker whose environment does not carry them. Resolving in-process there
* turns every credential whose access token has expired into a refresh failure, and a
* still-valid token hides it until the token lapses.
*
* See `.claude/rules/sim-architecture.md`, "The app/worker runtime boundary".
*
* The route authorizes the credential itself, so this never widens access.
*/
export async function fetchCredentialAccessToken(params: {
requestId: string
credentialId: string
userId: string
workflowId?: string
}): Promise<string> {
const { requestId, credentialId, userId, workflowId } = params

const url = new URL('/api/auth/oauth/token', getInternalApiBaseUrl())
if (workflowId) url.searchParams.set('workflowId', workflowId)

const headers: Record<string, string> = { 'Content-Type': 'application/json' }
try {
headers.Authorization = `Bearer ${await generateInternalToken(userId)}`
} catch (_e) {
// Swallow mint errors; the request then fails authentication and reports upstream.
}

// boundary-raw-fetch: same-origin token route, authenticated by the internal JWT minted above
const response = await fetch(url.toString(), {
method: 'POST',
headers,
body: JSON.stringify({ credentialId, ...(workflowId ? { workflowId } : {}) }),
})

if (!response.ok) {
const errorText = await response.text()
logger.error(`[${requestId}] Credential token request failed`, {
status: response.status,
credentialId,
})
let message = errorText
try {
const parsed = JSON.parse(errorText)
if (parsed.error) message = parsed.error
} catch {
// Use raw text
}
throw new Error(message)
}

const { accessToken } = (await response.json()) as { accessToken?: string }
if (!accessToken) {
throw new Error('Credential token response carried no access token')
}
return accessToken
}
73 changes: 67 additions & 6 deletions apps/sim/executor/utils/vertex-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTokenIfNeeded } =
vi.hoisted(() => ({
mockGetCredentialActorContext: vi.fn(),
mockGetServiceAccountToken: vi.fn(),
mockRefreshTokenIfNeeded: vi.fn(),
}))
const {
mockGetCredentialActorContext,
mockGetServiceAccountToken,
mockRefreshTokenIfNeeded,
mockFetchCredentialAccessToken,
} = vi.hoisted(() => ({
mockGetCredentialActorContext: vi.fn(),
mockGetServiceAccountToken: vi.fn(),
mockRefreshTokenIfNeeded: vi.fn(),
mockFetchCredentialAccessToken: vi.fn(),
}))

vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: mockGetCredentialActorContext,
Expand All @@ -19,6 +24,9 @@ vi.mock('@/lib/oauth/credential-service', () => ({
getServiceAccountToken: mockGetServiceAccountToken,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
}))
vi.mock('@/executor/utils/credential-token', () => ({
fetchCredentialAccessToken: mockFetchCredentialAccessToken,
}))

import { resolveVertexCredential } from '@/executor/utils/vertex-credential'

Expand Down Expand Up @@ -95,3 +103,56 @@ describe('resolveVertexCredential workspace binding', () => {
).rejects.toThrow('requires an authenticated user')
})
})

/**
* This resolver runs inside the Trigger.dev worker, whose environment carries no OAuth
* client config — an in-process refresh throws there once the stored token expires.
*/
describe('resolveVertexCredential OAuth branch', () => {
const oauthContext = {
credential: { id: 'cred-o', workspaceId: 'workspace-a', type: 'oauth', accountId: 'acct-1' },
member: { id: 'member-1' },
hasWorkspaceAccess: true,
canWriteWorkspace: true,
isAdmin: false,
}

beforeEach(() => {
vi.clearAllMocks()
mockGetCredentialActorContext.mockResolvedValue(oauthContext)
mockFetchCredentialAccessToken.mockResolvedValue('oauth-access-token')
})

it('fetches the token from the app instead of refreshing in-process', async () => {
await expect(
resolveVertexCredential({
credentialId: 'cred-o',
actingUserId: 'user-1',
workspaceId: 'workspace-a',
workflowId: 'wf-1',
})
).resolves.toBe('oauth-access-token')

expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetchCredentialAccessToken).toHaveBeenCalledWith(
expect.objectContaining({ credentialId: 'cred-o', userId: 'user-1', workflowId: 'wf-1' })
)
})

it('authorizes before requesting a token', async () => {
mockGetCredentialActorContext.mockResolvedValue({
...oauthContext,
credential: { ...oauthContext.credential, workspaceId: 'workspace-b' },
})

await expect(
resolveVertexCredential({
credentialId: 'cred-o',
actingUserId: 'user-1',
workspaceId: 'workspace-a',
})
).rejects.toThrow()

expect(mockFetchCredentialAccessToken).not.toHaveBeenCalled()
})
})
28 changes: 16 additions & 12 deletions apps/sim/executor/utils/vertex-credential.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access'
import { getServiceAccountToken, refreshTokenIfNeeded } from '@/lib/oauth/credential-service'
import { getServiceAccountToken } from '@/lib/oauth/credential-service'
import { fetchCredentialAccessToken } from '@/executor/utils/credential-token'

const logger = createLogger('VertexCredential')

Expand All @@ -12,6 +10,8 @@ export interface ResolveVertexCredentialParams {
actingUserId: string | undefined
/** Workspace of the executing workflow. The credential must belong to it. */
workspaceId: string | null | undefined
/** Pins the token request to this workflow's workspace. */
workflowId?: string
callerLabel?: string
}

Expand All @@ -26,6 +26,7 @@ export async function resolveVertexCredential({
credentialId,
actingUserId,
workspaceId,
workflowId,
callerLabel = 'vertex',
}: ResolveVertexCredentialParams): Promise<string> {
const requestId = `${callerLabel}-${Date.now()}`
Expand Down Expand Up @@ -64,16 +65,19 @@ export async function resolveVertexCredential({
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}

const accountRow = await db.query.account.findFirst({
where: eq(account.id, cred.accountId),
/**
* Fetched from the app rather than refreshed here: this runs inside the Trigger.dev
* worker, whose environment carries no OAuth client config, so an in-process refresh
* throws once the stored access token expires. The service-account branch above needs
* no such config and stays in-process.
*/
const accessToken = await fetchCredentialAccessToken({
requestId,
credentialId,
userId: actingUserId,
workflowId,
})

if (!accountRow) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}

const { accessToken } = await refreshTokenIfNeeded(requestId, accountRow, cred.accountId)

if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/hooks/queries/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,12 +1015,12 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
})
},
onMutate: async ({ rowId, data }) => {
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
await queryClient.cancelQueries({ queryKey: tableKeys.infiniteRowsRoot(tableId) })

const previousQueries = queryClient.getQueriesData<
InfiniteData<TableRowsResponse, TableRowsPageParam>
>({
queryKey: tableKeys.rowsRoot(tableId),
queryKey: tableKeys.infiniteRowsRoot(tableId),
})

const groups =
Expand Down Expand Up @@ -1105,12 +1105,12 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon
})
},
onMutate: async ({ updates }) => {
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
await queryClient.cancelQueries({ queryKey: tableKeys.infiniteRowsRoot(tableId) })

const previousQueries = queryClient.getQueriesData<
InfiniteData<TableRowsResponse, TableRowsPageParam>
>({
queryKey: tableKeys.rowsRoot(tableId),
queryKey: tableKeys.infiniteRowsRoot(tableId),
})

const updateMap = new Map(updates.map((u) => [u.rowId, u.data]))
Expand Down
Loading