diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index 3fea6b4a055..bcf547c7939 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -218,6 +218,30 @@ describe('deployConfig', async () => { expect(got).toMatchObject({theme_extension: {files: {}}}) }) + test('passes app configuration context to deployConfig', async () => { + const extensionInstance = await testThemeExtensions() + const originalDeployConfig = extensionInstance.specification.deployConfig + const deployConfig = vi.fn().mockResolvedValue({theme_extension: {files: {}}}) + extensionInstance.specification.deployConfig = deployConfig + + try { + await extensionInstance.deployConfig({ + apiKey: 'apiKey', + appConfiguration: placeholderAppConfiguration, + }) + + expect(deployConfig).toHaveBeenCalledWith( + extensionInstance.configuration, + extensionInstance.directory, + 'apiKey', + undefined, + {appConfiguration: placeholderAppConfiguration}, + ) + } finally { + extensionInstance.specification.deployConfig = originalDeployConfig + } + }) + test('returns transformed config when defined', async () => { const extensionInstance = await testAppConfigExtensions() diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 3554617eb81..348e25f671a 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -199,7 +199,15 @@ export class ExtensionInstance { - const deployConfig = await this.specification.deployConfig?.(this.configuration, this.directory, apiKey, undefined) + const deployConfig = await this.specification.deployConfig?.( + this.configuration, + this.directory, + apiKey, + undefined, + { + appConfiguration, + }, + ) const transformedConfig = this.specification.transformLocalToRemote?.(this.configuration, appConfiguration) as | {[key: string]: unknown} | undefined diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index bfbabb2af51..0dc1825a0a1 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -57,6 +57,10 @@ export interface BuildAsset { static?: boolean } +interface ExtensionDeployConfigContext { + appConfiguration: AppConfiguration +} + /** * Extension specification with all the needed properties and methods to load an extension. */ @@ -80,6 +84,7 @@ export interface ExtensionSpecification Promise | undefined> validate?: (config: TConfiguration, configPath: string, directory: string) => Promise> preDeployValidation?: (extension: ExtensionInstance) => Promise diff --git a/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts b/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts new file mode 100644 index 00000000000..555bd097754 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts @@ -0,0 +1,153 @@ +import {placeholderAppConfiguration, testFlowActionExtension} from '../../app/app.test-data.js' +import {ExtensionInstance} from '../extension-instance.js' +import {BaseConfigType} from '../schemas.js' +import {ApplicationURLs} from '../../../services/dev/urls.js' +import {beforeEach, describe, expect, test} from 'vitest' + +type FlowActionConfig = BaseConfigType & { + type: 'flow_action' + handle: string + name: string + runtime_url: string + validation_url?: string + config_page_url?: string + config_page_preview_url?: string +} + +const tunnelUrls: ApplicationURLs = { + applicationUrl: 'https://my-tunnel.example.com', + redirectUrlWhitelist: [], +} + +const urlFields = ['runtime_url', 'validation_url', 'config_page_url', 'config_page_preview_url'] as const + +describe('FlowActionExtension', () => { + let extension: ExtensionInstance + + const config: FlowActionConfig = { + type: 'flow_action', + handle: 'place-bid', + name: 'Place auction bid', + description: 'Place a bid on an auction', + runtime_url: '/api/execute', + validation_url: '/api/validate', + config_page_url: '/config', + config_page_preview_url: '/config/preview', + } + + beforeEach(async () => { + extension = (await testFlowActionExtension()) as ExtensionInstance + extension.configuration = {...config} + }) + + test('accepts an absolute https runtime_url', () => { + // When + const parsed = extension.specification.parseConfigurationObject({ + ...config, + runtime_url: 'https://example.com/api/execute', + }) + + // Then + expect(parsed.state).toBe('ok') + }) + + test('accepts a relative runtime_url starting with /', () => { + // When + const parsed = extension.specification.parseConfigurationObject(config) + + // Then + expect(parsed.state).toBe('ok') + }) + + test('rejects a non-https absolute runtime_url', () => { + // When + const parsed = extension.specification.parseConfigurationObject({ + ...config, + runtime_url: 'http://example.com/api/execute', + }) + + // Then + expect(parsed.state).toBe('error') + }) + + test('preserves absolute URLs and prepends the app URL to relative URLs in the deploy configuration', async () => { + // Given + extension.configuration = { + ...extension.configuration, + runtime_url: '/api/execute', + validation_url: 'https://my-app.example.com/api/validate', + config_page_url: '/config', + config_page_preview_url: 'https://my-app.example.com/config/preview', + } + + // When + const got = await extension.deployConfig({ + apiKey: 'api-key', + appConfiguration: { + ...placeholderAppConfiguration, + application_url: 'https://my-app.example.com', + }, + }) + + // Then + expect(got).toEqual({ + title: extension.configuration.name, + description: extension.configuration.description, + url: 'https://my-app.example.com/api/execute', + fields: [], + validation_url: 'https://my-app.example.com/api/validate', + custom_configuration_page_url: 'https://my-app.example.com/config', + custom_configuration_page_preview_url: 'https://my-app.example.com/config/preview', + schema_patch: '', + return_type_ref: undefined, + }) + }) + + test.each(urlFields)('throws when deploying a relative %s without an app URL', async (field) => { + // Given + extension.configuration = { + ...extension.configuration, + runtime_url: 'https://my-prod-host.example.com/api/execute', + validation_url: 'https://my-prod-host.example.com/api/validate', + config_page_url: 'https://my-prod-host.example.com/config', + config_page_preview_url: 'https://my-prod-host.example.com/config/preview', + } + extension.configuration[field] = `/${field}` + + // When/Then + await expect( + extension.deployConfig({ + apiKey: 'api-key', + appConfiguration: placeholderAppConfiguration, + }), + ).rejects.toThrow( + `Flow action ${field} is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.`, + ) + }) + + test('prepends the dev application URL to relative URL fields', () => { + // When + extension.patchWithAppDevURLs(tunnelUrls) + + // Then + expect(extension.configuration.runtime_url).toBe('https://my-tunnel.example.com/api/execute') + expect(extension.configuration.validation_url).toBe('https://my-tunnel.example.com/api/validate') + expect(extension.configuration.config_page_url).toBe('https://my-tunnel.example.com/config') + expect(extension.configuration.config_page_preview_url).toBe('https://my-tunnel.example.com/config/preview') + }) + + test('leaves absolute dev URLs untouched', () => { + // Given + extension.configuration.runtime_url = 'https://my-prod-host.example.com/api/execute' + extension.configuration.validation_url = undefined + extension.configuration.config_page_url = undefined + extension.configuration.config_page_preview_url = undefined + + // When + extension.patchWithAppDevURLs(tunnelUrls) + + // Then + expect(extension.configuration.runtime_url).toBe('https://my-prod-host.example.com/api/execute') + expect(extension.configuration.validation_url).toBeUndefined() + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/flow_action.ts b/packages/app/src/cli/models/extensions/specifications/flow_action.ts index 0df7fffb731..85ca7bfa962 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.ts @@ -1,22 +1,25 @@ +// import {prependApplicationUrl} from './validation/url_prepender.js' import {BaseSchemaWithHandle} from '../schemas.js' import {createExtensionSpecification} from '../specification.js' +import {validateRelativeUrl} from '../../app/validation/common.js' import { validateFieldShape, - startsWithHttps, validateCustomConfigurationPageConfig, validateReturnTypeConfig, } from '../../../services/flow/validation.js' import {serializeFields} from '../../../services/flow/serialize-fields.js' -import {loadSchemaFromPath} from '../../../services/flow/utils.js' +import {loadSchemaFromPath, resolveFlowActionUrl} from '../../../services/flow/utils.js' import {zod} from '@shopify/cli-kit/node/schema' +const RELATIVE_URL_FIELDS = ['runtime_url', 'validation_url', 'config_page_url', 'config_page_preview_url'] as const + const FlowActionExtensionSchema = BaseSchemaWithHandle.extend({ type: zod.literal('flow_action'), name: zod.string(), - runtime_url: zod.string().url().refine(startsWithHttps), - validation_url: zod.string().url().refine(startsWithHttps).optional(), - config_page_url: zod.string().url().refine(startsWithHttps).optional(), - config_page_preview_url: zod.string().url().refine(startsWithHttps).optional(), + runtime_url: validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})), + validation_url: validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(), + config_page_url: validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(), + config_page_preview_url: validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(), schema: zod.string().optional(), return_type_ref: zod.string().optional(), }).refine((config) => { @@ -45,15 +48,38 @@ const flowActionSpecification = createExtensionSpecification({ // https://github.com/Shopify/cli/blob/73ac91c0f40be0a57d1b18cb34254b12d3a071af/packages/app/src/cli/services/deploy.ts#L107 // Should be removed after unified deployment is 100% rolled out appModuleFeatures: (_) => [], - deployConfig: async (config, extensionPath) => { + /** + * During `app dev`, swap any relative URLs (starting with `/`) for the dev + * tunnel URL the CLI assigned. This lets developers write + * `runtime_url = "/api/execute"` in their TOML and have it resolved against + * the tunnel automatically — the same pattern app_proxy, webhooks, and + * events subscriptions already use. + * + */ + patchWithAppDevURLs: (config, urls) => { + for (const key of RELATIVE_URL_FIELDS) { + const value = config[key] + if (typeof value === 'string' && value.startsWith('/')) { + config[key] = resolveFlowActionUrl(key, value, urls.applicationUrl) ?? '' + } + } + }, + deployConfig: async (config, extensionPath, _apiKey, _moduleId, context) => { + const appConfiguration = context?.appConfiguration + const appUrl = typeof appConfiguration?.application_url === 'string' ? appConfiguration.application_url : undefined + return { title: config.name, description: config.description, - url: config.runtime_url, + url: resolveFlowActionUrl('runtime_url', config.runtime_url, appUrl), fields: serializeFields('flow_action', config.settings?.fields), - validation_url: config.validation_url, - custom_configuration_page_url: config.config_page_url, - custom_configuration_page_preview_url: config.config_page_preview_url, + validation_url: resolveFlowActionUrl('validation_url', config.validation_url, appUrl), + custom_configuration_page_url: resolveFlowActionUrl('config_page_url', config.config_page_url, appUrl), + custom_configuration_page_preview_url: resolveFlowActionUrl( + 'config_page_preview_url', + config.config_page_preview_url, + appUrl, + ), schema_patch: await loadSchemaFromPath(extensionPath, config.schema), return_type_ref: config.return_type_ref, } diff --git a/packages/app/src/cli/services/flow/utils.test.ts b/packages/app/src/cli/services/flow/utils.test.ts index d0582058c5c..d5bd9bc3050 100644 --- a/packages/app/src/cli/services/flow/utils.test.ts +++ b/packages/app/src/cli/services/flow/utils.test.ts @@ -1,8 +1,44 @@ -import {loadSchemaFromPath} from './utils.js' +import {loadSchemaFromPath, resolveFlowActionUrl} from './utils.js' import {describe, expect, test} from 'vitest' import {readFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' +describe('resolveFlowActionUrl', () => { + test('returns undefined when the URL is not configured', () => { + expect(resolveFlowActionUrl('validation_url', undefined, 'https://my-app.example.com')).toBeUndefined() + }) + + test('returns absolute URLs unchanged', () => { + expect( + resolveFlowActionUrl('runtime_url', 'https://my-prod-host.example.com/api/execute', 'https://my-app.example.com'), + ).toBe('https://my-prod-host.example.com/api/execute') + }) + + test('prepends the app URL to relative URLs', () => { + expect(resolveFlowActionUrl('runtime_url', '/api/execute', 'https://my-app.example.com/')).toBe( + 'https://my-app.example.com/api/execute', + ) + }) + + test('throws when a relative URL cannot be resolved without an app URL', () => { + expect(() => resolveFlowActionUrl('runtime_url', '/api/execute', undefined)).toThrow( + 'Flow action runtime_url is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.', + ) + }) + + test('throws when an absolute URL is not HTTPS', () => { + expect(() => resolveFlowActionUrl('runtime_url', 'http://my-prod-host.example.com/api/execute', undefined)).toThrow( + 'Flow action runtime_url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.', + ) + }) + + test('throws when a relative URL resolves against a non-HTTPS app URL', () => { + expect(() => resolveFlowActionUrl('runtime_url', '/api/execute', 'http://my-app.example.com')).toThrow( + 'Flow action runtime_url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.', + ) + }) +}) + describe('loadSchemaFromPath', () => { test('loading schema from valid file path should return file contents', async () => { const extensionPath = __dirname.concat('/fixtures') diff --git a/packages/app/src/cli/services/flow/utils.ts b/packages/app/src/cli/services/flow/utils.ts index cdc71c0575f..ce13859891e 100644 --- a/packages/app/src/cli/services/flow/utils.ts +++ b/packages/app/src/cli/services/flow/utils.ts @@ -1,9 +1,32 @@ +import {prependApplicationUrl} from '../../models/extensions/specifications/validation/url_prepender.js' import {joinPath} from '@shopify/cli-kit/node/path' import {glob, readFile} from '@shopify/cli-kit/node/fs' +import {AbortError} from '@shopify/cli-kit/node/error' /** * Loads the schema from the partner defined file. */ +export const resolveFlowActionUrl = (fieldName: string, url: string | undefined, appUrl: string | undefined) => { + if (!url) return undefined + + const resolvedUrl = prependApplicationUrl(url, appUrl) + if (resolvedUrl.startsWith('/')) { + throw new AbortError( + `Flow action ${fieldName} is a relative URL, but no application_url is configured. ` + + 'Set application_url in your app configuration or use an absolute HTTPS URL.', + ) + } + + if (!resolvedUrl.startsWith('https://')) { + throw new AbortError( + `Flow action ${fieldName} must resolve to an HTTPS URL. ` + + 'Set application_url to an HTTPS URL or use an absolute HTTPS URL.', + ) + } + + return resolvedUrl +} + export const loadSchemaFromPath = async (extensionPath: string, patchPath: string | undefined) => { if (!patchPath) { return '' diff --git a/packages/app/src/cli/services/flow/validation.ts b/packages/app/src/cli/services/flow/validation.ts index e69fa1be5c7..20b6bec3670 100644 --- a/packages/app/src/cli/services/flow/validation.ts +++ b/packages/app/src/cli/services/flow/validation.ts @@ -53,8 +53,6 @@ export const validateFieldShape = ( return baseFieldSchema.parse(configField) } -export const startsWithHttps = (url: string) => url.startsWith('https://') - export const isSchemaTypeReference = (type: string) => type.startsWith('schema.') export const validateCustomConfigurationPageConfig = (