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/chilly-glasses-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Fix crash "config2.map is not a function" when writing app configuration with unvalidated data (e.g., from third-party templates without `client_id`). Removes the Zod schema walker (`rewriteConfiguration`) that assumed config data matched schema types. Key ordering in the generated TOML now follows object insertion order rather than schema declaration order, which may cause a one-time key reorder in `shopify.app.toml` on the next `app link` or `app config pull`.
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ api_version = "2024-01"
compliance_topics = [ "customers/data_request", "customers/redact" ]

[[webhooks.subscriptions]]
topics = [ "orders/create" ]
uri = "/webhooks/orders"
topics = [ "orders/create" ]

[[webhooks.subscriptions]]
topics = [ "products/create", "products/update" ]
uri = "/webhooks/products"
topics = [ "products/create", "products/update" ]

[app_proxy]
url = "https://myapp.example.com/proxy"
Expand Down Expand Up @@ -109,9 +109,9 @@ redirect_urls = [
api_version = "2024-01"

[[webhooks.subscriptions]]
topics = [ "orders/create" ]
uri = "/webhooks"
compliance_topics = [ "customers/data_request", "customers/redact" ]
topics = [ "orders/create" ]

[app_proxy]
url = "https://myapp.example.com/proxy"
Expand Down Expand Up @@ -139,16 +139,16 @@ automatically_update_urls_on_dev = true
dev_store_url = "test-store.myshopify.com"
include_config_on_deploy = true

[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true

[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_products,write_orders"
use_legacy_install_flow = false
required_scopes = [ "read_products" ]
optional_scopes = [ "write_orders" ]
use_legacy_install_flow = false

[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true

[auth]
redirect_urls = [
Expand All @@ -160,13 +160,13 @@ redirect_urls = [
api_version = "2024-01"

[[webhooks.subscriptions]]
topics = [ "orders/create" ]
uri = "/webhooks/orders"
topics = [ "orders/create" ]
filter = "status:paid"

[[webhooks.subscriptions]]
topics = [ "orders/update" ]
uri = "/webhooks/orders"
topics = [ "orders/update" ]
filter = "status:pending"

[app_proxy]
Expand Down Expand Up @@ -195,16 +195,16 @@ automatically_update_urls_on_dev = true
dev_store_url = "test-store.myshopify.com"
include_config_on_deploy = true

[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true

[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_products,write_orders"
use_legacy_install_flow = false
required_scopes = [ "read_products" ]
optional_scopes = [ "write_orders" ]
use_legacy_install_flow = false

[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true

[auth]
redirect_urls = [
Expand All @@ -216,13 +216,13 @@ redirect_urls = [
api_version = "2024-01"

[[webhooks.subscriptions]]
topics = [ "products/create" ]
uri = "/webhooks/products"
topics = [ "products/create" ]
include_fields = [ "id", "title" ]

[[webhooks.subscriptions]]
topics = [ "products/update" ]
uri = "/webhooks/products"
topics = [ "products/update" ]
include_fields = [ "id" ]

[app_proxy]
Expand Down Expand Up @@ -251,16 +251,16 @@ automatically_update_urls_on_dev = true
dev_store_url = "test-store.myshopify.com"
include_config_on_deploy = true

[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true

[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_products,write_orders"
use_legacy_install_flow = false
required_scopes = [ "read_products" ]
optional_scopes = [ "write_orders" ]
use_legacy_install_flow = false

[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true

[auth]
redirect_urls = [
Expand All @@ -272,21 +272,21 @@ redirect_urls = [
api_version = "2024-01"

[[webhooks.subscriptions]]
topics = [ "orders/create", "orders/updated", "orders/cancelled" ]
uri = "/webhooks/orders"
topics = [ "orders/create", "orders/updated", "orders/cancelled" ]

[[webhooks.subscriptions]]
topics = [ "products/create" ]
uri = "/webhooks/products"
topics = [ "products/create" ]
include_fields = [ "id", "title" ]

[[webhooks.subscriptions]]
uri = "/webhooks/compliance"
compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ]

[[webhooks.subscriptions]]
topics = [ "app/uninstalled" ]
uri = "/webhooks/app"
topics = [ "app/uninstalled" ]

[app_proxy]
url = "https://myapp.example.com/proxy"
Expand Down Expand Up @@ -339,16 +339,16 @@ api_version = "2024-01"
compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ]

[[webhooks.subscriptions]]
topics = [ "app/uninstalled" ]
uri = "/webhooks/app"
topics = [ "app/uninstalled" ]

[[webhooks.subscriptions]]
topics = [ "orders/cancelled", "orders/create", "orders/updated" ]
uri = "/webhooks/orders"
topics = [ "orders/cancelled", "orders/create", "orders/updated" ]

[[webhooks.subscriptions]]
topics = [ "products/create" ]
uri = "/webhooks/products"
topics = [ "products/create" ]
include_fields = [ "id", "title" ]

[app_proxy]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,16 @@ describe('Config pipeline snapshots', () => {
// This test documents the current behavior as a snapshot.
await inTemporaryDirectory(async (tmp) => {
const filePath = joinPath(tmp, 'shopify.app.toml')
const {schema, configSpecifications: specs} = await buildVersionedAppSchema()
const {configSpecifications: specs} = await buildVersionedAppSchema()

// First write
await writeAppConfigurationFile(REALISTIC_CONFIG as CurrentAppConfiguration, schema, filePath)
await writeAppConfigurationFile(REALISTIC_CONFIG as CurrentAppConfiguration, filePath)

// Read back through the full parse pipeline (which fires Zod transforms)
const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)

// Second write from the parsed (transformed) config
await writeAppConfigurationFile(parsedConfig, schema, filePath)
await writeAppConfigurationFile(parsedConfig, filePath)
const secondWrite = await readFile(filePath)

// Snapshot the round-tripped output — it differs from the first write
Expand All @@ -111,17 +111,17 @@ describe('Config pipeline snapshots', () => {
// round-trips should be stable (idempotent).
await inTemporaryDirectory(async (tmp) => {
const filePath = joinPath(tmp, 'shopify.app.toml')
const {schema, configSpecifications: specs} = await buildVersionedAppSchema()
const {configSpecifications: specs} = await buildVersionedAppSchema()

// First write + read + second write (reordering happens here)
await writeAppConfigurationFile(REALISTIC_CONFIG as CurrentAppConfiguration, schema, filePath)
await writeAppConfigurationFile(REALISTIC_CONFIG as CurrentAppConfiguration, filePath)
const parsed1 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsed1, schema, filePath)
await writeAppConfigurationFile(parsed1, filePath)
const secondWrite = await readFile(filePath)

// Third write from re-read — should be identical to second
const parsed2 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsed2, schema, filePath)
await writeAppConfigurationFile(parsed2, filePath)
const thirdWrite = await readFile(filePath)

expect(thirdWrite).toEqual(secondWrite)
Expand All @@ -131,7 +131,7 @@ describe('Config pipeline snapshots', () => {
test('webhook subscriptions with mixed topics and compliance topics produce stable output', async () => {
await inTemporaryDirectory(async (tmp) => {
const filePath = joinPath(tmp, 'shopify.app.toml')
const {schema, configSpecifications: specs} = await buildVersionedAppSchema()
const {configSpecifications: specs} = await buildVersionedAppSchema()

const config = {
...REALISTIC_CONFIG,
Expand Down Expand Up @@ -160,22 +160,25 @@ describe('Config pipeline snapshots', () => {
}

// Snapshot the first write
await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath)
await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath)
const firstWrite = await readFile(filePath)
expect(firstWrite).toMatchSnapshot()

// Round-trip to verify reordering behavior on the most complex fixture
const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsedConfig, schema, filePath)
await writeAppConfigurationFile(parsedConfig, filePath)
const secondWrite = await readFile(filePath)
expect(secondWrite).toMatchSnapshot()
})
})

test('config with relative webhook URIs normalizes correctly through round-trip', async () => {
// First write uses JS object insertion order (no schema-driven rewriting).
// A Zod parse round-trip reorders keys to schema order, so the first round-trip may differ.
// Stability is guaranteed from the second write onward.
test('config with relative webhook URIs stabilizes after round-trip', async () => {
await inTemporaryDirectory(async (tmp) => {
const filePath = joinPath(tmp, 'shopify.app.toml')
const {schema, configSpecifications: specs} = await buildVersionedAppSchema()
const {configSpecifications: specs} = await buildVersionedAppSchema()

const config = {
...REALISTIC_CONFIG,
Expand All @@ -190,22 +193,24 @@ describe('Config pipeline snapshots', () => {
},
}

// Write, read, write
await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath)
const firstWrite = await readFile(filePath)

const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsedConfig, schema, filePath)
// Write, read, write (first round-trip may reorder)
await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath)
const parsed1 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsed1, filePath)
const secondWrite = await readFile(filePath)

expect(secondWrite).toEqual(firstWrite)
// Third write should be stable
const parsed2 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsed2, filePath)
const thirdWrite = await readFile(filePath)

expect(thirdWrite).toEqual(secondWrite)
})
})

test('minimal config without webhooks produces stable output', async () => {
await inTemporaryDirectory(async (tmp) => {
const filePath = joinPath(tmp, 'shopify.app.toml')
const {schema} = await buildVersionedAppSchema()

const config = {
client_id: '12345',
Expand All @@ -220,16 +225,19 @@ describe('Config pipeline snapshots', () => {
},
} satisfies CurrentAppConfiguration

await writeAppConfigurationFile(config, schema, filePath)
await writeAppConfigurationFile(config, filePath)
const content = await readFile(filePath)
expect(content).toMatchSnapshot()
})
})

// First write uses JS object insertion order (no schema-driven rewriting).
// A Zod parse round-trip reorders keys to schema order, so the first round-trip may differ.
// Stability is guaranteed from the second write onward.
test('subscriptions with same URI but different filters stay separate through round-trip', async () => {
await inTemporaryDirectory(async (tmp) => {
const filePath = joinPath(tmp, 'shopify.app.toml')
const {schema, configSpecifications: specs} = await buildVersionedAppSchema()
const {configSpecifications: specs} = await buildVersionedAppSchema()

const config = {
...REALISTIC_CONFIG,
Expand All @@ -242,22 +250,30 @@ describe('Config pipeline snapshots', () => {
},
}

await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath)
await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath)
const firstWrite = await readFile(filePath)
expect(firstWrite).toMatchSnapshot()

const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsedConfig, schema, filePath)
// Round-trip: write → read → write → read → write (stabilizes after first round-trip)
const parsed1 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsed1, filePath)
const secondWrite = await readFile(filePath)

expect(firstWrite).toMatchSnapshot()
expect(secondWrite).toEqual(firstWrite)
const parsed2 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsed2, filePath)
const thirdWrite = await readFile(filePath)

expect(thirdWrite).toEqual(secondWrite)
})
})

// First write uses JS object insertion order (no schema-driven rewriting).
// A Zod parse round-trip reorders keys to schema order, so the first round-trip may differ.
// Stability is guaranteed from the second write onward.
test('subscriptions with same URI but different include_fields stay separate through round-trip', async () => {
await inTemporaryDirectory(async (tmp) => {
const filePath = joinPath(tmp, 'shopify.app.toml')
const {schema, configSpecifications: specs} = await buildVersionedAppSchema()
const {configSpecifications: specs} = await buildVersionedAppSchema()

const config = {
...REALISTIC_CONFIG,
Expand All @@ -270,22 +286,27 @@ describe('Config pipeline snapshots', () => {
},
}

await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath)
await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath)
const firstWrite = await readFile(filePath)
expect(firstWrite).toMatchSnapshot()

const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsedConfig, schema, filePath)
// Round-trip: stabilizes after first round-trip
const parsed1 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsed1, filePath)
const secondWrite = await readFile(filePath)

expect(firstWrite).toMatchSnapshot()
expect(secondWrite).toEqual(firstWrite)
const parsed2 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsed2, filePath)
const thirdWrite = await readFile(filePath)

expect(thirdWrite).toEqual(secondWrite)
})
})

test('subscription with both topics and compliance_topics on same URI splits after round-trip', async () => {
await inTemporaryDirectory(async (tmp) => {
const filePath = joinPath(tmp, 'shopify.app.toml')
const {schema, configSpecifications: specs} = await buildVersionedAppSchema()
const {configSpecifications: specs} = await buildVersionedAppSchema()

const config = {
...REALISTIC_CONFIG,
Expand All @@ -301,11 +322,11 @@ describe('Config pipeline snapshots', () => {
},
}

await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath)
await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath)
const firstWrite = await readFile(filePath)

const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath)
await writeAppConfigurationFile(parsedConfig, schema, filePath)
await writeAppConfigurationFile(parsedConfig, filePath)
const secondWrite = await readFile(filePath)

// After round-trip, compliance and regular topics should be split into separate subscriptions
Expand Down
Loading
Loading