From 9c0a300c17fb899535c38d25c146373f823b6306 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 6 Mar 2026 17:42:01 -0700 Subject: [PATCH 1/2] Extract scopes transforms from Zod schemas into accessor functions Move normalizeDelimitedString (sort, deduplicate, trim) from parse-time Zod transforms to getAppScopes(), getTemplateScopesArray() accessors. Config objects now preserve the raw TOML value; normalization happens on read through the existing chokepoint functions. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/app.test.ts | 8 +++--- packages/app/src/cli/models/app/app.ts | 28 ++++++++++++++++++- .../specifications/app_config_app_access.ts | 6 +--- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/app/src/cli/models/app/app.test.ts b/packages/app/src/cli/models/app/app.test.ts index 7d30b987db8..34c2cb4ae6b 100644 --- a/packages/app/src/cli/models/app/app.test.ts +++ b/packages/app/src/cli/models/app/app.test.ts @@ -175,16 +175,16 @@ describe('getUIExtensionRendererVersion', () => { }) describe('getAppScopes', () => { - test('returns the access_scopes.scopes key', () => { + test('returns the access_scopes.scopes key with normalization', () => { const config = {...DEFAULT_CONFIG, access_scopes: {scopes: 'read_themes,read_themes'}} - expect(getAppScopes(config)).toEqual('read_themes,read_themes') + expect(getAppScopes(config)).toEqual('read_themes') }) }) describe('getAppScopesArray', () => { - test('returns the access_scopes.scopes key', () => { + test('returns the access_scopes.scopes key as array with normalization', () => { const config = {...DEFAULT_CONFIG, access_scopes: {scopes: 'read_themes, read_order ,write_products'}} - expect(getAppScopesArray(config)).toEqual(['read_themes', 'read_order', 'write_products']) + expect(getAppScopesArray(config)).toEqual(['read_order', 'read_themes', 'write_products']) }) }) diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 040f7702da1..bd462abd362 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -13,6 +13,7 @@ import {configurationFileNames} from '../../constants.js' import {ApplicationURLs} from '../../services/dev/urls.js' import {patchAppHiddenConfigFile} from '../../services/app/patch-app-configuration-file.js' import {WebhookSubscription} from '../extensions/specifications/types/app_config_webhook.js' +import {normalizeDelimitedString} from '@shopify/cli-kit/common/string' import {joinPath} from '@shopify/cli-kit/node/path' import {ZodObjectOf, zod} from '@shopify/cli-kit/node/schema' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' @@ -72,6 +73,31 @@ export const AppSchema = zod }) .passthrough() +/** + * Schema for loading template config during app init. + * Uses passthrough() to allow any extra keys from full-featured templates + * (e.g., metafields, metaobjects, webhooks) without strict validation. + */ +export const TemplateConfigSchema = zod + .object({ + scopes: zod.string().optional(), + access_scopes: zod + .object({ + scopes: zod.string(), + }) + .optional(), + web_directories: zod.array(zod.string()).optional(), + }) + .passthrough() + +export type TemplateConfig = zod.infer + +export function getTemplateScopesArray(config: TemplateConfig): string[] { + const scopesString = normalizeDelimitedString(config.scopes ?? config.access_scopes?.scopes) + if (!scopesString || scopesString.length === 0) return [] + return scopesString.split(',').map((scope) => scope.trim()) +} + /** * Hidden configuration for an app. Stored inside ./shopify/project.json * This is a set of values that are needed by the CLI that are not part of the app configuration. @@ -127,7 +153,7 @@ export function getAppVersionedSchema( * @param config - a configuration file */ export function getAppScopes(config: CurrentAppConfiguration): string { - return config.access_scopes?.scopes ?? '' + return normalizeDelimitedString(config.access_scopes?.scopes) ?? '' } /** 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 b46603c58c2..d8ff47a4566 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,7 +1,6 @@ import {validateUrl} from '../../app/validation/common.js' import {TransformationConfig, 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' const AppAccessSchema = BaseSchemaWithoutHandle.extend({ @@ -17,10 +16,7 @@ const AppAccessSchema = BaseSchemaWithoutHandle.extend({ .optional(), access_scopes: zod .object({ - scopes: zod - .string() - .transform((scopes) => normalizeDelimitedString(scopes) ?? '') - .optional(), + scopes: zod.string().optional(), required_scopes: zod.array(zod.string()).optional(), optional_scopes: zod.array(zod.string()).optional(), use_legacy_install_flow: zod.boolean().optional(), From cdcf9d7158e5915ce8753bd36584315880f6da35 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Sat, 7 Mar 2026 16:18:56 -0700 Subject: [PATCH 2/2] Fix scopes test to expect raw value after scopes transform extraction getAppConfigurationState returns raw config values in startingOptions; scopes normalization now happens at read time via getAppScopes(). Update the test assertion to match the raw TOML value. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/loader.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index dcde9758e94..41cc61e8abe 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -3486,6 +3486,19 @@ describe('WebhooksSchema', () => { describe('getAppConfigurationState', () => { test.each([ + [ + `scopes = " write_xyz, write_abc "`, + { + state: 'template-only', + configSource: 'cached', + configurationFileName: 'shopify.app.toml', + appDirectory: expect.any(String), + configurationPath: expect.stringMatching(/shopify.app.toml$/), + startingOptions: { + scopes: ' write_xyz, write_abc ', + }, + }, + ], [ `client_id="abcdef"`, {