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..e0a5f87bb 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,12 @@ 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" +import { notifyOpsEvent } from "@services/alerts/ops-events" const BridgeAddExternalAccountPayload = GT.Object({ name: "BridgeAddExternalAccountPayload", @@ -16,7 +21,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 +30,25 @@ 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 { 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())], + } + } + 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..c85d06e34 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -11,11 +11,18 @@ 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 { + addAttributesToCurrentSpan, + 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 +30,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 +609,90 @@ 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, 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 + * 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 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 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 + // 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) { + addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-private-ip" }) + return { available: true, country: null } + } + + const ipInfo = await IpFetcher().fetchIPInfo(ip) + if (ipInfo instanceof IpFetcherServiceError) { + recordExceptionInCurrentSpan({ + error: ipInfo, + level: ErrorLevel.Warn, + attributes: { ip }, + }) + 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 } + } + + 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. + recordExceptionInCurrentSpan({ + error: error instanceof Error ? error : new Error(String(error)), + level: ErrorLevel.Warn, + attributes: { ip }, + }) + addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-error" }) + return { available: true, country: null } + } +} + /** * Requests a Plaid link_token from Bridge for adding external accounts. */ @@ -1805,6 +1896,7 @@ export default wrapAsyncFunctionsToRunInSpan({ fns: { initiateKyc, createVirtualAccount, + 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 new file mode 100644 index 000000000..c8914be36 --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/bridge-add-external-account.spec.ts @@ -0,0 +1,105 @@ +// 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), 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: { + plaidGateForIp: 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() }, +})) + +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 + +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("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.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) 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 new file mode 100644 index 000000000..a5df517eb --- /dev/null +++ b/test/flash/unit/services/bridge/plaid-available-for-ip.spec.ts @@ -0,0 +1,153 @@ +// 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. + +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(), + addAttributesToCurrentSpan: 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" +import { addAttributesToCurrentSpan } from "@services/tracing" + +const mockAddAttributes = addAttributesToCurrentSpan as jest.Mock + +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.plaidGateForIp", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + 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. + 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")) + 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.plaidGateForIp(ip("8.8.8.8"))).available).toBe(true) + + mockFetchIPInfo.mockResolvedValue(ipInfo("jm")) + 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 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")) + expect((await BridgeService.plaidGateForIp(ip("2607:fb90:abcd::1"))).available).toBe( + true, + ) + }) + + it("fails open (allows) when no IP is available — no lookup", async () => { + 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 () => { + 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")) + 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("")) + const result = await BridgeService.plaidGateForIp(ip("69.160.103.177")) + expect(result).toEqual({ available: true, country: null }) + }) +})