Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5510,6 +5510,18 @@ export function AsanaIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function PlaidIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill='currentColor'
fillRule='evenodd'
d='M18.637 0L4.09 3.81.081 18.439l5.014 5.148L0 28.65l3.773 14.693 14.484 4.047 5.096-5.064 5.014 5.147 14.547-3.81 4.008-14.63-5.013-5.146 5.095-5.063L43.231 4.13 28.745.083l-5.094 5.063L18.637 0zM9.71 6.624l7.663-2.008 3.351 3.44-4.887 4.856L9.71 6.624zm16.822 1.478l3.405-3.383 7.63 2.132-6.227 6.187-4.808-4.936zM4.672 17.238l2.111-7.705 6.125 6.288-4.886 4.856-3.35-3.44zm29.547-1.243l6.227-6.189 1.986 7.74-3.404 3.384-4.809-4.935zm-15.502-.127l4.887-4.856 4.807 4.936-4.886 4.856-4.808-4.936zm-7.814 7.765l4.886-4.856 4.81 4.936-4.888 4.856-4.808-4.936zm15.503.127l4.886-4.856L36.1 23.84l-4.887 4.856-4.807-4.936zM4.57 29.927l3.406-3.385 4.807 4.937-6.225 6.186-1.988-7.738zm14.021 1.598l4.887-4.856 4.808 4.936-4.886 4.856-4.809-4.936zm15.502.128l4.887-4.856 3.351 3.439-2.11 7.705-6.128-6.288zm-24.656 8.97l6.226-6.189 4.81 4.936-3.406 3.385-7.63-2.133zm16.843-1.206l4.886-4.856 6.126 6.289-7.662 2.007-3.35-3.44z'
/>
</svg>
)
}

export function PipedriveIcon(props: SVGProps<SVGSVGElement>) {
const pathId = useId()
return (
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ import {
PineconeIcon,
PipedriveIcon,
PitchBookIcon,
PlaidIcon,
PolymarketIcon,
PostgresIcon,
PosthogIcon,
Expand Down Expand Up @@ -470,6 +471,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
pitchbook: PitchBookIcon,
plaid: PlaidIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@
"pipedrive",
"pipedrive-service-account",
"pitchbook",
"plaid",
"polymarket",
"postgresql",
"posthog",
Expand Down
418 changes: 418 additions & 0 deletions apps/docs/content/docs/en/integrations/plaid.mdx

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions apps/sim/app/api/tools/plaid/error-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { extendInternalErrorPolicy, internalErrorResponse } from '@/lib/api/server/routes'
import { internalCredentialDetailErrorPolicy } from '@/lib/credentials/api/route-policies'
import { PlaidGatewayError, PlaidProviderError } from '@/tools/plaid/utils.server'

export const plaidErrorPolicy = extendInternalErrorPolicy(
internalCredentialDetailErrorPolicy,
(error) => {
if (error instanceof PlaidProviderError) {
return internalErrorResponse(error.status, error.body)
}
if (error instanceof PlaidGatewayError) {
return internalErrorResponse(502, { error: error.message })
}
return null
}
)
94 changes: 94 additions & 0 deletions apps/sim/app/api/tools/plaid/options/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() }))

vi.mock('@/lib/credentials/application/list-plaid-options', async () => {
const { credentialOperations } = await vi.importActual<
typeof import('@/lib/credentials/application/operations')
>('@/lib/credentials/application/operations')
return {
listPlaidOptions: {
operation: credentialOperations.read,
execute: mockExecute,
},
}
})

import { OrchestrationError } from '@/lib/core/orchestration/types'
import { POST } from '@/app/api/tools/plaid/options/route'

const body = {
kind: 'accounts',
workspaceId: 'workspace-1',
credentialId: 'credential-1',
} as const

function request(requestBody: unknown = body, headers: Record<string, string> = {}) {
return createMockRequest('POST', requestBody, headers)
}

