Skip to content

Commit 65dde9a

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(plaid): complete integration remediation
1 parent b48e086 commit 65dde9a

43 files changed

Lines changed: 2047 additions & 221 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/integrations/plaid.mdx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
2323

2424
- Select the stored Plaid Item once per block. Reconnect after rotating the Item access token or app credentials for that same Item and environment; use a new credential for a different Item or environment. The opaque credential ID stays the same for existing workflows. Deleting the Sim credential removes only the local encrypted copy and does not revoke or remove the Item at Plaid.
2525
- Plaid Item credentials are workspace credentials and are available according to workspace credential permissions. Use dedicated, access-controlled workflows for Auth and Identity data.
26-
- Transaction Sync returns one page per call. Preserve `nextCursor` and continue while `hasMore` is true. If Plaid returns `TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION`, discard that batch and restart from the cursor where the batch began. A cursor belongs to its account-filter stream; start with no cursor after changing the account filter.
27-
- Account fields offer single- and multi-account selectors backed by the selected Item. Institution lookup offers searchable results and hydrates a saved selection by ID. Advanced manual fields remain available for account or institution IDs that cannot be loaded in the editor. Account filters are optional and default to all accounts on the Item.
26+
- Transaction Sync returns one page per call. Preserve `nextCursor` and continue while `hasMore` is true. Cursors are at most 256 characters. If Plaid returns `TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION`, discard that batch and restart from the cursor where the batch began. A cursor belongs to its account-filter stream; start with no cursor after changing the account filter. `daysRequested` applies only before Transactions is initialized; in Production, values below 30 still request at least 30 days.
27+
- Account fields offer single- and multi-account selectors backed by the selected Item. Institution lookup offers searchable results and hydrates a saved selection by ID. Advanced manual fields remain available for account or institution IDs that cannot be loaded in the editor. Account filters are optional and default to all accounts on the Item. The standard Auth selector includes checking, savings, and cash-management accounts but cannot prove that an account is debitable, and it excludes limited-purpose checking accounts that a Plaid customer has specifically enabled; use the advanced manual field only when the account is known to be eligible for the configured Auth use case.
2828
- Institution search returns at most ten matches. The Search Institutions action remains available when you need its full institution records or want to supply non-US country codes and product filters.
29-
- Get Balances usually completes in under ten seconds but can take 30 seconds or more. `minLastUpdatedDatetime` is an RFC 3339 date-time and is required by Plaid only for certain Capital One non-depository requests.
29+
- Get Balances usually completes in under ten seconds but can take 30 seconds or more. Each successful `/accounts/balance/get` call is billable. For evaluating insufficient-funds risk on a proposed ACH transaction, Plaid recommends `/signal/evaluate`; use Get Balances for other real-time balance needs. `minLastUpdatedDatetime` is an RFC 3339 date-time and is required by Plaid only for certain Capital One non-depository requests.
30+
- Every successful action returns `requestId`. Include it when troubleshooting in Plaid's Activity Log or contacting Plaid Support.
31+
- Sim accepts Plaid provider responses up to 10 MiB. Account endpoints are not automatically paginated; each action returns the single response supplied by Plaid.
3032
- Get Auth account and routing numbers, and Get Identity personally identifiable information, are standard tool outputs. They can enter workflow and execution state, downstream blocks, Agent or model context, and requests to the configured model provider. `hiddenFromDisplay` suppresses only the Get Auth source block's `numbers` field from log display; it does not enforce non-persistence or prevent downstream access. Send Auth numbers only to an approved non-Plaid-partner payment processor, and do not route Auth or Identity data to Agents, models, or durable sinks unless that transmission and retention are explicitly approved.
3133
- Plaid Sandbox is useful for contract testing but does not reproduce all Production institution behavior. Product access, optional fields, consent, and institution-specific errors still need Production validation.
3234
{/* MANUAL-CONTENT-END */}
@@ -48,16 +50,17 @@ Incrementally sync transactions for a linked Item. Omit the cursor on the first
4850

