Skip to content
Draft
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
88 changes: 88 additions & 0 deletions packages/app/src/cli/models/app/config/slice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {sliceConfigForSpec, findUnclaimedKeys} from './slice.js'
import {ExtensionSpecification} from '../../extensions/specification.js'
import {describe, expect, test} from 'vitest'

function specWithKeys(id: string, keys: string[]): ExtensionSpecification {
return {declaredKeys: keys, identifier: id} as unknown as ExtensionSpecification
}

describe('sliceConfigForSpec', () => {
test('picks only declared keys present in raw config', () => {
const raw = {name: 'my-app', application_url: 'https://example.com', client_id: '123'}
const spec = specWithKeys('app_home', ['name', 'application_url', 'embedded', 'app_preferences'])

const slice = sliceConfigForSpec(raw, spec)

expect(slice).toEqual({name: 'my-app', application_url: 'https://example.com'})
// embedded and app_preferences are declared but not in raw — excluded
expect(slice).not.toHaveProperty('embedded')
expect(slice).not.toHaveProperty('client_id')
})

test('returns empty object when no declared keys are in raw', () => {
const raw = {client_id: '123', build: {}}
const spec = specWithKeys('point_of_sale', ['pos', 'name', 'type'])

expect(sliceConfigForSpec(raw, spec)).toEqual({})
})

test('deep copies values so mutations do not affect original', () => {
const raw = {webhooks: {api_version: '2024-01', subscriptions: [{uri: '/test'}]}}
const spec = specWithKeys('webhooks', ['webhooks'])

const slice = sliceConfigForSpec(raw, spec) as {webhooks: {subscriptions: {uri: string}[]}}
slice.webhooks.subscriptions[0]!.uri = '/mutated'

// Original is untouched
expect(raw.webhooks.subscriptions[0]!.uri).toBe('/test')
})

test('handles nested objects correctly', () => {
const raw = {
access_scopes: {scopes: 'read_products', use_legacy_install_flow: false},
auth: {redirect_urls: ['https://example.com/callback']},
client_id: '123',
}
const spec = specWithKeys('app_access', ['access_scopes', 'auth'])

const slice = sliceConfigForSpec(raw, spec)

expect(slice).toEqual({
access_scopes: {scopes: 'read_products', use_legacy_install_flow: false},
auth: {redirect_urls: ['https://example.com/callback']},
})
})
})

describe('findUnclaimedKeys', () => {
test('returns keys not claimed by any spec or AppSchema', () => {
const raw = {
client_id: '123',
name: 'my-app',
unknown_section: {foo: 'bar'},
another_unknown: true,
}
const claimedKeyArrays = [['name']]

const unclaimed = findUnclaimedKeys(raw, claimedKeyArrays)

// client_id is in AppSchema — claimed
// name is claimed by branding
// unknown_section and another_unknown are unclaimed
expect(unclaimed.sort()).toEqual(['another_unknown', 'unknown_section'])
})

test('returns empty array when all keys are claimed', () => {
const raw = {client_id: '123', name: 'my-app'}
const claimedKeyArrays = [['name']]

expect(findUnclaimedKeys(raw, claimedKeyArrays)).toEqual([])
})

test('organization_id is always claimed', () => {
const raw = {client_id: '123', organization_id: 'org-1'}
const claimedKeyArrays: string[][] = []

expect(findUnclaimedKeys(raw, claimedKeyArrays)).toEqual([])
})
})
35 changes: 35 additions & 0 deletions packages/app/src/cli/models/app/config/slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {ExtensionSpecification} from '../../extensions/specification.js'
import {AppSchema} from '../app.js'

/**
* Extract a spec's portion of the raw app config by picking only the keys
* the spec declares. Returns a deep copy — mutations to the slice don't
* affect the original config.
*
* @param raw - The full app configuration object (raw from TOML parse)
* @param spec - The extension specification declaring which keys to pick
* @returns A deep-copied object containing only the spec's declared keys that are present in raw
*/
export function sliceConfigForSpec(raw: object, spec: ExtensionSpecification): object {
const result: Record<string, unknown> = {}
const rawRecord = raw as Record<string, unknown>
for (const key of spec.declaredKeys) {
if (key in rawRecord) {
result[key] = structuredClone(rawRecord[key])
}
}
return result
}

