diff --git a/.changeset/light-lamps-draw.md b/.changeset/light-lamps-draw.md new file mode 100644 index 00000000000..2781786deb9 --- /dev/null +++ b/.changeset/light-lamps-draw.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Allow Flow action extension URLs to be as relative paths and resolved against the application URL during dev and deploy 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..7b07fe11b19 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts @@ -0,0 +1,164 @@ +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.each(urlFields)('rejects a relative %s containing a newline', (field) => { + // When + const parsed = extension.specification.parseConfigurationObject({ + ...config, + [field]: `/${field}\nmalicious-header: value`, + }) + + // 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..09801cd0c48 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.ts @@ -2,21 +2,22 @@ import {BaseSchemaWithHandle} from '../schemas.js' import {createExtensionSpecification} from '../specification.js' import { validateFieldShape, - startsWithHttps, + validateFlowActionUrl, validateCustomConfigurationPageConfig, validateReturnTypeConfig, } from '../../../services/flow/validation.js' import {serializeFields} from '../../../services/flow/serialize-fields.js' -import {loadSchemaFromPath} from '../../../services/flow/utils.js' +import {FLOW_ACTION_URL_FIELDS} from '../../../services/flow/types.js' +import {loadSchemaFromPath, resolveFlowActionUrl} from '../../../services/flow/utils.js' import {zod} from '@shopify/cli-kit/node/schema' 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: validateFlowActionUrl(zod.string({invalid_type_error: 'Value must be string'})), + validation_url: validateFlowActionUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(), + config_page_url: validateFlowActionUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(), + config_page_preview_url: validateFlowActionUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(), schema: zod.string().optional(), return_type_ref: zod.string().optional(), }).refine((config) => { @@ -45,15 +46,40 @@ 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 FLOW_ACTION_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: config.validation_url + ? resolveFlowActionUrl('validation_url', config.validation_url, appUrl) + : undefined, + custom_configuration_page_url: config.config_page_url + ? resolveFlowActionUrl('config_page_url', config.config_page_url, appUrl) + : undefined, + custom_configuration_page_preview_url: config.config_page_preview_url + ? resolveFlowActionUrl('config_page_preview_url', config.config_page_preview_url, appUrl) + : undefined, schema_patch: await loadSchemaFromPath(extensionPath, config.schema), return_type_ref: config.return_type_ref, } diff --git a/packages/app/src/cli/services/flow/types.ts b/packages/app/src/cli/services/flow/types.ts index f7f7ac76541..a7e9c7fa2d0 100644 --- a/packages/app/src/cli/services/flow/types.ts +++ b/packages/app/src/cli/services/flow/types.ts @@ -21,3 +21,12 @@ export interface SerializedField { export type FlowExtensionTypes = 'flow_action' | 'flow_trigger' export type FlowPartnersExtensionTypes = 'flow_action_definition' | 'flow_trigger_definition' + +export const FLOW_ACTION_URL_FIELDS = [ + 'runtime_url', + 'validation_url', + 'config_page_url', + 'config_page_preview_url', +] as const + +export type FlowActionUrlField = (typeof FLOW_ACTION_URL_FIELDS)[number] diff --git a/packages/app/src/cli/services/flow/utils.test.ts b/packages/app/src/cli/services/flow/utils.test.ts index d0582058c5c..8f260c4e334 100644 --- a/packages/app/src/cli/services/flow/utils.test.ts +++ b/packages/app/src/cli/services/flow/utils.test.ts @@ -1,8 +1,46 @@ -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 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 the URL is empty', () => { + expect(() => resolveFlowActionUrl('runtime_url', '', 'https://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.', + ) + }) + + 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..9d458d77f79 100644 --- a/packages/app/src/cli/services/flow/utils.ts +++ b/packages/app/src/cli/services/flow/utils.ts @@ -1,5 +1,31 @@ +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' +import type {FlowActionUrlField} from './types.js' + +/** + * Resolves a Flow action URL by prepending the app URL to relative URLs and + * ensuring the resolved URL is HTTPS. + */ +export const resolveFlowActionUrl = (fieldName: FlowActionUrlField, url: string, appUrl: string | 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 +} /** * Loads the schema from the partner defined file. diff --git a/packages/app/src/cli/services/flow/validation.test.ts b/packages/app/src/cli/services/flow/validation.test.ts index 17bef91cf47..d49a1b459c6 100644 --- a/packages/app/src/cli/services/flow/validation.test.ts +++ b/packages/app/src/cli/services/flow/validation.test.ts @@ -1,8 +1,41 @@ -import {validateFieldShape, validateCustomConfigurationPageConfig, validateReturnTypeConfig} from './validation.js' +import { + validateFieldShape, + validateFlowActionUrl, + validateCustomConfigurationPageConfig, + validateReturnTypeConfig, +} from './validation.js' import {ConfigField} from './types.js' import {describe, expect, test} from 'vitest' import {zod} from '@shopify/cli-kit/node/schema' +describe('validateFlowActionUrl', () => { + const schema = validateFlowActionUrl(zod.string()) + + test('accepts absolute HTTPS URLs', () => { + expect(schema.safeParse('https://example.com/api/execute').success).toBe(true) + }) + + test('accepts relative URLs starting with /', () => { + expect(schema.safeParse('/api/execute').success).toBe(true) + }) + + test('rejects non-HTTPS absolute URLs', () => { + expect(schema.safeParse('http://example.com/api/execute').success).toBe(false) + }) + + test.each(['\n', '\r', '\t'])('rejects relative URLs containing %j', (controlCharacter) => { + expect(schema.safeParse(`/api/execute${controlCharacter}malicious-header: value`).success).toBe(false) + }) + + test.each(['\n', '\r', '\t'])('rejects absolute URLs containing %j', (controlCharacter) => { + expect(schema.safeParse(`https://example.com/api/execute${controlCharacter}malicious-header`).success).toBe(false) + }) + + test('rejects protocol-relative URLs', () => { + expect(schema.safeParse('//example.com/api/execute').success).toBe(false) + }) +}) + describe('validateFieldShape', () => { test('should return true when non-commerce object field has valid shape and is flow action', () => { // given diff --git a/packages/app/src/cli/services/flow/validation.ts b/packages/app/src/cli/services/flow/validation.ts index e69fa1be5c7..48f1a210d3e 100644 --- a/packages/app/src/cli/services/flow/validation.ts +++ b/packages/app/src/cli/services/flow/validation.ts @@ -1,6 +1,7 @@ import {ConfigField, FlowExtensionTypes} from './types.js' import {SUPPORTED_COMMERCE_OBJECTS} from './constants.js' import {FlowTriggerSettingsSchema} from '../../models/extensions/specifications/flow_trigger.js' +import {validateRelativeUrl} from '../../models/app/validation/common.js' import {zod} from '@shopify/cli-kit/node/schema' function fieldValidationErrorMessage(property: string, configField: ConfigField, handle: string, index: number) { @@ -53,10 +54,21 @@ export const validateFieldShape = ( return baseFieldSchema.parse(configField) } -export const startsWithHttps = (url: string) => url.startsWith('https://') - export const isSchemaTypeReference = (type: string) => type.startsWith('schema.') +const containsUrlControlCharacter = (value: string) => /[\r\n\t]/.test(value) + +export const validateFlowActionUrl = (zodType: zod.ZodString) => { + return validateRelativeUrl(zodType, { + message: + 'Invalid URL: URL must be an absolute HTTPS URL or a relative URL starting with a single slash (e.g. "/api/endpoint").', + }) + .refine((value) => !containsUrlControlCharacter(value), { + message: 'Invalid URL: URL must not contain control characters such as newlines or tabs.', + }) + .refine((value) => !value.startsWith('//'), {message: 'Invalid URL: Relative URLs must start with a single slash.'}) +} + export const validateCustomConfigurationPageConfig = ( configPageUrl?: string, configPagePreviewUrl?: string,