describe('POST /api/tools/plaid/options', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mockExecute.mockResolvedValue({ options: [{ id: 'acc-1', label: 'Checking' }] })
})

it('accepts a session and forwards only selector scope plus cancellation', async () => {
const incoming = request()
const response = await POST(incoming)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
options: [{ id: 'acc-1', label: 'Checking' }],
})
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { body, signal: incoming.signal },
request: incoming,
})
)
})

it('rejects unauthenticated and API-key callers', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
expect((await POST(request())).status).toBe(401)
expect((await POST(request(body, { 'x-api-key': 'key' }))).status).toBe(401)
expect(mockExecute).not.toHaveBeenCalled()
})

it('rejects malformed and overlong selector requests before execution', async () => {
expect((await POST(request({ ...body, unexpected: true }))).status).toBe(400)
expect(
(
await POST(
request({
...body,
kind: 'institution_search',
query: 'x'.repeat(257),
country_codes: ['US'],
})
)
).status
).toBe(400)
expect(mockExecute).not.toHaveBeenCalled()
})

it.each([
[new OrchestrationError('not_found', 'Credential not found'), 404],
[new OrchestrationError('forbidden', 'Credential access required'), 403],
])('projects credential access failures', async (error, status) => {
mockExecute.mockRejectedValueOnce(error)
const response = await POST(request())
expect(response.status).toBe(status)
expect(JSON.stringify(await response.json())).not.toContain('item-token')
})
})
22 changes: 22 additions & 0 deletions apps/sim/app/api/tools/plaid/options/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { plaidOptionsContract } from '@/lib/api/contracts/selectors/plaid'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listPlaidOptions } from '@/lib/credentials/application/list-plaid-options'
import { credentialOperations } from '@/lib/credentials/application/operations'
import { plaidErrorPolicy } from '@/app/api/tools/plaid/error-policy'

export const dynamic = 'force-dynamic'

export const POST = defineInternalJsonRoute({
contract: plaidOptionsContract,
auth: internalSessionAuth,
operation: credentialOperations.read,
rateLimit: internalRateLimits.none({ reason: 'Bounded editor selector request' }),
errorPolicy: plaidErrorPolicy,
parseOptions: { maxBodyBytes: 64 * 1024 },
mapInput: ({ body }, { request }) => ({ body, signal: request.signal }),
useCase: listPlaidOptions,
})
193 changes: 193 additions & 0 deletions apps/sim/app/api/tools/plaid/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* @vitest-environment node
*/
import { createMockRequest, resetEnvMock } from '@sim/testing'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { MockInvalidBindingError, mockBindDelegation, mockExecute, mockGetSession } = vi.hoisted(
() => ({
MockInvalidBindingError: class extends Error {},
mockBindDelegation: vi.fn(),
mockExecute: vi.fn(),
mockGetSession: vi.fn(),
})
)

vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/auth/internal-delegation', () => ({
bindInternalExecutorDelegation: mockBindDelegation,
InvalidInternalDelegationBindingError: MockInvalidBindingError,
}))
vi.unmock('@/lib/auth/internal')
vi.mock('@/lib/credentials/application/use-plaid-service-account', async () => {
const { credentialOperations } = await vi.importActual<
typeof import('@/lib/credentials/application/operations')
>('@/lib/credentials/application/operations')
return {
usePlaidServiceAccount: {
operation: credentialOperations.useServiceAccount,
execute: mockExecute,
},
}
})

import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { POST } from '@/app/api/tools/plaid/route'
import { PlaidProviderError } from '@/tools/plaid/utils.server'

const WORKFLOW_ID = '550e8400-e29b-41d4-a716-446655440001'
const WORKSPACE_ID = '550e8400-e29b-41d4-a716-446655440000'
const body = {
operation: 'plaid_get_item',
credentialId: 'credential-1',
input: {},
} as const
let delegationToken = ''
let legacyInternalToken = ''

