From 2fde5e02aea93a3a49e6073047f368e40d646b92 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Fri, 21 Aug 2026 13:45:04 -0400 Subject: [PATCH 01/12] Create helper file for relative app extension config urls --- .../src/cli/models/app/validation/common.ts | 10 ++ .../validation/app_relative_urls.test.ts | 135 ++++++++++++++++++ .../validation/app_relative_urls.ts | 78 ++++++++++ 3 files changed, 223 insertions(+) create mode 100644 packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts diff --git a/packages/app/src/cli/models/app/validation/common.ts b/packages/app/src/cli/models/app/validation/common.ts index 2850caa22ea..da24f2bb0f4 100644 --- a/packages/app/src/cli/models/app/validation/common.ts +++ b/packages/app/src/cli/models/app/validation/common.ts @@ -10,6 +10,16 @@ export function validateRelativeUrl(zodType: zod.ZodString, {message = 'URL must return zodType.refine((value) => value.startsWith('/') || isValidUrl(value, true), {message}) } +/** + * Characters that are never legal in a URL, and that would let a malformed configuration value smuggle extra content + * into a request when the URL is later interpolated. + */ +export const URL_CONTROL_CHARACTERS = /[\r\n\t]/ + +export function isHttpsUrl(url: string): boolean { + return isValidUrl(url, true) +} + function isValidUrl(input: string, httpsOnly: boolean) { try { const url = new URL(input) diff --git a/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts new file mode 100644 index 00000000000..3e12988a9dc --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts @@ -0,0 +1,135 @@ +import {patchAppRelativeUrls, resolveAppRelativeUrl} from './app_relative_urls.js' +import {describe, expect, test} from 'vitest' + +const LIFECYCLE_CALLBACK = 'flow_trigger_lifecycle_callback' + +describe('resolveAppRelativeUrl', () => { + const resolve = (url: string, appUrl: string | undefined) => resolveAppRelativeUrl('Test module', 'url', url, appUrl) + + test('returns absolute URLs unchanged', () => { + expect(resolve('https://my-prod-host.example.com/api/execute', 'https://my-app.example.com')).toBe( + 'https://my-prod-host.example.com/api/execute', + ) + }) + + test('accepts absolute HTTPS URLs regardless of scheme casing', () => { + expect(resolve('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(resolve('/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(() => resolve('/api/execute', undefined)).toThrow( + 'Test module 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(() => resolve('http://my-prod-host.example.com/api/execute', undefined)).toThrow( + 'Test module 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(() => resolve('', 'https://my-app.example.com')).toThrow( + 'Test module 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(() => resolve('/api/execute', 'http://my-app.example.com')).toThrow( + 'Test module url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.', + ) + }) + + test('throws on a protocol relative url', () => { + expect(() => resolve('//evil.example.com/api', 'https://my-app.example.com')).toThrow( + 'Test module url is invalid: a URL relative to the app URL must start with a single slash.', + ) + }) + + test('throws on a url containing control characters', () => { + expect(() => resolve('/api\nX-Injected: 1', 'https://my-app.example.com')).toThrow( + 'Test module url is invalid: a URL must not contain control characters such as newlines or tabs.', + ) + }) +}) + +describe('patchAppRelativeUrls', () => { + const patch = (config: object, appUrl: string | undefined, identifier = LIFECYCLE_CALLBACK) => { + patchAppRelativeUrls(identifier, config, appUrl) + return config + } + + test('prepends the app URL to a relative url', () => { + // When + const got = patch({name: 'Auction lifecycle', url: '/api/flow/lifecycle'}, 'https://my-app.example.com') + + // Then + expect(got).toEqual({name: 'Auction lifecycle', url: 'https://my-app.example.com/api/flow/lifecycle'}) + }) + + test('removes a trailing slash from the app URL', () => { + // When + const got = patch({url: '/api/flow/lifecycle'}, 'https://my-app.example.com/') + + // Then + expect(got).toEqual({url: 'https://my-app.example.com/api/flow/lifecycle'}) + }) + + test('leaves an absolute url untouched', () => { + // When + const got = patch({url: 'https://my-prod-host.example.com/api/flow/lifecycle'}, 'https://my-app.example.com') + + // Then + expect(got).toEqual({url: 'https://my-prod-host.example.com/api/flow/lifecycle'}) + }) + + test('leaves a module with no relative URL fields untouched', () => { + // When + const got = patch({url: '/api/something'}, 'https://my-app.example.com', 'some_other_contract_module') + + // Then + expect(got).toEqual({url: '/api/something'}) + }) + + test('throws when there is no app URL to resolve against', () => { + // When/Then + expect(() => patch({url: '/api/flow/lifecycle'}, undefined)).toThrow( + 'Flow trigger lifecycle callback 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 the app URL is not HTTPS', () => { + // When/Then + expect(() => patch({url: '/api/flow/lifecycle'}, 'http://my-app.example.com')).toThrow( + 'Flow trigger lifecycle callback url must resolve to an HTTPS URL.', + ) + }) + + test('throws on a protocol relative url', () => { + // When/Then + expect(() => patch({url: '//example.com/api'}, 'https://my-app.example.com')).toThrow( + 'a URL relative to the app URL must start with a single slash', + ) + }) + + test('throws on a url containing control characters', () => { + // When/Then + expect(() => patch({url: '/api/flow/lifecycle\nmalicious-header: value'}, 'https://my-app.example.com')).toThrow( + 'a URL must not contain control characters', + ) + }) + + test('resolves against the dev tunnel URL', () => { + // When + const got = patch({url: '/api/flow/lifecycle'}, 'https://my-tunnel.example.com') + + // Then + expect(got).toEqual({url: 'https://my-tunnel.example.com/api/flow/lifecycle'}) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts new file mode 100644 index 00000000000..b78b3bed0ae --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts @@ -0,0 +1,78 @@ +import {prependApplicationUrl} from './url_prepender.js' +import {URL_CONTROL_CHARACTERS, isHttpsUrl} from '../../../app/validation/common.js' +import {AbortError} from '@shopify/cli-kit/node/error' + +interface RelativeUrlModule { + label: string + fields: string[] +} + +/** + * Contract based modules have no local specification, so their configuration is sent to the server exactly as it + * appears in the TOML. These fields are the exception: a relative value (a path starting with a single slash) is + * resolved against the app's URL, the same way `flow_action`'s URL fields are resolved by its own specification. + * + * To give another contract based module the same treatment, add it here. The server side contract has to accept the + * relative form as well, otherwise the configuration is rejected when it is parsed, before any of this runs. + */ +const MODULES_WITH_RELATIVE_URLS: {[identifier: string]: RelativeUrlModule} = { + flow_trigger_lifecycle_callback: {label: 'Flow trigger lifecycle callback', fields: ['url']}, +} + +/** + * Resolves a single app relative URL field against the app's URL, rejecting anything that cannot become a valid + * absolute HTTPS URL. `label` and `fieldName` only appear in the error messages, so callers can name the field the + * same way it is spelled in the TOML. + */ +export const resolveAppRelativeUrl = ( + label: string, + fieldName: string, + url: string, + appUrl: string | undefined, +): string => { + if (url.startsWith('//')) { + throw new AbortError( + `${label} ${fieldName} is invalid: a URL relative to the app URL must start with a single slash.`, + ) + } + + if (URL_CONTROL_CHARACTERS.test(url)) { + throw new AbortError( + `${label} ${fieldName} is invalid: a URL must not contain control characters such as newlines or tabs.`, + ) + } + + const resolvedUrl = prependApplicationUrl(url, appUrl) + if (resolvedUrl.startsWith('/')) { + throw new AbortError( + `${label} ${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 (!isHttpsUrl(resolvedUrl)) { + throw new AbortError( + `${label} ${fieldName} must resolve to an HTTPS URL. ` + + 'Set application_url to an HTTPS URL or use an absolute HTTPS URL.', + ) + } + + return resolvedUrl +} + +/** + * Resolves in place every app relative URL field of a contract based module's configuration. Absolute URLs, and + * modules with no relative URL fields, are left untouched. + */ +export function patchAppRelativeUrls(identifier: string, config: object, appUrl: string | undefined): void { + const module = MODULES_WITH_RELATIVE_URLS[identifier] + if (!module) return + + const indexableConfig = config as {[key: string]: unknown} + for (const field of module.fields) { + const value = indexableConfig[field] + if (typeof value === 'string' && value.startsWith('/')) { + indexableConfig[field] = resolveAppRelativeUrl(module.label, field, value, appUrl) + } + } +} From 227ca8e60360afd393681bcece97a337764dd77f Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Fri, 21 Aug 2026 13:45:37 -0400 Subject: [PATCH 02/12] Add relative url patching to contract based extensions --- .../specification.integration.test.ts | 90 ++++++++++++++++++- .../cli/models/extensions/specification.ts | 14 ++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.integration.test.ts b/packages/app/src/cli/models/extensions/specification.integration.test.ts index f49d0712f3a..cb735acf4a5 100644 --- a/packages/app/src/cli/models/extensions/specification.integration.test.ts +++ b/packages/app/src/cli/models/extensions/specification.integration.test.ts @@ -5,7 +5,8 @@ import { createConfigExtensionSpecification, createExtensionSpecification, } from './specification.js' -import {BaseSchema} from './schemas.js' +import {BaseConfigType, BaseSchema} from './schemas.js' +import {placeholderAppConfiguration} from '../app/app.test-data.js' import {ClientSteps} from '../../services/build/client-steps.js' import {AppSchema} from '../app/app.js' import {describe, test, expect, beforeAll} from 'vitest' @@ -95,6 +96,93 @@ describe('createContractBasedModuleSpecification', () => { // Then expect(got.clientSteps).toBeUndefined() }) + + describe('app relative URLs', () => { + interface LifecycleCallbackConfig extends BaseConfigType { + url: string + } + + const lifecycleCallbackSpec = () => + createContractBasedModuleSpecification({ + identifier: 'flow_trigger_lifecycle_callback', + uidStrategy: 'uuid', + experience: 'extension', + appModuleFeatures: () => [], + }) + + test('resolves a relative url against the application URL when deploying', async () => { + // Given + const spec = lifecycleCallbackSpec() + + // When + const got = await spec.deployConfig!( + {type: 'flow_trigger_lifecycle_callback', name: 'Auction lifecycle', url: '/api/flow/lifecycle'}, + './my-extension', + 'api-key', + undefined, + { + appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'}, + }, + ) + + // Then + expect(got).toEqual({name: 'Auction lifecycle', url: 'https://my-app.example.com/api/flow/lifecycle'}) + }) + + test('leaves an absolute url untouched when deploying', async () => { + // Given + const spec = lifecycleCallbackSpec() + + // When + const got = await spec.deployConfig!( + { + type: 'flow_trigger_lifecycle_callback', + name: 'Auction lifecycle', + url: 'https://my-prod-host.example.com/api/flow/lifecycle', + }, + './my-extension', + 'api-key', + undefined, + { + appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'}, + }, + ) + + // Then + expect(got).toEqual({ + name: 'Auction lifecycle', + url: 'https://my-prod-host.example.com/api/flow/lifecycle', + }) + }) + + test('resolves a relative url against the dev tunnel URL', () => { + // Given + const spec = lifecycleCallbackSpec() + const config = {type: 'flow_trigger_lifecycle_callback', name: 'Auction lifecycle', url: '/api/flow/lifecycle'} + + // When + spec.patchWithAppDevURLs!(config, {applicationUrl: 'https://my-tunnel.example.com', redirectUrlWhitelist: []}) + + // Then + expect(config.url).toBe('https://my-tunnel.example.com/api/flow/lifecycle') + }) + + test('leaves a contract module without relative URL fields untouched', async () => { + // Given + const spec = createContractBasedModuleSpecification({ + identifier: 'test', + uidStrategy: 'uuid', + experience: 'extension', + appModuleFeatures: () => [], + }) + + // When + const got = await spec.deployConfig!({type: 'test', url: '/api/something'}, './my-extension', 'api-key') + + // Then + expect(got).toEqual({url: '/api/something'}) + }) + }) }) describe('createExtensionSpecification', () => { diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 65290be5c1f..84e34952c68 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -1,5 +1,6 @@ import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js' import {ExtensionInstance} from './extension-instance.js' +import {patchAppRelativeUrls} from './specifications/validation/app_relative_urls.js' import {blocks} from '../../constants.js' import {ClientSteps} from '../../services/build/client-steps.js' @@ -318,8 +319,19 @@ export function createContractBasedModuleSpecification { + // A contract based module has no local schema, so its configuration is deployed as authored. The exception is + // the app relative URL fields declared in app_relative_urls.ts: those are resolved against the dev tunnel here, + // and against the app's application_url in deployConfig below. + patchWithAppDevURLs: (config, urls) => { + patchAppRelativeUrls(spec.identifier, config, urls.applicationUrl) + }, + deployConfig: async (config, directory, _apiKey, _moduleId, context) => { + const applicationUrl = context?.appConfiguration.application_url + const appUrl = typeof applicationUrl === 'string' ? applicationUrl : undefined + + // configWithoutFirstClassFields returns a fresh object, so patching it in place cannot affect the caller. let parsedConfig = configWithoutFirstClassFields(config) + patchAppRelativeUrls(spec.identifier, parsedConfig, appUrl) if (spec.appModuleFeatures().includes('localization')) { const localization = await loadLocalesConfig(directory, spec.identifier) parsedConfig = {...parsedConfig, localization} From 23e6122c49166ac3cb67e9f7570d6ed2e8a5490a Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Fri, 21 Aug 2026 13:46:58 -0400 Subject: [PATCH 03/12] Replace flow action relative url resolver with new generic function --- .../extensions/specifications/flow_action.ts | 16 ++++--- packages/app/src/cli/services/flow/types.ts | 2 - .../app/src/cli/services/flow/utils.test.ts | 46 +------------------ packages/app/src/cli/services/flow/utils.ts | 35 -------------- .../app/src/cli/services/flow/validation.ts | 6 +-- 5 files changed, 13 insertions(+), 92 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 09801cd0c48..9dd3ebcbd4a 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.ts @@ -1,3 +1,4 @@ +import {resolveAppRelativeUrl} from './validation/app_relative_urls.js' import {BaseSchemaWithHandle} from '../schemas.js' import {createExtensionSpecification} from '../specification.js' import { @@ -8,9 +9,12 @@ import { } 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 {loadSchemaFromPath} from '../../../services/flow/utils.js' import {zod} from '@shopify/cli-kit/node/schema' +/** Prefixes the error messages raised while resolving this extension's URL fields. */ +const FLOW_ACTION_LABEL = 'Flow action' + const FlowActionExtensionSchema = BaseSchemaWithHandle.extend({ type: zod.literal('flow_action'), name: zod.string(), @@ -58,7 +62,7 @@ const flowActionSpecification = createExtensionSpecification({ 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] = resolveAppRelativeUrl(FLOW_ACTION_LABEL, key, value, urls.applicationUrl) } } }, @@ -69,16 +73,16 @@ const flowActionSpecification = createExtensionSpecification({ return { title: config.name, description: config.description, - url: resolveFlowActionUrl('runtime_url', config.runtime_url, appUrl), + url: resolveAppRelativeUrl(FLOW_ACTION_LABEL, 'runtime_url', config.runtime_url, appUrl), fields: serializeFields('flow_action', config.settings?.fields), validation_url: config.validation_url - ? resolveFlowActionUrl('validation_url', config.validation_url, appUrl) + ? resolveAppRelativeUrl(FLOW_ACTION_LABEL, 'validation_url', config.validation_url, appUrl) : undefined, custom_configuration_page_url: config.config_page_url - ? resolveFlowActionUrl('config_page_url', config.config_page_url, appUrl) + ? resolveAppRelativeUrl(FLOW_ACTION_LABEL, '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) + ? resolveAppRelativeUrl(FLOW_ACTION_LABEL, '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 a7e9c7fa2d0..10f6742a9ec 100644 --- a/packages/app/src/cli/services/flow/types.ts +++ b/packages/app/src/cli/services/flow/types.ts @@ -28,5 +28,3 @@ export const FLOW_ACTION_URL_FIELDS = [ '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 77a4e715742..d0582058c5c 100644 --- a/packages/app/src/cli/services/flow/utils.test.ts +++ b/packages/app/src/cli/services/flow/utils.test.ts @@ -1,52 +1,8 @@ -import {loadSchemaFromPath, resolveFlowActionUrl} from './utils.js' +import {loadSchemaFromPath} 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('accepts absolute HTTPS URLs regardless of scheme casing', () => { - 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 1df4e643bec..cdc71c0575f 100644 --- a/packages/app/src/cli/services/flow/utils.ts +++ b/packages/app/src/cli/services/flow/utils.ts @@ -1,40 +1,5 @@ -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' - -const isHttpsUrl = (url: string) => { - try { - return new URL(url).protocol === 'https:' - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (TypeError) { - return false - } -} - -/** - * 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 (!isHttpsUrl(resolvedUrl)) { - 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.ts b/packages/app/src/cli/services/flow/validation.ts index 48f1a210d3e..eb0ba5bff0a 100644 --- a/packages/app/src/cli/services/flow/validation.ts +++ b/packages/app/src/cli/services/flow/validation.ts @@ -1,7 +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 {URL_CONTROL_CHARACTERS, 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) { @@ -56,14 +56,12 @@ export const validateFieldShape = ( 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), { + .refine((value) => !URL_CONTROL_CHARACTERS.test(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.'}) From 265ada12d63ce6d46bfae53dfd89398e45d2bfc0 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Fri, 21 Aug 2026 14:26:12 -0400 Subject: [PATCH 04/12] Add changelog entry --- .changeset/flow-trigger-lifecycle-callback-relative-url.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/flow-trigger-lifecycle-callback-relative-url.md diff --git a/.changeset/flow-trigger-lifecycle-callback-relative-url.md b/.changeset/flow-trigger-lifecycle-callback-relative-url.md new file mode 100644 index 00000000000..009c36d0579 --- /dev/null +++ b/.changeset/flow-trigger-lifecycle-callback-relative-url.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Allow Flow trigger lifecycle callback `url`s to be relative to the app's `application_url` From c85246619e6a84a746b7a86a650ca0513db591ad Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Mon, 24 Aug 2026 13:09:32 -0400 Subject: [PATCH 05/12] Add optional guard for --- packages/app/src/cli/models/extensions/specification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 84e34952c68..bb14bf56501 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -326,7 +326,7 @@ export function createContractBasedModuleSpecification { - const applicationUrl = context?.appConfiguration.application_url + const applicationUrl = context?.appConfiguration?.application_url const appUrl = typeof applicationUrl === 'string' ? applicationUrl : undefined // configWithoutFirstClassFields returns a fresh object, so patching it in place cannot affect the caller. From c6cd041fb53d041f1a6034a672ae08c1bb84fcdd Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Mon, 14 Sep 2026 14:34:21 -0400 Subject: [PATCH 06/12] Optionally allow specs to pass a field of urls that can be relative --- .../cli/models/extensions/specification.ts | 31 +++++-- .../validation/app_relative_urls.test.ts | 92 ++++++++----------- .../validation/app_relative_urls.ts | 35 ++----- 3 files changed, 68 insertions(+), 90 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index bb14bf56501..70face9d5e1 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -99,6 +99,8 @@ export interface ExtensionSpecification ExtensionFeature[] getDevSessionUpdateMessages?: (config: TConfiguration, context: DevSessionUpdateContext) => Promise + /** Top-level URL fields to resolve against the app URL. The remote contract must accept relative values. */ + appRelativeUrlFields?: ReadonlyArray patchWithAppDevURLs?: (config: TConfiguration, urls: ApplicationURLs) => void /** @@ -303,6 +305,7 @@ export function createContractBasedModuleSpecification, | 'identifier' | 'appModuleFeatures' + | 'appRelativeUrlFields' | 'uidStrategy' | 'clientSteps' | 'experience' @@ -310,6 +313,8 @@ export function createContractBasedModuleSpecification, ) { + const appRelativeUrlFields = spec.appRelativeUrlFields + return createExtensionSpecification({ identifier: spec.identifier, schema: zod.any({}) as unknown as ZodSchemaType, @@ -319,19 +324,25 @@ export function createContractBasedModuleSpecification { - patchAppRelativeUrls(spec.identifier, config, urls.applicationUrl) - }, + appRelativeUrlFields, + patchWithAppDevURLs: appRelativeUrlFields?.length + ? (config, urls) => { + patchAppRelativeUrls( + capitalize(spec.identifier.replace(/_/g, ' ')), + appRelativeUrlFields, + config, + urls.applicationUrl, + ) + } + : undefined, deployConfig: async (config, directory, _apiKey, _moduleId, context) => { - const applicationUrl = context?.appConfiguration?.application_url - const appUrl = typeof applicationUrl === 'string' ? applicationUrl : undefined - // configWithoutFirstClassFields returns a fresh object, so patching it in place cannot affect the caller. let parsedConfig = configWithoutFirstClassFields(config) - patchAppRelativeUrls(spec.identifier, parsedConfig, appUrl) + if (appRelativeUrlFields?.length) { + const applicationUrl = context?.appConfiguration?.application_url + const appUrl = typeof applicationUrl === 'string' ? applicationUrl : undefined + patchAppRelativeUrls(capitalize(spec.identifier.replace(/_/g, ' ')), appRelativeUrlFields, parsedConfig, appUrl) + } if (spec.appModuleFeatures().includes('localization')) { const localization = await loadLocalesConfig(directory, spec.identifier) parsedConfig = {...parsedConfig, localization} diff --git a/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts index 3e12988a9dc..ea9c827eb79 100644 --- a/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts @@ -1,8 +1,7 @@ import {patchAppRelativeUrls, resolveAppRelativeUrl} from './app_relative_urls.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test} from 'vitest' -const LIFECYCLE_CALLBACK = 'flow_trigger_lifecycle_callback' - describe('resolveAppRelativeUrl', () => { const resolve = (url: string, appUrl: string | undefined) => resolveAppRelativeUrl('Test module', 'url', url, appUrl) @@ -60,76 +59,59 @@ describe('resolveAppRelativeUrl', () => { }) describe('patchAppRelativeUrls', () => { - const patch = (config: object, appUrl: string | undefined, identifier = LIFECYCLE_CALLBACK) => { - patchAppRelativeUrls(identifier, config, appUrl) - return config - } + test('resolves all declared relative fields without changing undeclared fields', () => { + const config = { + url: '/callback', + validation_url: '/validate', + other_url: '/leave-alone', + name: 'Example extension', + } - test('prepends the app URL to a relative url', () => { - // When - const got = patch({name: 'Auction lifecycle', url: '/api/flow/lifecycle'}, 'https://my-app.example.com') + patchAppRelativeUrls('Test module', ['url', 'validation_url'], config, 'https://my-app.example.com') - // Then - expect(got).toEqual({name: 'Auction lifecycle', url: 'https://my-app.example.com/api/flow/lifecycle'}) + expect(config).toEqual({ + url: 'https://my-app.example.com/callback', + validation_url: 'https://my-app.example.com/validate', + other_url: '/leave-alone', + name: 'Example extension', + }) }) - test('removes a trailing slash from the app URL', () => { - // When - const got = patch({url: '/api/flow/lifecycle'}, 'https://my-app.example.com/') - - // Then - expect(got).toEqual({url: 'https://my-app.example.com/api/flow/lifecycle'}) - }) + test('leaves absolute URLs untouched without an app URL', () => { + const config = {url: 'https://my-prod-host.example.com/callback'} - test('leaves an absolute url untouched', () => { - // When - const got = patch({url: 'https://my-prod-host.example.com/api/flow/lifecycle'}, 'https://my-app.example.com') + patchAppRelativeUrls('Test module', ['url'], config, undefined) - // Then - expect(got).toEqual({url: 'https://my-prod-host.example.com/api/flow/lifecycle'}) + expect(config).toEqual({url: 'https://my-prod-host.example.com/callback'}) }) - test('leaves a module with no relative URL fields untouched', () => { - // When - const got = patch({url: '/api/something'}, 'https://my-app.example.com', 'some_other_contract_module') + test('leaves configuration untouched when no fields are declared', () => { + const config = {url: '/callback'} - // Then - expect(got).toEqual({url: '/api/something'}) - }) + patchAppRelativeUrls('Test module', [], config, undefined) - test('throws when there is no app URL to resolve against', () => { - // When/Then - expect(() => patch({url: '/api/flow/lifecycle'}, undefined)).toThrow( - 'Flow trigger lifecycle callback url is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.', - ) + expect(config).toEqual({url: '/callback'}) }) - test('throws when the app URL is not HTTPS', () => { - // When/Then - expect(() => patch({url: '/api/flow/lifecycle'}, 'http://my-app.example.com')).toThrow( - 'Flow trigger lifecycle callback url must resolve to an HTTPS URL.', - ) - }) + test('leaves missing and non-string fields for schema validation', () => { + const config = {url: 42, optional_url: undefined} - test('throws on a protocol relative url', () => { - // When/Then - expect(() => patch({url: '//example.com/api'}, 'https://my-app.example.com')).toThrow( - 'a URL relative to the app URL must start with a single slash', - ) + patchAppRelativeUrls('Test module', ['url', 'optional_url', 'missing_url'], config, undefined) + + expect(config).toEqual({url: 42, optional_url: undefined}) }) - test('throws on a url containing control characters', () => { - // When/Then - expect(() => patch({url: '/api/flow/lifecycle\nmalicious-header: value'}, 'https://my-app.example.com')).toThrow( - 'a URL must not contain control characters', - ) + test('rejects relative fields that cannot be resolved', () => { + expect(() => patchAppRelativeUrls('Test module', ['url'], {url: '/callback'}, undefined)).toThrow(AbortError) }) - test('resolves against the dev tunnel URL', () => { - // When - const got = patch({url: '/api/flow/lifecycle'}, 'https://my-tunnel.example.com') + test.each([ + {name: 'newline', character: '\n'}, + {name: 'carriage return', character: '\r'}, + {name: 'tab', character: '\t'}, + ])('rejects relative URL fields containing a $name', ({character}) => { + const config = {url: `/callback${character}injected-content`} - // Then - expect(got).toEqual({url: 'https://my-tunnel.example.com/api/flow/lifecycle'}) + expect(() => patchAppRelativeUrls('Test module', ['url'], config, 'https://my-app.example.com')).toThrow(AbortError) }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts index b78b3bed0ae..f8084464c37 100644 --- a/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts +++ b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts @@ -2,23 +2,6 @@ import {prependApplicationUrl} from './url_prepender.js' import {URL_CONTROL_CHARACTERS, isHttpsUrl} from '../../../app/validation/common.js' import {AbortError} from '@shopify/cli-kit/node/error' -interface RelativeUrlModule { - label: string - fields: string[] -} - -/** - * Contract based modules have no local specification, so their configuration is sent to the server exactly as it - * appears in the TOML. These fields are the exception: a relative value (a path starting with a single slash) is - * resolved against the app's URL, the same way `flow_action`'s URL fields are resolved by its own specification. - * - * To give another contract based module the same treatment, add it here. The server side contract has to accept the - * relative form as well, otherwise the configuration is rejected when it is parsed, before any of this runs. - */ -const MODULES_WITH_RELATIVE_URLS: {[identifier: string]: RelativeUrlModule} = { - flow_trigger_lifecycle_callback: {label: 'Flow trigger lifecycle callback', fields: ['url']}, -} - /** * Resolves a single app relative URL field against the app's URL, rejecting anything that cannot become a valid * absolute HTTPS URL. `label` and `fieldName` only appear in the error messages, so callers can name the field the @@ -61,18 +44,20 @@ export const resolveAppRelativeUrl = ( } /** - * Resolves in place every app relative URL field of a contract based module's configuration. Absolute URLs, and - * modules with no relative URL fields, are left untouched. + * Resolves declared top-level URL fields in place. Absolute URLs, missing fields, and non-string values are left + * untouched; configuration validation remains the specification's responsibility. */ -export function patchAppRelativeUrls(identifier: string, config: object, appUrl: string | undefined): void { - const module = MODULES_WITH_RELATIVE_URLS[identifier] - if (!module) return - +export function patchAppRelativeUrls( + label: string, + fields: ReadonlyArray, + config: object, + appUrl: string | undefined, +): void { const indexableConfig = config as {[key: string]: unknown} - for (const field of module.fields) { + for (const field of fields) { const value = indexableConfig[field] if (typeof value === 'string' && value.startsWith('/')) { - indexableConfig[field] = resolveAppRelativeUrl(module.label, field, value, appUrl) + indexableConfig[field] = resolveAppRelativeUrl(label, field, value, appUrl) } } } From 99cfb799a9a22c6f0e5b10318d073ee2a7a96788 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Mon, 14 Sep 2026 14:52:20 -0400 Subject: [PATCH 07/12] Add small local specification for flowTriggerLifecycleCallback using appRelativeUrls --- .../extensions/extension-instance.test.ts | 8 +- .../models/extensions/load-specifications.ts | 2 + .../specification.integration.test.ts | 101 +++++++++--------- .../flow_trigger_lifecycle_callback.ts | 11 ++ 4 files changed, 69 insertions(+), 53 deletions(-) create mode 100644 packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.ts 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 25672f501f3..d4ab5636b1d 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -911,7 +911,13 @@ describe('getDevSessionUpdateMessages', () => { .map((specification) => specification.identifier) .sort() - expect(matching).toEqual(['editor_extension_collection', 'flow_action', 'flow_trigger', 'payments_extension']) + expect(matching).toEqual([ + 'editor_extension_collection', + 'flow_action', + 'flow_trigger', + 'flow_trigger_lifecycle_callback', + 'payments_extension', + ]) }) }) }) diff --git a/packages/app/src/cli/models/extensions/load-specifications.ts b/packages/app/src/cli/models/extensions/load-specifications.ts index 375be10885d..f08ed2f4d50 100644 --- a/packages/app/src/cli/models/extensions/load-specifications.ts +++ b/packages/app/src/cli/models/extensions/load-specifications.ts @@ -17,6 +17,7 @@ import checkoutSpec from './specifications/checkout_ui_extension.js' import flowActionSpecification from './specifications/flow_action.js' import flowTemplateSpec from './specifications/flow_template.js' import flowTriggerSpecification from './specifications/flow_trigger.js' +import flowTriggerLifecycleCallbackSpec from './specifications/flow_trigger_lifecycle_callback.js' import functionSpec from './specifications/function.js' import paymentExtensionSpec from './specifications/payments_app_extension.js' import posUISpec from './specifications/pos_ui_extension.js' @@ -70,6 +71,7 @@ function loadSpecifications() { flowActionSpecification, flowTemplateSpec, flowTriggerSpecification, + flowTriggerLifecycleCallbackSpec, functionSpec, paymentExtensionSpec, posUISpec, diff --git a/packages/app/src/cli/models/extensions/specification.integration.test.ts b/packages/app/src/cli/models/extensions/specification.integration.test.ts index cb735acf4a5..4420360de0f 100644 --- a/packages/app/src/cli/models/extensions/specification.integration.test.ts +++ b/packages/app/src/cli/models/extensions/specification.integration.test.ts @@ -9,6 +9,7 @@ import {BaseConfigType, BaseSchema} from './schemas.js' import {placeholderAppConfiguration} from '../app/app.test-data.js' import {ClientSteps} from '../../services/build/client-steps.js' import {AppSchema} from '../app/app.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {describe, test, expect, beforeAll} from 'vitest' // If the AppSchema is not instanced, the dynamic loading of loadLocalExtensionsSpecifications is not working @@ -98,89 +99,85 @@ describe('createContractBasedModuleSpecification', () => { }) describe('app relative URLs', () => { - interface LifecycleCallbackConfig extends BaseConfigType { + interface CallbackConfig extends BaseConfigType { url: string + other_url?: string } - const lifecycleCallbackSpec = () => - createContractBasedModuleSpecification({ - identifier: 'flow_trigger_lifecycle_callback', + const callbackSpec = () => + createContractBasedModuleSpecification({ + identifier: 'test_callback', uidStrategy: 'uuid', experience: 'extension', appModuleFeatures: () => [], + appRelativeUrlFields: ['url'], }) - test('resolves a relative url against the application URL when deploying', async () => { - // Given - const spec = lifecycleCallbackSpec() + test('resolves declared deployment URLs without mutating the original configuration', async () => { + const spec = callbackSpec() + const config = {type: 'test_callback', url: '/callback', other_url: '/leave-alone'} - // When - const got = await spec.deployConfig!( - {type: 'flow_trigger_lifecycle_callback', name: 'Auction lifecycle', url: '/api/flow/lifecycle'}, - './my-extension', - 'api-key', - undefined, - { - appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'}, - }, - ) + const got = await spec.deployConfig!(config, './my-extension', 'api-key', undefined, { + appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'}, + }) - // Then - expect(got).toEqual({name: 'Auction lifecycle', url: 'https://my-app.example.com/api/flow/lifecycle'}) + expect(got).toEqual({url: 'https://my-app.example.com/callback', other_url: '/leave-alone'}) + expect(config).toEqual({type: 'test_callback', url: '/callback', other_url: '/leave-alone'}) }) - test('leaves an absolute url untouched when deploying', async () => { - // Given - const spec = lifecycleCallbackSpec() + test('leaves an absolute deployment URL untouched without app configuration', async () => { + const spec = callbackSpec() - // When const got = await spec.deployConfig!( - { - type: 'flow_trigger_lifecycle_callback', - name: 'Auction lifecycle', - url: 'https://my-prod-host.example.com/api/flow/lifecycle', - }, + {type: 'test_callback', url: 'https://my-prod-host.example.com/callback'}, './my-extension', 'api-key', - undefined, - { - appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'}, - }, ) - // Then - expect(got).toEqual({ - name: 'Auction lifecycle', - url: 'https://my-prod-host.example.com/api/flow/lifecycle', - }) + expect(got).toEqual({url: 'https://my-prod-host.example.com/callback'}) }) - test('resolves a relative url against the dev tunnel URL', () => { - // Given - const spec = lifecycleCallbackSpec() - const config = {type: 'flow_trigger_lifecycle_callback', name: 'Auction lifecycle', url: '/api/flow/lifecycle'} + test('resolves declared URLs against the dev tunnel URL', () => { + const spec = callbackSpec() + const config = {type: 'test_callback', url: '/callback', other_url: '/leave-alone'} - // When spec.patchWithAppDevURLs!(config, {applicationUrl: 'https://my-tunnel.example.com', redirectUrlWhitelist: []}) - // Then - expect(config.url).toBe('https://my-tunnel.example.com/api/flow/lifecycle') + expect(config).toEqual({ + type: 'test_callback', + url: 'https://my-tunnel.example.com/callback', + other_url: '/leave-alone', + }) }) - test('leaves a contract module without relative URL fields untouched', async () => { - // Given - const spec = createContractBasedModuleSpecification({ - identifier: 'test', + test.each([ + {appRelativeUrlFields: undefined, description: 'omitted'}, + {appRelativeUrlFields: [], description: 'empty'}, + ])('does not opt in by identifier when URL fields are $description', async ({appRelativeUrlFields}) => { + const spec = createContractBasedModuleSpecification({ + identifier: 'flow_trigger_lifecycle_callback', uidStrategy: 'uuid', experience: 'extension', appModuleFeatures: () => [], + appRelativeUrlFields, }) + const config = {type: 'flow_trigger_lifecycle_callback', url: '/callback'} + + spec.patchWithAppDevURLs?.(config, {applicationUrl: 'https://my-tunnel.example.com', redirectUrlWhitelist: []}) + const got = await spec.deployConfig!(config, './my-extension', 'api-key', undefined, { + appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'}, + }) + + expect(config).toEqual({type: 'flow_trigger_lifecycle_callback', url: '/callback'}) + expect(got).toEqual({url: '/callback'}) + }) - // When - const got = await spec.deployConfig!({type: 'test', url: '/api/something'}, './my-extension', 'api-key') + test('rejects a relative deployment URL without app configuration', async () => { + const spec = callbackSpec() - // Then - expect(got).toEqual({url: '/api/something'}) + await expect( + spec.deployConfig!({type: 'test_callback', url: '/callback'}, './my-extension', 'api-key'), + ).rejects.toThrow(AbortError) }) }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.ts b/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.ts new file mode 100644 index 00000000000..88d8ed7d15f --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.ts @@ -0,0 +1,11 @@ +import {createContractBasedModuleSpecification} from '../specification.js' + +const flowTriggerLifecycleCallbackSpec = createContractBasedModuleSpecification({ + identifier: 'flow_trigger_lifecycle_callback', + uidStrategy: 'uuid', + experience: 'extension', + appModuleFeatures: () => [], + appRelativeUrlFields: ['url'], +}) + +export default flowTriggerLifecycleCallbackSpec From 4c4b6828db7cbf58209fc1ee3455c55dc6bae727 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Mon, 14 Sep 2026 16:31:53 -0400 Subject: [PATCH 08/12] Add tests for flow trigger lifecycle callback extensions --- .../flow_trigger_lifecycle_callback.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.test.ts diff --git a/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.test.ts b/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.test.ts new file mode 100644 index 00000000000..9c7cafbbdaa --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.test.ts @@ -0,0 +1,46 @@ +import {ExtensionInstance} from '../extension-instance.js' +import {loadLocalExtensionsSpecifications} from '../load-specifications.js' +import {placeholderAppConfiguration} from '../../app/app.test-data.js' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test} from 'vitest' + +describe('flow_trigger_lifecycle_callback', () => { + test('resolves relative URLs against the app URL for deployment and the tunnel URL for dev', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'flow_trigger_lifecycle_callback')! + const parsed = specification.parseConfigurationObject({ + type: 'flow_trigger_lifecycle_callback', + name: 'Lifecycle callback', + url: '/callback', + }) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + const extension = new ExtensionInstance({ + configuration: parsed.data, + directory: tmpDir, + specification, + configurationPath: joinPath(tmpDir, 'shopify.extension.toml'), + entryPath: '', + }) + + const deployConfig = await extension.deployConfig({ + apiKey: 'apiKey', + appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'}, + }) + expect(deployConfig).toEqual({ + name: 'Lifecycle callback', + url: 'https://my-app.example.com/callback', + }) + + extension.patchWithAppDevURLs({ + applicationUrl: 'https://my-tunnel.example.com', + redirectUrlWhitelist: [], + }) + expect(extension.configuration).toMatchObject({url: 'https://my-tunnel.example.com/callback'}) + }) + }) +}) From 7b6a2cd2134f6dd21f038adf33a50d05a0e04676 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Wed, 16 Sep 2026 15:31:15 -0400 Subject: [PATCH 09/12] Strongly appRelativeUrlFields keys --- packages/app/src/cli/models/extensions/specification.ts | 2 +- .../specifications/flow_trigger_lifecycle_callback.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 70face9d5e1..d9e9256d642 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -100,7 +100,7 @@ export interface ExtensionSpecification ExtensionFeature[] getDevSessionUpdateMessages?: (config: TConfiguration, context: DevSessionUpdateContext) => Promise /** Top-level URL fields to resolve against the app URL. The remote contract must accept relative values. */ - appRelativeUrlFields?: ReadonlyArray + appRelativeUrlFields?: ReadonlyArray patchWithAppDevURLs?: (config: TConfiguration, urls: ApplicationURLs) => void /** diff --git a/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.ts b/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.ts index 88d8ed7d15f..ccb1ca7a3e0 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.ts @@ -1,6 +1,11 @@ import {createContractBasedModuleSpecification} from '../specification.js' +import {BaseConfigType} from '../schemas.js' -const flowTriggerLifecycleCallbackSpec = createContractBasedModuleSpecification({ +interface FlowTriggerLifecycleCallbackConfig extends BaseConfigType { + url: string +} + +const flowTriggerLifecycleCallbackSpec = createContractBasedModuleSpecification({ identifier: 'flow_trigger_lifecycle_callback', uidStrategy: 'uuid', experience: 'extension', From 0c0bd33c2c8dcb0af188928daff91cb46b6f3837 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Wed, 16 Sep 2026 15:31:29 -0400 Subject: [PATCH 10/12] Update changelog entry --- .changeset/flow-trigger-lifecycle-callback-relative-url.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/flow-trigger-lifecycle-callback-relative-url.md b/.changeset/flow-trigger-lifecycle-callback-relative-url.md index 009c36d0579..3b9e8025b35 100644 --- a/.changeset/flow-trigger-lifecycle-callback-relative-url.md +++ b/.changeset/flow-trigger-lifecycle-callback-relative-url.md @@ -1,5 +1,5 @@ --- -'@shopify/cli': minor +'@shopify/app': minor --- Allow Flow trigger lifecycle callback `url`s to be relative to the app's `application_url` From 6ea2a6e3369d7b557d3e1b95d6be5902c326fe09 Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Wed, 16 Sep 2026 15:47:49 -0400 Subject: [PATCH 11/12] Improve flow trigger lifecycle callback test --- .../flow_trigger_lifecycle_callback.test.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.test.ts b/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.test.ts index 9c7cafbbdaa..9bcc51fa9bf 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_trigger_lifecycle_callback.test.ts @@ -1,6 +1,7 @@ import {ExtensionInstance} from '../extension-instance.js' -import {loadLocalExtensionsSpecifications} from '../load-specifications.js' -import {placeholderAppConfiguration} from '../../app/app.test-data.js' +import {placeholderAppConfiguration, testDeveloperPlatformClient, testOrganizationApp} from '../../app/app.test-data.js' +import {RemoteSpecification} from '../../../api/graphql/extension_specifications.js' +import {fetchSpecifications} from '../../../services/generate/fetch-extension-specifications.js' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {describe, expect, test} from 'vitest' @@ -8,8 +9,34 @@ import {describe, expect, test} from 'vitest' describe('flow_trigger_lifecycle_callback', () => { test('resolves relative URLs against the app URL for deployment and the tunnel URL for dev', async () => { await inTemporaryDirectory(async (tmpDir) => { - const allSpecs = await loadLocalExtensionsSpecifications() + const remoteSpec: RemoteSpecification = { + name: 'Flow trigger lifecycle callback', + externalName: 'Flow trigger lifecycle callback', + identifier: 'flow_trigger_lifecycle_callback', + externalIdentifier: 'flow_trigger_lifecycle_callback', + experience: 'extension', + managementExperience: 'cli', + gated: false, + registrationLimit: 1, + uidStrategy: 'uuid', + validationSchema: { + jsonSchema: JSON.stringify({ + type: 'object', + properties: { + name: {type: 'string'}, + url: {type: 'string', pattern: '^(https://|/[^/])'}, + }, + required: ['url'], + additionalProperties: false, + }), + }, + } + const allSpecs = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient({specifications: async () => [remoteSpec]}), + app: testOrganizationApp(), + }) const specification = allSpecs.find((spec) => spec.identifier === 'flow_trigger_lifecycle_callback')! + expect(specification.parseConfigurationObject({url: 42}).state).toBe('error') const parsed = specification.parseConfigurationObject({ type: 'flow_trigger_lifecycle_callback', name: 'Lifecycle callback', From bbaa484d2e8d7263fb0f39ed5b5bb8ae260dba2f Mon Sep 17 00:00:00 2001 From: Elias Hawa Date: Wed, 16 Sep 2026 16:08:03 -0400 Subject: [PATCH 12/12] Add regression test for dev and deploy --- packages/app/src/cli/models/app/app.test.ts | 154 ++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/packages/app/src/cli/models/app/app.test.ts b/packages/app/src/cli/models/app/app.test.ts index 89c4e4ffd64..6453eb0e5a4 100644 --- a/packages/app/src/cli/models/app/app.test.ts +++ b/packages/app/src/cli/models/app/app.test.ts @@ -17,12 +17,16 @@ import { testAppAccessConfigExtension, testAppHomeConfigExtension, testAppProxyConfigExtension, + testDeveloperPlatformClient, + testOrganizationApp, } from './app.test-data.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {FunctionConfigType} from '../extensions/specifications/function.js' import {WebhooksConfig} from '../extensions/specifications/types/app_config_webhook.js' import {EditorExtensionCollectionType} from '../extensions/specifications/editor_extension_collection.js' import {ApplicationURLs} from '../../services/dev/urls.js' +import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {fetchSpecifications} from '../../services/generate/fetch-extension-specifications.js' import {describe, expect, test, vi} from 'vitest' import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' @@ -779,6 +783,156 @@ describe('manifest', () => { ], }) }) + + test.each([ + {mode: 'deploy', devApplicationURLs: undefined, expectedAppUrl: 'https://my-app.example.com'}, + { + mode: 'dev', + devApplicationURLs: {applicationUrl: 'https://my-tunnel.example.com', redirectUrlWhitelist: []}, + expectedAppUrl: 'https://my-tunnel.example.com', + }, + ])('preserves admin links alongside Flow extensions during $mode', async ({devApplicationURLs, expectedAppUrl}) => { + await inTemporaryDirectory(async (tmpDir) => { + const adminLinkRemoteSpec: RemoteSpecification = { + name: 'Admin link', + externalName: 'Admin link', + identifier: 'admin_link', + externalIdentifier: 'admin_link', + experience: 'extension', + managementExperience: 'cli', + gated: false, + registrationLimit: 1, + uidStrategy: 'uuid', + validationSchema: { + jsonSchema: JSON.stringify({ + type: 'object', + properties: { + name: {type: 'string'}, + targeting: { + type: 'array', + items: { + type: 'object', + properties: {target: {type: 'string'}, url: {type: 'string'}}, + required: ['target', 'url'], + additionalProperties: false, + }, + }, + localization: {type: 'object'}, + }, + required: ['targeting'], + additionalProperties: false, + }), + }, + } + const lifecycleCallbackRemoteSpec: RemoteSpecification = { + name: 'Flow trigger lifecycle callback', + externalName: 'Flow trigger lifecycle callback', + identifier: 'flow_trigger_lifecycle_callback', + externalIdentifier: 'flow_trigger_lifecycle_callback', + experience: 'extension', + managementExperience: 'cli', + gated: false, + registrationLimit: 1, + uidStrategy: 'uuid', + validationSchema: { + jsonSchema: JSON.stringify({ + type: 'object', + properties: { + name: {type: 'string'}, + url: {type: 'string', pattern: '^(https://|/[^/])'}, + }, + required: ['url'], + additionalProperties: false, + }), + }, + } + const remoteApp = testOrganizationApp() + const remoteSpecs = await testDeveloperPlatformClient().specifications(remoteApp) + const specifications = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient({ + specifications: async () => [...remoteSpecs, adminLinkRemoteSpec, lifecycleCallbackRemoteSpec], + }), + app: remoteApp, + }) + const locale = JSON.stringify({title: 'Open app'}) + await mkdir(joinPath(tmpDir, 'admin_link', 'locales')) + await writeFile(joinPath(tmpDir, 'admin_link', 'locales', 'en.default.json'), locale) + + const configurations = [ + { + type: 'admin_link', + handle: 'admin-link', + name: 'Admin link', + targeting: [{target: 'admin.product-details.action.link', url: '/products'}], + }, + { + type: 'flow_action', + handle: 'flow-action', + name: 'Flow action', + runtime_url: '/execute', + validation_url: 'https://validation.example.com/validate', + }, + {type: 'flow_trigger', handle: 'flow-trigger', name: 'Flow trigger'}, + { + type: 'flow_trigger_lifecycle_callback', + handle: 'lifecycle-callback', + name: 'Lifecycle callback', + url: '/callback', + }, + {type: 'app_home', application_url: 'https://my-app.example.com', embedded: true}, + ] + const extensions = await Promise.all( + configurations.map(async (configuration) => { + const specification = specifications.find((spec) => spec.identifier === configuration.type)! + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') throw new Error(`Couldn't parse ${configuration.type} configuration`) + + const directory = joinPath(tmpDir, configuration.type) + await mkdir(directory) + return new ExtensionInstance({ + configuration: parsed.data, + directory, + specification, + configurationPath: joinPath(directory, 'shopify.extension.toml'), + entryPath: '', + }) + }), + ) + const app = testApp({ + directory: tmpDir, + allExtensions: extensions, + configuration: {...DEFAULT_CONFIG, application_url: 'https://my-app.example.com'}, + devApplicationURLs, + }) + + const manifest = await app.manifest(undefined) + + expect(manifest.modules).toMatchObject([ + { + type: 'admin_link', + config: { + name: 'Admin link', + targeting: [{target: 'admin.product-details.action.link', url: '/products'}], + localization: {default_locale: 'en', translations: {en: Buffer.from(locale).toString('base64')}}, + }, + }, + { + type: 'flow_action', + config: { + title: 'Flow action', + url: `${expectedAppUrl}/execute`, + validation_url: 'https://validation.example.com/validate', + }, + }, + {type: 'flow_trigger', config: {title: 'Flow trigger', fields: [], schema_patch: ''}}, + { + type: 'flow_trigger_lifecycle_callback', + config: {name: 'Lifecycle callback', url: `${expectedAppUrl}/callback`}, + }, + {type: 'app_home', config: {app_url: expectedAppUrl, embedded: true}}, + ]) + }) + }) }) describe('generateExtensionTypes', () => {