diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c7cd05d266d..c84d5b0cf06 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `RampsService.getDefaultRedirectCallbackUrl()` and the matching `RampsService:getDefaultRedirectCallbackUrl` messenger action (plus the exported `RampsServiceGetDefaultRedirectCallbackUrlAction` type), which return the widened Headless Buy default redirect ("fake callback") URL for the environment the service was constructed with. The method is synchronous. ([#9752](https://github.com/MetaMask/core/pull/9752)) + - `baseUrlOverride` deliberately does not apply. It overrides the ramps API host for local development, which in production and staging is not the host that serves `/regions/fake-callback` (`on-ramp-content` versus `on-ramp{-cache}`), and the redirect URL is matched by client UI to detect flow completion. For development the callback already shares the API host family (`on-ramp.dev-api`). Use `RampsEnvironment.Local` for a localhost callback, noting it is pinned to `http://localhost:3000` and does not follow a non-3000 `baseUrlOverride`. +- Add the exported `getDefaultRedirectCallbackUrl(environment)` helper, the canonical environment-to-callback map that `RampsService` uses: `on-ramp-content` hosts for production and staging, `on-ramp.dev-api` for development (there is no `on-ramp-content.dev-api` deployment), and `localhost:3000` for local. Client code that needs the value synchronously, without the messenger, can call it directly with the same environment the service was given. ([#9752](https://github.com/MetaMask/core/pull/9752)) + +### Changed + +- **BREAKING:** `RampsController` now calls `RampsService:getDefaultRedirectCallbackUrl` on the widened quote path, so hosts must delegate that action to the controller's messenger. It is included in the exported `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS` list; hosts that spell out their delegated action list instead of spreading that constant have to add it, or the entire `RampsController:getQuotes` call rejects with a messenger "handler has not been delegated" error (including MM Pay's fiat quote path, which omits `redirectUrl` and relies on widening). ([#9752](https://github.com/MetaMask/core/pull/9752)) + - The action is only called when the `moneyHeadlessAllProviders` widening is in effect and the caller omitted `redirectUrl`. An explicit `redirectUrl` and the native-only path never reach the service. + +### Removed + +- **BREAKING:** Remove the `getDefaultRedirectUrl` callback option from `RampsControllerOptions`. The controller asks `RampsService` for the default redirect URL instead, which keeps the environment a single runtime source of truth so the callback host cannot drift from the API host the service is talking to. ([#9752](https://github.com/MetaMask/core/pull/9752)) + - Mobile should drop the `getDefaultRedirectUrl: () => getRampCallbackBaseUrl()` argument from its `RampsController` init once it upgrades, and reimplement `getRampCallbackBaseUrl()` as `getDefaultRedirectCallbackUrl(getRampsEnvironment())` so the UI callback matcher and the controller default resolve from the same environment source. + ## [18.0.1] ### Changed diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 1bb8099f86a..e6535ad34dd 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -64,6 +64,14 @@ import type { PatchUserRequestBody, } from './TransakService.js'; +/** + * The default redirect ("fake callback") URL a staging `RampsService` returns. + * Written out in full so the tests pin the exact host the widened quote path + * forwards, rather than re-deriving it from the code under test. + */ +const STAGING_REDIRECT_CALLBACK_URL = + 'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback'; + describe('RampsController', () => { const circuitBreakerOpenErrorMessage = 'Execution prevented because the circuit breaker is open'; @@ -1387,24 +1395,24 @@ describe('RampsController', () => { ); }); - it('forwards the injected default redirectUrl on the widened path when the caller omits one', async () => { + it("forwards the service's default redirectUrl on the widened path when the caller omits one", async () => { const response: QuotesResponse = { success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; - const DEFAULT_REDIRECT = 'https://default.example/callback'; await withController( { options: { - getDefaultRedirectUrl: () => DEFAULT_REDIRECT, state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { registerFeatureFlagState(rootMessenger); + const getDefaultRedirectCallbackUrlSpy = + spyOnDefaultRedirectCallbackUrl(rootMessenger); let forwardedRedirectUrl: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -1416,74 +1424,69 @@ describe('RampsController', () => { await callScopedGetQuotes(messenger); - // The caller omitted redirectUrl, so the widened path supplies the - // injected default and forwards it to the service. - expect(forwardedRedirectUrl).toBe(DEFAULT_REDIRECT); + // The caller omitted redirectUrl, so the widened path asks the + // service for the callback URL of its environment and forwards it. + expect(getDefaultRedirectCallbackUrlSpy).toHaveBeenCalledTimes(1); + expect(forwardedRedirectUrl).toBe( + 'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback', + ); }, ); }); - it('prefers an explicit caller redirectUrl over the injected default on the widened path', async () => { + it('rejects the entire getQuotes call when the default-redirect service action is not delegated', async () => { const response: QuotesResponse = { success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; - const DEFAULT_REDIRECT = 'https://default.example/callback'; - const EXPLICIT_REDIRECT = 'https://explicit.example/callback'; await withController( { options: { - getDefaultRedirectUrl: () => DEFAULT_REDIRECT, state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { registerFeatureFlagState(rootMessenger); - let forwardedRedirectUrl: string | undefined; + // Simulate a host that upgraded without adding the new action to + // its hand-written messenger delegation list. + rootMessenger.unregisterActionHandler( + 'RampsService:getDefaultRedirectCallbackUrl', + ); rootMessenger.registerActionHandler( 'RampsService:getQuotes', - async (params: { redirectUrl?: string }) => { - forwardedRedirectUrl = params.redirectUrl; - return response; - }, + async () => response, ); - await callScopedGetQuotes(messenger, { - redirectUrl: EXPLICIT_REDIRECT, - }); - - // An explicit caller redirectUrl always wins; the default is not - // applied. - expect(forwardedRedirectUrl).toBe(EXPLICIT_REDIRECT); + await expect(callScopedGetQuotes(messenger)).rejects.toThrow( + /A handler for RampsService:getDefaultRedirectCallbackUrl has not been (registered|delegated)/u, + ); }, ); }); - it('does not inject the default redirectUrl when the flag is disabled', async () => { + it('forwards whichever callback URL the service reports, without rederiving it', async () => { const response: QuotesResponse = { - success: [appBrowserQuote(NATIVE, 70)], - sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + success: [appBrowserQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; - const DEFAULT_REDIRECT = 'https://default.example/callback'; await withController( { options: { - getDefaultRedirectUrl: () => DEFAULT_REDIRECT, - state: scopeState([buildScopeProvider(NATIVE, 'native')]), + state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { - registerFeatureFlagState(rootMessenger, { - remoteFeatureFlags: { - [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, - }, - }); + registerFeatureFlagState(rootMessenger); + spyOnDefaultRedirectCallbackUrl( + rootMessenger, + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ); let forwardedRedirectUrl: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -1495,20 +1498,23 @@ describe('RampsController', () => { await callScopedGetQuotes(messenger); - // The disabled flag never widens, so the default is not injected - // even when a `getDefaultRedirectUrl` callback is present. - expect(forwardedRedirectUrl).toBeUndefined(); + // A production service reports the production host, and the + // controller passes it through untouched. + expect(forwardedRedirectUrl).toBe( + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ); }, ); }); - it('forwards undefined on the widened path when no getDefaultRedirectUrl option is provided', async () => { + it('prefers an explicit caller redirectUrl and never asks the service on the widened path', async () => { const response: QuotesResponse = { success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; + const EXPLICIT_REDIRECT = 'https://explicit.example/callback'; await withController( { @@ -1518,23 +1524,65 @@ describe('RampsController', () => { }, async ({ messenger, rootMessenger }) => { registerFeatureFlagState(rootMessenger); + const getDefaultRedirectCallbackUrlSpy = + spyOnDefaultRedirectCallbackUrl(rootMessenger); + let forwardedRedirectUrl: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { redirectUrl?: string }) => { + forwardedRedirectUrl = params.redirectUrl; + return response; + }, + ); + + await callScopedGetQuotes(messenger, { + redirectUrl: EXPLICIT_REDIRECT, + }); + + // An explicit caller redirectUrl always wins, and the controller + // short-circuits before reaching the service. + expect(forwardedRedirectUrl).toBe(EXPLICIT_REDIRECT); + expect(getDefaultRedirectCallbackUrlSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not inject the default redirectUrl or ask the service when the flag is disabled', async () => { + const response: QuotesResponse = { + success: [appBrowserQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + error: [], + customActions: [], + }; + await withController( + { + options: { + state: scopeState([buildScopeProvider(NATIVE, 'native')]), + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + }); + const getDefaultRedirectCallbackUrlSpy = + spyOnDefaultRedirectCallbackUrl(rootMessenger); let forwardedRedirectUrl: string | undefined; - let redirectUrlWasSeen = false; rootMessenger.registerActionHandler( 'RampsService:getQuotes', async (params: { redirectUrl?: string }) => { forwardedRedirectUrl = params.redirectUrl; - redirectUrlWasSeen = true; return response; }, ); await callScopedGetQuotes(messenger); - // With no injected callback, the constructor default returns - // undefined, so the widened path forwards undefined. - expect(redirectUrlWasSeen).toBe(true); + // The disabled flag never widens, so nothing is injected and the + // service is not consulted. expect(forwardedRedirectUrl).toBeUndefined(); + expect(getDefaultRedirectCallbackUrlSpy).not.toHaveBeenCalled(); }, ); }); @@ -11422,7 +11470,39 @@ type WithControllerOptions = { * @returns The root messenger. */ function getRootMessenger(): RootMessenger { - return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + // Stands in for the real service, which derives this from its environment. + rootMessenger.registerActionHandler( + 'RampsService:getDefaultRedirectCallbackUrl', + () => STAGING_REDIRECT_CALLBACK_URL, + ); + return rootMessenger; +} + +/** + * Replaces the default `RampsService:getDefaultRedirectCallbackUrl` handler + * with a spy, so a test can assert whether the controller asked the service + * for the default redirect URL at all. + * + * @param rootMessenger - The root messenger to re-register the handler on. + * @param url - The URL the spy returns. Defaults to the staging callback URL. + * @returns The spy standing in for the service method. + */ +function spyOnDefaultRedirectCallbackUrl( + rootMessenger: RootMessenger, + url: string = STAGING_REDIRECT_CALLBACK_URL, +): jest.Mock { + const handler = jest.fn(() => url); + rootMessenger.unregisterActionHandler( + 'RampsService:getDefaultRedirectCallbackUrl', + ); + rootMessenger.registerActionHandler( + 'RampsService:getDefaultRedirectCallbackUrl', + handler, + ); + return handler; } /** diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 3f388e457f3..5c3df5c3186 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -20,6 +20,7 @@ import type { RampsControllerMethodActions } from './RampsController-method-acti import type { RampsErrorCode } from './rampsErrorCodes.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; import type { + RampsServiceGetDefaultRedirectCallbackUrlAction, RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, RampsServiceGetTokensAction, @@ -127,6 +128,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( | RampsServiceActions['type'] | TransakServiceActions['type'] )[] = [ + 'RampsService:getDefaultRedirectCallbackUrl', 'RampsService:getGeolocation', 'RampsService:getCountries', 'RampsService:getTokens', @@ -595,6 +597,7 @@ export type RampsControllerActions = */ type AllowedActions = | RemoteFeatureFlagControllerGetStateAction + | RampsServiceGetDefaultRedirectCallbackUrlAction | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction | RampsServiceGetTokensAction @@ -681,17 +684,6 @@ export type RampsControllerOptions = { requestCacheTTL?: number; /** Maximum number of entries in the request cache. Defaults to 250. */ requestCacheMaxSize?: number; - /** - * Optional callback returning the default redirect URL to use for the widened - * quote fetch when the caller omits `redirectUrl`. The quotes API only - * embeds a `buyURL`/`buyWidget` (the WebView page a non-native provider needs) - * when a `redirectUrl` is present, so supplying this default lets widened - * aggregator quotes carry a usable widget URL. Only applied on the - * widened path; an explicit caller `redirectUrl` always wins and the - * native-only default never injects. Defaults to a callback returning - * `undefined` when omitted. - */ - getDefaultRedirectUrl?: () => string | undefined; }; // === HELPER FUNCTIONS === @@ -878,13 +870,6 @@ export class RampsController extends BaseController< */ readonly #requestCacheMaxSize: number; - /** - * Resolves the default redirect URL for the widened quote fetch when - * the caller omits `redirectUrl`. Defaults to `() => undefined` when no - * callback is injected. - */ - readonly #getDefaultRedirectUrl: () => string | undefined; - /** * Map of pending requests for deduplication. * Key is the cache key, value is the pending request with abort controller. @@ -951,16 +936,12 @@ export class RampsController extends BaseController< * controller. Missing properties will be filled in with defaults. * @param args.requestCacheTTL - Time to live for cached requests in milliseconds. * @param args.requestCacheMaxSize - Maximum number of entries in the request cache. - * @param args.getDefaultRedirectUrl - Optional callback returning the default - * redirect URL used for the widened quote fetch when the caller omits - * `redirectUrl`. Defaults to a callback returning `undefined`. */ constructor({ messenger, state = {}, requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, - getDefaultRedirectUrl, }: RampsControllerOptions) { super({ messenger, @@ -976,8 +957,6 @@ export class RampsController extends BaseController< this.#requestCacheTTL = requestCacheTTL; this.#requestCacheMaxSize = requestCacheMaxSize; - this.#getDefaultRedirectUrl = - getDefaultRedirectUrl ?? ((): string | undefined => undefined); this.messenger.registerMethodActionHandlers( this, @@ -2000,13 +1979,17 @@ export class RampsController extends BaseController< const normalizedWalletAddress = options.walletAddress.trim(); // The quotes API only embeds a `buyURL`/`buyWidget` when a `redirectUrl` is - // present, so on the widened path (where MM Pay omits one) supply the - // injected default so aggregator quotes carry a usable widget URL. An - // explicit caller `redirectUrl` always wins, and the native-only path - // (flag off) never injects. + // present, so on the widened path (where MM Pay omits one) ask the service + // for the callback URL of the environment it is configured with, so + // aggregator quotes carry a usable widget URL that always matches the + // environment the quotes came from. An explicit caller `redirectUrl` + // always wins, and the native-only path (flag off) never injects, so + // neither reaches the service. const effectiveRedirectUrl = options.redirectUrl ?? - (widenToAllProviders ? this.#getDefaultRedirectUrl() : undefined); + (widenToAllProviders + ? this.messenger.call('RampsService:getDefaultRedirectCallbackUrl') + : undefined); const cacheKey = createCacheKey('getQuotes', [ normalizedRegion, diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index e4e3044b28a..c9b00ec92d4 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -5,6 +5,37 @@ import type { RampsService } from './RampsService.js'; +/** + * Returns the default redirect ("fake callback") URL for this service's + * environment. + * + * The quotes API only embeds a `buyURL`/`buyWidget` (the WebView page a + * non-native provider needs) when a `redirectUrl` is present, so callers + * that omit one (MM Pay's widened Headless Buy fetch) use this value. + * Exposing it here makes the service's environment the single runtime source + * of truth, so the callback can never point at a different environment than + * the one the quotes themselves came from. + * + * `baseUrlOverride` deliberately does not apply. That option overrides the + * ramps API base URL for local development. In production and staging the + * callback lives on a different host (`on-ramp-content` versus + * `on-ramp{-cache}`), so an API override says nothing about where + * `/regions/fake-callback` lives. In development the callback already shares + * the API host family (`on-ramp.dev-api`). The redirect URL is also handed + * to the provider and matched by client UI to detect flow completion, so + * returning an unrelated local API origin here would break completion + * detection rather than help it. Point `environment` at + * {@link RampsEnvironment.Local} for a localhost callback, noting that URL + * is pinned to `http://localhost:3000` and does not follow a non-3000 + * `baseUrlOverride`. + * + * @returns The default redirect callback URL for the configured environment. + */ +export type RampsServiceGetDefaultRedirectCallbackUrlAction = { + type: `RampsService:getDefaultRedirectCallbackUrl`; + handler: RampsService['getDefaultRedirectCallbackUrl']; +}; + /** * Makes a request to the API in order to retrieve the user's geolocation * based on their IP address. @@ -149,6 +180,7 @@ export type RampsServiceGetOrderFromCallbackAction = { * Union of all RampsService action types. */ export type RampsServiceMethodActions = + | RampsServiceGetDefaultRedirectCallbackUrlAction | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction | RampsServiceGetTokensAction diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index d645d447456..bb59974639f 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -9,7 +9,11 @@ import nock, { cleanAll } from 'nock'; import { flushPromises } from '../../../tests/helpers.js'; import packageJson from '../package.json'; import type { RampsServiceMessenger } from './RampsService.js'; -import { RampsService, RampsEnvironment } from './RampsService.js'; +import { + getDefaultRedirectCallbackUrl, + RampsService, + RampsEnvironment, +} from './RampsService.js'; const CONTROLLER_VERSION = packageJson.version; @@ -3129,6 +3133,93 @@ describe('RampsService', () => { await expect(orderPromise).rejects.toThrow("failed with status '500'"); }); }); + + describe('getDefaultRedirectCallbackUrl', () => { + it.each([ + [ + RampsEnvironment.Production, + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ], + [ + RampsEnvironment.Staging, + 'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback', + ], + [ + RampsEnvironment.Development, + 'https://on-ramp.dev-api.cx.metamask.io/regions/fake-callback', + ], + [RampsEnvironment.Local, 'http://localhost:3000/regions/fake-callback'], + ])( + 'returns the callback URL for the %s environment', + (environment, url) => { + const { service } = getService({ options: { environment } }); + + expect(service.getDefaultRedirectCallbackUrl()).toBe(url); + }, + ); + + it('defaults to the staging callback URL, matching the default environment', () => { + const { service } = getService(); + + expect(service.getDefaultRedirectCallbackUrl()).toBe( + 'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback', + ); + }); + + it('ignores baseUrlOverride, which only redirects the ramps API host', () => { + const { service } = getService({ + options: { + environment: RampsEnvironment.Production, + baseUrlOverride: 'http://custom-url.test', + }, + }); + + // The callback is served by the content host, not the API host, and the + // client matches this URL to detect flow completion, so a local API + // override must not move it. + expect(service.getDefaultRedirectCallbackUrl()).toBe( + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ); + }); + + it('is callable through the messenger', () => { + const { rootMessenger } = getService({ + options: { environment: RampsEnvironment.Production }, + }); + + expect( + rootMessenger.call('RampsService:getDefaultRedirectCallbackUrl'), + ).toBe( + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ); + }); + }); +}); + +describe('getDefaultRedirectCallbackUrl', () => { + it.each([ + [ + RampsEnvironment.Production, + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ], + [ + RampsEnvironment.Staging, + 'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback', + ], + [ + RampsEnvironment.Development, + 'https://on-ramp.dev-api.cx.metamask.io/regions/fake-callback', + ], + [RampsEnvironment.Local, 'http://localhost:3000/regions/fake-callback'], + ])('derives the callback URL for the %s environment', (environment, url) => { + expect(getDefaultRedirectCallbackUrl(environment)).toBe(url); + }); + + it('throws for an unknown environment', () => { + expect(() => + getDefaultRedirectCallbackUrl('unknown' as unknown as RampsEnvironment), + ).toThrow('Invalid environment: unknown'); + }); }); /** diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 819e95bcdc1..14c6c8b45bd 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -672,6 +672,7 @@ export enum RampsApiService { // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ + 'getDefaultRedirectCallbackUrl', 'getGeolocation', 'getCountries', 'getTokens', @@ -749,6 +750,53 @@ function getBaseUrl( } } +/** + * The path served by the ramps content host that redirects back into the + * client once a non-native provider's widget flow completes. + */ +const FAKE_CALLBACK_PATH = '/regions/fake-callback'; + +/** + * Derives the default redirect ("fake callback") URL for the widened Headless + * Buy quote fetch from the ramps environment. + * + * The quotes API only embeds a `buyURL`/`buyWidget` (the WebView page a + * non-native provider needs) when a `redirectUrl` is present, so the widened + * aggregator path supplies this default when the caller omits one. Production + * and staging serve the callback from the `on-ramp-content` CDN hosts; + * development has no `on-ramp-content.dev-api` deployment, so it uses the + * `on-ramp.dev-api` host (which serves `/regions/fake-callback` and returns + * 200). This intentionally does not reuse {@link getBaseUrl}, whose Regions + * host is `on-ramp{-cache}`, not `on-ramp-content`. + * + * This is the canonical environment-to-callback map for the whole package. + * Prefer the `RampsService:getDefaultRedirectCallbackUrl` messenger action at + * runtime so the value always follows the environment the service is actually + * configured with. Call this function directly only where a synchronous, + * messenger-free value is needed (for example client UI that matches the + * callback URL in a WebView), and pass the same environment the service was + * constructed with. + * + * @param environment - The environment to derive the callback URL for. + * @returns The default redirect callback URL for that environment. + */ +export function getDefaultRedirectCallbackUrl( + environment: RampsEnvironment, +): string { + switch (environment) { + case RampsEnvironment.Production: + return `https://on-ramp-content.api.cx.metamask.io${FAKE_CALLBACK_PATH}`; + case RampsEnvironment.Staging: + return `https://on-ramp-content.uat-api.cx.metamask.io${FAKE_CALLBACK_PATH}`; + case RampsEnvironment.Development: + return `https://on-ramp.dev-api.cx.metamask.io${FAKE_CALLBACK_PATH}`; + case RampsEnvironment.Local: + return `http://localhost:3000${FAKE_CALLBACK_PATH}`; + default: + throw new Error(`Invalid environment: ${String(environment)}`); + } +} + /** * Constructs an API path with a version prefix. * @@ -899,6 +947,36 @@ export class RampsService { return getBaseUrl(this.#environment, service); } + /** + * Returns the default redirect ("fake callback") URL for this service's + * environment. + * + * The quotes API only embeds a `buyURL`/`buyWidget` (the WebView page a + * non-native provider needs) when a `redirectUrl` is present, so callers + * that omit one (MM Pay's widened Headless Buy fetch) use this value. + * Exposing it here makes the service's environment the single runtime source + * of truth, so the callback can never point at a different environment than + * the one the quotes themselves came from. + * + * `baseUrlOverride` deliberately does not apply. That option overrides the + * ramps API base URL for local development. In production and staging the + * callback lives on a different host (`on-ramp-content` versus + * `on-ramp{-cache}`), so an API override says nothing about where + * `/regions/fake-callback` lives. In development the callback already shares + * the API host family (`on-ramp.dev-api`). The redirect URL is also handed + * to the provider and matched by client UI to detect flow completion, so + * returning an unrelated local API origin here would break completion + * detection rather than help it. Point `environment` at + * {@link RampsEnvironment.Local} for a localhost callback, noting that URL + * is pinned to `http://localhost:3000` and does not follow a non-3000 + * `baseUrlOverride`. + * + * @returns The default redirect callback URL for the configured environment. + */ + getDefaultRedirectCallbackUrl(): string { + return getDefaultRedirectCallbackUrl(this.#environment); + } + /** * Builds the request headers for authenticated ramps API calls. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 239e3a5b7ec..ef452ca5620 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -109,8 +109,10 @@ export { RampsApiService, RampsOrderStatus, RAMPS_SDK_VERSION, + getDefaultRedirectCallbackUrl, } from './RampsService.js'; export type { + RampsServiceGetDefaultRedirectCallbackUrlAction, RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, RampsServiceGetPaymentMethodsAction,