/**
* Find keys in the raw config that aren't claimed by any spec or by the base AppSchema.
* These are "unsupported sections" that the CLI doesn't recognize.
*
* @param raw - The full app configuration object
* @param claimedKeyArrays - Array of key arrays, one per spec (from Object.keys(slice))
* @returns Array of unclaimed key names
*/
export function findUnclaimedKeys(raw: object, claimedKeyArrays: string[][]): string[] {
const claimed = new Set([...Object.keys(AppSchema.shape), 'organization_id', ...claimedKeyArrays.flat()])
return Object.keys(raw).filter((key) => !claimed.has(key))
}
72 changes: 72 additions & 0 deletions packages/app/src/cli/models/app/config/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {validateSpecConfig} from './validate.js'
import {ExtensionSpecification} from '../../extensions/specification.js'
import {zod} from '@shopify/cli-kit/node/schema'
import {describe, expect, test} from 'vitest'

function specWithSchema(schema: zod.ZodTypeAny): ExtensionSpecification {
return {
schema,
identifier: 'test-spec',
declaredKeys: [],
} as unknown as ExtensionSpecification
}

describe('validateSpecConfig', () => {
test('returns empty array for valid config', () => {
const schema = zod.object({name: zod.string(), embedded: zod.boolean()})
const spec = specWithSchema(schema)

const errors = validateSpecConfig({name: 'my-app', embedded: true}, spec)

expect(errors).toEqual([])
})

test('returns Zod errors for invalid config', () => {
const schema = zod.object({name: zod.string(), embedded: zod.boolean()})
const spec = specWithSchema(schema)

const errors = validateSpecConfig({name: 123, embedded: 'not-bool'}, spec)

expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.path.includes('name'))).toBe(true)
expect(errors.some((e) => e.path.includes('embedded'))).toBe(true)
})

test('returns empty array for zod.any() schema (contract-based specs)', () => {
const schema = zod.any()
const spec = specWithSchema(schema)

const errors = validateSpecConfig({anything: 'goes'}, spec)

expect(errors).toEqual([])
})

test('deduplicates identical errors', () => {
// Create a schema that would produce the same error path/message
const schema = zod.object({name: zod.string()})
const spec = specWithSchema(schema)

const errors = validateSpecConfig({}, spec)

// Should have exactly one error for missing 'name', not duplicates
const nameErrors = errors.filter((e) => e.path.includes('name'))
expect(nameErrors.length).toBe(1)
})

