Skip to content
5 changes: 5 additions & 0 deletions .changeset/light-lamps-draw.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {placeholderAppConfiguration, testFlowActionExtension} from '../../app/app.test-data.js'
import {ExtensionInstance} from '../extension-instance.js'
import {BaseConfigType} from '../schemas.js'
import {ApplicationURLs} from '../../../services/dev/urls.js'
import {beforeEach, describe, expect, test} from 'vitest'

type FlowActionConfig = BaseConfigType & {
type: 'flow_action'
handle: string
name: string
runtime_url: string
validation_url?: string
config_page_url?: string
config_page_preview_url?: string
}

const tunnelUrls: ApplicationURLs = {
applicationUrl: 'https://my-tunnel.example.com',
redirectUrlWhitelist: [],
}

const urlFields = ['runtime_url', 'validation_url', 'config_page_url', 'config_page_preview_url'] as const

describe('FlowActionExtension', () => {
let extension: ExtensionInstance<FlowActionConfig>

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<FlowActionConfig>
extension.configuration = {...config}
})

test('accepts an absolute https runtime_url', () => {
// When
const parsed = extension.specification.parseConfigurationObject({
...config,
runtime_url: 'https://example.com/api/execute',
})

// Then
expect(parsed.state).toBe('ok')
})

test('accepts a relative runtime_url starting with /', () => {
// When
const parsed = extension.specification.parseConfigurationObject(config)

// Then
expect(parsed.state).toBe('ok')
})

test('rejects a non-https absolute runtime_url', () => {
// When
const parsed = extension.specification.parseConfigurationObject({
...config,
runtime_url: 'http://example.com/api/execute',
})

// Then
expect(parsed.state).toBe('error')
})

test.each(urlFields)('rejects a relative %s containing a newline', (field) => {
// When
const parsed = extension.specification.parseConfigurationObject({
...config,
[field]: `/${field}\nmalicious-header: value`,
})

// Then
expect(parsed.state).toBe('error')
})

test('preserves absolute URLs and prepends the app URL to relative URLs in the deploy configuration', async () => {
// Given
extension.configuration = {
...extension.configuration,
runtime_url: '/api/execute',
validation_url: 'https://my-app.example.com/api/validate',
config_page_url: '/config',
config_page_preview_url: 'https://my-app.example.com/config/preview',
}

// When
const got = await extension.deployConfig({
apiKey: 'api-key',
appConfiguration: {
...placeholderAppConfiguration,
application_url: 'https://my-app.example.com',
},
})

// Then
expect(got).toEqual({
title: extension.configuration.name,
description: extension.configuration.description,
url: 'https://my-app.example.com/api/execute',
fields: [],
validation_url: 'https://my-app.example.com/api/validate',
custom_configuration_page_url: 'https://my-app.example.com/config',
custom_configuration_page_preview_url: 'https://my-app.example.com/config/preview',
schema_patch: '',
return_type_ref: undefined,
})
})

test.each(urlFields)('throws when deploying a relative %s without an app URL', async (field) => {
// Given
extension.configuration = {
...extension.configuration,
runtime_url: 'https://my-prod-host.example.com/api/execute',
validation_url: 'https://my-prod-host.example.com/api/validate',
config_page_url: 'https://my-prod-host.example.com/config',
config_page_preview_url: 'https://my-prod-host.example.com/config/preview',
}
extension.configuration[field] = `/${field}`

// When/Then
await expect(
extension.deployConfig({
apiKey: 'api-key',
appConfiguration: placeholderAppConfiguration,
}),
).rejects.toThrow(
`Flow action ${field} is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.`,
)
})

test('prepends the dev application URL to relative URL fields', () => {
// When
extension.patchWithAppDevURLs(tunnelUrls)

// Then
expect(extension.configuration.runtime_url).toBe('https://my-tunnel.example.com/api/execute')
expect(extension.configuration.validation_url).toBe('https://my-tunnel.example.com/api/validate')
expect(extension.configuration.config_page_url).toBe('https://my-tunnel.example.com/config')
expect(extension.configuration.config_page_preview_url).toBe('https://my-tunnel.example.com/config/preview')
})

