From df6c26318551f642eaa52afcc26fe997a2863c3a Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Mon, 23 Feb 2026 10:09:01 -0700 Subject: [PATCH] AppModule: base class, 9 config modules, parity tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the AppModule architecture for TOML config management: - AppModule base class with extract/encode/decode lifecycle - 9 config modules (branding, app_access, webhooks, webhook_subscription, events, privacy_compliance_webhooks, app_proxy, point_of_sale, app_home) - AppToml orchestration class for deploy payloads and server-to-TOML decode - 40 parity tests proving encode matches transformLocalToRemote and decode matches transformRemoteToLocal for all modules Purely additive — no existing files modified. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/app-module.ts | 87 ++++ .../cli/models/app/app-modules/app-access.ts | 81 ++++ .../cli/models/app/app-modules/app-home.ts | 47 +++ .../cli/models/app/app-modules/app-proxy.ts | 61 +++ .../cli/models/app/app-modules/branding.ts | 54 +++ .../src/cli/models/app/app-modules/events.ts | 62 +++ .../src/cli/models/app/app-modules/index.ts | 34 ++ .../models/app/app-modules/point-of-sale.ts | 34 ++ .../privacy-compliance-webhooks.ts | 97 +++++ .../app/app-modules/webhook-subscription.ts | 110 +++++ .../cli/models/app/app-modules/webhooks.ts | 29 ++ .../app/src/cli/models/app/app-toml.test.ts | 396 ++++++++++++++++++ packages/app/src/cli/models/app/app-toml.ts | 136 ++++++ 13 files changed, 1228 insertions(+) create mode 100644 packages/app/src/cli/models/app/app-module.ts create mode 100644 packages/app/src/cli/models/app/app-modules/app-access.ts create mode 100644 packages/app/src/cli/models/app/app-modules/app-home.ts create mode 100644 packages/app/src/cli/models/app/app-modules/app-proxy.ts create mode 100644 packages/app/src/cli/models/app/app-modules/branding.ts create mode 100644 packages/app/src/cli/models/app/app-modules/events.ts create mode 100644 packages/app/src/cli/models/app/app-modules/index.ts create mode 100644 packages/app/src/cli/models/app/app-modules/point-of-sale.ts create mode 100644 packages/app/src/cli/models/app/app-modules/privacy-compliance-webhooks.ts create mode 100644 packages/app/src/cli/models/app/app-modules/webhook-subscription.ts create mode 100644 packages/app/src/cli/models/app/app-modules/webhooks.ts create mode 100644 packages/app/src/cli/models/app/app-toml.test.ts create mode 100644 packages/app/src/cli/models/app/app-toml.ts diff --git a/packages/app/src/cli/models/app/app-module.ts b/packages/app/src/cli/models/app/app-module.ts new file mode 100644 index 0000000000..ea84992929 --- /dev/null +++ b/packages/app/src/cli/models/app/app-module.ts @@ -0,0 +1,87 @@ +/** + * AppModule: The universal base class for all app modules. + * + * Every app module — config modules in shopify.app.toml AND extensions in + * their own .extension.toml files — is an instance of this class or a subclass. + * + * Contracts are the source of truth for validation. The CLI's job is to + * extract config, encode it to contract shape, validate, and send. Modules + * that need file I/O, localization, build manifests, etc. override the + * async encode() method. + */ + +import {AppConfigurationWithoutPath} from './app.js' + +/** + * Context available to encode() during deploy. + */ +export interface EncodeContext { + appConfiguration: AppConfigurationWithoutPath + directory: string + apiKey: string +} + +/** + * The universal module base class. + * + * Concrete modules extend this directly (max depth of 2). + * Default implementations are passthrough — contract-only modules + * use the base class with no overrides. + */ +export class AppModule { + readonly identifier: string + readonly uidStrategy: 'single' | 'dynamic' | 'uuid' + readonly tomlKeys?: string[] + + constructor(options: {identifier: string; uidStrategy: 'single' | 'dynamic' | 'uuid'; tomlKeys?: string[]}) { + this.identifier = options.identifier + this.uidStrategy = options.uidStrategy + this.tomlKeys = options.tomlKeys + } + + /** Extract this module's data from TOML content. */ + extract(content: {[key: string]: unknown}): TToml | TToml[] | undefined { + if (this.tomlKeys) return extractByKeys(this.tomlKeys, content) as unknown as TToml + // Extension modules own their whole file + return content as TToml + } + + /** Encode to contract format. Override for transforms, file I/O, computed fields. */ + async encode(toml: TToml, _context: EncodeContext): Promise { + // Default: passthrough + return toml as unknown as TContract + } + + /** Decode contract data back to TOML. Override for reverse transforms. */ + decode(contract: TContract): TToml { + // Default: passthrough + return contract as unknown as TToml + } +} + +/** + * For dynamic-UID modules that produce multiple instances from one TOML section. + */ +export interface DynamicAppModule + extends Omit, 'extract' | 'uidStrategy'> { + uidStrategy: 'dynamic' + extract(content: {[key: string]: unknown}): TToml[] | undefined +} + +export type AnyAppModule = AppModule | DynamicAppModule + +/** + * Extract a module's data from the full TOML based on its declared tomlKeys. + */ +export function extractByKeys( + tomlKeys: string[], + content: {[key: string]: unknown}, +): {[key: string]: unknown} | undefined { + const result: {[key: string]: unknown} = {} + for (const key of tomlKeys) { + if (content[key] !== undefined) { + result[key] = content[key] + } + } + return Object.keys(result).length > 0 ? result : undefined +} diff --git a/packages/app/src/cli/models/app/app-modules/app-access.ts b/packages/app/src/cli/models/app/app-modules/app-access.ts new file mode 100644 index 0000000000..d71c8c70a5 --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/app-access.ts @@ -0,0 +1,81 @@ +import {AppModule, EncodeContext} from '../app-module.js' +import {BaseSchemaWithoutHandle} from '../../extensions/schemas.js' +import {validateUrl} from '../../app/validation/common.js' +import {normalizeDelimitedString} from '@shopify/cli-kit/common/string' +import {zod} from '@shopify/cli-kit/node/schema' + +const AppAccessTomlSchema = BaseSchemaWithoutHandle.extend({ + access: zod + .object({ + admin: zod + .object({ + direct_api_mode: zod.union([zod.literal('online'), zod.literal('offline')]).optional(), + embedded_app_direct_api_access: zod.boolean().optional(), + }) + .optional(), + }) + .optional(), + access_scopes: zod + .object({ + scopes: zod + .string() + .transform((scopes) => normalizeDelimitedString(scopes) ?? '') + .optional(), + required_scopes: zod.array(zod.string()).optional(), + optional_scopes: zod.array(zod.string()).optional(), + use_legacy_install_flow: zod.boolean().optional(), + }) + .optional(), + auth: zod.object({ + redirect_urls: zod.array(validateUrl(zod.string())), + }), +}) + +type AppAccessToml = zod.infer + +interface AppAccessContract { + access?: {admin?: {direct_api_mode?: string; embedded_app_direct_api_access?: boolean}} + scopes?: string + required_scopes?: string[] + optional_scopes?: string[] + use_legacy_install_flow?: boolean + redirect_url_allowlist?: string[] +} + +class AppAccessModule extends AppModule { + constructor() { + super({identifier: 'app_access', uidStrategy: 'single', tomlKeys: ['access', 'access_scopes', 'auth']}) + } + + async encode(toml: AppAccessToml, _context: EncodeContext) { + const result: AppAccessContract = {} + if (toml.access !== undefined) result.access = toml.access + if (toml.access_scopes?.scopes !== undefined) result.scopes = toml.access_scopes.scopes + if (toml.access_scopes?.required_scopes !== undefined) result.required_scopes = toml.access_scopes.required_scopes + if (toml.access_scopes?.optional_scopes !== undefined) result.optional_scopes = toml.access_scopes.optional_scopes + if (toml.access_scopes?.use_legacy_install_flow !== undefined) + result.use_legacy_install_flow = toml.access_scopes.use_legacy_install_flow + if (toml.auth?.redirect_urls !== undefined) result.redirect_url_allowlist = toml.auth.redirect_urls + return result + } + + decode(contract: AppAccessContract) { + const result: {[key: string]: unknown} = {} + if (contract.access !== undefined) result.access = contract.access + + const accessScopes: {[key: string]: unknown} = {} + if (contract.scopes !== undefined) accessScopes.scopes = contract.scopes + if (contract.required_scopes !== undefined) accessScopes.required_scopes = contract.required_scopes + if (contract.optional_scopes !== undefined) accessScopes.optional_scopes = contract.optional_scopes + if (contract.use_legacy_install_flow !== undefined) + accessScopes.use_legacy_install_flow = contract.use_legacy_install_flow + if (Object.keys(accessScopes).length > 0) result.access_scopes = accessScopes + + if (contract.redirect_url_allowlist !== undefined) { + result.auth = {redirect_urls: contract.redirect_url_allowlist} + } + return result as AppAccessToml + } +} + +export const appAccessModule = new AppAccessModule() diff --git a/packages/app/src/cli/models/app/app-modules/app-home.ts b/packages/app/src/cli/models/app/app-modules/app-home.ts new file mode 100644 index 0000000000..8cc4e4d91b --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/app-home.ts @@ -0,0 +1,47 @@ +import {AppModule, EncodeContext} from '../app-module.js' +import {BaseSchemaWithoutHandle} from '../../extensions/schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' + +const AppHomeTomlSchema = BaseSchemaWithoutHandle.extend({ + application_url: zod.string().url().optional(), + embedded: zod.boolean().optional(), + app_preferences: zod + .object({ + url: zod.string().url().max(255).optional(), + }) + .optional(), +}) + +type AppHomeToml = zod.infer + +interface AppHomeContract { + app_url?: string + embedded?: boolean + preferences_url?: string +} + +class AppHomeModule extends AppModule { + constructor() { + super({identifier: 'app_home', uidStrategy: 'single', tomlKeys: ['application_url', 'embedded', 'app_preferences']}) + } + + async encode(toml: AppHomeToml, _context: EncodeContext) { + return { + app_url: toml.application_url, + embedded: toml.embedded, + preferences_url: toml.app_preferences?.url, + } + } + + decode(contract: AppHomeContract) { + const result: {[key: string]: unknown} = {} + if (contract.app_url !== undefined) result.application_url = contract.app_url + if (contract.embedded !== undefined) result.embedded = contract.embedded + if (contract.preferences_url !== undefined) { + result.app_preferences = {url: contract.preferences_url} + } + return result as AppHomeToml + } +} + +export const appHomeModule = new AppHomeModule() diff --git a/packages/app/src/cli/models/app/app-modules/app-proxy.ts b/packages/app/src/cli/models/app/app-modules/app-proxy.ts new file mode 100644 index 0000000000..f59d671d3c --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/app-proxy.ts @@ -0,0 +1,61 @@ +import {AppModule, EncodeContext} from '../app-module.js' +import {BaseSchemaWithoutHandle} from '../../extensions/schemas.js' +import {prependApplicationUrl} from '../../extensions/specifications/validation/url_prepender.js' +import {removeTrailingSlash} from '../../extensions/specifications/validation/common.js' +import {validateRelativeUrl} from '../../app/validation/common.js' +import {zod} from '@shopify/cli-kit/node/schema' + +const AppProxyTomlSchema = BaseSchemaWithoutHandle.extend({ + app_proxy: zod + .object({ + url: zod.preprocess( + removeTrailingSlash as (arg: unknown) => unknown, + validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})), + ), + subpath: zod.string({invalid_type_error: 'Value must be a string'}), + prefix: zod.string({invalid_type_error: 'Value must be a string'}), + }) + .optional(), +}) + +type AppProxyToml = zod.infer + +interface AppProxyContract { + url?: string + subpath?: string + prefix?: string +} + +class AppProxyModule extends AppModule { + constructor() { + super({identifier: 'app_proxy', uidStrategy: 'single', tomlKeys: ['app_proxy']}) + } + + async encode(toml: AppProxyToml, context: EncodeContext) { + if (!toml.app_proxy) return {} + + let appUrl: string | undefined + if ('application_url' in context.appConfiguration) { + appUrl = (context.appConfiguration as {application_url?: string}).application_url + } + + return { + url: prependApplicationUrl(toml.app_proxy.url, appUrl), + subpath: toml.app_proxy.subpath, + prefix: toml.app_proxy.prefix, + } + } + + decode(contract: AppProxyContract) { + if (!contract.url) return {} as AppProxyToml + return { + app_proxy: { + url: removeTrailingSlash(contract.url), + subpath: contract.subpath ?? '', + prefix: contract.prefix ?? '', + }, + } as AppProxyToml + } +} + +export const appProxyModule = new AppProxyModule() diff --git a/packages/app/src/cli/models/app/app-modules/branding.ts b/packages/app/src/cli/models/app/app-modules/branding.ts new file mode 100644 index 0000000000..dc5414096f --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/branding.ts @@ -0,0 +1,54 @@ +/** + * Branding AppModule — simplest case. + * Single UID, declarative rename (handle ↔ app_handle), no shared keys. + */ + +import {AppModule, EncodeContext} from '../app-module.js' +import {BaseSchemaWithoutHandle} from '../../extensions/schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' + +// --- TOML shape --- + +const BrandingTomlSchema = BaseSchemaWithoutHandle.extend({ + name: zod.string({required_error: 'String is required'}).max(30, {message: 'String must be less than 30 characters'}), + handle: zod + .string({required_error: 'String is required'}) + .max(256, {message: 'String must be less than 256 characters long'}) + .refine((value) => value && /^\w*(?!-)[_a-z0-9-]+(? + +// --- Contract shape --- + +interface BrandingContract { + name: string + app_handle?: string +} + +// --- Module definition --- + +class BrandingModule extends AppModule { + constructor() { + super({identifier: 'branding', uidStrategy: 'single', tomlKeys: ['name', 'handle']}) + } + + async encode(toml: BrandingToml, _context: EncodeContext) { + return { + name: toml.name, + app_handle: toml.handle, + } + } + + decode(contract: BrandingContract) { + return { + name: contract.name, + handle: contract.app_handle, + } as BrandingToml + } +} + +export const brandingModule = new BrandingModule() diff --git a/packages/app/src/cli/models/app/app-modules/events.ts b/packages/app/src/cli/models/app/app-modules/events.ts new file mode 100644 index 0000000000..f219914232 --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/events.ts @@ -0,0 +1,62 @@ +/** + * Events AppModule — no-transform case. + * Single UID, TOML = contract format, codec is absent. + * Reverse transform strips server-managed 'identifier' field (handled by codec.decode if needed). + */ + +import {AppModule, EncodeContext} from '../app-module.js' +import {BaseSchemaWithoutHandle} from '../../extensions/schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' + +// --- TOML shape = Contract shape --- + +const EventsTomlSchema = BaseSchemaWithoutHandle.extend({ + events: zod.any().optional(), +}) + +type EventsToml = zod.infer + +// --- Contract shape (same as TOML — forward is identity) --- + +// For the reverse direction (app config link), the server includes an 'identifier' field +// on each subscription that needs to be stripped. We model this with a codec that only +// does work in the decode direction. + +interface EventsContract { + events?: { + api_version?: string + subscription?: {identifier?: string; [key: string]: unknown}[] + } +} + +// --- Module definition --- + +class EventsModule extends AppModule { + constructor() { + super({identifier: 'events', uidStrategy: 'single', tomlKeys: ['events']}) + } + + // Forward is identity. Reverse strips 'identifier'. + // Even though forward is identity, we define encode/decode so decode can strip. + async encode(toml: EventsToml, _context: EncodeContext) { + return toml as unknown as EventsContract + } + + decode(contract: EventsContract) { + if (!contract.events?.subscription) return contract as unknown as EventsToml + + const cleanedSubscriptions = contract.events.subscription.map((sub) => { + const {identifier: _, ...rest} = sub + return rest + }) + + return { + events: { + api_version: contract.events.api_version, + subscription: cleanedSubscriptions, + }, + } as EventsToml + } +} + +export const eventsModule = new EventsModule() diff --git a/packages/app/src/cli/models/app/app-modules/index.ts b/packages/app/src/cli/models/app/app-modules/index.ts new file mode 100644 index 0000000000..6c7ff2dfce --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/index.ts @@ -0,0 +1,34 @@ +import {brandingModule} from './branding.js' +import {eventsModule} from './events.js' +import {webhookSubscriptionModule} from './webhook-subscription.js' +import {pointOfSaleModule} from './point-of-sale.js' +import {appHomeModule} from './app-home.js' +import {appAccessModule} from './app-access.js' +import {webhooksModule} from './webhooks.js' +import {appProxyModule} from './app-proxy.js' +import {privacyComplianceWebhooksModule} from './privacy-compliance-webhooks.js' +import {AnyAppModule} from '../app-module.js' + +export {brandingModule} from './branding.js' +export {eventsModule} from './events.js' +export {webhookSubscriptionModule} from './webhook-subscription.js' +export {pointOfSaleModule} from './point-of-sale.js' +export {appHomeModule} from './app-home.js' +export {appAccessModule} from './app-access.js' +export {webhooksModule} from './webhooks.js' +export {appProxyModule} from './app-proxy.js' +export {privacyComplianceWebhooksModule} from './privacy-compliance-webhooks.js' + +// Sorted to match SORTED_CONFIGURATION_SPEC_IDENTIFIERS in load-specifications.ts +// Config modules only — non-config extension modules added in Phase 2 +export const allAppModules: AnyAppModule[] = [ + brandingModule, + appAccessModule, + webhooksModule, + webhookSubscriptionModule, + eventsModule, + privacyComplianceWebhooksModule, + appProxyModule, + pointOfSaleModule, + appHomeModule, +] diff --git a/packages/app/src/cli/models/app/app-modules/point-of-sale.ts b/packages/app/src/cli/models/app/app-modules/point-of-sale.ts new file mode 100644 index 0000000000..55fd6f7c49 --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/point-of-sale.ts @@ -0,0 +1,34 @@ +import {AppModule, EncodeContext} from '../app-module.js' +import {BaseSchemaWithoutHandle} from '../../extensions/schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' + +const PosTomlSchema = BaseSchemaWithoutHandle.extend({ + pos: zod + .object({ + embedded: zod.boolean().optional(), + }) + .optional(), +}) + +type PosToml = zod.infer + +interface PosContract { + embedded?: boolean +} + +class PointOfSaleModule extends AppModule { + constructor() { + super({identifier: 'point_of_sale', uidStrategy: 'single', tomlKeys: ['pos']}) + } + + async encode(toml: PosToml, _context: EncodeContext) { + if (!toml.pos) return {} + return {embedded: toml.pos.embedded} + } + + decode(contract: PosContract) { + return {pos: {embedded: contract.embedded}} as PosToml + } +} + +export const pointOfSaleModule = new PointOfSaleModule() diff --git a/packages/app/src/cli/models/app/app-modules/privacy-compliance-webhooks.ts b/packages/app/src/cli/models/app/app-modules/privacy-compliance-webhooks.ts new file mode 100644 index 0000000000..4604732196 --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/privacy-compliance-webhooks.ts @@ -0,0 +1,97 @@ +import {AppModule, EncodeContext} from '../app-module.js' +import {WebhooksSchema} from '../../extensions/specifications/app_config_webhook_schemas/webhooks_schema.js' +import {WebhooksConfig, WebhookSubscription} from '../../extensions/specifications/types/app_config_webhook.js' +import {ComplianceTopic} from '../../extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.js' +import {mergeAllWebhooks} from '../../extensions/specifications/transform/app_config_webhook.js' +import {removeTrailingSlash} from '../../extensions/specifications/validation/common.js' +import {compact} from '@shopify/cli-kit/common/object' +import {zod} from '@shopify/cli-kit/node/schema' + +type PrivacyComplianceToml = zod.infer + +interface PrivacyComplianceContract { + api_version?: string + customers_redact_url?: string + customers_data_request_url?: string + shop_redact_url?: string +} + +function relativeUri(uri?: string, appUrl?: string) { + return appUrl && uri?.startsWith('/') ? `${removeTrailingSlash(appUrl)}${uri}` : uri +} + +function getComplianceUri(webhooks: WebhooksConfig, complianceTopic: string): string | undefined { + return webhooks.subscriptions?.find((sub) => sub.compliance_topics?.includes(complianceTopic))?.uri +} + +class PrivacyComplianceWebhooksModule extends AppModule { + constructor() { + super({identifier: 'privacy_compliance_webhooks', uidStrategy: 'single', tomlKeys: ['webhooks']}) + } + + extract(content: {[key: string]: unknown}) { + const webhooks = (content as {webhooks?: WebhooksConfig}).webhooks + if (!webhooks) return undefined + + // Only present when there are compliance subscriptions or legacy privacy_compliance config + const hasComplianceSubscriptions = webhooks.subscriptions?.some( + (sub) => sub.compliance_topics && sub.compliance_topics.length > 0, + ) + const hasCompliance = hasComplianceSubscriptions === true || webhooks.privacy_compliance !== undefined + if (!hasCompliance) return undefined + + return {webhooks} as unknown as PrivacyComplianceToml + } + + async encode(toml: PrivacyComplianceToml, context: EncodeContext) { + const webhooks = toml.webhooks as WebhooksConfig | undefined + if (!webhooks) return {} + + let appUrl: string | undefined + if ('application_url' in context.appConfiguration) { + appUrl = (context.appConfiguration as {application_url?: string}).application_url + } + + const customersRedactUrl = + getComplianceUri(webhooks, 'customers/redact') ?? webhooks?.privacy_compliance?.customer_deletion_url + const customersDataRequestUrl = + getComplianceUri(webhooks, 'customers/data_request') ?? webhooks?.privacy_compliance?.customer_data_request_url + const shopRedactUrl = getComplianceUri(webhooks, 'shop/redact') ?? webhooks?.privacy_compliance?.shop_deletion_url + + const urls = compact({ + customers_redact_url: relativeUri(customersRedactUrl, appUrl), + customers_data_request_url: relativeUri(customersDataRequestUrl, appUrl), + shop_redact_url: relativeUri(shopRedactUrl, appUrl), + }) + + if (Object.keys(urls).length === 0) return urls + + return { + api_version: webhooks.api_version, + ...urls, + } + } + + decode(contract: PrivacyComplianceContract) { + const webhooks: WebhookSubscription[] = [] + if (contract.customers_data_request_url) { + webhooks.push({ + compliance_topics: [ComplianceTopic.CustomersDataRequest], + uri: contract.customers_data_request_url, + }) + } + if (contract.customers_redact_url) { + webhooks.push({compliance_topics: [ComplianceTopic.CustomersRedact], uri: contract.customers_redact_url}) + } + if (contract.shop_redact_url) { + webhooks.push({compliance_topics: [ComplianceTopic.ShopRedact], uri: contract.shop_redact_url}) + } + + if (webhooks.length === 0) return {} as PrivacyComplianceToml + return { + webhooks: {subscriptions: mergeAllWebhooks(webhooks), privacy_compliance: undefined}, + } as unknown as PrivacyComplianceToml + } +} + +export const privacyComplianceWebhooksModule = new PrivacyComplianceWebhooksModule() diff --git a/packages/app/src/cli/models/app/app-modules/webhook-subscription.ts b/packages/app/src/cli/models/app/app-modules/webhook-subscription.ts new file mode 100644 index 0000000000..b5134163c4 --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/webhook-subscription.ts @@ -0,0 +1,110 @@ +/** + * Webhook Subscription AppModule — the hard case. + * Dynamic UID, shared 'webhooks' key, splits subscriptions into individual items, + * cross-module dependency on application_url for relative URI resolution. + */ + +import {DynamicAppModule} from '../app-module.js' +import {WebhooksConfig} from '../../extensions/specifications/types/app_config_webhook.js' +import {prependApplicationUrl} from '../../extensions/specifications/validation/url_prepender.js' + +// --- TOML shape (per-subscription, after splitting) --- + +// Each instance gets a single flattened subscription extracted from the webhooks array. +// This matches what createWebhookSubscriptionInstances produces today. +interface WebhookSubscriptionToml { + api_version: string + uri: string + topic: string + sub_topic?: string + include_fields?: string[] + filter?: string + payload_query?: string + name?: string + actions?: string[] +} + +// --- Contract shape (what the server receives per subscription) --- + +interface WebhookSubscriptionContract { + api_version: string + uri: string + topic: string + sub_topic?: string + include_fields?: string[] + filter?: string + payload_query?: string + name?: string + actions?: string[] +} + +// --- Module definition --- + +export const webhookSubscriptionModule: DynamicAppModule = { + identifier: 'webhook_subscription', + + tomlKeys: ['webhooks'], + + /** + * Extract individual webhook subscriptions from the shared webhooks section. + * This replaces the hardcoded createWebhookSubscriptionInstances() in loader.ts. + * + * Each multi-topic subscription is split into individual single-topic items. + * Compliance topics are excluded (handled by privacy_compliance_webhooks module). + */ + extract(content) { + const webhooks = (content as {webhooks?: WebhooksConfig}).webhooks + if (!webhooks?.subscriptions) return undefined + + const apiVersion = webhooks.api_version + const items: WebhookSubscriptionToml[] = [] + + for (const subscription of webhooks.subscriptions) { + // compliance_topics are handled by the privacy_compliance_webhooks module + const {uri, topics, compliance_topics: _, ...optionalFields} = subscription + if (!topics) continue + + for (const topic of topics) { + items.push({ + api_version: apiVersion, + uri, + topic, + ...optionalFields, + }) + } + } + + return items.length > 0 ? items : undefined + }, + + async encode(toml, context) { + // Resolve relative URIs using application_url from app config + let appUrl: string | undefined + if ('application_url' in context.appConfiguration) { + appUrl = (context.appConfiguration as {application_url?: string}).application_url + } + + return { + ...toml, + uri: prependApplicationUrl(toml.uri, appUrl), + } + }, + + decode(contract) { + // Reverse: convert singular topic back to topics array, wrap in webhooks structure. + // api_version is discarded in the reverse (it comes from the webhooks module). + const {api_version: _, topic, ...rest} = contract + return { + webhooks: { + subscriptions: [ + { + topics: [topic], + ...rest, + }, + ], + }, + } as unknown as WebhookSubscriptionToml + }, + + uidStrategy: 'dynamic', +} diff --git a/packages/app/src/cli/models/app/app-modules/webhooks.ts b/packages/app/src/cli/models/app/app-modules/webhooks.ts new file mode 100644 index 0000000000..1cc18f93eb --- /dev/null +++ b/packages/app/src/cli/models/app/app-modules/webhooks.ts @@ -0,0 +1,29 @@ +import {AppModule, EncodeContext} from '../app-module.js' +import {WebhooksSchema} from '../../extensions/specifications/app_config_webhook_schemas/webhooks_schema.js' +import {WebhooksConfig} from '../../extensions/specifications/types/app_config_webhook.js' +import {zod} from '@shopify/cli-kit/node/schema' + +type WebhooksToml = zod.infer + +interface WebhooksContract { + api_version?: string +} + +class WebhooksModule extends AppModule { + constructor() { + super({identifier: 'webhooks', uidStrategy: 'single', tomlKeys: ['webhooks']}) + } + + async encode(toml: WebhooksToml, _context: EncodeContext) { + const webhooks = toml.webhooks as WebhooksConfig | undefined + if (!webhooks) return {} + return {api_version: webhooks.api_version} + } + + decode(contract: WebhooksContract) { + if (!contract.api_version) return {} as WebhooksToml + return {webhooks: {api_version: contract.api_version}} as unknown as WebhooksToml + } +} + +export const webhooksModule = new WebhooksModule() diff --git a/packages/app/src/cli/models/app/app-toml.test.ts b/packages/app/src/cli/models/app/app-toml.test.ts new file mode 100644 index 0000000000..a3fee9d5d6 --- /dev/null +++ b/packages/app/src/cli/models/app/app-toml.test.ts @@ -0,0 +1,396 @@ +import {AppToml} from './app-toml.js' +import { + brandingModule, + eventsModule, + webhookSubscriptionModule, + pointOfSaleModule, + appHomeModule, + appAccessModule, + webhooksModule, + appProxyModule, + privacyComplianceWebhooksModule, + allAppModules, +} from './app-modules/index.js' +import {placeholderAppConfiguration} from './app.test-data.js' +import brandingSpec from '../extensions/specifications/app_config_branding.js' +import eventsSpec from '../extensions/specifications/app_config_events.js' +import posSpec from '../extensions/specifications/app_config_point_of_sale.js' +import appHomeSpec from '../extensions/specifications/app_config_app_home.js' +import appAccessSpec from '../extensions/specifications/app_config_app_access.js' +import webhooksSpec from '../extensions/specifications/app_config_webhook.js' +import appProxySpec from '../extensions/specifications/app_config_app_proxy.js' +import privacyComplianceSpec from '../extensions/specifications/app_config_privacy_compliance_webhooks.js' +import {describe, test, expect} from 'vitest' + +const encodeCtx = {appConfiguration: placeholderAppConfiguration, directory: '/tmp', apiKey: 'test'} + +// ============================================================================ +// Criterion 1: All 9 modules implement the interface +// ============================================================================ + +describe('all modules implement AppModule interface', () => { + test.each([ + ['branding', brandingModule], + ['events', eventsModule], + ['webhook_subscription', webhookSubscriptionModule], + ['point_of_sale', pointOfSaleModule], + ['app_home', appHomeModule], + ['app_access', appAccessModule], + ['webhooks', webhooksModule], + ['app_proxy', appProxyModule], + ['privacy_compliance_webhooks', privacyComplianceWebhooksModule], + ])('%s has required properties', (_name, mod) => { + expect(mod.identifier).toBeTruthy() + expect(mod.tomlKeys?.length ?? 0).toBeGreaterThan(0) + expect(mod.extract).toBeTypeOf('function') + expect(mod.uidStrategy).toMatch(/^(single|dynamic)$/) + }) + + test('allAppModules contains all 9 config modules', () => { + expect(allAppModules.length).toBe(9) + const configIds = allAppModules.map((mod) => mod.identifier).sort() + expect(configIds).toEqual([ + 'app_access', + 'app_home', + 'app_proxy', + 'branding', + 'events', + 'point_of_sale', + 'privacy_compliance_webhooks', + 'webhook_subscription', + 'webhooks', + ]) + }) +}) + +// ============================================================================ +// Criterion 2: extract() produces correct slices from full TOML +// ============================================================================ + +describe('extraction from full TOML config', () => { + const fullConfig = { + client_id: '123', + name: 'my-app', + handle: 'my-app-handle', + application_url: 'https://example.com', + embedded: true, + app_preferences: {url: 'https://example.com/preferences'}, + access_scopes: {scopes: 'read_products'}, + auth: {redirect_urls: ['https://example.com/callback']}, + access: {admin: {direct_api_mode: 'online' as const}}, + pos: {embedded: true}, + app_proxy: {url: 'https://proxy.com', subpath: 'apps', prefix: 'a'}, + webhooks: { + api_version: '2024-01', + subscriptions: [ + {topics: ['orders/create'], uri: 'https://example.com/webhooks'}, + {compliance_topics: ['customers/redact'], uri: 'https://example.com/compliance'}, + ], + }, + events: {api_version: '2024-01', subscription: [{topic: 'products/update', uri: 'https://example.com/events'}]}, + } + + test('branding extracts name and handle only', () => { + const result = brandingModule.extract(fullConfig) + expect(result).toEqual({name: 'my-app', handle: 'my-app-handle'}) + }) + + test('point_of_sale extracts only pos key', () => { + const result = pointOfSaleModule.extract(fullConfig) + expect(result).toEqual({pos: {embedded: true}}) + }) + + test('app_home extracts only its own keys', () => { + const result = appHomeModule.extract(fullConfig) + expect(result).toEqual({ + application_url: 'https://example.com', + embedded: true, + app_preferences: {url: 'https://example.com/preferences'}, + }) + }) + + test('app_access extracts only its own keys', () => { + const result = appAccessModule.extract(fullConfig) + expect(result).toEqual({ + access: {admin: {direct_api_mode: 'online'}}, + access_scopes: {scopes: 'read_products'}, + auth: {redirect_urls: ['https://example.com/callback']}, + }) + }) + + test('webhooks extracts the full webhooks section', () => { + const result = webhooksModule.extract(fullConfig) + expect(result).toHaveProperty('webhooks') + expect((result as any).webhooks.api_version).toBe('2024-01') + }) + + test('webhook_subscription splits into per-topic items', () => { + const result = webhookSubscriptionModule.extract(fullConfig) + // Only 1 non-compliance subscription with 1 topic + expect(result).toHaveLength(1) + expect(result![0]!.topic).toBe('orders/create') + }) + + test('privacy_compliance extracts when compliance_topics present', () => { + const result = privacyComplianceWebhooksModule.extract(fullConfig) + expect(result).toBeDefined() + expect((result as any).webhooks.subscriptions).toBeDefined() + }) + + test('app_proxy (absolute URL)', async () => { + const result = appProxyModule.extract(fullConfig) + expect(result).toEqual({app_proxy: {url: 'https://proxy.com', subpath: 'apps', prefix: 'a'}}) + }) + + test('events extracts only events key', () => { + const result = eventsModule.extract(fullConfig) + expect(result).toEqual({ + events: {api_version: '2024-01', subscription: [{topic: 'products/update', uri: 'https://example.com/events'}]}, + }) + }) + + test('modules return undefined when their keys are absent', () => { + const emptyConfig = {client_id: '123'} + expect(brandingModule.extract(emptyConfig)).toBeUndefined() + expect(pointOfSaleModule.extract(emptyConfig)).toBeUndefined() + expect(appHomeModule.extract(emptyConfig)).toBeUndefined() + expect(appAccessModule.extract(emptyConfig)).toBeUndefined() + expect(webhooksModule.extract(emptyConfig)).toBeUndefined() + expect(webhookSubscriptionModule.extract(emptyConfig)).toBeUndefined() + expect(privacyComplianceWebhooksModule.extract(emptyConfig)).toBeUndefined() + expect(appProxyModule.extract(emptyConfig)).toBeUndefined() + expect(eventsModule.extract(emptyConfig)).toBeUndefined() + }) +}) + +// ============================================================================ +// Criterion 3: encode() matches existing transformLocalToRemote +// ============================================================================ + +describe('encode matches existing transformLocalToRemote', () => { + test('branding', async () => { + const toml = {name: 'my-app', handle: 'my-app-handle'} + const encoded = await brandingModule.encode(toml, encodeCtx) + const existing = brandingSpec.transformLocalToRemote!(toml, placeholderAppConfiguration) + expect(encoded).toEqual(existing) + }) + + test('point_of_sale', async () => { + const toml = {pos: {embedded: true}} + const encoded = await pointOfSaleModule.encode(toml, encodeCtx) + const existing = posSpec.transformLocalToRemote!(toml, placeholderAppConfiguration) + expect(encoded).toEqual(existing) + }) + + test('app_home', async () => { + const toml = { + application_url: 'https://example.com', + embedded: true, + app_preferences: {url: 'https://example.com/prefs'}, + } + const encoded = await appHomeModule.encode(toml, encodeCtx) + const existing = appHomeSpec.transformLocalToRemote!(toml, placeholderAppConfiguration) + expect(encoded).toEqual(existing) + }) + + test('app_access', async () => { + const toml = { + access: {admin: {direct_api_mode: 'online' as const}}, + access_scopes: { + scopes: 'read_products,write_products', + optional_scopes: ['read_customers'], + required_scopes: ['write_orders', 'read_inventory'], + use_legacy_install_flow: true, + }, + auth: {redirect_urls: ['https://example.com/auth/callback']}, + } + const encoded = await appAccessModule.encode(toml, encodeCtx) + const existing = appAccessSpec.transformLocalToRemote!(toml, placeholderAppConfiguration) + expect(encoded).toEqual(existing) + }) + + test('webhooks', async () => { + const toml = { + webhooks: { + api_version: '2024-01', + subscriptions: [{topics: ['orders/create'], uri: 'https://example.com'}], + }, + } + const encoded = await webhooksModule.encode(toml, encodeCtx) + const existing = webhooksSpec.transformLocalToRemote!(toml, placeholderAppConfiguration) + expect(encoded).toEqual(existing) + }) + + test('app_proxy (absolute URL)', async () => { + const toml = {app_proxy: {url: 'https://proxy.com', subpath: 'apps', prefix: 'a'}} + const encoded = await appProxyModule.encode(toml, encodeCtx) + const existing = appProxySpec.transformLocalToRemote!(toml, placeholderAppConfiguration) + expect(encoded).toEqual(existing) + }) + + test('events (identity)', async () => { + const toml = { + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: 'https://example.com'}], + }, + } + const encoded = await eventsModule.encode(toml, encodeCtx) + const existing = eventsSpec.transformLocalToRemote!(toml, placeholderAppConfiguration) + expect(encoded).toEqual(existing) + }) + + test('privacy_compliance', async () => { + const toml = { + webhooks: { + api_version: '2024-01', + subscriptions: [ + {compliance_topics: ['customers/redact'], uri: 'https://example.com/redact'}, + {compliance_topics: ['customers/data_request'], uri: 'https://example.com/data'}, + {compliance_topics: ['shop/redact'], uri: 'https://example.com/shop'}, + ], + }, + } + const encoded = await privacyComplianceWebhooksModule.encode(toml, encodeCtx) + const existing = privacyComplianceSpec.transformLocalToRemote!(toml, placeholderAppConfiguration) + expect(encoded).toEqual(existing) + }) +}) + +// ============================================================================ +// Criterion 4: decode() matches existing transformRemoteToLocal +// ============================================================================ + +describe('decode matches existing transformRemoteToLocal', () => { + test('branding', async () => { + const contract = {name: 'my-app', app_handle: 'my-app-handle'} + const decoded = brandingModule.decode(contract) + const existing = brandingSpec.transformRemoteToLocal!(contract) + expect(decoded).toEqual(existing) + }) + + test('point_of_sale', async () => { + const contract = {embedded: true} + const decoded = pointOfSaleModule.decode(contract) + const existing = posSpec.transformRemoteToLocal!(contract) + expect(decoded).toEqual(existing) + }) + + test('app_home', async () => { + const contract = {app_url: 'https://example.com', embedded: true, preferences_url: 'https://example.com/prefs'} + const decoded = appHomeModule.decode(contract) + const existing = appHomeSpec.transformRemoteToLocal!(contract) + expect(decoded).toEqual(existing) + }) + + test('app_access', async () => { + const contract = { + access: {admin: {direct_api_mode: 'offline'}}, + scopes: 'read_products,write_products', + optional_scopes: ['read_customers'], + required_scopes: ['write_orders', 'read_inventory'], + use_legacy_install_flow: true, + redirect_url_allowlist: ['https://example.com/auth/callback'], + } + const decoded = appAccessModule.decode(contract) + const existing = appAccessSpec.transformRemoteToLocal!(contract) + expect(decoded).toEqual(existing) + }) + + test('webhooks', async () => { + const contract = {api_version: '2024-01'} + const decoded = webhooksModule.decode(contract) + const existing = webhooksSpec.transformRemoteToLocal!(contract) + expect(decoded).toEqual(existing) + }) + + test('app_proxy (absolute URL)', async () => { + const contract = {url: 'https://proxy.com', subpath: 'apps', prefix: 'a'} + const decoded = appProxyModule.decode(contract) + const existing = appProxySpec.transformRemoteToLocal!(contract) + expect(decoded).toEqual(existing) + }) + + test('events (strips identifier)', async () => { + const contract = { + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com', identifier: 'id-1'}, + {topic: 'products/update', uri: 'https://example.com', identifier: 'id-2'}, + ], + }, + } + const decoded = eventsModule.decode(contract) + const existing = eventsSpec.transformRemoteToLocal!(contract) + expect(decoded).toEqual(existing) + }) + + test('privacy_compliance', async () => { + const contract = { + api_version: '2024-01', + customers_redact_url: 'https://example.com/redact', + customers_data_request_url: 'https://example.com/data', + shop_redact_url: 'https://example.com/shop', + } + const decoded = privacyComplianceWebhooksModule.decode(contract) + const existing = privacyComplianceSpec.transformRemoteToLocal!(contract) + expect(decoded).toEqual(existing) + }) +}) + +// ============================================================================ +// Criterion 5-7: AppToml orchestration +// ============================================================================ + +describe('AppToml orchestration', () => { + test('toDeployPayloads produces payloads for all present modules', async () => { + const fullConfig = { + name: 'my-app', + handle: 'my-handle', + pos: {embedded: true}, + events: {api_version: '2024-01', subscription: [{topic: 'orders/create', uri: 'https://e.com'}]}, + } + + const appToml = new AppToml(fullConfig, [brandingModule, pointOfSaleModule, eventsModule]) + const payloads = await appToml.toDeployPayloads(encodeCtx) + + expect(payloads).toHaveLength(3) + expect(payloads.map((payload) => payload.identifier).sort()).toEqual(['branding', 'events', 'point_of_sale']) + }) + + test('toDeployPayloads skips modules not present in TOML', async () => { + const fullConfig = {name: 'my-app', handle: 'my-handle'} + const appToml = new AppToml(fullConfig, [brandingModule, pointOfSaleModule]) + const payloads = await appToml.toDeployPayloads(encodeCtx) + + // Only branding is present (has 'name' and 'handle' keys). + // point_of_sale is absent (no 'pos' key). + expect(payloads).toHaveLength(1) + expect(payloads[0]!.identifier).toBe('branding') + expect(payloads[0]!.config).toEqual({name: 'my-app', app_handle: 'my-handle'}) + }) + + test('getKeyOwnership shows shared webhooks key', () => { + const appToml = new AppToml({}, allAppModules) + const ownership = appToml.getKeyOwnership() + + const webhooksOwners = ownership.get('webhooks')! + expect(webhooksOwners).toContain('webhooks') + expect(webhooksOwners).toContain('webhook_subscription') + expect(webhooksOwners).toContain('privacy_compliance_webhooks') + expect(webhooksOwners).toHaveLength(3) + }) + + test('fromServerModules reconstructs TOML from server data', () => { + const serverModules = [ + {identifier: 'branding', config: {name: 'my-app', app_handle: 'my-handle'}}, + {identifier: 'point_of_sale', config: {embedded: true}}, + ] + + const toml = AppToml.fromServerModules(serverModules, [brandingModule, pointOfSaleModule]) + expect(toml.name).toBe('my-app') + expect(toml.handle).toBe('my-handle') + expect((toml.pos as any).embedded).toBe(true) + }) +}) diff --git a/packages/app/src/cli/models/app/app-toml.ts b/packages/app/src/cli/models/app/app-toml.ts new file mode 100644 index 0000000000..46b463f759 --- /dev/null +++ b/packages/app/src/cli/models/app/app-toml.ts @@ -0,0 +1,136 @@ +/** + * AppToml: The file-level interface for the decoded app TOML. + * + * Owns the full decoded content. Knows all registered AppModules. + * Handles extraction (partitioning the TOML into module slices) and + * orchestrates deploy and link flows. + */ + +import {AnyAppModule, AppModule, DynamicAppModule, EncodeContext} from './app-module.js' +import {AppConfigurationWithoutPath} from './app.js' + +export interface DeployPayload { + identifier: string + config: {[key: string]: unknown} +} + +export interface DeployContext { + appConfiguration: AppConfigurationWithoutPath + directory: string + apiKey: string +} + +export class AppToml { + /** + * Reconstruct TOML content from server module data. + * Uses each module's decode() to convert contract to TOML format. + */ + static fromServerModules( + serverModules: {identifier: string; config: {[key: string]: unknown}}[], + modules: AnyAppModule[], + ): {[key: string]: unknown} { + let result: {[key: string]: unknown} = {} + + for (const serverModule of serverModules) { + const module = modules.find((mod) => mod.identifier === serverModule.identifier) + if (!module?.decode) continue + + const tomlSlice = module.decode(serverModule.config) as {[key: string]: unknown} + result = deepMerge(result, tomlSlice) + } + + return result + } + + readonly content: {[key: string]: unknown} + private readonly modules: AnyAppModule[] + + constructor(content: {[key: string]: unknown}, modules: AnyAppModule[]) { + this.content = content + this.modules = modules + } + + /** + * Extract a single module's slice from the TOML. + */ + extractForModule(module: AnyAppModule): unknown { + return module.extract(this.content) + } + + /** + * Produce deploy payloads for all registered modules. + * Async because encode() can do file I/O. + */ + async toDeployPayloads(context: DeployContext): Promise { + const encodeContext: EncodeContext = { + appConfiguration: context.appConfiguration, + directory: context.directory, + apiKey: context.apiKey, + } + + const payloadArrays = await Promise.all( + this.modules.map(async (mod) => { + if (mod.uidStrategy === 'dynamic') { + const items = (mod as DynamicAppModule).extract(this.content) + if (!items) return [] + + const results = await Promise.all( + items.map(async (item) => { + const config = (await mod.encode(item, encodeContext)) as {[key: string]: unknown} + if (Object.keys(config).length > 0) { + return {identifier: mod.identifier, config} + } + return undefined + }), + ) + return results.filter((result): result is DeployPayload => result !== undefined) + } else { + const extracted = (mod as AppModule).extract(this.content) + if (!extracted) return [] + + const config = (await mod.encode(extracted, encodeContext)) as {[key: string]: unknown} + if (Object.keys(config).length > 0) { + return [{identifier: mod.identifier, config}] + } + return [] + } + }), + ) + + return payloadArrays.flat() + } + + /** + * Report which TOML keys are claimed by which modules. + */ + getKeyOwnership(): Map { + const ownership = new Map() + for (const module of this.modules) { + for (const key of module.tomlKeys ?? []) { + const owners = ownership.get(key) ?? [] + owners.push(module.identifier) + ownership.set(key, owners) + } + } + return ownership + } +} + +function deepMerge(target: {[key: string]: unknown}, source: {[key: string]: unknown}): {[key: string]: unknown} { + const result = {...target} + for (const [key, value] of Object.entries(source)) { + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + result[key] !== null && + typeof result[key] === 'object' && + !Array.isArray(result[key]) + ) { + result[key] = deepMerge(result[key] as {[key: string]: unknown}, value as {[key: string]: unknown}) + } else { + result[key] = value + } + } + return result +}