test('validates with contract schema when present', () => {
const schema = zod.any()
const spec = {
...specWithSchema(schema),
contractSchema: {
type: 'object',
properties: {name: {type: 'string'}},
required: ['name'],
},
} as unknown as ExtensionSpecification

const errors = validateSpecConfig({}, spec)

expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.message === 'Required')).toBe(true)
})
})
53 changes: 53 additions & 0 deletions packages/app/src/cli/models/app/config/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {ExtensionSpecification} from '../../extensions/specification.js'
import {HandleInvalidAdditionalProperties, jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema'
import {SchemaObject} from 'ajv'

interface ValidationError {
path: (string | number)[]
message: string
}

/**
* Validate a config slice against a spec's Zod schema and JSON Schema contract.
* Returns errors only — no data shaping, no stripping.
*
* For locally-defined specs: Zod validates structure, AJV validates contract (if exists).
* For contract-based specs (zod.any()): Zod is a no-op, AJV does all validation.
*
* @param slice - The spec's portion of the app config (from sliceConfigForSpec)
* @param spec - The extension specification to validate against
* @returns Deduplicated array of validation errors (empty = valid)
*/
export function validateSpecConfig(slice: object, spec: ExtensionSpecification): ValidationError[] {
const errors: ValidationError[] = []

// Zod validation (no-op for contract-based specs with zod.any())
const zodResult = spec.schema.safeParse(slice)
if (!zodResult.success) {
errors.push(...zodResult.error.issues.map((issue) => ({path: issue.path, message: issue.message})))
}

// AJV validation in 'fail' mode (if contract exists)
// contractSchema is set during mergeLocalAndRemoteSpecs for specs with a remote contract
const contractSchema = (spec as ExtensionSpecificationWithContract).contractSchema
if (contractSchema) {
const ajvResult = jsonSchemaValidate(slice, contractSchema, 'fail' as HandleInvalidAdditionalProperties)
if (ajvResult.state === 'error') {
errors.push(...ajvResult.errors)
}
}

// Deduplicate errors (same logic as unifiedConfigurationParserFactory)
const seen = new Set<string>()
return errors.filter((error) => {
const key = JSON.stringify({path: error.path, message: error.message})
if (seen.has(key)) return false
seen.add(key)
return true
})
}

// Extended spec type that may have a pre-normalized contract schema
interface ExtensionSpecificationWithContract extends ExtensionSpecification {
contractSchema?: SchemaObject
}
77 changes: 51 additions & 26 deletions packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
getTemplateScopesArray,
normalizeExtensionDirectories,
} from './app.js'
import {sliceConfigForSpec, findUnclaimedKeys} from './config/slice.js'
import {validateSpecConfig} from './config/validate.js'
import {parseHumanReadableError} from './error-parsing.js'
import {configurationFileNames, dotEnvFileNames} from '../../constants.js'
import metadata from '../../metadata.js'
Expand Down Expand Up @@ -727,39 +729,62 @@ class AppLoader<TConfig extends AppConfiguration, TModuleSpec extends ExtensionS
this.specifications
.filter((specification) => specification.uidStrategy === 'single')
.map(async (specification) => {
const specConfiguration = parseConfigurationObjectAgainstSpecification(
// Slice the raw config to extract only this spec's keys (deep copy)
const slice = sliceConfigForSpec(appConfiguration, specification)
const sliceKeys = Object.keys(slice)
if (sliceKeys.length === 0) return [null, []] as const

// Validate the slice — errors only, no data shaping
const errors = validateSpecConfig(slice, specification)
if (errors.length > 0) {
this.abortOrReport(
outputContent`App configuration is not valid\nValidation errors in ${outputToken.path(
configPath,
)}:\n\n${parseHumanReadableError(errors)}`,
undefined,
configPath,
)
return [null, []] as const
}

// Create instance directly with the slice — no second parse
const entryPath = await this.findEntryPath(directory, specification)
const instance = new ExtensionInstance({
configuration: slice,
configurationPath: configPath,
entryPath,
directory,
specification,
configPath,
appConfiguration,
this.abortOrReport.bind(this),
)
})

if (Object.keys(specConfiguration).length === 0) return [null, Object.keys(specConfiguration)] as const
// Preserve devUUID from previous app (for reload consistency)
const previousExtension = this.previousApp?.allExtensions.find((ext) => ext.handle === instance.handle)
if (previousExtension) {
instance.devUUID = previousExtension.devUUID
}

const instance = await this.createExtensionInstance(
specification.identifier,
specConfiguration,
configPath,
directory,
).then((extensionInstance) =>
this.validateConfigurationExtensionInstance(
appConfiguration.client_id,
appConfiguration,
extensionInstance,
),
// Spec-level business rule validation (separate from schema validation)
if (specification.validate) {
const validateResult = await instance.validate()
if (validateResult.isErr()) {
this.abortOrReport(outputContent`\n${validateResult.error}`, undefined, configPath)
}
}

// Check that deploy payload is non-empty (same as validateConfigurationExtensionInstance)
const validInstance = await this.validateConfigurationExtensionInstance(
appConfiguration.client_id,
appConfiguration,
instance,
)
return [instance, Object.keys(specConfiguration)] as const
return [validInstance, sliceKeys] as const
}),
)

// get all the keys from appConfiguration that aren't used by any of the results

const unusedKeys = Object.keys(appConfiguration)
.filter((key) => !extensionInstancesWithKeys.some(([_, keys]) => keys.includes(key)))
.filter((key) => {
const configKeysThatAreNeverModules = [...Object.keys(AppSchema.shape), 'organization_id']
return !configKeysThatAreNeverModules.includes(key)
})
const unusedKeys = findUnclaimedKeys(
appConfiguration,
extensionInstancesWithKeys.map(([_, keys]) => keys),
)

if (unusedKeys.length > 0 && this.mode !== 'local') {
this.abortOrReport(
Expand Down
Loading
Loading