Skip to content

feat(bridge): switch Plaid linking to link_token + public_token exchange#446

Open
heyolaniran wants to merge 2 commits into
lnflash:mainfrom
heyolaniran:fix/plaid_linking_flow
Open

feat(bridge): switch Plaid linking to link_token + public_token exchange#446
heyolaniran wants to merge 2 commits into
lnflash:mainfrom
heyolaniran:fix/plaid_linking_flow

Conversation

@heyolaniran

Copy link
Copy Markdown
Collaborator

Summary

  • Replace the hosted Bridge Plaid linkUrl flow with Bridge’s documented Plaid Link SDK path: bridgeAddExternalAccount now returns a linkToken (+ expiresAt) from POST …/plaid_link_requests.
  • Add bridgeExchangePlaidPublicToken so the app sends linkToken + publicToken to Flash, which exchanges with Bridge server-side (Api-Key never leaves the backend). External accounts still land asynchronously via the existing external_account webhook.
  • Deprecate getExternalAccountLinkUrl, block the new mutation for API keys (BLOCKED), validate empty tokens (BRIDGE_INVALID_PLAID_TOKEN), and update docs / e2e / error-map coverage.

Breaking change

  • GraphQL: BridgeExternalAccountLink.linkUrllinkToken.
  • Mobile must open Plaid Link with linkToken, then call bridgeExchangePlaidPublicToken before relying on bridgeExternalAccounts.

Test plan

  • Unit: yarn test:unitbridge-error-map.spec.ts includes BridgeInvalidPlaidTokenError
  • Call bridgeAddExternalAccount → receive non-empty linkToken / expiresAt
  • Empty/ tokens on exchange → BRIDGE_INVALID_PLAID_TOKEN
  • Successful exchange → webhook persists account → bridgeExternalAccounts lists it
  • API key cannot call bridgeExchangePlaidPublicToken
  • Manual fallback bridgeCreateExternalAccount still works when Plaid is unavailable
  • Confirm mobile ships the linkToken cutover in the same release

Replace hosted linkUrl with Bridge plaid_link_requests and a server-side
bridgeExchangePlaidPublicToken mutation so Api-Key never leaves the backend.
…e failures

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 <noreply@anthropic.com>

@islandbitcoin islandbitcoin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid direction — server-side exchange with the Api-Key never leaving the backend is the right architecture, and the resolver/scope-map/docs work is consistent with the existing Bridge mutations. I reviewed against the shipped mobile client and pushed a follow-up commit (bba1dce9a) addressing the blocking findings directly. Summary:

Pushed in bba1dce9a

1. The linkUrllinkToken rename would have broken every installed app on deploy day. The shipped mobile client's generated.gql selects linkUrl (opened in BridgeExternalAccountWebView), and GraphQL validation rejects the whole query for an unknown field — so this deploy would have killed bank linking for the entire installed base, while the mobile Plaid SDK work hasn't started yet (no plaid dep in flash-mobile). Made the change additive: linkUrl is back as a nullable @deprecated field, served best-effort from the hosted endpoint, alongside the new linkToken. Remove it after the fleet migrates.

2. Real-world exchange failures mapped to a garbage error. The only handled case was empty/whitespace tokens — which GraphQL non-null mostly prevents anyway. The failures that will actually happen daily (expired link_token, already-exchanged public_token) came back as BRIDGE_API_ERROR: "Bridge API error: 400 Bad Request" with Bridge's actual detail unread in error.response — and mobile already treats BRIDGE_API_ERROR as "account already linked" in the manual flow. Now: 400/404/409/422 → BRIDGE_INVALID_PLAID_TOKEN (surfacing Bridge's detail), 401/403 → BRIDGE_PLAID_NOT_AVAILABLE (consistent with addExternalAccount three definitions up), 5xx passes through raw for alerting.

3. Tests. The new code path had zero coverage (one error-map table line ≠ coverage; every test-plan checkbox unchecked). Added test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts — 9 tests: validation/trimming, every error mapping, the KYC gate, and the linkUrl compat behavior. Also dropped the dead idempotencyKey param (no caller; request() already mints one per call) and regenerated SDL + supergraph via yarn write-sdl.

Verified locally: tsc clean on both configs, eslint clean, 9/9 unit tests green, SDL matches generator output.

Still open — needs your input

4. The backend never binds a linkToken to the customer it was issued for. createPlaidLinkRequest's response is persisted nowhere, and on exchange customerId is only used as a KYC null-check — then we exchange whatever token the client sent via the global /plaid_exchange_public_token/{linkToken} endpoint. Any level-1 user can exchange any token pair through Flash's Api-Key, with no audit trail. Recommend: persist issued link tokens per customer (small TTL'd collection — they expire anyway) and reject exchanges for tokens we didn't issue to that account; or confirm whether Bridge offers a customer-scoped exchange endpoint. Didn't implement this — it's a persistence-layer design decision that should be yours. Fine as a fast-follow, but please open a ticket before merge.

5. Confirm link_token_expires_at against Bridge's docs — I couldn't verify the response field name offline.

6. Heads-up: the supergraph regen in this PR also carries catch-up drift from #444 (BankAccountUpdateRequest was missing from the committed supergraph). Legitimate, but worth a line in the PR description.

Also: branch is one commit behind main — please rebase or merge main before landing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants