Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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?

* 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