From 7b009827d141460261b748ad6993d25d48dbb29c Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 25 Jul 2026 13:43:18 -0700 Subject: [PATCH 1/3] feat(bridge): gate Plaid bank-linking to US IPs, fall back to manual entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg --- .../mutation/bridge-add-external-account.ts | 18 ++- src/services/bridge/index.ts | 43 ++++++- .../bridge/plaid-available-for-ip.spec.ts | 113 ++++++++++++++++++ 3 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts diff --git a/src/graphql/public/root/mutation/bridge-add-external-account.ts b/src/graphql/public/root/mutation/bridge-add-external-account.ts index 2877471e2..864e57ce2 100644 --- a/src/graphql/public/root/mutation/bridge-add-external-account.ts +++ b/src/graphql/public/root/mutation/bridge-add-external-account.ts @@ -4,7 +4,11 @@ import IError from "@graphql/shared/types/abstract/error" import BridgeExternalAccountLink from "@graphql/public/types/object/bridge-external-account-link" import { BridgeConfig } from "@config" import BridgeService from "@services/bridge" -import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" +import { + BridgeDisabledError, + BridgeAccountLevelError, + BridgePlaidNotAvailableError, +} from "@services/bridge/errors" const BridgeAddExternalAccountPayload = GT.Object({ name: "BridgeAddExternalAccountPayload", @@ -16,7 +20,7 @@ const BridgeAddExternalAccountPayload = GT.Object({ const bridgeAddExternalAccount = GT.Field({ type: GT.NonNull(BridgeAddExternalAccountPayload), - resolve: async (_, __, { domainAccount }: GraphQLPublicContextAuth) => { + resolve: async (_, __, { domainAccount, ip }: GraphQLPublicContextAuth) => { if (!BridgeConfig.enabled) { return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } } @@ -25,6 +29,16 @@ const bridgeAddExternalAccount = GT.Field({ return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } } + // Plaid onboarding is US-only. Send a non-US IP straight to the client's + // manual-entry fallback (BRIDGE_PLAID_NOT_AVAILABLE) rather than opening a + // Plaid session that gets blocked inside the webview. + const plaidAvailable = await BridgeService.plaidAvailableForIp(ip) + if (!plaidAvailable) { + return { + errors: [mapAndParseErrorForGqlResponse(new BridgePlaidNotAvailableError())], + } + } + const result = await BridgeService.addExternalAccount(domainAccount.id) if (result instanceof Error) { return { errors: [mapAndParseErrorForGqlResponse(result)] } diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 684da09e5..e35723532 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -11,11 +11,17 @@ import { BridgeConfig } from "@config" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import { AccountsRepository } from "@services/mongoose/accounts" import { BridgeVirtualAccount } from "@services/mongoose/schema" -import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" +import { + recordExceptionInCurrentSpan, + wrapAsyncFunctionsToRunInSpan, +} from "@services/tracing" import { baseLogger } from "@services/logger" +import { IpFetcher } from "@services/ipfetcher" import { CacheServiceError } from "@domain/cache" import { RepositoryError } from "@domain/errors" +import { isPrivateIp } from "@domain/accounts-ips" +import { IpFetcherServiceError } from "@domain/ipfetcher" import { toBridgeCustomerId, toBridgeExternalAccountId, @@ -23,7 +29,7 @@ import { } from "@domain/primitives/bridge" import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" -import { USDTAmount, WalletCurrency } from "@domain/shared" +import { ErrorLevel, USDTAmount, WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" import { WalletsRepository } from "@services/mongoose/wallets" @@ -602,6 +608,38 @@ const createVirtualAccount = async ( } } +/** + * Whether Plaid Link is usable from the caller's IP. + * + * 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. We gate it here so the + * caller can return BRIDGE_PLAID_NOT_AVAILABLE and the client routes straight + * to manual bank-details entry instead of opening a Plaid session that can't + * complete. + * + * Fail-open: only a CONFIRMED non-US IP returns false. No IP, a private/dev IP, + * a geo-lookup failure, or an unknown country all return true — so a lookup + * blip never locks a legitimate US user out of Plaid, and the client's on-exit + * fallback still catches any non-US user that slips through. + */ +const plaidAvailableForIp = async (ip: IpAddress | undefined): Promise => { + if (!ip || isPrivateIp(ip)) return true + + const ipInfo = await IpFetcher().fetchIPInfo(ip) + if (ipInfo instanceof IpFetcherServiceError) { + recordExceptionInCurrentSpan({ + error: ipInfo, + level: ErrorLevel.Warn, + attributes: { ip }, + }) + return true + } + + if (!ipInfo.isoCode) return true + return ipInfo.isoCode.toUpperCase() === "US" +} + /** * Requests a Plaid link_token from Bridge for adding external accounts. */ @@ -1805,6 +1843,7 @@ export default wrapAsyncFunctionsToRunInSpan({ fns: { initiateKyc, createVirtualAccount, + plaidAvailableForIp, addExternalAccount, exchangePlaidPublicToken, createExternalAccount, diff --git a/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts b/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts new file mode 100644 index 000000000..add395805 --- /dev/null +++ b/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts @@ -0,0 +1,113 @@ +// Unit tests for BridgeService.plaidAvailableForIp — the US-only Plaid gate. +// Plaid onboarding is US-only, so a non-US egress IP must be routed to manual +// entry (BRIDGE_PLAID_NOT_AVAILABLE) rather than opening a Plaid session it +// can't complete. The gate is fail-open: only a CONFIRMED non-US IP blocks. + +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, + recordExceptionInCurrentSpan: jest.fn(), +})) + +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(), + }, +})) +jest.mock("@services/cache", () => ({ + RedisCacheService: () => ({ set: jest.fn(), get: jest.fn(), clear: jest.fn() }), + consumeCacheKey: jest.fn(), +})) + +const mockFetchIPInfo = jest.fn() +jest.mock("@services/ipfetcher", () => ({ + IpFetcher: () => ({ fetchIPInfo: mockFetchIPInfo }), +})) + +import BridgeService from "@services/bridge" +import { UnknownIpFetcherServiceError } from "@domain/ipfetcher" + +const ip = (value: string) => value as unknown as IpAddress + +const ipInfo = (isoCode: string) => ({ + provider: "test", + country: isoCode === "US" ? "United States" : "Jamaica", + isoCode, + region: "", + city: "", + type: "", + asn: "AS0", + proxy: false, +}) + +describe("BridgeService.plaidAvailableForIp", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("blocks a confirmed non-US IP (Jamaica) without opening Plaid", async () => { + mockFetchIPInfo.mockResolvedValue(ipInfo("JM")) + // 69.160.103.177 = Digicel Jamaica — the real reported case. + expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(false) + expect(mockFetchIPInfo).toHaveBeenCalledWith("69.160.103.177") + }) + + it("allows a US IP", async () => { + mockFetchIPInfo.mockResolvedValue(ipInfo("US")) + expect(await BridgeService.plaidAvailableForIp(ip("8.8.8.8"))).toBe(true) + }) + + it("treats isoCode case-insensitively", async () => { + mockFetchIPInfo.mockResolvedValue(ipInfo("us")) + expect(await BridgeService.plaidAvailableForIp(ip("8.8.8.8"))).toBe(true) + + mockFetchIPInfo.mockResolvedValue(ipInfo("jm")) + expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(false) + }) + + it("fails open (allows) when no IP is available — no lookup", async () => { + expect(await BridgeService.plaidAvailableForIp(undefined)).toBe(true) + expect(mockFetchIPInfo).not.toHaveBeenCalled() + }) + + it("fails open (allows) for a private/dev IP — no lookup", async () => { + expect(await BridgeService.plaidAvailableForIp(ip("192.168.1.10"))).toBe(true) + expect(mockFetchIPInfo).not.toHaveBeenCalled() + }) + + it("fails open (allows) when the geo lookup errors", async () => { + mockFetchIPInfo.mockResolvedValue(new UnknownIpFetcherServiceError("boom")) + expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(true) + }) + + it("fails open (allows) when the country is unknown (empty isoCode)", async () => { + mockFetchIPInfo.mockResolvedValue(ipInfo("")) + expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(true) + }) +}) From 3e3f6db13876b09c8aeb67986285967371e4dd1c Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 25 Jul 2026 14:43:55 -0700 Subject: [PATCH 2/3] fix(bridge): don't crash the mutation on IPv6 IPs; test resolver wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg --- src/services/bridge/index.ts | 37 +++++++-- .../bridge-add-external-account.spec.ts | 82 +++++++++++++++++++ .../bridge/plaid-available-for-ip.spec.ts | 16 ++++ 3 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index e35723532..2e40249f1 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -624,20 +624,43 @@ const createVirtualAccount = async ( * fallback still catches any non-US user that slips through. */ const plaidAvailableForIp = async (ip: IpAddress | undefined): Promise => { - if (!ip || isPrivateIp(ip)) return true + if (!ip) return true - const ipInfo = await IpFetcher().fetchIPInfo(ip) - if (ipInfo instanceof IpFetcherServiceError) { + try { + // isPrivateIp parses as IPv4 and THROWS on a real IPv6 address. Treat a + // throw as "not private" so IPv6 clients (common on mobile carriers) still + // get geo-checked rather than crashing the mutation — proxycheck resolves + // IPv6 fine, so the US gate keeps working for them. + let isPrivate = false + try { + isPrivate = isPrivateIp(ip) + } catch { + isPrivate = false + } + if (isPrivate) return true + + const ipInfo = await IpFetcher().fetchIPInfo(ip) + if (ipInfo instanceof IpFetcherServiceError) { + recordExceptionInCurrentSpan({ + error: ipInfo, + level: ErrorLevel.Warn, + attributes: { ip }, + }) + return true + } + + if (!ipInfo.isoCode) return true + return ipInfo.isoCode.toUpperCase() === "US" + } catch (error) { + // Backstop: any unexpected failure fails open. Never block a legitimate US + // user from linking a bank because of a geo hiccup. recordExceptionInCurrentSpan({ - error: ipInfo, + error: error instanceof Error ? error : new Error(String(error)), level: ErrorLevel.Warn, attributes: { ip }, }) return true } - - if (!ipInfo.isoCode) return true - return ipInfo.isoCode.toUpperCase() === "US" } /** diff --git a/test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts b/test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts new file mode 100644 index 000000000..a80bf9c8a --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts @@ -0,0 +1,82 @@ +// Resolver-level tests for the US-only Plaid gate wiring: a non-US IP must +// short-circuit to BRIDGE_PLAID_NOT_AVAILABLE (which the client routes to +// manual entry) and must NOT open a Plaid session; a US IP proceeds normally. + +jest.mock("@services/bridge", () => ({ + __esModule: true, + default: { + plaidAvailableForIp: jest.fn(), + addExternalAccount: jest.fn(), + }, +})) + +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true }, + getOnChainWalletConfig: jest.fn().mockReturnValue({ dustThreshold: 546 }), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import BridgeService from "@services/bridge" +import BridgeAddExternalAccountMutation from "@graphql/public/root/mutation/bridge-add-external-account" + +const ACCOUNT_ID = "account-001" as AccountId + +const ctxWithIp = (ipValue?: string) => + ({ + domainAccount: { id: ACCOUNT_ID, level: 2 }, + ip: ipValue as unknown as IpAddress | undefined, + }) as unknown as GraphQLPublicContextAuth + +type MutationResult = { + errors: Array<{ code?: string; message?: string }> + externalAccount?: unknown +} + +type ResolvableMutation = { + resolve?: ( + source: null, + args: Record, + context: GraphQLPublicContextAuth, + info: never, + ) => Promise | unknown +} + +const resolveAddExternal = async ( + ctx: GraphQLPublicContextAuth, +): Promise => { + const mutation = BridgeAddExternalAccountMutation as ResolvableMutation + if (!mutation.resolve) throw new Error("Missing resolver") + return (await mutation.resolve(null, {}, ctx, {} as never)) as MutationResult +} + +describe("bridgeAddExternalAccount — US-only Plaid gate", () => { + beforeEach(() => jest.clearAllMocks()) + + it("returns BRIDGE_PLAID_NOT_AVAILABLE and never opens Plaid for a non-US IP", async () => { + ;(BridgeService.plaidAvailableForIp as jest.Mock).mockResolvedValue(false) + + const result = await resolveAddExternal(ctxWithIp("69.160.103.177")) + + expect(BridgeService.plaidAvailableForIp).toHaveBeenCalledWith("69.160.103.177") + // The whole point: Plaid is never requested for a blocked IP. + expect(BridgeService.addExternalAccount).not.toHaveBeenCalled() + expect(result.errors).toHaveLength(1) + expect(result.errors[0].code).toBe("BRIDGE_PLAID_NOT_AVAILABLE") + expect(result.externalAccount).toBeUndefined() + }) + + it("issues the Plaid link token when the IP is allowed (US)", async () => { + ;(BridgeService.plaidAvailableForIp as jest.Mock).mockResolvedValue(true) + const linkResult = { linkToken: "link-tok", expiresAt: "later" } + ;(BridgeService.addExternalAccount as jest.Mock).mockResolvedValue(linkResult) + + const result = await resolveAddExternal(ctxWithIp("8.8.8.8")) + + expect(BridgeService.addExternalAccount).toHaveBeenCalledWith(ACCOUNT_ID) + expect(result.errors).toEqual([]) + expect(result.externalAccount).toEqual(linkResult) + }) +}) diff --git a/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts b/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts index add395805..238a28cfa 100644 --- a/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts +++ b/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts @@ -91,6 +91,22 @@ describe("BridgeService.plaidAvailableForIp", () => { expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(false) }) + it("geo-checks an IPv6 client instead of crashing (blocks non-US)", async () => { + // isPrivateIp throws on a real IPv6 address; the gate must swallow that and + // still geo-check, not throw out of the mutation. + mockFetchIPInfo.mockResolvedValue(ipInfo("JM")) + const ipv6 = ip("2607:fb90:abcd::1") + await expect(BridgeService.plaidAvailableForIp(ipv6)).resolves.toBe(false) + expect(mockFetchIPInfo).toHaveBeenCalledWith("2607:fb90:abcd::1") + }) + + it("allows a US IPv6 client", async () => { + mockFetchIPInfo.mockResolvedValue(ipInfo("US")) + await expect( + BridgeService.plaidAvailableForIp(ip("2607:fb90:abcd::1")), + ).resolves.toBe(true) + }) + it("fails open (allows) when no IP is available — no lookup", async () => { expect(await BridgeService.plaidAvailableForIp(undefined)).toBe(true) expect(mockFetchIPInfo).not.toHaveBeenCalled() From 9fa1085ec5a1ae11e6a3937ee22a703f8905eb21 Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 25 Jul 2026 15:00:26 -0700 Subject: [PATCH 3/3] feat(bridge): observability for the Plaid US gate + #flash-activity notify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg --- .../mutation/bridge-add-external-account.ts | 12 +++- src/services/bridge/index.ts | 56 ++++++++++++++----- .../bridge-add-external-account.spec.ts | 37 +++++++++--- .../bridge/plaid-available-for-ip.spec.ts | 56 +++++++++++++------ 4 files changed, 124 insertions(+), 37 deletions(-) diff --git a/src/graphql/public/root/mutation/bridge-add-external-account.ts b/src/graphql/public/root/mutation/bridge-add-external-account.ts index 864e57ce2..e0a5f87bb 100644 --- a/src/graphql/public/root/mutation/bridge-add-external-account.ts +++ b/src/graphql/public/root/mutation/bridge-add-external-account.ts @@ -9,6 +9,7 @@ import { BridgeAccountLevelError, BridgePlaidNotAvailableError, } from "@services/bridge/errors" +import { notifyOpsEvent } from "@services/alerts/ops-events" const BridgeAddExternalAccountPayload = GT.Object({ name: "BridgeAddExternalAccountPayload", @@ -32,8 +33,17 @@ const bridgeAddExternalAccount = GT.Field({ // Plaid onboarding is US-only. Send a non-US IP straight to the client's // manual-entry fallback (BRIDGE_PLAID_NOT_AVAILABLE) rather than opening a // Plaid session that gets blocked inside the webview. - const plaidAvailable = await BridgeService.plaidAvailableForIp(ip) + const { available: plaidAvailable, country } = await BridgeService.plaidGateForIp(ip) if (!plaidAvailable) { + // Surface to the #flash-activity ops feed so we can see non-US demand and + // confirm the gate is actually firing in prod. Fire-and-forget. + notifyOpsEvent({ + flow: "cashout", + phase: "plaid-blocked-non-us", + status: "failed", + accountId: domainAccount.id, + meta: { country: country ?? "unknown" }, + }) return { errors: [mapAndParseErrorForGqlResponse(new BridgePlaidNotAvailableError())], } diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 2e40249f1..c85d06e34 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -12,6 +12,7 @@ import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import { AccountsRepository } from "@services/mongoose/accounts" import { BridgeVirtualAccount } from "@services/mongoose/schema" import { + addAttributesToCurrentSpan, recordExceptionInCurrentSpan, wrapAsyncFunctionsToRunInSpan, } from "@services/tracing" @@ -608,8 +609,15 @@ const createVirtualAccount = async ( } } +type PlaidGateResult = { + // false only when the IP is a CONFIRMED non-US country. + available: boolean + // ISO country code when known (uppercased), else null. For telemetry/ops. + country: string | null +} + /** - * Whether Plaid Link is usable from the caller's IP. + * Whether Plaid Link is usable from the caller's IP, plus the resolved country. * * 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 @@ -618,13 +626,20 @@ const createVirtualAccount = async ( * to manual bank-details entry instead of opening a Plaid session that can't * complete. * - * Fail-open: only a CONFIRMED non-US IP returns false. No IP, a private/dev IP, - * a geo-lookup failure, or an unknown country all return true — so a lookup - * blip never locks a legitimate US user out of Plaid, and the client's on-exit - * fallback still catches any non-US user that slips through. + * Fail-open: only a CONFIRMED non-US IP is unavailable. No IP, a private/dev + * IP, a geo-lookup failure, or an unknown country all stay available — so a + * lookup blip never locks a legitimate US user out of Plaid, and the client's + * on-exit fallback still catches any non-US user that slips through. + * + * Every branch tags the current span with `plaid.gate.decision` (and country) + * so prod efficacy — and silent fail-open (e.g. missing proxycheck key) — is + * observable rather than invisible. */ -const plaidAvailableForIp = async (ip: IpAddress | undefined): Promise => { - if (!ip) return true +const plaidGateForIp = async (ip: IpAddress | undefined): Promise => { + if (!ip) { + addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-no-ip" }) + return { available: true, country: null } + } try { // isPrivateIp parses as IPv4 and THROWS on a real IPv6 address. Treat a @@ -637,7 +652,10 @@ const plaidAvailableForIp = async (ip: IpAddress | undefined): Promise } catch { isPrivate = false } - if (isPrivate) return true + if (isPrivate) { + addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-private-ip" }) + return { available: true, country: null } + } const ipInfo = await IpFetcher().fetchIPInfo(ip) if (ipInfo instanceof IpFetcherServiceError) { @@ -646,11 +664,22 @@ const plaidAvailableForIp = async (ip: IpAddress | undefined): Promise level: ErrorLevel.Warn, attributes: { ip }, }) - return true + addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-lookup-failed" }) + return { available: true, country: null } + } + + const country = ipInfo.isoCode ? ipInfo.isoCode.toUpperCase() : null + if (!country) { + addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-unknown-country" }) + return { available: true, country: null } } - if (!ipInfo.isoCode) return true - return ipInfo.isoCode.toUpperCase() === "US" + const available = country === "US" + addAttributesToCurrentSpan({ + "plaid.gate.decision": available ? "allowed-us" : "blocked-non-us", + "plaid.gate.country": country, + }) + return { available, country } } catch (error) { // Backstop: any unexpected failure fails open. Never block a legitimate US // user from linking a bank because of a geo hiccup. @@ -659,7 +688,8 @@ const plaidAvailableForIp = async (ip: IpAddress | undefined): Promise level: ErrorLevel.Warn, attributes: { ip }, }) - return true + addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-error" }) + return { available: true, country: null } } } @@ -1866,7 +1896,7 @@ export default wrapAsyncFunctionsToRunInSpan({ fns: { initiateKyc, createVirtualAccount, - plaidAvailableForIp, + plaidGateForIp, addExternalAccount, exchangePlaidPublicToken, createExternalAccount, diff --git a/test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts b/test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts index a80bf9c8a..c8914be36 100644 --- a/test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts +++ b/test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts @@ -1,11 +1,12 @@ // Resolver-level tests for the US-only Plaid gate wiring: a non-US IP must // short-circuit to BRIDGE_PLAID_NOT_AVAILABLE (which the client routes to -// manual entry) and must NOT open a Plaid session; a US IP proceeds normally. +// manual entry), must NOT open a Plaid session, and must emit an ops-feed +// event; a US IP proceeds normally and emits nothing. jest.mock("@services/bridge", () => ({ __esModule: true, default: { - plaidAvailableForIp: jest.fn(), + plaidGateForIp: jest.fn(), addExternalAccount: jest.fn(), }, })) @@ -19,7 +20,12 @@ jest.mock("@services/logger", () => ({ baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, })) +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: jest.fn(), +})) + import BridgeService from "@services/bridge" +import { notifyOpsEvent } from "@services/alerts/ops-events" import BridgeAddExternalAccountMutation from "@graphql/public/root/mutation/bridge-add-external-account" const ACCOUNT_ID = "account-001" as AccountId @@ -55,27 +61,44 @@ const resolveAddExternal = async ( describe("bridgeAddExternalAccount — US-only Plaid gate", () => { beforeEach(() => jest.clearAllMocks()) - it("returns BRIDGE_PLAID_NOT_AVAILABLE and never opens Plaid for a non-US IP", async () => { - ;(BridgeService.plaidAvailableForIp as jest.Mock).mockResolvedValue(false) + it("blocks a non-US IP: returns BRIDGE_PLAID_NOT_AVAILABLE, skips Plaid, notifies ops", async () => { + ;(BridgeService.plaidGateForIp as jest.Mock).mockResolvedValue({ + available: false, + country: "JM", + }) const result = await resolveAddExternal(ctxWithIp("69.160.103.177")) - expect(BridgeService.plaidAvailableForIp).toHaveBeenCalledWith("69.160.103.177") + expect(BridgeService.plaidGateForIp).toHaveBeenCalledWith("69.160.103.177") // The whole point: Plaid is never requested for a blocked IP. expect(BridgeService.addExternalAccount).not.toHaveBeenCalled() expect(result.errors).toHaveLength(1) expect(result.errors[0].code).toBe("BRIDGE_PLAID_NOT_AVAILABLE") expect(result.externalAccount).toBeUndefined() + // Ops-feed signal carries the resolved country. + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + flow: "cashout", + phase: "plaid-blocked-non-us", + status: "failed", + accountId: ACCOUNT_ID, + meta: { country: "JM" }, + }), + ) }) - it("issues the Plaid link token when the IP is allowed (US)", async () => { - ;(BridgeService.plaidAvailableForIp as jest.Mock).mockResolvedValue(true) + it("issues the Plaid link token when the IP is allowed (US) and notifies nothing", async () => { + ;(BridgeService.plaidGateForIp as jest.Mock).mockResolvedValue({ + available: true, + country: "US", + }) const linkResult = { linkToken: "link-tok", expiresAt: "later" } ;(BridgeService.addExternalAccount as jest.Mock).mockResolvedValue(linkResult) const result = await resolveAddExternal(ctxWithIp("8.8.8.8")) expect(BridgeService.addExternalAccount).toHaveBeenCalledWith(ACCOUNT_ID) + expect(notifyOpsEvent).not.toHaveBeenCalled() expect(result.errors).toEqual([]) expect(result.externalAccount).toEqual(linkResult) }) diff --git a/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts b/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts index 238a28cfa..a5df517eb 100644 --- a/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts +++ b/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts @@ -1,4 +1,4 @@ -// Unit tests for BridgeService.plaidAvailableForIp — the US-only Plaid gate. +// Unit tests for BridgeService.plaidGateForIp — the US-only Plaid gate. // Plaid onboarding is US-only, so a non-US egress IP must be routed to manual // entry (BRIDGE_PLAID_NOT_AVAILABLE) rather than opening a Plaid session it // can't complete. The gate is fail-open: only a CONFIRMED non-US IP blocks. @@ -15,6 +15,7 @@ jest.mock("@services/mongoose/wallets", () => ({ WalletsRepository: jest.fn() }) jest.mock("@services/tracing", () => ({ wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: F }) => fns, recordExceptionInCurrentSpan: jest.fn(), + addAttributesToCurrentSpan: jest.fn(), })) jest.mock("@services/logger", () => ({ @@ -52,6 +53,9 @@ jest.mock("@services/ipfetcher", () => ({ import BridgeService from "@services/bridge" import { UnknownIpFetcherServiceError } from "@domain/ipfetcher" +import { addAttributesToCurrentSpan } from "@services/tracing" + +const mockAddAttributes = addAttributesToCurrentSpan as jest.Mock const ip = (value: string) => value as unknown as IpAddress @@ -66,64 +70,84 @@ const ipInfo = (isoCode: string) => ({ proxy: false, }) -describe("BridgeService.plaidAvailableForIp", () => { +describe("BridgeService.plaidGateForIp", () => { beforeEach(() => { jest.clearAllMocks() }) - it("blocks a confirmed non-US IP (Jamaica) without opening Plaid", async () => { + it("blocks a confirmed non-US IP (Jamaica) and tags the span", async () => { mockFetchIPInfo.mockResolvedValue(ipInfo("JM")) // 69.160.103.177 = Digicel Jamaica — the real reported case. - expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(false) + const result = await BridgeService.plaidGateForIp(ip("69.160.103.177")) + + expect(result).toEqual({ available: false, country: "JM" }) expect(mockFetchIPInfo).toHaveBeenCalledWith("69.160.103.177") + // Observability: the block decision is visible on the span. + expect(mockAddAttributes).toHaveBeenCalledWith({ + "plaid.gate.decision": "blocked-non-us", + "plaid.gate.country": "JM", + }) }) it("allows a US IP", async () => { mockFetchIPInfo.mockResolvedValue(ipInfo("US")) - expect(await BridgeService.plaidAvailableForIp(ip("8.8.8.8"))).toBe(true) + const result = await BridgeService.plaidGateForIp(ip("8.8.8.8")) + + expect(result).toEqual({ available: true, country: "US" }) + expect(mockAddAttributes).toHaveBeenCalledWith({ + "plaid.gate.decision": "allowed-us", + "plaid.gate.country": "US", + }) }) it("treats isoCode case-insensitively", async () => { mockFetchIPInfo.mockResolvedValue(ipInfo("us")) - expect(await BridgeService.plaidAvailableForIp(ip("8.8.8.8"))).toBe(true) + expect((await BridgeService.plaidGateForIp(ip("8.8.8.8"))).available).toBe(true) mockFetchIPInfo.mockResolvedValue(ipInfo("jm")) - expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(false) + const jm = await BridgeService.plaidGateForIp(ip("69.160.103.177")) + expect(jm.available).toBe(false) + expect(jm.country).toBe("JM") }) it("geo-checks an IPv6 client instead of crashing (blocks non-US)", async () => { // isPrivateIp throws on a real IPv6 address; the gate must swallow that and // still geo-check, not throw out of the mutation. mockFetchIPInfo.mockResolvedValue(ipInfo("JM")) - const ipv6 = ip("2607:fb90:abcd::1") - await expect(BridgeService.plaidAvailableForIp(ipv6)).resolves.toBe(false) + const result = await BridgeService.plaidGateForIp(ip("2607:fb90:abcd::1")) + + expect(result).toEqual({ available: false, country: "JM" }) expect(mockFetchIPInfo).toHaveBeenCalledWith("2607:fb90:abcd::1") }) it("allows a US IPv6 client", async () => { mockFetchIPInfo.mockResolvedValue(ipInfo("US")) - await expect( - BridgeService.plaidAvailableForIp(ip("2607:fb90:abcd::1")), - ).resolves.toBe(true) + expect((await BridgeService.plaidGateForIp(ip("2607:fb90:abcd::1"))).available).toBe( + true, + ) }) it("fails open (allows) when no IP is available — no lookup", async () => { - expect(await BridgeService.plaidAvailableForIp(undefined)).toBe(true) + const result = await BridgeService.plaidGateForIp(undefined) + expect(result).toEqual({ available: true, country: null }) expect(mockFetchIPInfo).not.toHaveBeenCalled() }) it("fails open (allows) for a private/dev IP — no lookup", async () => { - expect(await BridgeService.plaidAvailableForIp(ip("192.168.1.10"))).toBe(true) + const result = await BridgeService.plaidGateForIp(ip("192.168.1.10")) + expect(result).toEqual({ available: true, country: null }) expect(mockFetchIPInfo).not.toHaveBeenCalled() }) it("fails open (allows) when the geo lookup errors", async () => { mockFetchIPInfo.mockResolvedValue(new UnknownIpFetcherServiceError("boom")) - expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(true) + const result = await BridgeService.plaidGateForIp(ip("69.160.103.177")) + expect(result).toEqual({ available: true, country: null }) }) it("fails open (allows) when the country is unknown (empty isoCode)", async () => { mockFetchIPInfo.mockResolvedValue(ipInfo("")) - expect(await BridgeService.plaidAvailableForIp(ip("69.160.103.177"))).toBe(true) + const result = await BridgeService.plaidGateForIp(ip("69.160.103.177")) + expect(result).toEqual({ available: true, country: null }) }) })