function request(
requestBody: unknown = body,
headers: Record<string, string> = { authorization: `Bearer ${delegationToken}` }
) {
return createMockRequest('POST', requestBody, headers)
}

beforeAll(async () => {
delegationToken = await generateInternalDelegationToken({
subjectUserId: 'user-1',
workflowId: WORKFLOW_ID,
})
legacyInternalToken = await generateInternalToken('user-1')
})

afterAll(resetEnvMock)

beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue(null)
mockBindDelegation.mockImplementation(async (claims, options) => ({
kind: 'delegated',
serviceId: 'executor',
subjectUserId: claims.subjectUserId,
workspaceId: WORKSPACE_ID,
delegationId: claims.delegationId,
audience: options.audience,
issuedAt: claims.issuedAt,
expiresAt: claims.expiresAt,
delegationContext: {
kind: 'workflow_execution',
workflowId: claims.workflowId,
executionId: claims.executionId,
},
}))
mockExecute.mockResolvedValue({ item: { item_id: 'item-1' } })
})

describe('POST /api/tools/plaid', () => {
it('accepts executor delegation and forwards the validated operation with cancellation', async () => {
const incoming = request()
const response = await POST(incoming)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ item: { item_id: 'item-1' } })
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
principal: expect.objectContaining({
kind: 'delegated',
serviceId: 'executor',
workspaceId: WORKSPACE_ID,
}),
input: { body, signal: incoming.signal },
})
)
})

it.each([
[
'session',
() => ({}),
async () =>
mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
}),
],
['API key', () => ({ 'x-api-key': 'api-key' }), async () => undefined],
[
'generic internal JWT',
() => ({ authorization: `Bearer ${legacyInternalToken}` }),
async () => undefined,
],
])('rejects %s authentication', async (_label, buildHeaders, arrange) => {
await arrange()
const response = await POST(request(body, buildHeaders()))
expect(response.status).toBe(401)
expect(mockExecute).not.toHaveBeenCalled()
})

it('rejects delegation that no longer binds to an active workflow execution', async () => {
mockBindDelegation.mockRejectedValueOnce(new MockInvalidBindingError())
const response = await POST(request())
expect(response.status).toBe(401)
expect(mockExecute).not.toHaveBeenCalled()
})

it('authenticates before parsing the body', async () => {
const response = await POST(request({ operation: 'made_up' }, {}))
expect(response.status).toBe(401)
})

it('rejects malformed operation input at the route contract', async () => {
const response = await POST(request({ ...body, unexpected: true }))
expect(response.status).toBe(400)
expect(mockExecute).not.toHaveBeenCalled()
})

it('accepts RFC3339 balance timestamps with a numeric offset', async () => {
const offsetBody = {
operation: 'plaid_get_balances',
credentialId: 'credential-1',
input: { min_last_updated_datetime: '2026-08-18T12:30:00-07:00' },
} as const
const response = await POST(request(offsetBody))

expect(response.status).toBe(200)
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({ input: expect.objectContaining({ body: offsetBody }) })
)
})

it.each([
[
'wrong workspace or provider',
new OrchestrationError('not_found', 'Credential not found'),
404,
],
[
'inaccessible credential',
new OrchestrationError('forbidden', 'Credential access required'),
403,
],
])('projects %s without exposing secrets', async (_label, error, status) => {
mockExecute.mockRejectedValueOnce(error)
const response = await POST(request())
expect(response.status).toBe(status)
expect(JSON.stringify(await response.json())).not.toContain('client-secret')
})

it('preserves Plaid provider status and error fields', async () => {
mockExecute.mockRejectedValueOnce(
new PlaidProviderError(400, {
error_code: 'ITEM_LOGIN_REQUIRED',
error_type: 'ITEM_ERROR',
})
)
const response = await POST(request())
expect(response.status).toBe(400)
await expect(response.json()).resolves.toMatchObject({
error_code: 'ITEM_LOGIN_REQUIRED',
error_type: 'ITEM_ERROR',
})
})
})
Loading