4951
| Parameter | Type | Required | Description |
5052
| --------- | ---- | -------- | ----------- |
51-
| `cursor` | string | No | Cursor from a previous sync \(nextCursor\); omit to start from the beginning |
53+
| `cursor` | string | No | Cursor from a previous sync \(nextCursor\), up to 256 characters; omit to start from the beginning |
5254
| `count` | number | No | Number of updates to fetch per page \(Plaid requires 1-500; default 100\) |
5355
| `accountId` | string | No | Scope the sync \(and cursor\) to a single account ID |
5456
| `includeOriginalDescription` | boolean | No | Include the unmodified original_description from the institution |
55-
| `daysRequested` | number | No | Days of history to request \(Plaid allows 1-730; default 90\). Only applies before Transactions is initialized on the Item |
57+
| `daysRequested` | number | No | Days of history to request \(Plaid allows 1-730; default 90 and Production requests at least 30\). Only applies before Transactions is initialized on the Item |
5658

5759
#### Output
5860

5961
| Parameter | Type | Description |
6062
| --------- | ---- | ----------- |
63+
| `requestId` | string | Unique Plaid request ID for troubleshooting and support |
6164
| `added` | array | Transactions added since the cursor |
6265
|`transaction_id` | string | Unique ID of the transaction |
6366
|`account_id` | string | ID of the account the transaction belongs to |
@@ -161,6 +164,7 @@ List the accounts linked to an Item with their names, types, and balances. Balan
161164

162165
| Parameter | Type | Description |
163166
| --------- | ---- | ----------- |
167+
| `requestId` | string | Unique Plaid request ID for troubleshooting and support |
164168
| `accounts` | array | Accounts linked to the Item |
165169
|`account_id` | string | Unique Plaid account ID |
166170
|`name` | string | Account name |
@@ -195,6 +199,7 @@ Get real-time balances for the accounts linked to an Item. The live institution
195199