test('leaves absolute dev URLs untouched', () => {
// Given
extension.configuration.runtime_url = 'https://my-prod-host.example.com/api/execute'
extension.configuration.validation_url = undefined
extension.configuration.config_page_url = undefined
extension.configuration.config_page_preview_url = undefined

// When
extension.patchWithAppDevURLs(tunnelUrls)

// Then
expect(extension.configuration.runtime_url).toBe('https://my-prod-host.example.com/api/execute')
expect(extension.configuration.validation_url).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@ import {BaseSchemaWithHandle} from '../schemas.js'
import {createExtensionSpecification} from '../specification.js'
import {
validateFieldShape,
startsWithHttps,
validateFlowActionUrl,
validateCustomConfigurationPageConfig,
validateReturnTypeConfig,
} from '../../../services/flow/validation.js'
import {serializeFields} from '../../../services/flow/serialize-fields.js'
import {loadSchemaFromPath} from '../../../services/flow/utils.js'
import {FLOW_ACTION_URL_FIELDS} from '../../../services/flow/types.js'
import {loadSchemaFromPath, resolveFlowActionUrl} from '../../../services/flow/utils.js'
import {zod} from '@shopify/cli-kit/node/schema'

const FlowActionExtensionSchema = BaseSchemaWithHandle.extend({
type: zod.literal('flow_action'),
name: zod.string(),
runtime_url: zod.string().url().refine(startsWithHttps),
validation_url: zod.string().url().refine(startsWithHttps).optional(),
config_page_url: zod.string().url().refine(startsWithHttps).optional(),
config_page_preview_url: zod.string().url().refine(startsWithHttps).optional(),
runtime_url: validateFlowActionUrl(zod.string({invalid_type_error: 'Value must be string'})),
validation_url: validateFlowActionUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(),
config_page_url: validateFlowActionUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(),
config_page_preview_url: validateFlowActionUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(),
schema: zod.string().optional(),
return_type_ref: zod.string().optional(),
}).refine((config) => {
Expand Down Expand Up @@ -45,15 +46,40 @@ const flowActionSpecification = createExtensionSpecification({
// https://github.com/Shopify/cli/blob/73ac91c0f40be0a57d1b18cb34254b12d3a071af/packages/app/src/cli/services/deploy.ts#L107
// Should be removed after unified deployment is 100% rolled out
appModuleFeatures: (_) => [],
deployConfig: async (config, extensionPath) => {
/**
* During `app dev`, swap any relative URLs (starting with `/`) for the dev
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming behaviour - this only happens for app dev, not app deploy?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, during app dev relative urls will be prepended by the auto-generated cloudflare tunnel url and during app deploy they'll be prepended with the configured application url

* tunnel URL the CLI assigned. This lets developers write
* `runtime_url = "/api/execute"` in their TOML and have it resolved against
* the tunnel automatically — the same pattern app_proxy, webhooks, and
* events subscriptions already use.
*
*/
patchWithAppDevURLs: (config, urls) => {
for (const key of FLOW_ACTION_URL_FIELDS) {
const value = config[key]
if (typeof value === 'string' && value.startsWith('/')) {
config[key] = resolveFlowActionUrl(key, value, urls.applicationUrl)
}
}
},
deployConfig: async (config, extensionPath, _apiKey, _moduleId, context) => {
const appConfiguration = context?.appConfiguration
const appUrl = typeof appConfiguration?.application_url === 'string' ? appConfiguration.application_url : undefined

return {
title: config.name,
description: config.description,
url: config.runtime_url,
url: resolveFlowActionUrl('runtime_url', config.runtime_url, appUrl),
fields: serializeFields('flow_action', config.settings?.fields),
validation_url: config.validation_url,
custom_configuration_page_url: config.config_page_url,
custom_configuration_page_preview_url: config.config_page_preview_url,
validation_url: config.validation_url
? resolveFlowActionUrl('validation_url', config.validation_url, appUrl)
: undefined,
custom_configuration_page_url: config.config_page_url
? resolveFlowActionUrl('config_page_url', config.config_page_url, appUrl)
: undefined,
custom_configuration_page_preview_url: config.config_page_preview_url
? resolveFlowActionUrl('config_page_preview_url', config.config_page_preview_url, appUrl)
: undefined,
schema_patch: await loadSchemaFromPath(extensionPath, config.schema),
return_type_ref: config.return_type_ref,
}
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/cli/services/flow/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
40 changes: 39 additions & 1 deletion packages/app/src/cli/services/flow/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,46 @@
import {loadSchemaFromPath} from './utils.js'
import {loadSchemaFromPath, resolveFlowActionUrl} from './utils.js'
import {describe, expect, test} from 'vitest'
import {readFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'

describe('resolveFlowActionUrl', () => {
test('returns absolute URLs unchanged', () => {
expect(
resolveFlowActionUrl('runtime_url', 'https://my-prod-host.example.com/api/execute', 'https://my-app.example.com'),
).toBe('https://my-prod-host.example.com/api/execute')
})

test('prepends the app URL to relative URLs', () => {
expect(resolveFlowActionUrl('runtime_url', '/api/execute', 'https://my-app.example.com/')).toBe(
'https://my-app.example.com/api/execute',
)
})

test('throws when a relative URL cannot be resolved without an app URL', () => {
expect(() => resolveFlowActionUrl('runtime_url', '/api/execute', undefined)).toThrow(
'Flow action runtime_url is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.',
)
})

test('throws when an absolute URL is not HTTPS', () => {
expect(() => resolveFlowActionUrl('runtime_url', 'http://my-prod-host.example.com/api/execute', undefined)).toThrow(
'Flow action runtime_url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
})

test('throws when the URL is empty', () => {
expect(() => resolveFlowActionUrl('runtime_url', '', 'https://my-app.example.com')).toThrow(
'Flow action runtime_url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
})

test('throws when a relative URL resolves against a non-HTTPS app URL', () => {
expect(() => resolveFlowActionUrl('runtime_url', '/api/execute', 'http://my-app.example.com')).toThrow(
'Flow action runtime_url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
})
})

describe('loadSchemaFromPath', () => {
test('loading schema from valid file path should return file contents', async () => {
const extensionPath = __dirname.concat('/fixtures')
Expand Down
26 changes: 26 additions & 0 deletions packages/app/src/cli/services/flow/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
import {prependApplicationUrl} from '../../models/extensions/specifications/validation/url_prepender.js'
import {joinPath} from '@shopify/cli-kit/node/path'
import {glob, readFile} from '@shopify/cli-kit/node/fs'
import {AbortError} from '@shopify/cli-kit/node/error'
import type {FlowActionUrlField} from './types.js'

/**
* Resolves a Flow action URL by prepending the app URL to relative URLs and
* ensuring the resolved URL is HTTPS.
*/
export const resolveFlowActionUrl = (fieldName: FlowActionUrlField, url: string, appUrl: string | undefined) => {
const resolvedUrl = prependApplicationUrl(url, appUrl)
if (resolvedUrl.startsWith('/')) {
throw new AbortError(
`Flow action ${fieldName} is a relative URL, but no application_url is configured. ` +
'Set application_url in your app configuration or use an absolute HTTPS URL.',
)
}

if (!resolvedUrl.startsWith('https://')) {
throw new AbortError(
`Flow action ${fieldName} must resolve to an HTTPS URL. ` +
'Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
}

return resolvedUrl
}

/**
* Loads the schema from the partner defined file.
Expand Down
35 changes: 34 additions & 1 deletion packages/app/src/cli/services/flow/validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
import {validateFieldShape, validateCustomConfigurationPageConfig, validateReturnTypeConfig} from './validation.js'
import {
validateFieldShape,
validateFlowActionUrl,
validateCustomConfigurationPageConfig,
validateReturnTypeConfig,
} from './validation.js'
import {ConfigField} from './types.js'
import {describe, expect, test} from 'vitest'
import {zod} from '@shopify/cli-kit/node/schema'

describe('validateFlowActionUrl', () => {
const schema = validateFlowActionUrl(zod.string())

test('accepts absolute HTTPS URLs', () => {
expect(schema.safeParse('https://example.com/api/execute').success).toBe(true)
})

test('accepts relative URLs starting with /', () => {
expect(schema.safeParse('/api/execute').success).toBe(true)
})

test('rejects non-HTTPS absolute URLs', () => {
expect(schema.safeParse('http://example.com/api/execute').success).toBe(false)
})

test.each(['\n', '\r', '\t'])('rejects relative URLs containing %j', (controlCharacter) => {
expect(schema.safeParse(`/api/execute${controlCharacter}malicious-header: value`).success).toBe(false)
})

test.each(['\n', '\r', '\t'])('rejects absolute URLs containing %j', (controlCharacter) => {
expect(schema.safeParse(`https://example.com/api/execute${controlCharacter}malicious-header`).success).toBe(false)
})

test('rejects protocol-relative URLs', () => {
expect(schema.safeParse('//example.com/api/execute').success).toBe(false)
})
})

describe('validateFieldShape', () => {
test('should return true when non-commerce object field has valid shape and is flow action', () => {
// given
Expand Down
Loading
Loading