Skip to content

feat(bridge): gate Plaid bank-linking to US IPs, fall back to manual entry#461

Merged
islandbitcoin merged 3 commits into
mainfrom
eng-plaid-us-ip-gate
Jul 25, 2026
Merged

feat(bridge): gate Plaid bank-linking to US IPs, fall back to manual entry#461
islandbitcoin merged 3 commits into
mainfrom
eng-plaid-us-ip-gate

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

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 IP 69.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 existing IpFetcher (proxycheck.io → isoCode) and returns false only for a confirmed non-US country.
  • bridgeAddExternalAccount resolver reads ctx.ip; when Plaid isn't available it returns the existing BRIDGE_PLAID_NOT_AVAILABLE code — which the mobile client already routes to its manual bank-details form.

Why this shape

  • Fixes already-installed apps with no app-store release — the prod app already handles BRIDGE_PLAID_NOT_AVAILABLE → manual entry, so the affected users are unblocked by a backend deploy alone.
  • Fail-open by design: no IP, a private/dev IP, a geo-lookup failure, or an unknown country all allow Plaid — a lookup blip never locks out a legitimate US user. The mobile on-exit fallback (flash-mobile #678) still catches any non-US user that slips through.

Deploy note ⚠️

Relies on IP geolocation working in prod. ipRecording.proxyChecking.enabled defaults false and the gate calls IpFetcher directly, so the only requirement is a real PROXY_CHECK_APIKEY in 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 --noEmit clean.

Related

  • flash-mobile #678 — reactive on-exit fallback + plaid_link_failure analytics (safety net under this).

🤖 Generated with Claude Code

https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg

bobodread876 and others added 3 commits July 25, 2026 13:43
…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
@islandbitcoin
islandbitcoin merged commit feecd3a into main Jul 25, 2026
15 checks passed
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