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
87 changes: 87 additions & 0 deletions packages/app/src/cli/models/app/app-module.ts
Original file line number Diff line number Diff line change
@@ -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<TToml = unknown, TContract = unknown> {
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<TContract> {
// 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<TToml = object, TContract = object>
extends Omit<AppModule<TToml, TContract>, '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
}
81 changes: 81 additions & 0 deletions packages/app/src/cli/models/app/app-modules/app-access.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AppAccessTomlSchema>

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<AppAccessToml, AppAccessContract> {
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()
47 changes: 47 additions & 0 deletions packages/app/src/cli/models/app/app-modules/app-home.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AppHomeTomlSchema>

interface AppHomeContract {
app_url?: string
embedded?: boolean
preferences_url?: string
}

class AppHomeModule extends AppModule<AppHomeToml, AppHomeContract> {
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()
61 changes: 61 additions & 0 deletions packages/app/src/cli/models/app/app-modules/app-proxy.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AppProxyTomlSchema>

interface AppProxyContract {
url?: string
subpath?: string
prefix?: string
}

class AppProxyModule extends AppModule<AppProxyToml, AppProxyContract> {
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()
54 changes: 54 additions & 0 deletions packages/app/src/cli/models/app/app-modules/branding.ts
Original file line number Diff line number Diff line change
@@ -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-]+(?<!-)$/.test(value), {
message: "String can't contain special characters",
})
.optional(),
})

type BrandingToml = zod.infer<typeof BrandingTomlSchema>

// --- Contract shape ---

interface BrandingContract {
name: string
app_handle?: string
}

// --- Module definition ---

class BrandingModule extends AppModule<BrandingToml, BrandingContract> {
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()
62 changes: 62 additions & 0 deletions packages/app/src/cli/models/app/app-modules/events.ts
Original file line number Diff line number Diff line change
@@ -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<typeof EventsTomlSchema>

// --- 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<EventsToml, EventsContract> {
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()
Loading
Loading