From f95c113c7516e33cc877614cf79b0eaa919c09aa Mon Sep 17 00:00:00 2001 From: Matt Elphick Date: Wed, 21 Oct 2026 17:41:37 +0100 Subject: [PATCH] Merge shop_scopes back into access_scopes.scopes when pulling app config --- .changeset/app-access-shop-scopes.md | 5 +++ .../cli/models/extensions/specification.ts | 2 +- .../app_config_app_access.test.ts | 45 +++++++++++++++++++ .../specifications/app_config_app_access.ts | 32 ++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 .changeset/app-access-shop-scopes.md diff --git a/.changeset/app-access-shop-scopes.md b/.changeset/app-access-shop-scopes.md new file mode 100644 index 00000000000..2e94cd6e96f --- /dev/null +++ b/.changeset/app-access-shop-scopes.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fix `app config link` so that it no longer drops Shop scopes (e.g. `shop_app:oauth`). diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index f3b4a618ca4..f6f98ff73e6 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -422,7 +422,7 @@ function resolveReverseAppConfigTransform( * @returns the transformed object */ -function appConfigTransform( +export function appConfigTransform( content: object, config: TransformationConfig | CustomTransformationConfig, reverse = false, diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_app_access.test.ts b/packages/app/src/cli/models/extensions/specifications/app_config_app_access.test.ts index e514e8d3864..02465a63ea1 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_app_access.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_app_access.test.ts @@ -73,6 +73,51 @@ describe('app_config_app_access', () => { }, }) }) + test('merges shop_scopes back into scopes', () => { + // Given + const object = { + scopes: 'write_products,read_products', + shop_scopes: 'shop_app:oauth,shop_app:read_profile', + redirect_url_allowlist: ['https://example.com/auth/callback'], + } + + // When + const result = spec.transformRemoteToLocal!(object) + + // Then + expect(result).toEqual({ + access_scopes: { + scopes: 'read_products,shop_app:oauth,shop_app:read_profile,write_products', + }, + auth: { + redirect_urls: ['https://example.com/auth/callback'], + }, + }) + }) + + test('uses shop_scopes as scopes when the platform scopes are empty or missing', () => { + expect(spec.transformRemoteToLocal!({scopes: '', shop_scopes: 'shop_app:oauth'})).toEqual({ + access_scopes: {scopes: 'shop_app:oauth'}, + }) + expect(spec.transformRemoteToLocal!({shop_scopes: 'shop_app:oauth'})).toEqual({ + access_scopes: {scopes: 'shop_app:oauth'}, + }) + }) + + test('leaves scopes untouched when shop_scopes is absent or blank', () => { + expect(spec.transformRemoteToLocal!({scopes: 'read_products', shop_scopes: ''})).toEqual({ + access_scopes: {scopes: 'read_products'}, + }) + expect(spec.transformRemoteToLocal!({scopes: 'read_products'})).toEqual({ + access_scopes: {scopes: 'read_products'}, + }) + }) + + test('round-trips: forward after reverse sends shop scopes in scopes', () => { + const local = spec.transformRemoteToLocal!({scopes: 'read_products', shop_scopes: 'shop_app:oauth'}) + const remote = spec.transformLocalToRemote!(local, placeholderAppConfiguration) + expect(remote).toEqual({scopes: 'read_products,shop_app:oauth'}) + }) }) describe('getDevSessionUpdateMessages', () => { diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_app_access.ts b/packages/app/src/cli/models/extensions/specifications/app_config_app_access.ts index c53f4f136e6..20c158b85fe 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_app_access.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_app_access.ts @@ -1,5 +1,10 @@ import {validateUrl} from '../../app/validation/common.js' -import {TransformationConfig, createConfigExtensionSpecification} from '../specification.js' +import { + CustomTransformationConfig, + TransformationConfig, + appConfigTransform, + createConfigExtensionSpecification, +} from '../specification.js' import {BaseSchemaWithoutHandle} from '../schemas.js' import {normalizeDelimitedString} from '@shopify/cli-kit/common/string' import {zod} from '@shopify/cli-kit/node/schema' @@ -35,7 +40,7 @@ const AppAccessSchema = BaseSchemaWithoutHandle.extend({ export const AppAccessSpecIdentifier = 'app_access' -const AppAccessTransformConfig: TransformationConfig = { +const AppAccessFieldMapping: TransformationConfig = { access: 'access', scopes: 'access_scopes.scopes', required_scopes: 'access_scopes.required_scopes', @@ -44,6 +49,29 @@ const AppAccessTransformConfig: TransformationConfig = { redirect_url_allowlist: 'auth.redirect_urls', } +/** + * The platform stores Shop scopes (e.g. `shop_app:oauth`) in a separate `shop_scopes` field, split out of + * `scopes` when a version is created. Locally they live in `access_scopes.scopes`, so fold them back in. + * Without this, `config link` writes a TOML without the Shop scopes and the next deploy removes them. + * + * @param remoteContent - The app_access module config as returned by the platform + * @returns The config with `shop_scopes` merged into `scopes` and removed + */ +function mergeShopScopesIntoScopes(remoteContent: {[key: string]: unknown}): {[key: string]: unknown} { + const {shop_scopes: shopScopes, ...rest} = remoteContent + const shopScopesString = typeof shopScopes === 'string' ? normalizeDelimitedString(shopScopes) : undefined + if (!shopScopesString) return rest + + const platformScopes = typeof rest.scopes === 'string' ? rest.scopes : '' + return {...rest, scopes: normalizeDelimitedString([platformScopes, shopScopesString].join(','))} +} + +const AppAccessTransformConfig: CustomTransformationConfig = { + forward: (content) => appConfigTransform(content, AppAccessFieldMapping), + reverse: (content) => + appConfigTransform(mergeShopScopesIntoScopes(content as {[key: string]: unknown}), AppAccessFieldMapping, true), +} + const appAccessSpec = createConfigExtensionSpecification({ identifier: AppAccessSpecIdentifier, schema: AppAccessSchema,