feat(bridge): gate Plaid bank-linking to US IPs, fall back to manual entry#461
Merged
Conversation
…entry Plaid onboarding is US-only. A non-US egress IP is rejected inside the Plaid webview (surfacing as "rate limit exceeded" on the phone step), which the backend never sees and which dead-ends the user. Confirmed with a Jamaica user on 69.160.103.177 (Digicel JM): the same request links fine from a US IP, stays blocked on the JM IP across time, and can't reproduce on a US IP — i.e. it tracks the IP country, not the phone or a rate limit. Gate it server-side where we already issue the Plaid link_token: - New BridgeService.plaidAvailableForIp(ip) geolocates the caller's IP via the existing IpFetcher (proxycheck.io) and returns false only for a CONFIRMED non-US country. - bridgeAddExternalAccount resolver reads ctx.ip and, when Plaid isn't available, returns the existing BRIDGE_PLAID_NOT_AVAILABLE code — which the mobile client already routes to its manual bank-details form. So already-installed apps get fixed with no app-store release, and Plaid never opens for a user who can't complete it. Fail-open by design: no IP, a private/dev IP, a geo-lookup failure, or an unknown country all allow Plaid, so a lookup blip never locks out a legitimate US user (the mobile on-exit fallback still catches any non-US user that slips through). Deploy note: this relies on IP geolocation working in prod — ensure a real PROXY_CHECK_APIKEY is set (the lookup adds ~500ms to the link-token request; fail-open keeps it safe if the key/service is unavailable). Tests: test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts (non-US blocks, US allows, case-insensitive, and all four fail-open paths). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
Simon-review blocking fix. isPrivateIp parses as IPv4 and THROWS on a real IPv6 address (verified: 2607:fb90:... / 2001:4860:4860::8888). The gate called it unguarded, the span wrapper rethrows, and the resolver has no try/catch — so any user on a public IPv6 (common on mobile carriers, incl. Digicel and T-Mobile) got a hard error adding a bank: no Plaid AND no manual fallback. That's a regression that hits the very non-US users this gate targets. - Guard isPrivateIp so a throw means "not private": IPv6 clients now get geo-checked (proxycheck resolves IPv6) rather than crashing, so the US gate keeps working for them. Whole body wrapped in a fail-open backstop. - Add IPv6 cases to the helper spec (non-US IPv6 blocks, US IPv6 allows). - Add resolver-level spec (bridge-add-external-account): a non-US IP yields BRIDGE_PLAID_NOT_AVAILABLE and never calls addExternalAccount; a US IP proceeds — the end-to-end wiring the helper spec didn't cover. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
…otify
Two review follow-ups.
Observability: plaidAvailableForIp -> plaidGateForIp now returns
{ available, country } and tags the current span with plaid.gate.decision
(allowed-us | blocked-non-us | allowed-no-ip | allowed-private-ip |
allowed-lookup-failed | allowed-unknown-country | allowed-error) plus the
resolved country. This makes prod efficacy — and silent fail-open, e.g. a
missing/dummy proxycheck key — visible in traces instead of invisible.
Ops feed: on a non-US block the resolver emits notifyOpsEvent (the
#flash-activity Discord feed, #458) with flow=cashout,
phase=plaid-blocked-non-us, the accountId, and the resolved country — so we
can see non-US demand and confirm the gate is firing. Fire-and-forget;
no-ops when the webhook is unset. Raw IP stays in the span, not Discord.
Tests updated for the new shape + span attributes; resolver spec now
asserts notifyOpsEvent fires on block and not on allow. 11 unit tests,
tsc + eslint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Plaid onboarding is US-only. A non-US egress IP is rejected inside the Plaid webview — it surfaces as "rate limit exceeded" on the phone/Layer step, before any token exchange, so the backend never sees it and the user is dead-ended.
Confirmed on a real case (
+18762194534, plus a US-number user physically abroad): blocked IP69.160.103.177= Digicel Jamaica (AS33576). The same account links fine from a US IP, stays blocked on the JM IP across time (not a decaying rate limit), and can't be reproduced on a US IP. It tracks the IP country, not the phone number.Bridge/Plaid can't "fix" this — Plaid simply doesn't serve non-US IPs. So we should never open a Plaid session for a non-US user; route them to manual bank-entry instead.
Change
Gate it server-side, where we already issue the Plaid
link_token:BridgeService.plaidAvailableForIp(ip)(src/services/bridge/index.ts) — geolocates the caller's IP via the existingIpFetcher(proxycheck.io →isoCode) and returnsfalseonly for a confirmed non-US country.bridgeAddExternalAccountresolver readsctx.ip; when Plaid isn't available it returns the existingBRIDGE_PLAID_NOT_AVAILABLEcode — which the mobile client already routes to its manual bank-details form.Why this shape
BRIDGE_PLAID_NOT_AVAILABLE→ manual entry, so the affected users are unblocked by a backend deploy alone.Deploy note⚠️
Relies on IP geolocation working in prod.
ipRecording.proxyChecking.enableddefaultsfalseand the gate callsIpFetcherdirectly, so the only requirement is a realPROXY_CHECK_APIKEYin the target env (historically a "dummy" value). The lookup adds ~500ms to the link-token request; fail-open keeps it safe if the key/service is unavailable. Verify the prod proxycheck key before relying on this to block.Tests
test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts— 7 cases: non-US blocks (Jamaica), US allows, case-insensitive isoCode, and all four fail-open paths (no IP / private IP / lookup error / unknown country). Existing bridge unit specs still green (22 tests total);tsc --noEmitclean.Related
plaid_link_failureanalytics (safety net under this).🤖 Generated with Claude Code
https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg