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 @@ -5684,6 +5684,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 @@ -182,6 +182,7 @@ import {
PineconeIcon,
PipedriveIcon,
PitchBookIcon,
PlaidIcon,
PolymarketIcon,
PostgresIcon,
PosthogIcon,
Expand Down Expand Up @@ -478,6 +479,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 @@ -194,6 +194,7 @@
"pipedrive",
"pipedrive-service-account",
"pitchbook",
"plaid",
"polymarket",
"postgresql",
"posthog",
Expand Down
429 changes: 429 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
}
)
212 changes: 212 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,212 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

vi.mock('@/lib/credentials/application/list-plaid-options', () => ({
listPlaidOptions: {
operation: { id: 'credentials.read' },
execute: mockExecute,
},
}))

import { PLAID_OPTIONS_REQUEST_MAX_BYTES } from '@/lib/api/contracts/selectors/plaid'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { POST } from '@/app/api/tools/plaid/options/route'
import { PlaidGatewayError, PlaidProviderError } from '@/tools/plaid/utils.server'

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)
}

function rawRequest(
requestBody: string,
signal?: AbortSignal,
headers: Record<string, string> = {}
) {
return new NextRequest('http://localhost:3000/api/tools/plaid/options', {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: requestBody,
signal,
})
}

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 selector requests before execution', async () => {
expect((await POST(request({ ...body, unexpected: true }))).status).toBe(400)
expect(
(
await POST(
request({
...body,
kind: 'institution_search',
query: 'Bank',
country_codes: ['ZZ'],
})
)
).status
).toBe(400)
expect(mockExecute).not.toHaveBeenCalled()
})

it('rejects invalid JSON before application execution', async () => {
const response = await POST(rawRequest('{"kind":'))

expect(response.status).toBe(400)
await expect(response.json()).resolves.toMatchObject({
error: 'Request body must be valid JSON',
})
expect(mockExecute).not.toHaveBeenCalled()
})

it('rejects an actual body larger than the Plaid selector-route ceiling', async () => {
const response = await POST(
rawRequest(
JSON.stringify({
...body,
padding: 'x'.repeat(PLAID_OPTIONS_REQUEST_MAX_BYTES),
})
)
)

expect(response.status).toBe(413)
await expect(response.json()).resolves.toMatchObject({
error: `Request body exceeds the maximum allowed size of ${PLAID_OPTIONS_REQUEST_MAX_BYTES} bytes`,
})
expect(mockExecute).not.toHaveBeenCalled()
})

it('accepts long provider identifiers within the route byte ceiling', async () => {
const query = 'x'.repeat(10_001)
const response = await POST(
request({
...body,
kind: 'institution_search',
query,
country_codes: ['US'],
})
)

expect(response.status).toBe(200)
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
input: { body: expect.objectContaining({ query }), signal: expect.any(AbortSignal) },
})
)
})

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')
})

it('preserves sanitized Plaid provider errors', async () => {
mockExecute.mockRejectedValueOnce(
new PlaidProviderError(400, {
error_code: 'ITEM_LOGIN_REQUIRED',
error_type: 'ITEM_ERROR',
request_id: 'plaid-request-1',
})
)

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',
request_id: 'plaid-request-1',
})
})

it('projects Plaid gateway failures as a safe 502 response', async () => {
mockExecute.mockRejectedValueOnce(new PlaidGatewayError('Plaid request timed out'))

const response = await POST(request())

expect(response.status).toBe(502)
await expect(response.json()).resolves.toMatchObject({ error: 'Plaid request timed out' })
})

it('returns 499 when selector execution fails after the client disconnects', async () => {
const controller = new AbortController()
mockExecute.mockImplementationOnce(async () => {
controller.abort()
throw new DOMException('The operation was aborted.', 'AbortError')
})

const response = await POST(rawRequest(JSON.stringify(body), controller.signal))

expect(response.status).toBe(499)
await expect(response.json()).resolves.toMatchObject({ error: 'Client cancelled request' })
})

it('fails closed on an unexpected selector error', async () => {
mockExecute.mockRejectedValueOnce(new Error('item-token-should-not-leak'))

const response = await POST(request())

expect(response.status).toBe(500)
const responseBody = await response.json()
expect(responseBody).toMatchObject({ error: 'Internal server error' })
expect(JSON.stringify(responseBody)).not.toContain('item-token-should-not-leak')
})

it('fails closed when selector output violates the response contract', async () => {
mockExecute.mockResolvedValueOnce({ options: [{ id: '', label: '' }] })

const response = await POST(request())

expect(response.status).toBe(500)
await expect(response.json()).resolves.toMatchObject({ error: 'Internal server error' })
})
})
25 changes: 25 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,25 @@
import {
PLAID_OPTIONS_REQUEST_MAX_BYTES,
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: PLAID_OPTIONS_REQUEST_MAX_BYTES },
mapInput: ({ body }, { request }) => ({ body, signal: request.signal }),
useCase: listPlaidOptions,
})
Loading
Loading