Skip to content

Commit f1fee47

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(plaid): keep credentials inside app boundary
1 parent b86c604 commit f1fee47

44 files changed

Lines changed: 949 additions & 496 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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1717

1818
1. In the [Plaid Dashboard](https://dashboard.plaid.com/), copy the application **client ID** and the secret for the environment you will use.
1919
2. Create the Item through Plaid Link in your application and exchange its public token on your server. Plaid public tokens expire after 30 minutes. The resulting Item access token is long-lived until it is revoked or rotated and must not be embedded in client-side application code or stored in workflow state. Enter it only in Sim's credential form, which sends it to the authenticated credential API for validation and encryption and does not return it. For Sandbox testing, create and exchange a Sandbox public token through Plaid's server-side Sandbox API.
20-
3. Add a Plaid block, open **Plaid Item**, and create a credential with the environment, client ID, matching secret, and Item access token. Sim verifies the values with Plaid `/item/get`, encrypts them, and never returns them from a Plaid action. Create one credential per Item.
20+
3. Connect a Plaid Item from **Integrations**, or add a Plaid block and open **Plaid Item**. Enter the environment, client ID, matching secret, and Item access token. Sim verifies the values with Plaid `/item/get`, encrypts them, and gives workflows only the credential's opaque ID. Create one credential per Item.
2121

2222
## Usage notes
2323

2424
- Select the stored Plaid Item once per block. Reconnect the credential after rotating the Plaid access token or environment secret; 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
- 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.
26-
- Institution search returns at most ten matches. Use Search Institutions, then paste the selected `institution_id` into Get Institution. Account filters are optional and default to all accounts on the Item.
26+
- 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.
27+
- 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.
2728
- 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.
2829
- Get Auth returns full account and routing identifiers for downstream payment steps. Sim hides the `numbers` field from execution-log display; do not write it to tables, files, messages, or other durable outputs.
2930
- 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.

apps/docs/openapi-v2-resources.json

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4852,18 +4852,6 @@
48524852
"minLength": 1,
48534853
"maxLength": 1024
48544854
},
4855-
"accessToken": {
4856-
"description": "Write-only provider access token.",
4857-
"writeOnly": true,
4858-
"type": "string",
4859-
"minLength": 1,
4860-
"maxLength": 8192
4861-
},
4862-
"environment": {
4863-
"description": "Provider environment.",
4864-
"type": "string",
4865-
"enum": ["production", "sandbox"]
4866-
},
48674855
"certificateId": {
48684856
"description": "Provider certificate mapping identifier.",
48694857
"type": "string",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { extendInternalErrorPolicy, internalErrorResponse } from '@/lib/api/server/routes'
2+
import { internalCredentialDetailErrorPolicy } from '@/lib/credentials/api/route-policies'
3+
import { PlaidGatewayError, PlaidProviderError } from '@/tools/plaid/utils.server'
4+
5+
export const plaidErrorPolicy = extendInternalErrorPolicy(
6+
internalCredentialDetailErrorPolicy,
7+
(error) => {
8+
if (error instanceof PlaidProviderError) {
9+
return internalErrorResponse(error.status, error.body)
10+
}
11+
if (error instanceof PlaidGatewayError) {
12+
return internalErrorResponse(502, { error: error.message })
13+
}
14+
return null
15+
}
16+
)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() }))
8+
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+
})
20+
21+
import { OrchestrationError } from '@/lib/core/orchestration/types'
22+
import { POST } from '@/app/api/tools/plaid/options/route'
23+
24+
const body = {
25+
kind: 'accounts',
26+
workspaceId: 'workspace-1',
27+
credentialId: 'credential-1',
28+
} as const
29+
30+
function request(requestBody: unknown = body, headers: Record<string, string> = {}) {
31+
return createMockRequest('POST', requestBody, headers)
32+
}
33+
34+
describe('POST /api/tools/plaid/options', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
authMockFns.mockGetSession.mockResolvedValue({
38+
user: { id: 'user-1' },
39+
session: { id: 'session-1' },
40+
})
41+
mockExecute.mockResolvedValue({ options: [{ id: 'acc-1', label: 'Checking' }] })
42+
})
43+
44+
it('accepts a session and forwards only selector scope plus cancellation', async () => {
45+
const incoming = request()
46+
const response = await POST(incoming)
47+
48+
expect(response.status).toBe(200)
49+
await expect(response.json()).resolves.toEqual({
50+
options: [{ id: 'acc-1', label: 'Checking' }],
51+
})
52+
expect(mockExecute).toHaveBeenCalledWith(
53+
expect.objectContaining({
54+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
55+
input: { body, signal: incoming.signal },
56+
request: incoming,
57+
})
58+
)
59+
})
60+
61+
it('rejects unauthenticated and API-key callers', async () => {
62+
authMockFns.mockGetSession.mockResolvedValue(null)
63+
expect((await POST(request())).status).toBe(401)
64+
expect((await POST(request(body, { 'x-api-key': 'key' }))).status).toBe(401)
65+
expect(mockExecute).not.toHaveBeenCalled()
66+
})
67+
68+
it('rejects malformed and overlong selector requests before execution', async () => {
69+
expect((await POST(request({ ...body, unexpected: true }))).status).toBe(400)
70+
expect(
71+
(
72+
await POST(
73+
request({
74+
...body,
75+
kind: 'institution_search',
76+
query: 'x'.repeat(257),
77+
country_codes: ['US'],
78+
})
79+
)
80+
).status
81+
).toBe(400)
82+
expect(mockExecute).not.toHaveBeenCalled()
83+
})
84+
85+
it.each([
86+
[new OrchestrationError('not_found', 'Credential not found'), 404],
87+
[new OrchestrationError('forbidden', 'Credential access required'), 403],
88+
])('projects credential access failures', async (error, status) => {
89+
mockExecute.mockRejectedValueOnce(error)
90+
const response = await POST(request())
91+
expect(response.status).toBe(status)
92+
expect(JSON.stringify(await response.json())).not.toContain('item-token')
93+
})
94+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { plaidOptionsContract } from '@/lib/api/contracts/selectors/plaid'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { listPlaidOptions } from '@/lib/credentials/application/list-plaid-options'
8+
import { credentialOperations } from '@/lib/credentials/application/operations'
9+
import { plaidErrorPolicy } from '@/app/api/tools/plaid/error-policy'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
export const POST = defineInternalJsonRoute({
14+
contract: plaidOptionsContract,
15+
auth: internalSessionAuth,
16+
operation: credentialOperations.read,
17+
rateLimit: internalRateLimits.none({ reason: 'Bounded editor selector request' }),
18+
errorPolicy: plaidErrorPolicy,
19+
parseOptions: { maxBodyBytes: 64 * 1024 },
20+
mapInput: ({ body }, { request }) => ({ body, signal: request.signal }),
21+
useCase: listPlaidOptions,
22+
})

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ const WORKSPACE_ID = '550e8400-e29b-41d4-a716-446655440000'
4141
const body = {
4242
operation: 'plaid_get_item',
4343
credentialId: 'credential-1',
44-
accessToken: 'item-token',
4544
input: {},
4645
} as const
4746
let delegationToken = ''
@@ -145,6 +144,20 @@ describe('POST /api/tools/plaid', () => {
145144
expect(mockExecute).not.toHaveBeenCalled()
146145
})
147146