196200
| Parameter | Type | Description |
197201
| --------- | ---- | ----------- |
202+
| `requestId` | string | Unique Plaid request ID for troubleshooting and support |
198203
| `accounts` | array | Accounts with refreshed real-time balances |
199204
|`account_id` | string | Unique Plaid account ID |
200205
|`name` | string | Account name |
@@ -228,6 +233,7 @@ Get account-holder identity information (names, emails, phone numbers, and addre
228233

229234
| Parameter | Type | Description |
230235
| --------- | ---- | ----------- |
236+
| `requestId` | string | Unique Plaid request ID for troubleshooting and support |
231237
| `accounts` | array | Accounts with their owners identity data |
232238
|`account_id` | string | Unique Plaid account ID |
233239
|`name` | string | Account name |
@@ -279,6 +285,7 @@ Get account and routing numbers for depository accounts linked to an Item (ACH f
279285

280286
| Parameter | Type | Description |
281287
| --------- | ---- | ----------- |
288+
| `requestId` | string | Unique Plaid request ID for troubleshooting and support |
282289
| `accounts` | array | Depository accounts on the Item |
283290
|`account_id` | string | Unique Plaid account ID |
284291
|`name` | string | Account name |
@@ -330,6 +337,7 @@ Get metadata and health status for a linked Item, including its institution, ena
330337

331338
| Parameter | Type | Description |
332339
| --------- | ---- | ----------- |
340+
| `requestId` | string | Unique Plaid request ID for troubleshooting and support |
333341
| `item` | object | Item metadata |
334342
|`item_id` | string | Unique ID of the Item |
335343
|`institution_id` | string | Plaid institution ID the Item is linked to |
@@ -380,6 +388,7 @@ Search financial institutions supported by Plaid by name, returning at most 10
380388

381389
| Parameter | Type | Description |
382390
| --------- | ---- | ----------- |
391+
| `requestId` | string | Unique Plaid request ID for troubleshooting and support |
383392
| `institutions` | array | Institutions matching the search |
384393
|`institution_id` | string | Unique Plaid institution ID |
385394
|`name` | string | Institution name |
@@ -388,7 +397,7 @@ Search financial institutions supported by Plaid by name, returning at most 10
388397
|`url` | string | Institution website URL |
389398
|`primary_color` | string | Institution brand color \(hex\) |
390399
|`routing_numbers` | array | Known routing numbers for the institution |
391-
|`oauth` | boolean | Whether the institution uses an OAuth login flow |
400+
|`oauth` | boolean | Whether some Items may require OAuth or the institution may be migrating to OAuth |
392401
| `count` | number | Number of institutions returned |
393402

394403
### Plaid Get Institution
@@ -406,6 +415,7 @@ Get details for a financial institution by its Plaid institution ID
406415

407416
| Parameter | Type | Description |
408417
| --------- | ---- | ----------- |
418+
| `requestId` | string | Unique Plaid request ID for troubleshooting and support |
409419
| `institution` | object | Institution details |
410420
|`institution_id` | string | Unique Plaid institution ID |
411421
|`name` | string | Institution name |
@@ -414,6 +424,6 @@ Get details for a financial institution by its Plaid institution ID
414424
|`url` | string | Institution website URL |
415425
|`primary_color` | string | Institution brand color \(hex\) |
416426
|`routing_numbers` | array | Known routing numbers for the institution |
417-
|`oauth` | boolean | Whether the institution uses an OAuth login flow |
427+
|`oauth` | boolean | Whether some Items may require OAuth or the institution may be migrating to OAuth |
418428

419429

apps/sim/app/api/tools/plaid/options/route.test.ts

Lines changed: 110 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,22 @@
22
* @vitest-environment node
33
*/
44
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
56
import { beforeEach, describe, expect, it, vi } from 'vitest'
67

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

9-
vi.mock('@/lib/credentials/application/list-plaid-options', async () => {
10-
const { credentialOperations } = await vi.importActual<
11-
typeof import('@/lib/credentials/application/operations')
12-
>('@/lib/credentials/application/operations')
13-
return {
14-
listPlaidOptions: {
15-
operation: credentialOperations.read,
16-
execute: mockExecute,
17-
},
18-
}
19-
})
10+
vi.mock('@/lib/credentials/application/list-plaid-options', () => ({
11+
listPlaidOptions: {
12+
operation: { id: 'credentials.read' },
13+
execute: mockExecute,
14+
},
15+
}))
2016

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

2422
const body = {
2523
kind: 'accounts',
@@ -31,6 +29,19 @@ function request(requestBody: unknown = body, headers: Record<string, string> =
3129
return createMockRequest('POST', requestBody, headers)
3230
}
3331

32+
function rawRequest(
33+
requestBody: string,
34+
signal?: AbortSignal,
35+
headers: Record<string, string> = {}
36+
) {
37+
return new NextRequest('http://localhost:3000/api/tools/plaid/options', {
38+
method: 'POST',
39+
headers: { 'content-type': 'application/json', ...headers },
40+
body: requestBody,
41+
signal,
42+
})
43+
}
44+
3445
describe('POST /api/tools/plaid/options', () => {
3546
beforeEach(() => {
3647
vi.clearAllMocks()
@@ -82,6 +93,33 @@ describe('POST /api/tools/plaid/options', () => {
8293
expect(mockExecute).not.toHaveBeenCalled()
8394
})
8495

96+
it('rejects invalid JSON before application execution', async () => {
97+
const response = await POST(rawRequest('{"kind":'))
98+
99+
expect(response.status).toBe(400)
100+
await expect(response.json()).resolves.toMatchObject({
101+
error: 'Request body must be valid JSON',
102+
})
103+
expect(mockExecute).not.toHaveBeenCalled()
104+
})
105+
106+
it('rejects an actual body larger than the Plaid selector-route ceiling', async () => {
107+
const response = await POST(
108+
rawRequest(
109+
JSON.stringify({
110+
...body,
111+
padding: 'x'.repeat(PLAID_OPTIONS_REQUEST_MAX_BYTES),
112+
})
113+
)
114+
)
115+
116+
expect(response.status).toBe(413)
117+
await expect(response.json()).resolves.toMatchObject({
118+
error: `Request body exceeds the maximum allowed size of ${PLAID_OPTIONS_REQUEST_MAX_BYTES} bytes`,
119+
})
120+
expect(mockExecute).not.toHaveBeenCalled()
121+
})
122+
85123
it('accepts long provider identifiers within the route byte ceiling', async () => {
86124
const query = 'x'.repeat(10_001)
87125
const response = await POST(
@@ -110,4 +148,65 @@ describe('POST /api/tools/plaid/options', () => {
110148
expect(response.status).toBe(status)
111149
expect(JSON.stringify(await response.json())).not.toContain('item-token')
112150
})
151+
152+
it('preserves sanitized Plaid provider errors', async () => {
153+
mockExecute.mockRejectedValueOnce(
154+
new PlaidProviderError(400, {
155+
error_code: 'ITEM_LOGIN_REQUIRED',
156+
error_type: 'ITEM_ERROR',
157+
request_id: 'plaid-request-1',
158+
})
159+
)
160+
161+
const response = await POST(request())
162+
163+
expect(response.status).toBe(400)
164+
await expect(response.json()).resolves.toMatchObject({
165+
error_code: 'ITEM_LOGIN_REQUIRED',
166+
error_type: 'ITEM_ERROR',
167+
request_id: 'plaid-request-1',
168+
})
169+
})
170+
171+
it('projects Plaid gateway failures as a safe 502 response', async () => {
172+
mockExecute.mockRejectedValueOnce(new PlaidGatewayError('Plaid request timed out'))
173+
174+
const response = await POST(request())
175+
176+
expect(response.status).toBe(502)
177+
await expect(response.json()).resolves.toMatchObject({ error: 'Plaid request timed out' })
178+
})
179+
180+
it('returns 499 when selector execution fails after the client disconnects', async () => {
181+
const controller = new AbortController()
182+
mockExecute.mockImplementationOnce(async () => {
183+
controller.abort()
184+
throw new DOMException('The operation was aborted.', 'AbortError')
185+
})
186+
187+
const response = await POST(rawRequest(JSON.stringify(body), controller.signal))
188+
189+
expect(response.status).toBe(499)
190+
await expect(response.json()).resolves.toMatchObject({ error: 'Client cancelled request' })
191+
})
192+
193+
it('fails closed on an unexpected selector error', async () => {
194+
mockExecute.mockRejectedValueOnce(new Error('item-token-should-not-leak'))
195+
196+
const response = await POST(request())
197+
198+
expect(response.status).toBe(500)
199+
const responseBody = await response.json()
200+
expect(responseBody).toMatchObject({ error: 'Internal server error' })
201+
expect(JSON.stringify(responseBody)).not.toContain('item-token-should-not-leak')
202+
})
203+
204+
it('fails closed when selector output violates the response contract', async () => {
205+
mockExecute.mockResolvedValueOnce({ options: [{ id: '', label: '' }] })
206+
207+
const response = await POST(request())
208+
209+
expect(response.status).toBe(500)
210+
await expect(response.json()).resolves.toMatchObject({ error: 'Internal server error' })
211+
})
113212
})

apps/sim/app/api/tools/plaid/options/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { plaidOptionsContract } from '@/lib/api/contracts/selectors/plaid'
1+
import {
2+
PLAID_OPTIONS_REQUEST_MAX_BYTES,
3+
plaidOptionsContract,
4+
} from '@/lib/api/contracts/selectors/plaid'
25
import {
36
defineInternalJsonRoute,
47
internalRateLimits,
@@ -16,7 +19,7 @@ export const POST = defineInternalJsonRoute({
1619
operation: credentialOperations.read,
1720
rateLimit: internalRateLimits.none({ reason: 'Bounded editor selector request' }),
1821
errorPolicy: plaidErrorPolicy,
19-
parseOptions: { maxBodyBytes: 64 * 1024 },
22+
parseOptions: { maxBodyBytes: PLAID_OPTIONS_REQUEST_MAX_BYTES },
2023
mapInput: ({ body }, { request }) => ({ body, signal: request.signal }),
2124
useCase: listPlaidOptions,
2225
})

0 commit comments

Comments
 (0)