From 487d5bd1fc2037262ff0a703d9973d53da79ccda Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Fri, 29 May 2026 16:44:46 -0400 Subject: [PATCH 01/10] Allow for relative urls in Flow action extensions during development --- .../specifications/flow_action.test.ts | 117 ++++++++++++++++++ .../extensions/specifications/flow_action.ts | 29 ++++- 2 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/cli/models/extensions/specifications/flow_action.test.ts 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..a32d000834f --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts @@ -0,0 +1,117 @@ +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: [], +} + +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('handles the deploy configuration', async () => { + // When + const got = await extension.deployConfig({ + apiKey: 'api-key', + appConfiguration: placeholderAppConfiguration, + }) + + // Then + expect(got).toEqual({ + title: extension.configuration.name, + description: extension.configuration.description, + url: extension.configuration.runtime_url, + fields: [], + validation_url: extension.configuration.validation_url, + custom_configuration_page_url: extension.configuration.config_page_url, + custom_configuration_page_preview_url: extension.configuration.config_page_preview_url, + schema_patch: '', + return_type_ref: undefined, + }) + }) + + 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..4b76cf867dc 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.ts @@ -1,8 +1,9 @@ +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' @@ -10,13 +11,15 @@ import {serializeFields} from '../../../services/flow/serialize-fields.js' import {loadSchemaFromPath} 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,6 +48,22 @@ 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: (_) => [], + /** + * 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] = prependApplicationUrl(value, urls.applicationUrl) + } + } + }, deployConfig: async (config, extensionPath) => { return { title: config.name, From 67fd91dc829ebcbaa4f0be2f2a265ec99f138cd2 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Thu, 4 Jun 2026 10:52:02 -0400 Subject: [PATCH 02/10] Allow flow action deployments to use relative urls --- .../extensions/specifications/flow_action.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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 4b76cf867dc..46f5388b601 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.ts @@ -64,15 +64,22 @@ const flowActionSpecification = createExtensionSpecification({ } } }, - deployConfig: async (config, extensionPath) => { + 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: prependApplicationUrl(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 ? prependApplicationUrl(config.validation_url, appUrl) : undefined, + custom_configuration_page_url: config.config_page_url + ? prependApplicationUrl(config.config_page_url, appUrl) + : undefined, + custom_configuration_page_preview_url: config.config_page_preview_url + ? prependApplicationUrl(config.config_page_preview_url, appUrl) + : undefined, schema_patch: await loadSchemaFromPath(extensionPath, config.schema), return_type_ref: config.return_type_ref, } From 563c8dd8a06d7cf5ee0799dbd7e80fc0ab5baedc Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Thu, 4 Jun 2026 13:12:50 -0400 Subject: [PATCH 03/10] Have runtime urls be properly resolved with app url at dev and deploy time --- .../specifications/flow_action.test.ts | 39 ++++++++++++++++--- .../extensions/specifications/flow_action.ts | 22 +++++------ .../app/src/cli/services/flow/utils.test.ts | 38 +++++++++++++++++- packages/app/src/cli/services/flow/utils.ts | 23 +++++++++++ 4 files changed, 104 insertions(+), 18 deletions(-) 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 index a32d000834f..49c10fdd169 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts @@ -19,6 +19,8 @@ const tunnelUrls: ApplicationURLs = { redirectUrlWhitelist: [], } +const urlFields = ['runtime_url', 'validation_url', 'config_page_url', 'config_page_preview_url'] as const + describe('FlowActionExtension', () => { let extension: ExtensionInstance @@ -68,27 +70,52 @@ describe('FlowActionExtension', () => { expect(parsed.state).toBe('error') }) - test('handles the deploy configuration', async () => { + test('prepends the app URL to relative URL fields in the deploy configuration', async () => { // When const got = await extension.deployConfig({ apiKey: 'api-key', - appConfiguration: placeholderAppConfiguration, + appConfiguration: { + ...placeholderAppConfiguration, + application_url: 'https://my-app.example.com', + }, }) // Then expect(got).toEqual({ title: extension.configuration.name, description: extension.configuration.description, - url: extension.configuration.runtime_url, + url: 'https://my-app.example.com/api/execute', fields: [], - validation_url: extension.configuration.validation_url, - custom_configuration_page_url: extension.configuration.config_page_url, - custom_configuration_page_preview_url: extension.configuration.config_page_preview_url, + 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) 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 46f5388b601..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,4 +1,4 @@ -import {prependApplicationUrl} from './validation/url_prepender.js' +// import {prependApplicationUrl} from './validation/url_prepender.js' import {BaseSchemaWithHandle} from '../schemas.js' import {createExtensionSpecification} from '../specification.js' import {validateRelativeUrl} from '../../app/validation/common.js' @@ -8,7 +8,7 @@ import { 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 @@ -60,7 +60,7 @@ const flowActionSpecification = createExtensionSpecification({ for (const key of RELATIVE_URL_FIELDS) { const value = config[key] if (typeof value === 'string' && value.startsWith('/')) { - config[key] = prependApplicationUrl(value, urls.applicationUrl) + config[key] = resolveFlowActionUrl(key, value, urls.applicationUrl) ?? '' } } }, @@ -71,15 +71,15 @@ const flowActionSpecification = createExtensionSpecification({ return { title: config.name, description: config.description, - url: prependApplicationUrl(config.runtime_url, appUrl), + url: resolveFlowActionUrl('runtime_url', config.runtime_url, appUrl), fields: serializeFields('flow_action', config.settings?.fields), - validation_url: config.validation_url ? prependApplicationUrl(config.validation_url, appUrl) : undefined, - custom_configuration_page_url: config.config_page_url - ? prependApplicationUrl(config.config_page_url, appUrl) - : undefined, - custom_configuration_page_preview_url: config.config_page_preview_url - ? prependApplicationUrl(config.config_page_preview_url, appUrl) - : undefined, + 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 '' From 26c25d20c3e6d624e7401a89bcc0129511b60255 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Thu, 4 Jun 2026 13:13:06 -0400 Subject: [PATCH 04/10] Remove unused validation function --- packages/app/src/cli/services/flow/validation.ts | 2 -- 1 file changed, 2 deletions(-) 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 = ( From 3b294505435d0f39ef7b43431628468ed5d43c89 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Thu, 4 Jun 2026 14:09:11 -0400 Subject: [PATCH 05/10] Expand deploy config url resolution test --- .../extensions/specifications/flow_action.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 index 49c10fdd169..555bd097754 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts @@ -70,7 +70,16 @@ describe('FlowActionExtension', () => { expect(parsed.state).toBe('error') }) - test('prepends the app URL to relative URL fields in the deploy configuration', async () => { + 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', From 4b9e23bad6edf835cdd4f3edb7f88e9704991fb6 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Thu, 4 Jun 2026 15:03:01 -0400 Subject: [PATCH 06/10] Add and use custom Flow action url validation --- .../specifications/flow_action.test.ts | 11 ++++++++ .../extensions/specifications/flow_action.ts | 11 ++++---- packages/app/src/cli/services/flow/utils.ts | 6 ++++- .../src/cli/services/flow/validation.test.ts | 27 ++++++++++++++++++- .../app/src/cli/services/flow/validation.ts | 5 ++++ 5 files changed, 52 insertions(+), 8 deletions(-) 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 index 555bd097754..7b07fe11b19 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.test.ts @@ -70,6 +70,17 @@ describe('FlowActionExtension', () => { 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 = { 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 85ca7bfa962..a624d49db85 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.ts @@ -1,9 +1,8 @@ -// 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, + validateFlowActionUrl, validateCustomConfigurationPageConfig, validateReturnTypeConfig, } from '../../../services/flow/validation.js' @@ -16,10 +15,10 @@ const RELATIVE_URL_FIELDS = ['runtime_url', 'validation_url', 'config_page_url', const FlowActionExtensionSchema = BaseSchemaWithHandle.extend({ type: zod.literal('flow_action'), name: zod.string(), - 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(), + 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) => { diff --git a/packages/app/src/cli/services/flow/utils.ts b/packages/app/src/cli/services/flow/utils.ts index ce13859891e..8b3a2a41d10 100644 --- a/packages/app/src/cli/services/flow/utils.ts +++ b/packages/app/src/cli/services/flow/utils.ts @@ -4,7 +4,8 @@ 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. + * resolves url for fieldName by either prepending with appUrl on confirming that + * url start with https */ export const resolveFlowActionUrl = (fieldName: string, url: string | undefined, appUrl: string | undefined) => { if (!url) return undefined @@ -27,6 +28,9 @@ export const resolveFlowActionUrl = (fieldName: string, url: string | undefined, return resolvedUrl } +/** + * Loads the schema from the partner defined file. + */ export const loadSchemaFromPath = async (extensionPath: string, patchPath: string | undefined) => { if (!patchPath) { return '' diff --git a/packages/app/src/cli/services/flow/validation.test.ts b/packages/app/src/cli/services/flow/validation.test.ts index 17bef91cf47..0fe74bcebfd 100644 --- a/packages/app/src/cli/services/flow/validation.test.ts +++ b/packages/app/src/cli/services/flow/validation.test.ts @@ -1,8 +1,33 @@ -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('rejects relative URLs containing a newline', () => { + expect(schema.safeParse('/api/execute\nmalicious-header: value').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 20b6bec3670..604fdbccfdc 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) { @@ -55,6 +56,10 @@ export const validateFieldShape = ( export const isSchemaTypeReference = (type: string) => type.startsWith('schema.') +export const validateFlowActionUrl = (zodType: zod.ZodString) => { + return validateRelativeUrl(zodType).refine((value) => !value.includes('\n'), {message: 'Invalid URL'}) +} + export const validateCustomConfigurationPageConfig = ( configPageUrl?: string, configPagePreviewUrl?: string, From 569fe8dc3b1ff943a4b456d0d9e6635d08ab3feb Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Thu, 4 Jun 2026 15:45:25 -0400 Subject: [PATCH 07/10] Tighten URL validation --- .../app/src/cli/services/flow/validation.test.ts | 12 ++++++++++-- packages/app/src/cli/services/flow/validation.ts | 10 +++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/services/flow/validation.test.ts b/packages/app/src/cli/services/flow/validation.test.ts index 0fe74bcebfd..d49a1b459c6 100644 --- a/packages/app/src/cli/services/flow/validation.test.ts +++ b/packages/app/src/cli/services/flow/validation.test.ts @@ -23,8 +23,16 @@ describe('validateFlowActionUrl', () => { expect(schema.safeParse('http://example.com/api/execute').success).toBe(false) }) - test('rejects relative URLs containing a newline', () => { - expect(schema.safeParse('/api/execute\nmalicious-header: value').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) }) }) diff --git a/packages/app/src/cli/services/flow/validation.ts b/packages/app/src/cli/services/flow/validation.ts index 604fdbccfdc..7d5d9077cf8 100644 --- a/packages/app/src/cli/services/flow/validation.ts +++ b/packages/app/src/cli/services/flow/validation.ts @@ -56,8 +56,16 @@ export const validateFieldShape = ( export const isSchemaTypeReference = (type: string) => type.startsWith('schema.') +const containsUrlControlCharacter = (value: string) => /[\r\n\t]/.test(value) + +const isFlowActionRelativeUrl = (value: string) => { + return value.startsWith('/') && !value.startsWith('//') && !containsUrlControlCharacter(value) +} + export const validateFlowActionUrl = (zodType: zod.ZodString) => { - return validateRelativeUrl(zodType).refine((value) => !value.includes('\n'), {message: 'Invalid URL'}) + return validateRelativeUrl(zodType) + .refine((value) => !containsUrlControlCharacter(value), {message: 'Invalid URL'}) + .refine((value) => !value.startsWith('/') || isFlowActionRelativeUrl(value), {message: 'Invalid URL'}) } export const validateCustomConfigurationPageConfig = ( From d7f6e391705563154a97d82a364e6fef36119711 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Thu, 4 Jun 2026 15:52:28 -0400 Subject: [PATCH 08/10] Tigheten URL resolver function --- .../extensions/specifications/flow_action.ts | 23 ++++++++++--------- packages/app/src/cli/services/flow/types.ts | 9 ++++++++ .../app/src/cli/services/flow/utils.test.ts | 10 ++++---- packages/app/src/cli/services/flow/utils.ts | 9 ++++---- 4 files changed, 31 insertions(+), 20 deletions(-) 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 a624d49db85..09801cd0c48 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.ts @@ -7,11 +7,10 @@ import { validateReturnTypeConfig, } from '../../../services/flow/validation.js' import {serializeFields} from '../../../services/flow/serialize-fields.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 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(), @@ -56,10 +55,10 @@ const flowActionSpecification = createExtensionSpecification({ * */ patchWithAppDevURLs: (config, urls) => { - for (const key of RELATIVE_URL_FIELDS) { + 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) ?? '' + config[key] = resolveFlowActionUrl(key, value, urls.applicationUrl) } } }, @@ -72,13 +71,15 @@ const flowActionSpecification = createExtensionSpecification({ description: config.description, url: resolveFlowActionUrl('runtime_url', config.runtime_url, appUrl), fields: serializeFields('flow_action', config.settings?.fields), - 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, - ), + 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 d5bd9bc3050..8f260c4e334 100644 --- a/packages/app/src/cli/services/flow/utils.test.ts +++ b/packages/app/src/cli/services/flow/utils.test.ts @@ -4,10 +4,6 @@ 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'), @@ -32,6 +28,12 @@ describe('resolveFlowActionUrl', () => { ) }) + 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.', diff --git a/packages/app/src/cli/services/flow/utils.ts b/packages/app/src/cli/services/flow/utils.ts index 8b3a2a41d10..9d458d77f79 100644 --- a/packages/app/src/cli/services/flow/utils.ts +++ b/packages/app/src/cli/services/flow/utils.ts @@ -2,14 +2,13 @@ import {prependApplicationUrl} from '../../models/extensions/specifications/vali 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 url for fieldName by either prepending with appUrl on confirming that - * url start with https + * Resolves a Flow action URL by prepending the app URL to relative URLs and + * ensuring the resolved URL is HTTPS. */ -export const resolveFlowActionUrl = (fieldName: string, url: string | undefined, appUrl: string | undefined) => { - if (!url) return undefined - +export const resolveFlowActionUrl = (fieldName: FlowActionUrlField, url: string, appUrl: string | undefined) => { const resolvedUrl = prependApplicationUrl(url, appUrl) if (resolvedUrl.startsWith('/')) { throw new AbortError( From e9e8ed7e608dcc8a70e3617e17f644d5f7f471d0 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Thu, 4 Jun 2026 15:56:34 -0400 Subject: [PATCH 09/10] Add changeset --- .changeset/light-lamps-draw.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/light-lamps-draw.md 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 From dde49be12330dc0e5f1dfd59b9ac22e8473a667b Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Fri, 5 Jun 2026 09:11:28 -0400 Subject: [PATCH 10/10] Clean up validation and validation messages --- packages/app/src/cli/services/flow/validation.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/services/flow/validation.ts b/packages/app/src/cli/services/flow/validation.ts index 7d5d9077cf8..48f1a210d3e 100644 --- a/packages/app/src/cli/services/flow/validation.ts +++ b/packages/app/src/cli/services/flow/validation.ts @@ -58,14 +58,15 @@ export const isSchemaTypeReference = (type: string) => type.startsWith('schema.' const containsUrlControlCharacter = (value: string) => /[\r\n\t]/.test(value) -const isFlowActionRelativeUrl = (value: string) => { - return value.startsWith('/') && !value.startsWith('//') && !containsUrlControlCharacter(value) -} - export const validateFlowActionUrl = (zodType: zod.ZodString) => { - return validateRelativeUrl(zodType) - .refine((value) => !containsUrlControlCharacter(value), {message: 'Invalid URL'}) - .refine((value) => !value.startsWith('/') || isFlowActionRelativeUrl(value), {message: 'Invalid URL'}) + 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 = (