feat(bridge): switch Plaid linking to link_token + public_token exchange#446
feat(bridge): switch Plaid linking to link_token + public_token exchange#446heyolaniran wants to merge 2 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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 linkUrl → linkToken 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.
Summary
linkUrlflow with Bridge’s documented Plaid Link SDK path:bridgeAddExternalAccountnow returns alinkToken(+expiresAt) fromPOST …/plaid_link_requests.bridgeExchangePlaidPublicTokenso the app sendslinkToken+publicTokento Flash, which exchanges with Bridge server-side (Api-Key never leaves the backend). External accounts still land asynchronously via the existingexternal_accountwebhook.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
BridgeExternalAccountLink.linkUrl→linkToken.linkToken, then callbridgeExchangePlaidPublicTokenbefore relying onbridgeExternalAccounts.Test plan
yarn test:unit—bridge-error-map.spec.tsincludesBridgeInvalidPlaidTokenErrorbridgeAddExternalAccount→ receive non-emptylinkToken/expiresAttokens on exchange →BRIDGE_INVALID_PLAID_TOKENbridgeExternalAccountslists itbridgeExchangePlaidPublicTokenbridgeCreateExternalAccountstill works when Plaid is unavailablelinkTokencutover in the same release