Skip to content

Commit 18fb773

Browse files
committed
Merge shop_scopes back into access_scopes.scopes when pulling app config
1 parent f3dfb35 commit 18fb773

4 files changed

Lines changed: 81 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
Keep Shop scopes (e.g. shop_app:oauth) in access_scopes.scopes when pulling app config, so config link and deploy previews no longer drop them.

‎packages/app/src/cli/models/extensions/specification.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ function resolveReverseAppConfigTransform<T>(
422422
* @returns the transformed object
423423
*/
424424

425-
function appConfigTransform(
425+
export function appConfigTransform(
426426
content: object,
427427
config: TransformationConfig | CustomTransformationConfig,
428428
reverse = false,

‎packages/app/src/cli/models/extensions/specifications/app_config_app_access.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,51 @@ describe('app_config_app_access', () => {
7373
},
7474
})
7575
})
76+
test('merges shop_scopes back into scopes', () => {
77+
// Given
78+
const object = {
79+
scopes: 'write_products,read_products',
80+
shop_scopes: 'shop_app:oauth,shop_app:read_profile',
81+
redirect_url_allowlist: ['https://example.com/auth/callback'],
82+
}
83+
84+
// When
85+
const result = spec.transformRemoteToLocal!(object)
86+
87+
// Then
88+
expect(result).toEqual({
89+
access_scopes: {
90+
scopes: 'read_products,shop_app:oauth,shop_app:read_profile,write_products',
91+
},
92+
auth: {
93+
redirect_urls: ['https://example.com/auth/callback'],
94+
},
95+
})
96+
})
97+
98+
test('uses shop_scopes as scopes when the platform scopes are empty or missing', () => {
99+
expect(spec.transformRemoteToLocal!({scopes: '', shop_scopes: 'shop_app:oauth'})).toEqual({
100+
access_scopes: {scopes: 'shop_app:oauth'},
101+
})
102+
expect(spec.transformRemoteToLocal!({shop_scopes: 'shop_app:oauth'})).toEqual({
103+
access_scopes: {scopes: 'shop_app:oauth'},
104+
})
105+
})
106+
107+
test('leaves scopes untouched when shop_scopes is absent or blank', () => {
108+
expect(spec.transformRemoteToLocal!({scopes: 'read_products', shop_scopes: ''})).toEqual({
109+
access_scopes: {scopes: 'read_products'},
110+
})
111+
expect(spec.transformRemoteToLocal!({scopes: 'read_products'})).toEqual({
112+
access_scopes: {scopes: 'read_products'},
113+
})
114+
})
115+
116+
test('round-trips: forward after reverse sends shop scopes in scopes', () => {
117+
const local = spec.transformRemoteToLocal!({scopes: 'read_products', shop_scopes: 'shop_app:oauth'})
118+
const remote = spec.transformLocalToRemote!(local, placeholderAppConfiguration)
119+
expect(remote).toEqual({scopes: 'read_products,shop_app:oauth'})
120+
})
76121
})
77122

78123
describe('getDevSessionUpdateMessages', () => {

‎packages/app/src/cli/models/extensions/specifications/app_config_app_access.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import {validateUrl} from '../../app/validation/common.js'
2-
import {TransformationConfig, createConfigExtensionSpecification} from '../specification.js'
2+
import {
3+
CustomTransformationConfig,
4+
TransformationConfig,
5+
appConfigTransform,
6+
createConfigExtensionSpecification,
7+
} from '../specification.js'
38
import {BaseSchemaWithoutHandle} from '../schemas.js'
49
import {normalizeDelimitedString} from '@shopify/cli-kit/common/string'
510
import {zod} from '@shopify/cli-kit/node/schema'
@@ -35,7 +40,7 @@ const AppAccessSchema = BaseSchemaWithoutHandle.extend({
3540

3641
export const AppAccessSpecIdentifier = 'app_access'
3742

38-
const AppAccessTransformConfig: TransformationConfig = {
43+
const AppAccessFieldMapping: TransformationConfig = {
3944
access: 'access',
4045
scopes: 'access_scopes.scopes',
4146
required_scopes: 'access_scopes.required_scopes',
@@ -44,6 +49,29 @@ const AppAccessTransformConfig: TransformationConfig = {
4449
redirect_url_allowlist: 'auth.redirect_urls',
4550
}
4651

52+
/**
53+
* The platform stores Shop scopes (e.g. `shop_app:oauth`) in a separate `shop_scopes` field, split out of
54+
* `scopes` when a version is created. Locally they live in `access_scopes.scopes`, so fold them back in.
55+
* Without this, `config link` writes a TOML without the Shop scopes and the next deploy removes them.
56+
*
57+
* @param remoteContent - The app_access module config as returned by the platform
58+
* @returns The config with `shop_scopes` merged into `scopes` and removed
59+
*/
60+
export function mergeShopScopesIntoScopes(remoteContent: {[key: string]: unknown}): {[key: string]: unknown} {
61+
const {shop_scopes: shopScopes, ...rest} = remoteContent
62+
const shopScopesString = typeof shopScopes === 'string' ? normalizeDelimitedString(shopScopes) : undefined
63+
if (!shopScopesString) return rest
64+
65+
const platformScopes = typeof rest.scopes === 'string' ? rest.scopes : ''
66+
return {...rest, scopes: normalizeDelimitedString([platformScopes, shopScopesString].join(','))}
67+
}
68+
69+
const AppAccessTransformConfig: CustomTransformationConfig = {
70+
forward: (content) => appConfigTransform(content, AppAccessFieldMapping),
71+
reverse: (content) =>
72+
appConfigTransform(mergeShopScopesIntoScopes(content as {[key: string]: unknown}), AppAccessFieldMapping, true),
73+
}
74+
4775
const appAccessSpec = createConfigExtensionSpecification({
4876
identifier: AppAccessSpecIdentifier,
4977
schema: AppAccessSchema,

0 commit comments

Comments
 (0)