147+
it('accepts RFC3339 balance timestamps with a numeric offset', async () => {
148+
const offsetBody = {
149+
operation: 'plaid_get_balances',
150+
credentialId: 'credential-1',
151+
input: { min_last_updated_datetime: '2026-08-18T12:30:00-07:00' },
152+
} as const
153+
const response = await POST(request(offsetBody))
154+
155+
expect(response.status).toBe(200)
156+
expect(mockExecute).toHaveBeenCalledWith(
157+
expect.objectContaining({ input: expect.objectContaining({ body: offsetBody }) })
158+
)
159+
})
160+
148161
it.each([
149162
[
150163
'wrong workspace or provider',
@@ -156,12 +169,11 @@ describe('POST /api/tools/plaid', () => {
156169
new OrchestrationError('forbidden', 'Credential access required'),
157170
403,
158171
],
159-
['token mismatch', new OrchestrationError('forbidden', 'Credential token does not match'), 403],
160172
])('projects %s without exposing secrets', async (_label, error, status) => {
161173
mockExecute.mockRejectedValueOnce(error)
162174
const response = await POST(request())
163175
expect(response.status).toBe(status)
164-
expect(JSON.stringify(await response.json())).not.toContain('item-token')
176+
expect(JSON.stringify(await response.json())).not.toContain('client-secret')
165177
})
166178

167179
it('preserves Plaid provider status and error fields', async () => {

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

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,13 @@ import { plaidOperationContract } from '@/lib/api/contracts/tools/plaid'
44
import {
55
createInternalSessionOrExecutorAuth,
66
defineInternalJsonRoute,
7-
extendInternalErrorPolicy,
87
InternalUnauthenticatedError,
9-
internalErrorResponse,
108
internalRateLimits,
119
} from '@/lib/api/server/routes'
12-
import { internalCredentialDetailErrorPolicy } from '@/lib/credentials/api/route-policies'
1310
import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization'
1411
import { credentialOperations } from '@/lib/credentials/application/operations'
1512
import { usePlaidServiceAccount } from '@/lib/credentials/application/use-plaid-service-account'
16-
import { PlaidGatewayError, PlaidProviderError } from '@/tools/plaid/utils.server'
13+
import { plaidErrorPolicy } from '@/app/api/tools/plaid/error-policy'
1714

1815
export const dynamic = 'force-dynamic'
1916

@@ -34,16 +31,6 @@ const plaidExecutorAuth = {
3431
},
3532
}
3633

37-
const plaidErrorPolicy = extendInternalErrorPolicy(internalCredentialDetailErrorPolicy, (error) => {
38-
if (error instanceof PlaidProviderError) {
39-
return internalErrorResponse(error.status, error.body)
40-
}
41-
if (error instanceof PlaidGatewayError) {
42-
return internalErrorResponse(502, { error: error.message })
43-
}
44-
return null
45-
})
46-
4734
export const POST = defineInternalJsonRoute({
4835
contract: plaidOperationContract,
4936
auth: plaidExecutorAuth,

apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/plaid-service-account-modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export function PlaidServiceAccountModal({
123123
credentialId,
124124
...secretFields,
125125
displayName: submittedDisplayName,
126-
description: description.trim() || undefined,
126+
description: description.trim() || null,
127127
})
128128
onCreated?.(credentialId)
129129
} else {

0 commit comments

Comments
 (0)