diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 6da9a76ebf7..1a495d3b022 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -285,7 +285,7 @@ export class ExtensionInstance & BaseConfigType +export type AdminConfigType = zod.infer & BaseConfigType const adminSpecificationSpec = createExtensionSpecification({ identifier: 'admin', @@ -33,6 +34,8 @@ const adminSpecificationSpec = createExtensionSpecification({ admin: { // eslint-disable-next-line @typescript-eslint/no-explicit-any static_root: (remoteContent as any).admin.static_root, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + allowed_domains: (remoteContent as any).admin.allowed_domains, }, } }, diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts index 6429801a725..ac4e89dfaba 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -1044,7 +1044,7 @@ describe('executeIncludeAssetsStep', () => { ) }) - test('throws when manifest.json already exists in the output directory', async () => { + test('overwrites manifest.json when it already exists in the output directory', async () => { // Given — a prior inclusion already copied a manifest.json to the output dir const contextWithConfig = { ...mockContext, @@ -1056,8 +1056,7 @@ describe('executeIncludeAssetsStep', () => { } as unknown as ExtensionInstance, } - // Source files exist; output manifest.json already exists (simulating conflict); - // candidate output paths for tools.json are free so copyConfigKeyEntry succeeds. + // Source files exist; output manifest.json already exists vi.mocked(fs.fileExists).mockImplementation(async (path) => { const pathStr = String(path) return pathStr === '/test/output/manifest.json' || pathStr.startsWith('/test/extension/') @@ -1081,10 +1080,9 @@ describe('executeIncludeAssetsStep', () => { }, } - // When / Then — throws rather than silently overwriting - await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( - `Can't write manifest.json: a file already exists at '/test/output/manifest.json'`, - ) + // When / Then — overwrites existing manifest.json + await expect(executeIncludeAssetsStep(step, contextWithConfig)).resolves.not.toThrow() + expect(fs.writeFile).toHaveBeenCalledWith('/test/output/manifest.json', expect.any(String)) }) test('writes an empty manifest when anchor resolves to a non-array value', async () => { diff --git a/packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts b/packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts index c39cc8b9529..20cdc405ead 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts @@ -1,6 +1,6 @@ import {getNestedValue, tokenizePath} from './copy-config-key-entry.js' import {joinPath} from '@shopify/cli-kit/node/path' -import {fileExists, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {mkdir, writeFile} from '@shopify/cli-kit/node/fs' import {outputDebug} from '@shopify/cli-kit/node/output' import type {BuildContext} from '../../client-steps.js' @@ -20,7 +20,7 @@ interface ConfigKeyManifestEntry { * 3. Build root-level entries. * 4. Build grouped entries (anchor/groupBy logic) with path strings resolved * via `resolveManifestPaths` using the copy-tracked `pathMap`. - * 5. Write `outputDir/manifest.json`; throw if the file already exists. + * 5. Write `outputDir/manifest.json`, overwriting any existing file. * * @param pathMap - Map from raw config path values to their output-relative * paths, as recorded during the copy phase by `copyConfigKeyEntry`. @@ -113,12 +113,6 @@ export async function generateManifestFile( } const manifestPath = joinPath(outputDir, 'manifest.json') - if (await fileExists(manifestPath)) { - throw new Error( - `Can't write manifest.json: a file already exists at '${manifestPath}'. ` + - `Remove or rename the conflicting inclusion to avoid overwriting the generated manifest.`, - ) - } await mkdir(outputDir) await writeFile(manifestPath, JSON.stringify(manifest, null, 2)) outputDebug(`Generated manifest.json in ${outputDir}\n`, options.stdout) diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.ts index ca55158bae2..6b022e82cf2 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.ts @@ -150,7 +150,7 @@ export class FileWatcher { private getAllWatchedFiles(): string[] { this.extensionWatchedFiles.clear() - const extensionResults = this.app.nonConfigExtensions.map((extension) => ({ + const extensionResults = this.app.realExtensions.map((extension) => ({ extension, watchedFiles: extension.watchedFiles(), })) diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index a6b696159bf..f63ce543464 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -33,7 +33,14 @@ describe('devUIExtensions()', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({mock: 'payload-store'})) + vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation( + () => + ({ + mock: 'payload-store', + updateAdminConfigFromExtensionEvents: vi.fn(), + getAppAssets: vi.fn(), + }) as unknown as store.ExtensionsPayloadStore, + ) vi.spyOn(server, 'setupHTTPServer').mockReturnValue({ mock: 'http-server', close: serverCloseSpy, @@ -67,8 +74,9 @@ describe('devUIExtensions()', () => { // THEN expect(server.setupHTTPServer).toHaveBeenCalledWith({ devOptions: {...options, websocketURL: 'wss://mock.url/extensions'}, - payloadStore: {mock: 'payload-store'}, + payloadStore: expect.objectContaining({mock: 'payload-store'}), getExtensions: expect.any(Function), + getAppAssets: expect.any(Function), }) }) @@ -94,7 +102,7 @@ describe('devUIExtensions()', () => { expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith({ ...options, httpServer: expect.objectContaining({mock: 'http-server'}), - payloadStore: {mock: 'payload-store'}, + payloadStore: expect.objectContaining({mock: 'payload-store'}), websocketURL: 'wss://mock.url/extensions', }) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 301f1313c4d..49aae24d55c 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -35,7 +35,8 @@ export interface ExtensionDevOptions { buildDirectory?: string /** - * The extension to be built. + * All real extensions in the app, including non-previewable ones (e.g., admin config). + * Previewable extensions are filtered internally for the UI payload. */ extensions: ExtensionInstance[] @@ -126,14 +127,20 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise ext.isPreviewable) const getExtensions = () => { return extensions } outputDebug(`Setting up the UI extensions HTTP server...`, payloadOptions.stdout) - const httpServer = setupHTTPServer({devOptions: payloadOptions, payloadStore, getExtensions}) + const getAppAssets = () => payloadStore.getAppAssets() + const httpServer = setupHTTPServer({ + devOptions: payloadOptions, + payloadStore, + getExtensions, + getAppAssets, + }) outputDebug(`Setting up the UI extensions Websocket server...`, payloadOptions.stdout) const websocketConnection = setupWebsocketConnection({...payloadOptions, httpServer, payloadStore}) @@ -144,6 +151,8 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise ext.isPreviewable) } + payloadStore.updateAdminConfigFromExtensionEvents(extensionEvents) + for (const event of extensionEvents) { if (!event.extension.isPreviewable) continue const status = event.buildResult?.status === 'ok' ? 'success' : 'error' diff --git a/packages/app/src/cli/services/dev/extension/payload/models.ts b/packages/app/src/cli/services/dev/extension/payload/models.ts index 796f93108bd..487f29e58c6 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -8,6 +8,13 @@ interface ExtensionsPayloadInterface { url: string mobileUrl: string title: string + allowed_domains?: string[] + assets?: { + [key: string]: { + url: string + lastUpdated: number + } + } } appId?: string store: string diff --git a/packages/app/src/cli/services/dev/extension/payload/store.test.ts b/packages/app/src/cli/services/dev/extension/payload/store.test.ts index cd9a5229da8..f48e70a04d5 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.test.ts @@ -7,8 +7,18 @@ import { import {UIExtensionPayload, ExtensionsEndpointPayload} from './models.js' import * as payload from '../payload.js' import {ExtensionInstance} from '../../../../models/extensions/extension-instance.js' +import {ExtensionEvent} from '../../app-events/app-event-watcher.js' import {beforeEach, describe, expect, test, vi} from 'vitest' +function createAdminExtension(config: {static_root?: string; allowed_domains?: string[]} = {}) { + return { + type: 'admin', + isPreviewable: false, + configuration: {admin: config}, + specification: {}, + } as unknown as ExtensionInstance +} + describe('getExtensionsPayloadStoreRawPayload()', () => { test('returns the raw payload', async () => { // Given @@ -21,7 +31,11 @@ describe('getExtensionsPayloadStoreRawPayload()', () => { appName: 'mock-app-name', url: 'https://mock-url.com', websocketURL: 'wss://mock-websocket-url.com', - extensions: [{}, {}, {}], + extensions: [ + {specification: {}, isPreviewable: true}, + {specification: {}, isPreviewable: true}, + {specification: {}, isPreviewable: true}, + ], storeFqdn: 'mock-store-fqdn.myshopify.com', manifestVersion: '3', } as unknown as ExtensionsPayloadStoreOptions @@ -52,10 +66,86 @@ describe('getExtensionsPayloadStoreRawPayload()', () => { extensions: [{mock: 'extension-payload'}, {mock: 'extension-payload'}, {mock: 'extension-payload'}], }) }) + + test('includes allowed_domains and assets when admin extension is present', async () => { + // Given + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({mock: 'ext'} as unknown as UIExtensionPayload) + const adminExt = createAdminExtension({static_root: 'public', allowed_domains: ['https://cdn.example.com']}) + const previewableExt = {specification: {}, isPreviewable: true} as unknown as ExtensionInstance + + const options = { + apiKey: 'api-key', + appName: 'my-app', + url: 'https://tunnel.example.com', + websocketURL: 'wss://tunnel.example.com', + extensions: [previewableExt, adminExt], + storeFqdn: 'store.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + // When + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'bundle-path') + + // Then + expect(rawPayload.app.allowed_domains).toStrictEqual(['https://cdn.example.com']) + expect(rawPayload.app.assets).toStrictEqual({ + staticRoot: { + url: 'https://tunnel.example.com/extensions/assets/staticRoot/', + lastUpdated: expect.any(Number), + }, + }) + // Admin extension should not appear in the UI extensions payload + expect(rawPayload.extensions).toHaveLength(1) + }) + + test('does not include assets or allowed_domains when no admin extension exists', async () => { + // Given + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({mock: 'ext'} as unknown as UIExtensionPayload) + + const options = { + apiKey: 'api-key', + appName: 'my-app', + url: 'https://tunnel.example.com', + websocketURL: 'wss://tunnel.example.com', + extensions: [{specification: {}, isPreviewable: true}], + storeFqdn: 'store.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + // When + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'bundle-path') + + // Then + expect(rawPayload.app.allowed_domains).toBeUndefined() + expect(rawPayload.app.assets).toBeUndefined() + }) + + test('includes allowed_domains but not assets when admin has no static_root', async () => { + // Given + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({mock: 'ext'} as unknown as UIExtensionPayload) + const adminExt = createAdminExtension({allowed_domains: ['https://cdn.example.com']}) + + const options = { + apiKey: 'api-key', + appName: 'my-app', + url: 'https://tunnel.example.com', + websocketURL: 'wss://tunnel.example.com', + extensions: [adminExt], + storeFqdn: 'store.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + // When + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'bundle-path') + + // Then + expect(rawPayload.app.allowed_domains).toStrictEqual(['https://cdn.example.com']) + expect(rawPayload.app.assets).toBeUndefined() + }) }) describe('ExtensionsPayloadStore()', () => { - const mockOptions = {} as unknown as ExtensionsPayloadStoreOptions + const mockOptions = {extensions: []} as unknown as ExtensionsPayloadStoreOptions test('getRawPayload() returns the raw payload', async () => { // Given @@ -365,4 +455,95 @@ describe('ExtensionsPayloadStore()', () => { expect(onUpdateSpy).not.toHaveBeenCalled() }) }) + + describe('getAppAssets()', () => { + test('returns asset directories when admin extension has static_root', () => { + const adminExt = createAdminExtension({static_root: 'public'}) + const options = {extensions: [adminExt], appDirectory: '/app'} as unknown as ExtensionsPayloadStoreOptions + const store = new ExtensionsPayloadStore({extensions: []} as unknown as ExtensionsEndpointPayload, options) + + expect(store.getAppAssets()).toStrictEqual({staticRoot: '/app/public'}) + }) + + test('returns undefined when no admin extension exists', () => { + const store = new ExtensionsPayloadStore({extensions: []} as unknown as ExtensionsEndpointPayload, mockOptions) + + expect(store.getAppAssets()).toBeUndefined() + }) + + test('returns undefined when admin extension has no static_root', () => { + const adminExt = createAdminExtension({allowed_domains: ['https://example.com']}) + const options = {extensions: [adminExt], appDirectory: '/app'} as unknown as ExtensionsPayloadStoreOptions + const store = new ExtensionsPayloadStore({extensions: []} as unknown as ExtensionsEndpointPayload, options) + + expect(store.getAppAssets()).toBeUndefined() + }) + }) + + describe('updateAdminConfigFromExtensionEvents()', () => { + test('updates allowed_domains and bumps asset timestamps on admin change', () => { + // Given + const adminExt = createAdminExtension({allowed_domains: ['https://new.example.com'], static_root: 'public'}) + const options = {extensions: [adminExt], appDirectory: '/app'} as unknown as ExtensionsPayloadStoreOptions + const initialPayload = { + app: { + allowed_domains: ['https://old.example.com'], + assets: {staticRoot: {url: 'https://tunnel/extensions/assets/staticRoot/', lastUpdated: 1000}}, + }, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const store = new ExtensionsPayloadStore(initialPayload, options) + const onUpdateSpy = vi.fn() + store.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + store.updateAdminConfigFromExtensionEvents([{extension: adminExt} as unknown as ExtensionEvent]) + + // Then + const result = store.getRawPayload() + expect(result.app.allowed_domains).toStrictEqual(['https://new.example.com']) + expect(result.app.assets!.staticRoot!.lastUpdated).toBeGreaterThan(1000) + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + + test('clears allowed_domains when admin config removes them', () => { + // Given + const adminExt = createAdminExtension({static_root: 'public'}) + const options = {extensions: [adminExt], appDirectory: '/app'} as unknown as ExtensionsPayloadStoreOptions + const initialPayload = { + app: {allowed_domains: ['https://old.example.com'], assets: {}}, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const store = new ExtensionsPayloadStore(initialPayload, options) + + // When + store.updateAdminConfigFromExtensionEvents([{extension: adminExt} as unknown as ExtensionEvent]) + + // Then + expect(store.getRawPayload().app.allowed_domains).toBeUndefined() + }) + + test('does nothing when no admin extension event is present', () => { + // Given + const store = new ExtensionsPayloadStore( + {app: {allowed_domains: ['https://example.com']}, extensions: []} as unknown as ExtensionsEndpointPayload, + mockOptions, + ) + const onUpdateSpy = vi.fn() + store.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + const nonAdminEvent = { + extension: {type: 'ui_extension'}, + } as unknown as ExtensionEvent + + // When + store.updateAdminConfigFromExtensionEvents([nonAdminEvent]) + + // Then + expect(store.getRawPayload().app.allowed_domains).toStrictEqual(['https://example.com']) + expect(onUpdateSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/app/src/cli/services/dev/extension/payload/store.ts b/packages/app/src/cli/services/dev/extension/payload/store.ts index ae4919485c1..1b9345f5d2a 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.ts @@ -3,6 +3,9 @@ import {ExtensionDevOptions} from '../../extension.js' import {getUIExtensionPayload, isNewExtensionPointsSchema} from '../payload.js' import {buildAppURLForMobile, buildAppURLForWeb} from '../../../../utilities/app/app-url.js' import {ExtensionInstance} from '../../../../models/extensions/extension-instance.js' +import {AdminConfigType} from '../../../../models/extensions/specifications/admin.js' +import {ExtensionEvent} from '../../app-events/app-event-watcher.js' +import {joinPath} from '@shopify/cli-kit/node/path' import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {outputDebug, outputContent} from '@shopify/cli-kit/node/output' import {EventEmitter} from 'events' @@ -11,6 +14,21 @@ export interface ExtensionsPayloadStoreOptions extends ExtensionDevOptions { websocketURL: string } +interface AdminConfig { + allowedDomains?: string[] + staticRoot?: string +} + +function getAdminConfig(extensions: ExtensionInstance[]): AdminConfig | undefined { + const adminExtension = extensions.find((ext) => ext.type === 'admin') + if (!adminExtension) return undefined + const admin = (adminExtension.configuration as AdminConfigType).admin + return { + allowedDomains: admin?.allowed_domains, + staticRoot: admin?.static_root, + } +} + export enum ExtensionsPayloadStoreEvent { Update = 'PayloadUpdatedEvent:UPDATE', } @@ -19,7 +37,7 @@ export async function getExtensionsPayloadStoreRawPayload( options: Omit, bundlePath: string, ): Promise { - return { + const payload: ExtensionsEndpointPayload = { app: { title: options.appName, apiKey: options.apiKey, @@ -38,18 +56,46 @@ export async function getExtensionsPayloadStoreRawPayload( url: new URL('/extensions/dev-console', options.url).toString(), }, store: options.storeFqdn, - extensions: await Promise.all(options.extensions.map((ext) => getUIExtensionPayload(ext, bundlePath, options))), + extensions: await Promise.all( + options.extensions + .filter((ext) => ext.isPreviewable) + .map((ext) => getUIExtensionPayload(ext, bundlePath, options)), + ), } + + // Admin extension contributes app-level config to the payload + const adminConfig = getAdminConfig(options.extensions) + if (adminConfig) { + payload.app.allowed_domains = adminConfig.allowedDomains + if (adminConfig.staticRoot) { + const assetKey = 'staticRoot' + payload.app.assets = { + [assetKey]: { + url: new URL(`/extensions/assets/${assetKey}/`, options.url).toString(), + lastUpdated: Date.now(), + }, + } + } + } + + return payload } export class ExtensionsPayloadStore extends EventEmitter { private readonly options: ExtensionsPayloadStoreOptions private rawPayload: ExtensionsEndpointPayload + private appAssetDirectories: Record | undefined constructor(rawPayload: ExtensionsEndpointPayload, options: ExtensionsPayloadStoreOptions) { super() this.rawPayload = rawPayload this.options = options + + this.refreshAppAssetDirectories() + } + + getAppAssets(): Record | undefined { + return this.appAssetDirectories } getConnectedPayload() { @@ -170,6 +216,30 @@ export class ExtensionsPayloadStore extends EventEmitter { this.emitUpdate([extension.devUUID]) } + updateAdminConfigFromExtensionEvents(extensionEvents: ExtensionEvent[]) { + const adminEvent = extensionEvents.find((event) => event.extension.type === 'admin') + if (!adminEvent) return + + const adminConfig = getAdminConfig([adminEvent.extension]) + this.rawPayload.app.allowed_domains = adminConfig?.allowedDomains + + this.refreshAppAssetDirectories() + if (this.rawPayload.app.assets) { + for (const key of Object.keys(this.rawPayload.app.assets)) { + this.rawPayload.app.assets[key]!.lastUpdated = Date.now() + } + } + + this.emitUpdate([]) + } + + private refreshAppAssetDirectories() { + const adminConfig = getAdminConfig(this.options.extensions) + this.appAssetDirectories = adminConfig?.staticRoot + ? {staticRoot: joinPath(this.options.appDirectory, adminConfig.staticRoot)} + : undefined + } + private emitUpdate(extensionIds: string[]) { this.emit(ExtensionsPayloadStoreEvent.Update, extensionIds) } diff --git a/packages/app/src/cli/services/dev/extension/server.ts b/packages/app/src/cli/services/dev/extension/server.ts index 456c8364c61..b7a5bd7adec 100644 --- a/packages/app/src/cli/services/dev/extension/server.ts +++ b/packages/app/src/cli/services/dev/extension/server.ts @@ -2,6 +2,7 @@ import { corsMiddleware, devConsoleAssetsMiddleware, devConsoleIndexMiddleware, + getAppAssetsMiddleware, getExtensionAssetMiddleware, getExtensionPayloadMiddleware, getExtensionPointMiddleware, @@ -19,6 +20,7 @@ interface SetupHTTPServerOptions { devOptions: ExtensionsPayloadStoreOptions payloadStore: ExtensionsPayloadStore getExtensions: () => ExtensionInstance[] + getAppAssets?: () => Record | undefined } export function setupHTTPServer(options: SetupHTTPServerOptions) { @@ -28,6 +30,9 @@ export function setupHTTPServer(options: SetupHTTPServerOptions) { httpApp.use(getLogMiddleware(options)) httpApp.use(corsMiddleware) httpApp.use(noCacheMiddleware) + if (options.getAppAssets) { + httpRouter.use('/extensions/assets/:assetKey/**:filePath', getAppAssetsMiddleware(options.getAppAssets)) + } httpRouter.use('/extensions/dev-console', devConsoleIndexMiddleware) httpRouter.use('/extensions/dev-console/assets/**:assetPath', devConsoleAssetsMiddleware) httpRouter.use('/extensions/:extensionId', getExtensionPayloadMiddleware(options)) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index ed17a0f474d..c7ab7e8967f 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -5,7 +5,7 @@ import {getHTML} from '../templates.js' import {getWebSocketUrl} from '../../extension.js' import {fileExists, isDirectory, readFile, findPathUp} from '@shopify/cli-kit/node/fs' import {sendRedirect, defineEventHandler, getRequestHeader, getRouterParams, setResponseHeader} from 'h3' -import {joinPath, dirname, extname, moduleDirectory} from '@shopify/cli-kit/node/path' +import {joinPath, resolvePath, dirname, extname, moduleDirectory} from '@shopify/cli-kit/node/path' import {outputDebug} from '@shopify/cli-kit/node/output' import type {H3Event} from 'h3' @@ -134,6 +134,25 @@ export const devConsoleAssetsMiddleware = defineEventHandler(async (event) => { }) }) +export function getAppAssetsMiddleware(getAppAssets: () => Record | undefined) { + return defineEventHandler(async (event) => { + const {assetKey = '', filePath = ''} = getRouterParams(event) + const appAssets = getAppAssets() + const directory = appAssets?.[assetKey] + if (!directory) { + return sendError(event, {statusCode: 404, statusMessage: `No app assets configured for key: ${assetKey}`}) + } + const resolvedDirectory = resolvePath(directory) + const resolvedFilePath = resolvePath(directory, filePath) + if (!resolvedFilePath.startsWith(resolvedDirectory)) { + return sendError(event, {statusCode: 403, statusMessage: 'Path traversal is not allowed'}) + } + return fileServerMiddleware(event, { + filePath: resolvedFilePath, + }) + }) +} + export function getLogMiddleware({devOptions}: GetExtensionsMiddlewareOptions) { return defineEventHandler((event) => { outputDebug(`UI extensions server received a ${event.method} request to URL ${event.path}`, devOptions.stdout) diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index 1e9602ce520..cb7cf9d8a0c 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -351,6 +351,12 @@ export class DevSession { .filter((event) => event.type !== 'deleted') .map((event) => event.extension.uid) + // WORKAROUND. This is a temporary fix because `admin` is not compatible with inheritedUids in Core. + // It needs to be included in the manifest always if present in the app. + if (appEvent.app.allExtensions.some((ext) => ext.type === 'admin') && !updatedUids.includes('admin')) { + updatedUids.push('admin') + } + const nonUpdatedUids = appEvent.app.allExtensions .filter((ext) => !updatedUids.includes(ext.uid)) .map((ext) => ext.uid) diff --git a/packages/app/src/cli/services/dev/processes/previewable-extension.ts b/packages/app/src/cli/services/dev/processes/previewable-extension.ts index 387d97ebeed..c6e064ba1d4 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -22,7 +22,7 @@ interface PreviewableExtensionOptions { appDirectory: string appId?: string grantedScopes: string[] - previewableExtensions: ExtensionInstance[] + allExtensions: ExtensionInstance[] appWatcher: AppEventWatcher } @@ -44,7 +44,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction & { +}: Omit & { allExtensions: ExtensionInstance[] checkoutCartUrl?: string }): Promise { @@ -92,7 +92,7 @@ export async function setupPreviewableExtensionsProcess({ pathPrefix: '/extensions', port: -1, storeFqdn, - previewableExtensions, + allExtensions, cartUrl, ...options, }, diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index add3d387079..aeb6702a4ea 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -220,7 +220,7 @@ describe('setup-dev-processes', () => { prefix: 'extensions', options: { apiKey: 'api-key', - previewableExtensions: [previewable], + allExtensions: expect.arrayContaining([previewable]), storeFqdn, proxyUrl: 'https://example.com/proxy', port: expect.any(Number), diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 9a2a3723740..a7ece06e3dc 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -149,7 +149,7 @@ export async function setupDevProcesses({ }) : undefined, await setupPreviewableExtensionsProcess({ - allExtensions: reloadedApp.allExtensions, + allExtensions: reloadedApp.realExtensions, storeFqdn, storeId, apiKey, diff --git a/packages/ui-extensions-server-kit/src/types.ts b/packages/ui-extensions-server-kit/src/types.ts index e2ef8e8eb61..46a09de168f 100644 --- a/packages/ui-extensions-server-kit/src/types.ts +++ b/packages/ui-extensions-server-kit/src/types.ts @@ -182,4 +182,11 @@ export interface App { } supportEmail?: string supportLocales?: string[] + allowed_domains?: string[] + assets?: { + [key: string]: { + url: string + lastUpdated: number + } + } }