From 071e480e941cfaf03c1918b27cfa6602b5a0a057 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 14 Jul 2026 14:04:36 +0100 Subject: [PATCH 1/7] feat(bridge): switch Plaid linking to link_token + public_token exchange Replace hosted linkUrl with Bridge plaid_link_requests and a server-side bridgeExchangePlaidPublicToken mutation so Api-Key never leaves the backend. --- dev/apollo-federation/supergraph.graphql | 75 ++++++++++++++++++- docs/bridge-integration/API.md | 32 +++++++- docs/bridge-integration/FLOWS.md | 71 ++++++++++-------- src/domain/api-keys/scope-map.ts | 1 + src/graphql/error-map.ts | 7 ++ src/graphql/public/mutations.ts | 2 + .../bridge-exchange-plaid-public-token.ts | 48 ++++++++++++ src/graphql/public/schema.graphql | 13 +++- ...ridge-exchange-plaid-public-token-input.ts | 11 +++ .../object/bridge-external-account-link.ts | 2 +- src/services/bridge/client.ts | 41 ++++++++++ src/services/bridge/errors.ts | 6 ++ src/services/bridge/index.ts | 74 ++++++++++++++++-- test/flash/bridge-sandbox-e2e/README.md | 2 +- .../external-account.spec.ts | 25 +++---- test/flash/bridge-sandbox-e2e/helpers.ts | 4 +- .../unit/graphql/bridge-error-map.spec.ts | 2 + 17 files changed, 356 insertions(+), 60 deletions(-) create mode 100644 src/graphql/public/root/mutation/bridge-exchange-plaid-public-token.ts create mode 100644 src/graphql/public/types/input/bridge-exchange-plaid-public-token-input.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 663f4f3e1..8fbcf81cc 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -384,6 +384,11 @@ type BankAccount """ERPNext bank account identifier""" id: ID isDefault: Boolean! + + """ + The account's in-flight update request when it needs the user's attention — Pending (awaiting review) or Rejected (declined). Null once approved/closed, or when none exists. + """ + pendingUpdate: BankAccountUpdateRequest } input BankAccountInput @@ -396,6 +401,58 @@ input BankAccountInput currency: String! } +""" +A pending request to change the details of an approved bank account, awaiting admin review. +""" +type BankAccountUpdateRequest + @join__type(graph: PUBLIC) +{ + """Proposed new account number""" + accountNumber: String! + + """Proposed new account type""" + accountType: String! + + """Proposed new bank branch""" + bankBranch: String! + + """Proposed new bank name""" + bankName: String! + + """Account currency (unchanged from the current account)""" + currency: String! + + """Reviewer note, set when status is Rejected""" + rejectionReason: String + + """Pending | Approved | Rejected | Closed""" + status: String! +} + +input BankAccountUpdateRequestInput + @join__type(graph: PUBLIC) +{ + accountNumber: AccountNumber! + accountType: String! + + """ERPNext identifier of the account to update""" + bankAccountId: ID! + bankBranch: String! + bankName: String! + + """Must match the account's current currency (currency is locked)""" + currency: String! +} + +type BankAccountUpdateRequestPayload + @join__type(graph: PUBLIC) +{ + errors: [Error] + + """Status of the created request (Pending on success)""" + status: String +} + type BridgeAddExternalAccountPayload @join__type(graph: PUBLIC) { @@ -458,6 +515,20 @@ type BridgeDeleteExternalAccountPayload externalAccount: BridgeExternalAccount } +input BridgeExchangePlaidPublicTokenInput + @join__type(graph: PUBLIC) +{ + linkToken: String! + publicToken: String! +} + +type BridgeExchangePlaidPublicTokenPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + message: String +} + type BridgeExternalAccount @join__type(graph: PUBLIC) { @@ -472,7 +543,7 @@ type BridgeExternalAccountLink @join__type(graph: PUBLIC) { expiresAt: String! - linkUrl: String! + linkToken: String! } input BridgeInitiateKycInput @@ -1486,11 +1557,13 @@ type Mutation Rotate an API key: a replacement with a new secret (and keyId) is created with the same name, scopes, and expiry, and the old key is revoked. The new raw key is only shown once. """ apiKeyRotate(input: ApiKeyRotateInput!): ApiKeyRotatePayload! + bankAccountUpdateRequest(input: BankAccountUpdateRequestInput!): BankAccountUpdateRequestPayload! bridgeAddExternalAccount: BridgeAddExternalAccountPayload! bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! bridgeDeleteExternalAccount(input: BridgeDeleteExternalAccountInput!): BridgeDeleteExternalAccountPayload! + bridgeExchangePlaidPublicToken(input: BridgeExchangePlaidPublicTokenInput!): BridgeExchangePlaidPublicTokenPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md index 65211ab37..a0dd7809e 100644 --- a/docs/bridge-integration/API.md +++ b/docs/bridge-integration/API.md @@ -60,7 +60,7 @@ mutation BridgeCreateVirtualAccount { ### `bridgeAddExternalAccount` -Returns a hosted URL for the user to link their external bank account (via Plaid/Bridge). +Returns a Plaid `linkToken` so the app can open the Plaid Link SDK and connect a bank account via Bridge. **Request:** ```graphql @@ -70,7 +70,7 @@ mutation BridgeAddExternalAccount { message } externalAccount { - linkUrl + linkToken expiresAt } } @@ -78,8 +78,32 @@ mutation BridgeAddExternalAccount { ``` **Response:** -- `linkUrl`: URL to the bank linking flow. -- `expiresAt`: Expiration timestamp for the link. +- `linkToken`: Plaid Link token for the client SDK. +- `expiresAt`: Expiration timestamp for the token. + +--- + +### `bridgeExchangePlaidPublicToken` + +Exchanges the Plaid `publicToken` (from Link `onSuccess`) with Bridge. Call from the app backend path only — Flash keeps the Bridge Api-Key server-side. External accounts are created asynchronously and arrive via webhook. + +**Request:** +```graphql +mutation BridgeExchangePlaidPublicToken( + $input: BridgeExchangePlaidPublicTokenInput! +) { + bridgeExchangePlaidPublicToken(input: $input) { + errors { + message + } + message + } +} +``` + +**Input:** +- `linkToken`: Same token returned by `bridgeAddExternalAccount`. +- `publicToken`: Token from Plaid Link `onSuccess`. --- diff --git a/docs/bridge-integration/FLOWS.md b/docs/bridge-integration/FLOWS.md index b1b8e7a40..48d992fce 100644 --- a/docs/bridge-integration/FLOWS.md +++ b/docs/bridge-integration/FLOWS.md @@ -70,7 +70,7 @@ This flow allows users to withdraw USDT from their Flash wallet to their externa ```ascii User Flash App Flash Backend Bridge.xyz Bank - | (Check for KYC, if complete, skip to 12. | | + | (Check for KYC, if complete, skip to Link Bank.) | | | 1. Start KYC | | | | |----------------->| 2. bridgeInitKyc | | | | |------------------>| 3. Create Customer | | @@ -86,33 +86,41 @@ User Flash App Flash Backend Bridge.xyz | | |<--------------------| | | 8. Link Bank | | | | |----------------->| 9. bridgeAddExtAcc| | | - | |------------------>| 10. Get Link URL | | + | |------------------>| 10. plaid_link_reqs | | | | |-------------------->| | - | | 11. Link URL | | | + | | 11. linkToken | | | | |<------------------| | | - | 12. Auth Bank | | | | + | 12. Plaid Link | | | | + | SDK / Auth | | | | |----------------->| | | | | (Plaid Flow) | | | | - | | | 13. ext_acc.verified| | + | | 13. bridgeExchange| | | + | | PlaidPublicTok| 14. Exchange public | | + | |------------------>| token | | + | | |-------------------->| | + | | 15. Exchange OK | | | + | |<------------------| | | + | | | 16. ext_acc created | | + | | | (async webhook) | | | | |<--------------------| | - | 14. Withdraw | | | | - |----------------->| 15. bridgeRequest | | | + | 17. Withdraw | | | | + |----------------->| 18. bridgeRequest | | | | | Withdrawal | | | - | |------------------>| 16. Store pending | | - | | 17. Confirm screen| withdrawal | | + | |------------------>| 19. Store pending | | + | | 20. Confirm screen| withdrawal | | | |<------------------| | | - | 18. Confirm | | | | - |----------------->| 19. bridgeInitWith| | | - | | (withdrawalId)| 20. Create Transfer | | + | 21. Confirm | | | | + |----------------->| 22. bridgeInitWith| | | + | | (withdrawalId)| 23. Create Transfer | | | |------------------>|-------------------->| | - | | 21. Pending | | | + | | 24. Pending | | | |<-----------------| | | | - | | | | 22. Convert USDT | - | | | | 23. Send ACH | + | | | | 25. Convert USDT | + | | | | 26. Send ACH | | | | |------------------>| - | | | 24. trans.completed | | + | | | 27. trans.completed | | | | |<--------------------| | - | 25. Funds Arrive | | | | + | 28. Funds Arrive | | | | |<-------------------------------------------------------------------------------| ``` @@ -120,22 +128,23 @@ User Flash App Flash Backend Bridge.xyz 1. **Link Bank**: User chooses to add a bank account. 2. **GraphQL Mutation**: App calls `bridgeAddExternalAccount`. -3. **Link URL**: Flash requests a hosted link URL from Bridge. -4. **Redirect**: App opens the Bridge/Plaid flow. -5. **Authentication**: User logs into their bank and selects an account. -6. **Verification Webhook**: Bridge notifies Flash when the external account is verified. -7. **Request Withdrawal**: User enters amount and selects the linked bank account. -8. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`. -9. **Validation**: Flash checks USDT balance, account level, and external account ownership/verification. A `pending` withdrawal record is stored in MongoDB. If an identical pending request already exists (same account, amount, and bank account), the existing record is reused. -10. **Confirmation Screen**: App fetches the pending withdrawal via `bridgeWithdrawalRequest(id)` and displays amount, bank account, and fees for user review. -11. **User Confirms or Cancels**: +3. **Link Token**: Flash requests a Plaid `link_token` from Bridge (`POST …/plaid_link_requests`) and returns `{ linkToken, expiresAt }`. +4. **Plaid Link SDK**: App opens Plaid Link with `linkToken` (not a hosted Bridge URL). +5. **Authentication**: User logs into their bank and selects an account; Plaid returns a `publicToken` via `onSuccess`. +6. **Exchange Mutation**: App calls `bridgeExchangePlaidPublicToken` with `linkToken` + `publicToken`. Flash exchanges the token with Bridge server-side (Api-Key never leaves the backend). +7. **Verification Webhook**: Bridge creates External Accounts asynchronously and notifies Flash (`external_account` webhook). App may poll `bridgeExternalAccounts` until the bank appears as verified. +8. **Request Withdrawal**: User enters amount and selects the linked bank account. +9. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`. +10. **Validation**: Flash checks USDT balance, account level, and external account ownership/verification. A `pending` withdrawal record is stored in MongoDB. If an identical pending request already exists (same account, amount, and bank account), the existing record is reused. +11. **Confirmation Screen**: App fetches the pending withdrawal via `bridgeWithdrawalRequest(id)` and displays amount, bank account, and fees for user review. +12. **User Confirms or Cancels**: - **Confirm**: App calls `bridgeInitiateWithdrawal` with `withdrawalId`. Flash re-checks balance, then creates a transfer in Bridge from the user's Ethereum USDT address to the external account. - **Cancel**: App calls `bridgeCancelWithdrawalRequest` with `withdrawalId`. The pending record is marked `cancelled` and a push notification is sent. -12. **Pending State**: After initiation, app shows the withdrawal as "Pending". -13. **Conversion**: Bridge converts USDT from the user's balance to USD. -14. **ACH Transfer**: Bridge sends USD to the user's bank via ACH. -15. **Transfer Webhook**: Bridge sends `transfer.completed` (or failure) webhook to Flash. -16. **Completion**: User receives funds in their bank account (usually 1-3 business days). +13. **Pending State**: After initiation, app shows the withdrawal as "Pending". +14. **Conversion**: Bridge converts USDT from the user's balance to USD. +15. **ACH Transfer**: Bridge sends USD to the user's bank via ACH. +16. **Transfer Webhook**: Bridge sends `transfer.completed` (or failure) webhook to Flash. +17. **Completion**: User receives funds in their bank account (usually 1-3 business days). ## Fee Structure diff --git a/src/domain/api-keys/scope-map.ts b/src/domain/api-keys/scope-map.ts index 0ea01c591..a5649721a 100644 --- a/src/domain/api-keys/scope-map.ts +++ b/src/domain/api-keys/scope-map.ts @@ -48,6 +48,7 @@ export const apiKeyScopeForField: Readonly> = bridgeInitiateKyc: "BLOCKED", bridgeCreateVirtualAccount: "BLOCKED", bridgeAddExternalAccount: "BLOCKED", + bridgeExchangePlaidPublicToken: "BLOCKED", bridgeCreateExternalAccount: "BLOCKED", bridgeSetDefaultExternalAccount: "BLOCKED", bridgeDeleteExternalAccount: "BLOCKED", diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 1905dfd8c..0e7b37169 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -662,6 +662,13 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message, }) + case "BridgeInvalidPlaidTokenError": + message = error.message || "linkToken and publicToken are required" + return bridgeGqlError({ + code: "BRIDGE_INVALID_PLAID_TOKEN", + message, + }) + case "BridgeError": message = error.message || "Bridge API error" return bridgeGqlError({ code: "BRIDGE_ERROR", message }) diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index a02f23055..b43216a57 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -65,6 +65,7 @@ import UpdateExternalWalletMutation from "./root/mutation/update-external-wallet import BridgeInitiateKycMutation from "./root/mutation/bridge-initiate-kyc" import BridgeCreateVirtualAccountMutation from "./root/mutation/bridge-create-virtual-account" import BridgeAddExternalAccountMutation from "./root/mutation/bridge-add-external-account" +import BridgeExchangePlaidPublicTokenMutation from "./root/mutation/bridge-exchange-plaid-public-token" import BridgeCreateExternalAccountMutation from "./root/mutation/bridge-create-external-account" import BridgeSetDefaultExternalAccountMutation from "./root/mutation/bridge-set-default-external-account" import BridgeDeleteExternalAccountMutation from "./root/mutation/bridge-delete-external-account" @@ -133,6 +134,7 @@ export const mutationFields = { bridgeInitiateKyc: BridgeInitiateKycMutation, bridgeCreateVirtualAccount: BridgeCreateVirtualAccountMutation, bridgeAddExternalAccount: BridgeAddExternalAccountMutation, + bridgeExchangePlaidPublicToken: BridgeExchangePlaidPublicTokenMutation, bridgeCreateExternalAccount: BridgeCreateExternalAccountMutation, bridgeSetDefaultExternalAccount: BridgeSetDefaultExternalAccountMutation, bridgeDeleteExternalAccount: BridgeDeleteExternalAccountMutation, diff --git a/src/graphql/public/root/mutation/bridge-exchange-plaid-public-token.ts b/src/graphql/public/root/mutation/bridge-exchange-plaid-public-token.ts new file mode 100644 index 000000000..9741e9275 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-exchange-plaid-public-token.ts @@ -0,0 +1,48 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeExchangePlaidPublicTokenInput from "@graphql/public/types/input/bridge-exchange-plaid-public-token-input" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeExchangePlaidPublicTokenPayload = GT.Object({ + name: "BridgeExchangePlaidPublicTokenPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + message: { type: GT.String }, + }), +}) + +const bridgeExchangePlaidPublicToken = GT.Field({ + type: GT.NonNull(BridgeExchangePlaidPublicTokenPayload), + args: { + input: { type: GT.NonNull(BridgeExchangePlaidPublicTokenInput) }, + }, + resolve: async ( + _, + { input }: { input: { linkToken: string; publicToken: string } }, + { domainAccount }: GraphQLPublicContextAuth, + ) => { + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.exchangePlaidPublicToken( + domainAccount.id, + input.linkToken, + input.publicToken, + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { message: result.message, errors: [] } + }, +}) + +export default bridgeExchangePlaidPublicToken diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index e94c19a1f..3da1cac3c 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -450,6 +450,16 @@ type BridgeDeleteExternalAccountPayload { externalAccount: BridgeExternalAccount } +input BridgeExchangePlaidPublicTokenInput { + linkToken: String! + publicToken: String! +} + +type BridgeExchangePlaidPublicTokenPayload { + errors: [Error!]! + message: String +} + type BridgeExternalAccount { accountNumberLast4: String! bankName: String! @@ -460,7 +470,7 @@ type BridgeExternalAccount { type BridgeExternalAccountLink { expiresAt: String! - linkUrl: String! + linkToken: String! } input BridgeInitiateKycInput { @@ -1227,6 +1237,7 @@ type Mutation { bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! bridgeDeleteExternalAccount(input: BridgeDeleteExternalAccountInput!): BridgeDeleteExternalAccountPayload! + bridgeExchangePlaidPublicToken(input: BridgeExchangePlaidPublicTokenInput!): BridgeExchangePlaidPublicTokenPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! diff --git a/src/graphql/public/types/input/bridge-exchange-plaid-public-token-input.ts b/src/graphql/public/types/input/bridge-exchange-plaid-public-token-input.ts new file mode 100644 index 000000000..ae3e6d004 --- /dev/null +++ b/src/graphql/public/types/input/bridge-exchange-plaid-public-token-input.ts @@ -0,0 +1,11 @@ +import { GT } from "@graphql/index" + +const BridgeExchangePlaidPublicTokenInput = GT.Input({ + name: "BridgeExchangePlaidPublicTokenInput", + fields: () => ({ + linkToken: { type: GT.NonNull(GT.String) }, + publicToken: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeExchangePlaidPublicTokenInput diff --git a/src/graphql/public/types/object/bridge-external-account-link.ts b/src/graphql/public/types/object/bridge-external-account-link.ts index 5d51c664d..f440b2f88 100644 --- a/src/graphql/public/types/object/bridge-external-account-link.ts +++ b/src/graphql/public/types/object/bridge-external-account-link.ts @@ -3,7 +3,7 @@ import { GT } from "@graphql/index" const BridgeExternalAccountLink = GT.Object({ name: "BridgeExternalAccountLink", fields: () => ({ - linkUrl: { type: GT.NonNull(GT.String) }, + linkToken: { type: GT.NonNull(GT.String) }, expiresAt: { type: GT.NonNull(GT.String) }, }), }) diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index 9cf852251..116f01d79 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -201,10 +201,22 @@ export interface ExternalAccount { } export interface ExternalAccountLinkUrl { + /** @deprecated Prefer Plaid Link SDK flow via createPlaidLinkRequest. */ link_url: string + /** @deprecated Prefer Plaid Link SDK flow via createPlaidLinkRequest. */ expires_at: string } +export interface PlaidLinkRequest { + link_token: string + link_token_expires_at: string + callback_url: string +} + +export interface PlaidExchangePublicTokenResponse { + message: string +} + export interface ListResponse { data: T[] has_more: boolean @@ -515,6 +527,10 @@ export class BridgeClient { ) } + /** + * @deprecated Use {@link createPlaidLinkRequest} + {@link exchangePlaidPublicToken} + * (Plaid Link SDK). Hosted `/external_accounts/link` is the legacy Bridge UI path. + */ async getExternalAccountLinkUrl( customerId: BridgeCustomerId, ): Promise { @@ -524,6 +540,31 @@ export class BridgeClient { ) } + // ============ Plaid Link ============ + + async createPlaidLinkRequest( + customerId: BridgeCustomerId, + idempotencyKey?: string, + ): Promise { + return this.request( + "POST", + `/customers/${customerId}/plaid_link_requests`, + undefined, + idempotencyKey ?? crypto.randomUUID(), + ) + } + + async exchangePlaidPublicToken( + linkToken: string, + publicToken: string, + ): Promise { + return this.request( + "POST", + `/plaid_exchange_public_token/${encodeURIComponent(linkToken)}`, + { public_token: publicToken }, + ) + } + async listExternalAccounts( customerId: BridgeCustomerId, ): Promise> { diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index 3166f20f3..e586fa641 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -141,6 +141,12 @@ export class BridgePlaidNotAvailableError extends BridgeError { } } +export class BridgeInvalidPlaidTokenError extends BridgeError { + constructor(message: string = "linkToken and publicToken are required") { + super(message) + } +} + /** * Maps HTTP status codes from Bridge API to domain error types * diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 8cd4f50df..c2180c17c 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -44,6 +44,7 @@ import { BridgeWithdrawalNotFoundError, BridgeWithdrawalAlreadyInitiatedError, BridgePlaidNotAvailableError, + BridgeInvalidPlaidTokenError, BridgeDepositInstructionsMissingError, BridgeWithdrawalNetAmountTooLowError, } from "./errors" @@ -89,10 +90,14 @@ type CreateVirtualAccountResult = { } type AddExternalAccountResult = { - linkUrl: string + linkToken: string expiresAt: string } +type ExchangePlaidPublicTokenResult = { + message: string +} + type WithdrawalRequestResult = PresentedBridgeWithdrawal type InitiateWithdrawalResult = PresentedBridgeWithdrawal @@ -595,7 +600,7 @@ const createVirtualAccount = async ( } /** - * Returns Bridge hosted bank linking URL for adding external accounts + * Requests a Plaid link_token from Bridge for adding external accounts. */ const addExternalAccount = async ( accountId: AccountId, @@ -619,11 +624,11 @@ const addExternalAccount = async ( ) } - const linkUrl = await BridgeApiClient.getExternalAccountLinkUrl(customerId) + const plaid = await BridgeApiClient.createPlaidLinkRequest(customerId) const result: AddExternalAccountResult = { - linkUrl: linkUrl.link_url, - expiresAt: linkUrl.expires_at, + linkToken: plaid.link_token, + expiresAt: plaid.link_token_expires_at, } baseLogger.info( @@ -649,6 +654,64 @@ const addExternalAccount = async ( } } +/** + * Exchanges a Plaid public_token with Bridge after the user completes Link. + * External accounts are created asynchronously and arrive via webhook. + */ +const exchangePlaidPublicToken = async ( + accountId: AccountId, + linkToken: string, + publicToken: string, +): Promise => { + baseLogger.info( + { accountId, operation: "exchangePlaidPublicToken" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + const trimmedLinkToken = linkToken?.trim() ?? "" + const trimmedPublicToken = publicToken?.trim() ?? "" + if (!trimmedLinkToken || !trimmedPublicToken) { + return new BridgeInvalidPlaidTokenError() + } + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const exchanged = await BridgeApiClient.exchangePlaidPublicToken( + trimmedLinkToken, + trimmedPublicToken, + ) + + const result: ExchangePlaidPublicTokenResult = { + message: exchanged.message, + } + + baseLogger.info( + { accountId, operation: "exchangePlaidPublicToken" }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "exchangePlaidPublicToken", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + /** * Creates an external account directly via Bridge API (bypassing Plaid Link). * Used as a fallback when Plaid Link is unavailable. @@ -1678,6 +1741,7 @@ export default wrapAsyncFunctionsToRunInSpan({ initiateKyc, createVirtualAccount, addExternalAccount, + exchangePlaidPublicToken, createExternalAccount, setDefaultExternalAccount, deleteExternalAccount, diff --git a/test/flash/bridge-sandbox-e2e/README.md b/test/flash/bridge-sandbox-e2e/README.md index d85b493bb..b6dc4472a 100644 --- a/test/flash/bridge-sandbox-e2e/README.md +++ b/test/flash/bridge-sandbox-e2e/README.md @@ -101,7 +101,7 @@ IBEX_ENVIRONMENT=sandbox yarn test:bridge-sandbox-e2e | Preflight | Source-code check of `checkAccountLevel()` allows level ≥ 1 | `src/services/bridge/index.ts` guard must be `level < 1`, not `level < 2` | | KYC spec | `bridgeInitiateKyc` returns `{kycLink, tosLink}` URLs | Ensure ENG-345 deployed, sandbox has Bridge customer API set up | | Virtual account | Skipped by default; with `BRIDGE_SANDBOX_VIRTUAL_ACCOUNT_CONFIRMED=true`, `bridgeCreateVirtualAccount` returns account details | Requires a Bridge-side KYC-approved sandbox customer; local webhook injection alone does not approve the hosted Bridge customer | -| External account | Skipped by default; with `BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED=true`, `bridgeAddExternalAccount` returns `{linkUrl, expiresAt}` | Requires Bridge sandbox API key/customer entitlement for hosted bank-linking | +| External account | Skipped by default; with `BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED=true`, `bridgeAddExternalAccount` returns `{linkToken, expiresAt}` | Requires Bridge sandbox API key/customer entitlement for Plaid link-token generation | | Deposit webhook | Injected webhook processes and persists deposit | Verify webhook secret in config.yaml | | Withdrawal error paths | Validation errors returned for invalid inputs | Check withdrawal schema deployed (ENG-348) | | Withdrawal **success** path | ⚠️ **Not expected to pass first run** — requires real KYC-approved sandbox customer, funded wallet, and verified external account. | The full withdrawal flow only runs with `BRIDGE_SANDBOX_WITHDRAWAL_CONFIRMED=true`; error-path tests run without it | diff --git a/test/flash/bridge-sandbox-e2e/external-account.spec.ts b/test/flash/bridge-sandbox-e2e/external-account.spec.ts index 412eefd03..dfb3bb8f9 100644 --- a/test/flash/bridge-sandbox-e2e/external-account.spec.ts +++ b/test/flash/bridge-sandbox-e2e/external-account.spec.ts @@ -6,17 +6,15 @@ * * Verified shapes (from source audit): * - bridgeAddExternalAccount returns - * { errors, externalAccount: { linkUrl: string!, expiresAt: string! } } + * { errors, externalAccount: { linkToken: string!, expiresAt: string! } } * - externalAccountHandler accepts * { event_id, event_object: { id, customer_id, bank_name, last_4, active } } * and returns { status: "success" } or { status: "already_processed" } on 200 * - * ⚠️ Plaid sandbox linking is a manual step — the test generates the link URL + * ⚠️ Plaid sandbox linking is a manual step — the test generates a link_token * and verifies it's well-formed, then simulates the webhook that follows - * successful Plaid linking. The test does NOT automate the Plaid browser UI. - * - * ⚠️ Some sandboxes return "link_url" instead of "linkUrl" — check actual response - * against the configured return shape and update assertions if needed. + * successful Plaid linking. The test does NOT automate the Plaid browser UI + * or bridgeExchangePlaidPublicToken. */ import { @@ -65,18 +63,17 @@ describe("Bridge External Account", () => { ;(EXTERNAL_ACCOUNT_LINK_TESTS ? describe : describe.skip)( "Plaid Link URL Generation", () => { - it("generates a Plaid link URL when called", async () => { + it("generates a Plaid link token when called", async () => { const result = await addExternalAccount(user.accountId) expect(result.errors).toBeDefined() expect(result.errors).toHaveLength(0) expect(result.externalAccount).toBeDefined() - expect(result.externalAccount!.linkUrl).toBeTruthy() - expect(result.externalAccount!.linkUrl).toMatch(/^https:\/\//) + expect(result.externalAccount!.linkToken).toBeTruthy() expect(result.externalAccount!.expiresAt).toBeTruthy() }) - it("link URL is different on each call (one-time use tokens)", async () => { + it("link token is different on each call (one-time use tokens)", async () => { const result1 = await addExternalAccount(user.accountId) const result2 = await addExternalAccount(user.accountId) @@ -84,10 +81,10 @@ describe("Bridge External Account", () => { expect(result2.errors).toHaveLength(0) // Plaid link tokens are one-time use; consecutive calls should differ - expect(result1.externalAccount?.linkUrl).toBeTruthy() - expect(result2.externalAccount?.linkUrl).toBeTruthy() - expect(result1.externalAccount!.linkUrl).not.toBe( - result2.externalAccount!.linkUrl, + expect(result1.externalAccount?.linkToken).toBeTruthy() + expect(result2.externalAccount?.linkToken).toBeTruthy() + expect(result1.externalAccount!.linkToken).not.toBe( + result2.externalAccount!.linkToken, ) }) }, diff --git a/test/flash/bridge-sandbox-e2e/helpers.ts b/test/flash/bridge-sandbox-e2e/helpers.ts index 06ea0c544..cb346290f 100644 --- a/test/flash/bridge-sandbox-e2e/helpers.ts +++ b/test/flash/bridge-sandbox-e2e/helpers.ts @@ -59,7 +59,7 @@ type VirtualAccountResult = GraphQlErrorResponse & { } type ExternalAccountResult = GraphQlErrorResponse & { - externalAccount?: { linkUrl: string; expiresAt: string } + externalAccount?: { linkToken: string; expiresAt: string } } type WithdrawalResult = GraphQlErrorResponse & { @@ -187,7 +187,7 @@ const BRIDGE_ADD_EXTERNAL_ACCOUNT = ` mutation BridgeAddExternalAccount { bridgeAddExternalAccount { errors { message } - externalAccount { linkUrl expiresAt } + externalAccount { linkToken expiresAt } } } ` diff --git a/test/flash/unit/graphql/bridge-error-map.spec.ts b/test/flash/unit/graphql/bridge-error-map.spec.ts index 95b5b7c7a..194237f72 100644 --- a/test/flash/unit/graphql/bridge-error-map.spec.ts +++ b/test/flash/unit/graphql/bridge-error-map.spec.ts @@ -8,6 +8,7 @@ import { BridgeError, BridgeInsufficientFundsError, BridgeInvalidAmountError, + BridgeInvalidPlaidTokenError, BridgeKycOffboardedError, BridgeKycPendingError, BridgeKycRejectedError, @@ -23,6 +24,7 @@ import { describe("error-map: Bridge errors", () => { const cases: Array<[Error, string]> = [ [new BridgeInvalidAmountError(), "BRIDGE_INVALID_AMOUNT"], + [new BridgeInvalidPlaidTokenError(), "BRIDGE_INVALID_PLAID_TOKEN"], [new BridgeBelowMinimumWithdrawalError(10), "BRIDGE_BELOW_MINIMUM_WITHDRAWAL"], [new BridgeDisabledError(), "BRIDGE_DISABLED"], [new BridgeAccountLevelError(), "BRIDGE_ACCOUNT_LEVEL_ERROR"], From bba1dce9a9b4b6b6c39b4471d5c67d0096305dd8 Mon Sep 17 00:00:00 2001 From: Island Bitcoin Date: Tue, 14 Jul 2026 17:53:04 -0700 Subject: [PATCH 2/7] fix(bridge): make linkToken cutover additive + map real Plaid exchange failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the Plaid link_token PR: - Keep the deprecated linkUrl field on BridgeExternalAccountLink (now nullable, served best-effort from the hosted endpoint) so already-shipped mobile clients that select linkUrl don't fail GraphQL validation the moment this deploys. Remove after the fleet is on linkToken. - Map Bridge 4xx rejections on the exchange (expired link_token, already-used public_token, mismatched pair) to BRIDGE_INVALID_PLAID_TOKEN with Bridge's response detail, and 401/403 to BRIDGE_PLAID_NOT_AVAILABLE — consistent with addExternalAccount. 5xx still passes through raw for the alerting path. - Drop the dead idempotencyKey parameter on createPlaidLinkRequest; request() already mints a fresh key per call. - Add unit coverage: token validation/trimming, all exchange error mappings, KYC gate, and the linkUrl compat behavior (9 tests). - Regenerate public SDL + supergraph via yarn write-sdl. Co-Authored-By: Claude Fable 5 --- dev/apollo-federation/supergraph.graphql | 1 + docs/bridge-integration/API.md | 1 + src/graphql/public/schema.graphql | 1 + .../object/bridge-external-account-link.ts | 5 + src/services/bridge/client.ts | 9 +- src/services/bridge/index.ts | 30 +++ .../exchange-plaid-public-token.spec.ts | 237 ++++++++++++++++++ 7 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 8fbcf81cc..b50589828 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -544,6 +544,7 @@ type BridgeExternalAccountLink { expiresAt: String! linkToken: String! + linkUrl: String @deprecated(reason: "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.") } input BridgeInitiateKycInput diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md index a0dd7809e..dc00f1737 100644 --- a/docs/bridge-integration/API.md +++ b/docs/bridge-integration/API.md @@ -80,6 +80,7 @@ mutation BridgeAddExternalAccount { **Response:** - `linkToken`: Plaid Link token for the client SDK. - `expiresAt`: Expiration timestamp for the token. +- `linkUrl` *(deprecated)*: Hosted bank-linking URL, served best-effort so already-shipped clients keep working; may be `null`. New clients must use `linkToken`. --- diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 3da1cac3c..b25627017 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -471,6 +471,7 @@ type BridgeExternalAccount { type BridgeExternalAccountLink { expiresAt: String! linkToken: String! + linkUrl: String @deprecated(reason: "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.") } input BridgeInitiateKycInput { diff --git a/src/graphql/public/types/object/bridge-external-account-link.ts b/src/graphql/public/types/object/bridge-external-account-link.ts index f440b2f88..1fc743cbb 100644 --- a/src/graphql/public/types/object/bridge-external-account-link.ts +++ b/src/graphql/public/types/object/bridge-external-account-link.ts @@ -4,6 +4,11 @@ const BridgeExternalAccountLink = GT.Object({ name: "BridgeExternalAccountLink", fields: () => ({ linkToken: { type: GT.NonNull(GT.String) }, + linkUrl: { + type: GT.String, + deprecationReason: + "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.", + }, expiresAt: { type: GT.NonNull(GT.String) }, }), }) diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index 116f01d79..db4a23aa9 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -542,15 +542,12 @@ export class BridgeClient { // ============ Plaid Link ============ - async createPlaidLinkRequest( - customerId: BridgeCustomerId, - idempotencyKey?: string, - ): Promise { + async createPlaidLinkRequest(customerId: BridgeCustomerId): Promise { + // request() generates a fresh Idempotency-Key per call, so consecutive + // calls mint distinct one-time link tokens. return this.request( "POST", `/customers/${customerId}/plaid_link_requests`, - undefined, - idempotencyKey ?? crypto.randomUUID(), ) } diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index c2180c17c..1076e1e97 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -91,6 +91,7 @@ type CreateVirtualAccountResult = { type AddExternalAccountResult = { linkToken: string + linkUrl: string | null expiresAt: string } @@ -626,8 +627,22 @@ const addExternalAccount = async ( const plaid = await BridgeApiClient.createPlaidLinkRequest(customerId) + // Deployed clients still select the deprecated linkUrl field, so keep + // serving the hosted flow best-effort until the fleet is on linkToken. + let linkUrl: string | null = null + try { + const hosted = await BridgeApiClient.getExternalAccountLinkUrl(customerId) + linkUrl = hosted.link_url + } catch (hostedError) { + baseLogger.warn( + { accountId, operation: "addExternalAccount", error: hostedError }, + "Hosted link URL unavailable; returning linkToken only", + ) + } + const result: AddExternalAccountResult = { linkToken: plaid.link_token, + linkUrl, expiresAt: plaid.link_token_expires_at, } @@ -708,6 +723,21 @@ const exchangePlaidPublicToken = async ( { accountId, operation: "exchangePlaidPublicToken", error }, "Bridge operation failed", ) + + if (error instanceof BridgeApiError) { + if (error.statusCode === 401 || error.statusCode === 403) { + return new BridgePlaidNotAvailableError() + } + // These mean the token pair was rejected — expired link_token, + // already-exchanged public_token, or a mismatched pair. + if ([400, 404, 409, 422].includes(error.statusCode)) { + const detail = (error.response as { message?: string } | null)?.message + return new BridgeInvalidPlaidTokenError( + detail || "Invalid or expired Plaid token — restart bank linking", + ) + } + } + return error instanceof Error ? error : new Error(String(error)) } } diff --git a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts new file mode 100644 index 000000000..6306ac714 --- /dev/null +++ b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts @@ -0,0 +1,237 @@ +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/schema", () => ({ + BridgeVirtualAccount: {}, +})) + +jest.mock("@services/mongoose/wallets", () => ({ + WalletsRepository: jest.fn(), +})) + +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: F }) => fns, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@app/wallets/get-balance-for-wallet", () => ({ + getBalanceForWallet: jest.fn(), +})) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn(), +})) + +jest.mock("@services/kratos", () => ({ + IdentityRepository: jest.fn(), +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: {}, +})) + +jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ + writeBridgeCashoutPending: jest.fn(), +})) + +jest.mock("@services/bridge/client", () => ({ + __esModule: true, + default: { + exchangePlaidPublicToken: jest.fn(), + createPlaidLinkRequest: jest.fn(), + getExternalAccountLinkUrl: jest.fn(), + }, +})) + +import BridgeService from "@services/bridge" +import BridgeApiClient from "@services/bridge/client" +import { AccountsRepository } from "@services/mongoose/accounts" +import { + BridgeApiError, + BridgeInvalidPlaidTokenError, + BridgePlaidNotAvailableError, +} from "@services/bridge/errors" + +const ACCOUNT_ID = "account-001" as AccountId + +const mockAccount = (overrides: Record = {}) => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue({ + id: ACCOUNT_ID, + level: 1, + bridgeCustomerId: "cust_1", + ...overrides, + }), + }) +} + +describe("BridgeService.exchangePlaidPublicToken", () => { + beforeEach(() => { + jest.clearAllMocks() + mockAccount() + }) + + it("rejects empty or whitespace tokens with BridgeInvalidPlaidTokenError", async () => { + for (const [linkToken, publicToken] of [ + ["", "public-ok"], + [" ", "public-ok"], + ["link-ok", ""], + ["link-ok", " "], + ]) { + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + linkToken, + publicToken, + ) + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + } + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + }) + + it("trims tokens before exchanging with Bridge", async () => { + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockResolvedValue({ + message: "ok", + }) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + " link-1 ", + " public-1 ", + ) + + expect(BridgeApiClient.exchangePlaidPublicToken).toHaveBeenCalledWith( + "link-1", + "public-1", + ) + expect(result).toEqual({ message: "ok" }) + }) + + it("maps Bridge 400 (rejected token pair) to BridgeInvalidPlaidTokenError with detail", async () => { + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( + new BridgeApiError("Bridge API error: 400 Bad Request", 400, { + message: "link_token has expired", + }), + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toBe("link_token has expired") + }) + + it("maps Bridge 400 without response detail to a fallback message", async () => { + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( + new BridgeApiError("Bridge API error: 400 Bad Request", 400, null), + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toMatch(/restart bank linking/i) + }) + + it("maps Bridge 401/403 to BridgePlaidNotAvailableError", async () => { + for (const status of [401, 403]) { + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( + new BridgeApiError(`Bridge API error: ${status}`, status, null), + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgePlaidNotAvailableError) + } + }) + + it("passes through Bridge 5xx as the raw error (alerting path)", async () => { + const serverError = new BridgeApiError("Bridge API error: 500", 500, null) + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( + serverError, + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBe(serverError) + }) + + it("requires a Bridge customer ID (KYC) before exchanging", async () => { + mockAccount({ bridgeCustomerId: undefined }) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toMatch(/Complete KYC first/) + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + }) +}) + +describe("BridgeService.addExternalAccount (deprecated linkUrl compat)", () => { + beforeEach(() => { + jest.clearAllMocks() + mockAccount() + ;(BridgeApiClient.createPlaidLinkRequest as jest.Mock).mockResolvedValue({ + link_token: "link-token-1", + link_token_expires_at: "2026-07-15T00:00:00Z", + callback_url: "https://example.test/cb", + }) + }) + + it("returns both linkToken and the hosted linkUrl while clients migrate", async () => { + ;(BridgeApiClient.getExternalAccountLinkUrl as jest.Mock).mockResolvedValue({ + link_url: "https://hosted.bridge.test/link", + expires_at: "2026-07-15T00:00:00Z", + }) + + const result = await BridgeService.addExternalAccount(ACCOUNT_ID) + + expect(result).toEqual({ + linkToken: "link-token-1", + linkUrl: "https://hosted.bridge.test/link", + expiresAt: "2026-07-15T00:00:00Z", + }) + }) + + it("still succeeds with linkUrl null when the hosted endpoint fails", async () => { + ;(BridgeApiClient.getExternalAccountLinkUrl as jest.Mock).mockRejectedValue( + new BridgeApiError("Bridge API error: 404", 404, null), + ) + + const result = await BridgeService.addExternalAccount(ACCOUNT_ID) + + expect(result).toEqual({ + linkToken: "link-token-1", + linkUrl: null, + expiresAt: "2026-07-15T00:00:00Z", + }) + }) +}) From 2bc37afcb62334c09f3933507d8cfba7b3ea594f Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Wed, 15 Jul 2026 18:23:17 +0100 Subject: [PATCH 3/7] fix(bridge): bind Plaid link tokens to the issuing Flash account Persist issued link tokens in Redis with TTL from link_token_expires_at, and reject exchange for unknown, expired, or cross-account tokens so Flash does not act as an unbound Bridge Api-Key proxy. --- docs/bridge-integration/API.md | 2 +- docs/bridge-integration/FLOWS.md | 4 +- src/services/bridge/index.ts | 37 +++++- src/services/bridge/plaid-link-token-store.ts | 77 ++++++++++++ .../exchange-plaid-public-token.spec.ts | 114 +++++++++++++++++- 5 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 src/services/bridge/plaid-link-token-store.ts diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md index dc00f1737..3c919862c 100644 --- a/docs/bridge-integration/API.md +++ b/docs/bridge-integration/API.md @@ -103,7 +103,7 @@ mutation BridgeExchangePlaidPublicToken( ``` **Input:** -- `linkToken`: Same token returned by `bridgeAddExternalAccount`. +- `linkToken`: Same token returned by `bridgeAddExternalAccount` (must be exchanged by the same account that requested it; tokens are one-time-use). - `publicToken`: Token from Plaid Link `onSuccess`. --- diff --git a/docs/bridge-integration/FLOWS.md b/docs/bridge-integration/FLOWS.md index 48d992fce..ea9b9f014 100644 --- a/docs/bridge-integration/FLOWS.md +++ b/docs/bridge-integration/FLOWS.md @@ -128,10 +128,10 @@ User Flash App Flash Backend Bridge.xyz 1. **Link Bank**: User chooses to add a bank account. 2. **GraphQL Mutation**: App calls `bridgeAddExternalAccount`. -3. **Link Token**: Flash requests a Plaid `link_token` from Bridge (`POST …/plaid_link_requests`) and returns `{ linkToken, expiresAt }`. +3. **Link Token**: Flash requests a Plaid `link_token` from Bridge (`POST …/plaid_link_requests`), binds it to the caller’s account in Redis (TTL = `link_token_expires_at`), and returns `{ linkToken, expiresAt }`. 4. **Plaid Link SDK**: App opens Plaid Link with `linkToken` (not a hosted Bridge URL). 5. **Authentication**: User logs into their bank and selects an account; Plaid returns a `publicToken` via `onSuccess`. -6. **Exchange Mutation**: App calls `bridgeExchangePlaidPublicToken` with `linkToken` + `publicToken`. Flash exchanges the token with Bridge server-side (Api-Key never leaves the backend). +6. **Exchange Mutation**: App calls `bridgeExchangePlaidPublicToken` with `linkToken` + `publicToken`. Flash verifies the token was issued to this account (and consumes it), then exchanges with Bridge server-side (Api-Key never leaves the backend). 7. **Verification Webhook**: Bridge creates External Accounts asynchronously and notifies Flash (`external_account` webhook). App may poll `bridgeExternalAccounts` until the bank appears as verified. 8. **Request Withdrawal**: User enters amount and selects the linked bank account. 9. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`. diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 1076e1e97..684da09e5 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -14,6 +14,7 @@ import { BridgeVirtualAccount } from "@services/mongoose/schema" import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" import { baseLogger } from "@services/logger" +import { CacheServiceError } from "@domain/cache" import { RepositoryError } from "@domain/errors" import { toBridgeCustomerId, @@ -52,6 +53,7 @@ import BridgeApiClient, { type CreateExternalAccountRequest, type ExternalAccount, } from "./client" +import { PlaidLinkTokenStore } from "./plaid-link-token-store" import { presentBridgeWithdrawal, receiptFeesFromTransfer, @@ -627,6 +629,20 @@ const addExternalAccount = async ( const plaid = await BridgeApiClient.createPlaidLinkRequest(customerId) + // Bind token → account before returning it so exchange can enforce ownership. + const saved = await PlaidLinkTokenStore.save(plaid.link_token, { + accountId, + bridgeCustomerId: customerId, + expiresAt: plaid.link_token_expires_at, + }) + if (saved instanceof CacheServiceError) { + baseLogger.error( + { accountId, operation: "addExternalAccount", error: saved }, + "Failed to bind Plaid link token", + ) + return new BridgeError("Unable to start bank linking — please try again") + } + // Deployed clients still select the deprecated linkUrl field, so keep // serving the hosted flow best-effort until the fleet is on linkToken. let linkUrl: string | null = null @@ -703,6 +719,21 @@ const exchangePlaidPublicToken = async ( ) } + const binding = await PlaidLinkTokenStore.consumeForAccount( + trimmedLinkToken, + accountId, + ) + if (binding instanceof BridgeInvalidPlaidTokenError) return binding + if (binding instanceof CacheServiceError) { + baseLogger.error( + { accountId, operation: "exchangePlaidPublicToken", error: binding }, + "Failed to load Plaid link token binding", + ) + return new BridgeInvalidPlaidTokenError( + "Unable to verify Plaid link token — restart bank linking", + ) + } + const exchanged = await BridgeApiClient.exchangePlaidPublicToken( trimmedLinkToken, trimmedPublicToken, @@ -713,7 +744,11 @@ const exchangePlaidPublicToken = async ( } baseLogger.info( - { accountId, operation: "exchangePlaidPublicToken" }, + { + accountId, + operation: "exchangePlaidPublicToken", + bridgeCustomerId: binding.bridgeCustomerId, + }, "Bridge operation completed", ) diff --git a/src/services/bridge/plaid-link-token-store.ts b/src/services/bridge/plaid-link-token-store.ts new file mode 100644 index 000000000..a22ccf5cc --- /dev/null +++ b/src/services/bridge/plaid-link-token-store.ts @@ -0,0 +1,77 @@ +/** + * Binds Bridge-issued Plaid link tokens to the Flash account that requested them. + * Tokens live only as long as Bridge says they do; exchange rejects strangers / reuse. + */ + +import { CacheServiceError, CacheUndefinedError } from "@domain/cache" +import { RedisCacheService } from "@services/cache" + +import { BridgeInvalidPlaidTokenError } from "./errors" + +export type PlaidLinkTokenBinding = { + accountId: AccountId + bridgeCustomerId: string + expiresAt: string +} + +const KEY_PREFIX = "plaid:link:" +/** Plaid’s default link_token lifetime when expires_at is unparseable. */ +const FALLBACK_TTL_SECS = 4 * 60 * 60 + +const cacheKey = (linkToken: string): string => `${KEY_PREFIX}${linkToken}` + +const ttlSecsFromExpiresAt = (expiresAt: string): Seconds => { + const ms = Date.parse(expiresAt) + if (Number.isNaN(ms)) return FALLBACK_TTL_SECS as Seconds + const secs = Math.floor((ms - Date.now()) / 1000) + return Math.max(1, secs) as Seconds +} + +export const PlaidLinkTokenStore = { + save: async ( + linkToken: string, + binding: PlaidLinkTokenBinding, + ): Promise => { + const result = await RedisCacheService().set({ + key: cacheKey(linkToken), + value: binding, + ttlSecs: ttlSecsFromExpiresAt(binding.expiresAt), + }) + if (result instanceof CacheServiceError) return result + return true + }, + + /** Load binding, enforce ownership, and delete so the token is one-time-use. */ + consumeForAccount: async ( + linkToken: string, + accountId: AccountId, + ): Promise => { + const key = cacheKey(linkToken) + const cached = await RedisCacheService().get({ key }) + + if (cached instanceof CacheUndefinedError) { + return new BridgeInvalidPlaidTokenError( + "Unknown or already-used Plaid link token — restart bank linking", + ) + } + if (cached instanceof CacheServiceError) return cached + + if (cached.accountId !== accountId) { + return new BridgeInvalidPlaidTokenError( + "Plaid link token was not issued for this account — restart bank linking", + ) + } + + const expiresMs = Date.parse(cached.expiresAt) + if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) { + await RedisCacheService().clear({ key }) + return new BridgeInvalidPlaidTokenError( + "Plaid link token has expired — restart bank linking", + ) + } + + // Consume before the Bridge call so concurrent exchanges can't reuse the token. + await RedisCacheService().clear({ key }) + return cached + }, +} diff --git a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts index 6306ac714..fd12db5a8 100644 --- a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts +++ b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts @@ -54,16 +54,47 @@ jest.mock("@services/bridge/client", () => ({ }, })) +const mockCacheSet = jest.fn() +const mockCacheGet = jest.fn() +const mockCacheClear = jest.fn() + +jest.mock("@services/cache", () => ({ + RedisCacheService: () => ({ + set: mockCacheSet, + get: mockCacheGet, + clear: mockCacheClear, + }), +})) + import BridgeService from "@services/bridge" import BridgeApiClient from "@services/bridge/client" import { AccountsRepository } from "@services/mongoose/accounts" import { BridgeApiError, + BridgeError, BridgeInvalidPlaidTokenError, BridgePlaidNotAvailableError, } from "@services/bridge/errors" +import { CacheUndefinedError, UnknownCacheServiceError } from "@domain/cache" const ACCOUNT_ID = "account-001" as AccountId +const OTHER_ACCOUNT_ID = "account-002" as AccountId + +const futureExpiresAt = () => new Date(Date.now() + 60 * 60 * 1000).toISOString() + +const bindTokenForAccount = ( + linkToken: string, + accountId: AccountId = ACCOUNT_ID, + expiresAt = futureExpiresAt(), +) => { + mockCacheGet.mockResolvedValue({ + accountId, + bridgeCustomerId: "cust_1", + expiresAt, + }) + mockCacheClear.mockResolvedValue(true) + return { linkToken, accountId, expiresAt } +} const mockAccount = (overrides: Record = {}) => { ;(AccountsRepository as jest.Mock).mockReturnValue({ @@ -80,6 +111,8 @@ describe("BridgeService.exchangePlaidPublicToken", () => { beforeEach(() => { jest.clearAllMocks() mockAccount() + mockCacheGet.mockResolvedValue(new CacheUndefinedError()) + mockCacheClear.mockResolvedValue(true) }) it("rejects empty or whitespace tokens with BridgeInvalidPlaidTokenError", async () => { @@ -97,9 +130,59 @@ describe("BridgeService.exchangePlaidPublicToken", () => { expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) } expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + expect(mockCacheGet).not.toHaveBeenCalled() + }) + + it("rejects tokens that were never issued (or already consumed)", async () => { + mockCacheGet.mockResolvedValue(new CacheUndefinedError()) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toMatch(/Unknown or already-used/) + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + }) + + it("rejects tokens issued to a different Flash account", async () => { + bindTokenForAccount("link-1", OTHER_ACCOUNT_ID) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toMatch(/not issued for this account/) + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + expect(mockCacheClear).not.toHaveBeenCalled() }) - it("trims tokens before exchanging with Bridge", async () => { + it("rejects expired bound tokens without calling Bridge", async () => { + bindTokenForAccount( + "link-1", + ACCOUNT_ID, + new Date(Date.now() - 1000).toISOString(), + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toMatch(/expired/) + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + expect(mockCacheClear).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) + }) + + it("trims tokens, consumes the binding, then exchanges with Bridge", async () => { + bindTokenForAccount("link-1") ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockResolvedValue({ message: "ok", }) @@ -110,6 +193,8 @@ describe("BridgeService.exchangePlaidPublicToken", () => { " public-1 ", ) + expect(mockCacheGet).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) + expect(mockCacheClear).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) expect(BridgeApiClient.exchangePlaidPublicToken).toHaveBeenCalledWith( "link-1", "public-1", @@ -118,6 +203,7 @@ describe("BridgeService.exchangePlaidPublicToken", () => { }) it("maps Bridge 400 (rejected token pair) to BridgeInvalidPlaidTokenError with detail", async () => { + bindTokenForAccount("link-1") ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( new BridgeApiError("Bridge API error: 400 Bad Request", 400, { message: "link_token has expired", @@ -135,6 +221,7 @@ describe("BridgeService.exchangePlaidPublicToken", () => { }) it("maps Bridge 400 without response detail to a fallback message", async () => { + bindTokenForAccount("link-1") ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( new BridgeApiError("Bridge API error: 400 Bad Request", 400, null), ) @@ -151,6 +238,9 @@ describe("BridgeService.exchangePlaidPublicToken", () => { it("maps Bridge 401/403 to BridgePlaidNotAvailableError", async () => { for (const status of [401, 403]) { + jest.clearAllMocks() + mockAccount() + bindTokenForAccount("link-1") ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( new BridgeApiError(`Bridge API error: ${status}`, status, null), ) @@ -166,6 +256,7 @@ describe("BridgeService.exchangePlaidPublicToken", () => { }) it("passes through Bridge 5xx as the raw error (alerting path)", async () => { + bindTokenForAccount("link-1") const serverError = new BridgeApiError("Bridge API error: 500", 500, null) ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( serverError, @@ -192,6 +283,7 @@ describe("BridgeService.exchangePlaidPublicToken", () => { expect(result).toBeInstanceOf(Error) expect((result as Error).message).toMatch(/Complete KYC first/) expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + expect(mockCacheGet).not.toHaveBeenCalled() }) }) @@ -199,6 +291,7 @@ describe("BridgeService.addExternalAccount (deprecated linkUrl compat)", () => { beforeEach(() => { jest.clearAllMocks() mockAccount() + mockCacheSet.mockResolvedValue({ accountId: ACCOUNT_ID }) ;(BridgeApiClient.createPlaidLinkRequest as jest.Mock).mockResolvedValue({ link_token: "link-token-1", link_token_expires_at: "2026-07-15T00:00:00Z", @@ -214,6 +307,15 @@ describe("BridgeService.addExternalAccount (deprecated linkUrl compat)", () => { const result = await BridgeService.addExternalAccount(ACCOUNT_ID) + expect(mockCacheSet).toHaveBeenCalledWith({ + key: "plaid:link:link-token-1", + value: { + accountId: ACCOUNT_ID, + bridgeCustomerId: "cust_1", + expiresAt: "2026-07-15T00:00:00Z", + }, + ttlSecs: expect.any(Number), + }) expect(result).toEqual({ linkToken: "link-token-1", linkUrl: "https://hosted.bridge.test/link", @@ -234,4 +336,14 @@ describe("BridgeService.addExternalAccount (deprecated linkUrl compat)", () => { expiresAt: "2026-07-15T00:00:00Z", }) }) + + it("fails closed when the link-token binding cannot be persisted", async () => { + mockCacheSet.mockResolvedValue(new UnknownCacheServiceError("redis down")) + + const result = await BridgeService.addExternalAccount(ACCOUNT_ID) + + expect(result).toBeInstanceOf(BridgeError) + expect((result as Error).message).toMatch(/Unable to start bank linking/) + expect(BridgeApiClient.getExternalAccountLinkUrl).not.toHaveBeenCalled() + }) }) From fe1a9a31f451d7f8420150cbd36dd61401cd8944 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Wed, 15 Jul 2026 18:23:17 +0100 Subject: [PATCH 4/7] =?UTF-8?q?fix(bridge):=20unblock=20CI=20=E2=80=94=20t?= =?UTF-8?q?ypos=20unparsable=20+=20mock=20cache=20in=20bridge=20index=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/bridge/plaid-link-token-store.ts | 2 +- test/flash/unit/services/bridge/index.spec.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/services/bridge/plaid-link-token-store.ts b/src/services/bridge/plaid-link-token-store.ts index a22ccf5cc..70a913a61 100644 --- a/src/services/bridge/plaid-link-token-store.ts +++ b/src/services/bridge/plaid-link-token-store.ts @@ -15,7 +15,7 @@ export type PlaidLinkTokenBinding = { } const KEY_PREFIX = "plaid:link:" -/** Plaid’s default link_token lifetime when expires_at is unparseable. */ +/** Plaid’s default link_token lifetime when expires_at is unparsable. */ const FALLBACK_TTL_SECS = 4 * 60 * 60 const cacheKey = (linkToken: string): string => `${KEY_PREFIX}${linkToken}` diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 0461a99d4..25edf5d6c 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -142,6 +142,15 @@ jest.mock("@app/bridge/send-withdrawal-notification", () => ({ sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), })) +// Plaid link-token store pulls RedisCacheService; keep unit suites offline. +jest.mock("@services/cache", () => ({ + RedisCacheService: () => ({ + set: jest.fn().mockResolvedValue(true), + get: jest.fn().mockResolvedValue(undefined), + clear: jest.fn().mockResolvedValue(true), + }), +})) + import BridgeService, { deriveWithdrawalIdempotencyKey } from "@services/bridge" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import BridgeClient from "@services/bridge/client" From c436382aaeb13b8cb338d8e2cdc14fc31f38353f Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Wed, 15 Jul 2026 17:34:23 +0000 Subject: [PATCH 5/7] style(bridge): fix prettier formatting for plaid link token store --- src/services/bridge/plaid-link-token-store.ts | 4 +++- .../services/bridge/exchange-plaid-public-token.spec.ts | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services/bridge/plaid-link-token-store.ts b/src/services/bridge/plaid-link-token-store.ts index 70a913a61..e31ef7817 100644 --- a/src/services/bridge/plaid-link-token-store.ts +++ b/src/services/bridge/plaid-link-token-store.ts @@ -45,7 +45,9 @@ export const PlaidLinkTokenStore = { consumeForAccount: async ( linkToken: string, accountId: AccountId, - ): Promise => { + ): Promise< + PlaidLinkTokenBinding | BridgeInvalidPlaidTokenError | CacheServiceError + > => { const key = cacheKey(linkToken) const cached = await RedisCacheService().get({ key }) diff --git a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts index fd12db5a8..9dab92b76 100644 --- a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts +++ b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts @@ -163,11 +163,7 @@ describe("BridgeService.exchangePlaidPublicToken", () => { }) it("rejects expired bound tokens without calling Bridge", async () => { - bindTokenForAccount( - "link-1", - ACCOUNT_ID, - new Date(Date.now() - 1000).toISOString(), - ) + bindTokenForAccount("link-1", ACCOUNT_ID, new Date(Date.now() - 1000).toISOString()) const result = await BridgeService.exchangePlaidPublicToken( ACCOUNT_ID, From 036b9eeac728c2a590f664f0dbb46b9cb673b137 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 15 Jul 2026 19:54:51 -0700 Subject: [PATCH 6/7] fix(bridge): make Plaid link-token consume atomic (close TOCTOU) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consumeForAccount used a non-atomic get-then-clear: two concurrent exchanges of the same link token could both read the binding before either cleared it, so both proceeded — the "concurrent reuse can't slip through" comment was false. clear() also discards Redis's DEL count, so it cannot tell the winner of the race from the loser. Add consumeCacheKey() to the redis cache service: one DEL, returns true only for the caller that actually removed the key (Redis serializes DELs, so among concurrent callers exactly one sees a removed-count of 1). Gate single-use on it, after the ownership check on the non-destructive get — so a stranger still cannot burn the owner's token. Tests: replace the stateless single-use stub with real coverage — a second exchange of a consumed token is rejected, and two concurrent exchanges yield exactly one winner with Bridge called once. Cross-account now also asserts the atomic consume is never reached. Verified: tsc --noEmit clean; exchange spec 15/15 (incl. 2 new concurrency tests), bridge index 64/64, bridge-error-map 41/41. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/bridge/plaid-link-token-store.ts | 24 +++++-- src/services/cache/redis-cache.ts | 22 +++++++ .../exchange-plaid-public-token.spec.ts | 66 ++++++++++++++++++- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/services/bridge/plaid-link-token-store.ts b/src/services/bridge/plaid-link-token-store.ts index e31ef7817..a5f9ad544 100644 --- a/src/services/bridge/plaid-link-token-store.ts +++ b/src/services/bridge/plaid-link-token-store.ts @@ -4,7 +4,7 @@ */ import { CacheServiceError, CacheUndefinedError } from "@domain/cache" -import { RedisCacheService } from "@services/cache" +import { RedisCacheService, consumeCacheKey } from "@services/cache" import { BridgeInvalidPlaidTokenError } from "./errors" @@ -41,7 +41,12 @@ export const PlaidLinkTokenStore = { return true }, - /** Load binding, enforce ownership, and delete so the token is one-time-use. */ + /** + * Load binding, enforce ownership, then atomically consume so the token is + * one-time-use even under concurrent exchanges (see consumeCacheKey). Ownership + * is checked on a non-destructive read, so a stranger cannot burn the owner's + * token by attempting to exchange it. + */ consumeForAccount: async ( linkToken: string, accountId: AccountId, @@ -72,8 +77,19 @@ export const PlaidLinkTokenStore = { ) } - // Consume before the Bridge call so concurrent exchanges can't reuse the token. - await RedisCacheService().clear({ key }) + // Atomic single-use gate. A plain get-then-clear is a TOCTOU race: two + // concurrent exchanges of the same token can both read the binding before + // either clears it, so both would proceed. consumeCacheKey issues one DEL + // and returns true only for the caller that actually removed the key — among + // concurrent exchanges of the same token, exactly one wins; the rest are + // rejected here, before the Bridge call. + const consumed = await consumeCacheKey({ key }) + if (consumed instanceof CacheServiceError) return consumed + if (!consumed) { + return new BridgeInvalidPlaidTokenError( + "Unknown or already-used Plaid link token — restart bank linking", + ) + } return cached }, } diff --git a/src/services/cache/redis-cache.ts b/src/services/cache/redis-cache.ts index 5da0ca9a9..e7f17b8c9 100644 --- a/src/services/cache/redis-cache.ts +++ b/src/services/cache/redis-cache.ts @@ -73,3 +73,25 @@ export const RedisCacheService = (): ICacheService => { fns: { set, get, getOrSet, clear }, }) } + +/** + * Atomically deletes `key`, returning `true` only for the caller whose DEL + * actually removed it. Redis executes each DEL serially, so among concurrent + * callers exactly one sees a removed-count of 1 — the basis for one-time-use + * keys (e.g. Plaid link tokens). `false` means the key was already gone: lost + * the race, expired, or never existed. + * + * `clear` cannot express this: it discards the removed-count and always + * resolves `true`, so get-then-`clear` is a TOCTOU race in which concurrent + * consumers all "succeed". Gate single-use on this function instead. + */ +export const consumeCacheKey = async ({ + key, +}: LocalCacheClearArgs): Promise => { + try { + const removed = await redisCache.deleteCache(key) + return removed === 1 + } catch (err) { + return new UnknownCacheServiceError(err) + } +} diff --git a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts index 9dab92b76..6034f1f5c 100644 --- a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts +++ b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts @@ -57,6 +57,7 @@ jest.mock("@services/bridge/client", () => ({ const mockCacheSet = jest.fn() const mockCacheGet = jest.fn() const mockCacheClear = jest.fn() +const mockCacheConsume = jest.fn() jest.mock("@services/cache", () => ({ RedisCacheService: () => ({ @@ -64,6 +65,9 @@ jest.mock("@services/cache", () => ({ get: mockCacheGet, clear: mockCacheClear, }), + // Lazy wrapper: the factory is hoisted above the const, so reference + // mockCacheConsume only when called, not at factory-eval time. + consumeCacheKey: (args: { key: string }) => mockCacheConsume(args), })) import BridgeService from "@services/bridge" @@ -93,6 +97,7 @@ const bindTokenForAccount = ( expiresAt, }) mockCacheClear.mockResolvedValue(true) + mockCacheConsume.mockResolvedValue(true) return { linkToken, accountId, expiresAt } } @@ -113,6 +118,7 @@ describe("BridgeService.exchangePlaidPublicToken", () => { mockAccount() mockCacheGet.mockResolvedValue(new CacheUndefinedError()) mockCacheClear.mockResolvedValue(true) + mockCacheConsume.mockResolvedValue(true) }) it("rejects empty or whitespace tokens with BridgeInvalidPlaidTokenError", async () => { @@ -159,7 +165,10 @@ describe("BridgeService.exchangePlaidPublicToken", () => { expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) expect((result as Error).message).toMatch(/not issued for this account/) expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + // A stranger must not be able to burn the owner's token: ownership is + // checked on the non-destructive get, before the atomic consume. expect(mockCacheClear).not.toHaveBeenCalled() + expect(mockCacheConsume).not.toHaveBeenCalled() }) it("rejects expired bound tokens without calling Bridge", async () => { @@ -190,7 +199,9 @@ describe("BridgeService.exchangePlaidPublicToken", () => { ) expect(mockCacheGet).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) - expect(mockCacheClear).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) + // Consume goes through the atomic gate, not the non-gating clear(). + expect(mockCacheConsume).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) + expect(mockCacheClear).not.toHaveBeenCalled() expect(BridgeApiClient.exchangePlaidPublicToken).toHaveBeenCalledWith( "link-1", "public-1", @@ -198,6 +209,59 @@ describe("BridgeService.exchangePlaidPublicToken", () => { expect(result).toEqual({ message: "ok" }) }) + it("consumes the token: a second exchange of the same token is rejected", async () => { + bindTokenForAccount("link-1") + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockResolvedValue({ + message: "ok", + }) + + // First exchange atomically removes the binding (DEL removed 1 key)... + mockCacheConsume.mockResolvedValueOnce(true) + const first = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + expect(first).toEqual({ message: "ok" }) + + // ...so the second is rejected because its DEL removes nothing (0 keys), + // even though the stateless get still returns a binding. + mockCacheConsume.mockResolvedValueOnce(false) + const second = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + expect(second).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((second as Error).message).toMatch(/Unknown or already-used/) + expect(BridgeApiClient.exchangePlaidPublicToken).toHaveBeenCalledTimes(1) + }) + + it("lets only one of two concurrent exchanges of the same token through", async () => { + bindTokenForAccount("link-1") + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockResolvedValue({ + message: "ok", + }) + // Model the atomic DEL under concurrency: exactly one caller removes the key. + // (With the old get-then-clear this would resolve true for both and both + // would reach Bridge — the race this test guards against.) + mockCacheConsume.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + const outcomes = await Promise.all([ + BridgeService.exchangePlaidPublicToken(ACCOUNT_ID, "link-1", "public-1"), + BridgeService.exchangePlaidPublicToken(ACCOUNT_ID, "link-1", "public-1"), + ]) + + const successes = outcomes.filter((r) => !(r instanceof Error)) + const rejected = outcomes.filter( + (r) => r instanceof BridgeInvalidPlaidTokenError, + ) + expect(successes).toEqual([{ message: "ok" }]) + expect(rejected).toHaveLength(1) + // The losing exchange never reaches Bridge. + expect(BridgeApiClient.exchangePlaidPublicToken).toHaveBeenCalledTimes(1) + }) + it("maps Bridge 400 (rejected token pair) to BridgeInvalidPlaidTokenError with detail", async () => { bindTokenForAccount("link-1") ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( From 5df900119636356d53c6f5e09f374d95afea8882 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 15 Jul 2026 21:20:56 -0700 Subject: [PATCH 7/7] style(bridge): fix prettier formatting in plaid public-token spec Collapse the multi-line filter arrow flagged by prettier/prettier in Check Code CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../unit/services/bridge/exchange-plaid-public-token.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts index 6034f1f5c..335250ae2 100644 --- a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts +++ b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts @@ -253,9 +253,7 @@ describe("BridgeService.exchangePlaidPublicToken", () => { ]) const successes = outcomes.filter((r) => !(r instanceof Error)) - const rejected = outcomes.filter( - (r) => r instanceof BridgeInvalidPlaidTokenError, - ) + const rejected = outcomes.filter((r) => r instanceof BridgeInvalidPlaidTokenError) expect(successes).toEqual([{ message: "ok" }]) expect(rejected).toHaveLength(1) // The losing exchange never reaches Bridge.