UNPKG

@shopify/shop-minis-react

Version:

React component library for Shopify Shop Minis with Tailwind CSS v4 support (source-only, requires TypeScript)

795 lines (708 loc) 25.1 kB
/** * ESLint rule to validate manifest.json configuration * @fileoverview Validates that manifest.json contains required scopes and permissions */ const fs = require('fs') const path = require('path') // Load the hook-scopes map const hookScopesMap = require('../../generated-hook-maps/hook-scopes-map.json') // Load the component-scopes map (may not exist yet) let componentScopesMap = {} try { componentScopesMap = require('../../generated-hook-maps/component-scopes-map.json') } catch (err) { // Component scopes map doesn't exist yet, that's okay } // Module-level cache for manifest.json to avoid repeated file I/O // ESLint reuses the same rule module across all files, so this cache // persists for the entire lint run, dramatically improving performance // Cache is invalidated if manifest.json modification time changes (for IDE support) let manifestCache = null let manifestPathCache = null let manifestParseErrorCache = null let manifestMtimeCache = null // Hook to permission mappings const hookPermissionsMap = { useImagePicker: ['CAMERA'], } // Extract domain and path from URL (returns {hostname, pathname} for CSP matching) function extractDomainAndPath(url) { if (!url || typeof url !== 'string') return null try { // Handle relative URLs if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) { return null } // Handle data: and blob: URLs if (url.startsWith('data:') || url.startsWith('blob:')) { return null } // Parse URL const urlObj = new URL(url) return { hostname: urlObj.hostname, pathname: urlObj.pathname, } } catch (err) { // If URL parsing fails, might be a relative path return null } } // Check if a URL matches a trusted_domain pattern // Supports: wildcards (*.example.com), paths with CSP semantics // CSP path matching: path with trailing slash matches directory, without matches exact or prefix function urlMatchesPattern(urlInfo, pattern) { if (pattern === '*') return true // Parse the pattern to extract hostname and optional path let patternHostname = pattern let patternPath = null // Check if pattern contains a path (has / after the domain) const slashIndex = pattern.indexOf('/') if (slashIndex !== -1) { patternHostname = pattern.slice(0, slashIndex) patternPath = pattern.slice(slashIndex) } // First check hostname match let hostnameMatches = false if (patternHostname === urlInfo.hostname) { hostnameMatches = true } else if (patternHostname.startsWith('*.')) { // Handle wildcard subdomains (*.example.com) const basePattern = patternHostname.slice(2) hostnameMatches = urlInfo.hostname === basePattern || urlInfo.hostname.endsWith(`.${basePattern}`) } if (!hostnameMatches) { return false } // If no path in pattern, hostname match is sufficient if (!patternPath) { return true } // CSP path matching semantics: // - Path ending with / matches that directory and everything under it (prefix match) // - Path without trailing / matches exact path only if (patternPath.endsWith('/')) { // Directory match: URL path must start with pattern path return urlInfo.pathname.startsWith(patternPath) } else { // Exact match only return urlInfo.pathname === patternPath } } module.exports = { meta: { type: 'problem', docs: { description: 'Ensure manifest.json includes required scopes and permissions for Shop Minis features', category: 'Possible Errors', recommended: true, }, fixable: 'code', messages: { missingScope: '{{source}} requires scope "{{scope}}" in src/manifest.json. Add "{{scope}}" to the "scopes" array.', missingPermission: '{{reason}} requires permission "{{permission}}" in src/manifest.json. Add "{{permission}}" to the "permissions" array.', missingTrustedDomain: '{{reason}} loads from "{{domain}}" which is not in trusted_domains. Add "{{domain}}" to the "trusted_domains" array in src/manifest.json.', manifestNotFound: 'Cannot find src/manifest.json. Create a manifest file with required scopes/permissions/trusted_domains.', manifestInvalidJson: 'Failed to parse src/manifest.json: {{error}}. Please fix the JSON syntax (common issues: trailing commas, missing quotes).', }, schema: [], }, create(context) { // eslint-disable-next-line @shopify/prefer-module-scope-constants const SDK_PACKAGE = '@shopify/shop-minis-react' let manifestPath = null let manifest = null let manifestParseError = null const usedHooks = new Set() const usedComponents = new Set() const requiredPermissions = new Set() const requiredDomains = new Set() const fixedIssues = new Set() // Check module-level cache first to avoid repeated file I/O if (manifestPathCache && fs.existsSync(manifestPathCache)) { // Check if manifest.json has been modified (for IDE integration) const currentMtime = fs.statSync(manifestPathCache).mtimeMs if (manifestMtimeCache === currentMtime) { // Cache is still valid - use it manifestPath = manifestPathCache manifest = manifestCache manifestParseError = manifestParseErrorCache } else { // File was modified - invalidate cache and reload manifestCache = null manifestParseErrorCache = null manifestMtimeCache = null } } // Load manifest if not cached or cache was invalidated if (!manifestPath) { // Cache miss - find and load manifest.json const filename = context.getFilename() if (filename && filename !== '<input>') { const dir = path.dirname(filename) // Look for src/manifest.json let currentDir = dir for (let i = 0; i < 5; i++) { const testPath = path.join(currentDir, 'src', 'manifest.json') if (fs.existsSync(testPath)) { manifestPath = testPath try { manifest = JSON.parse(fs.readFileSync(testPath, 'utf8')) // Store modification time for cache invalidation manifestMtimeCache = fs.statSync(testPath).mtimeMs } catch (err) { // Invalid JSON - save error for reporting manifestParseError = err.message } break } currentDir = path.join(currentDir, '..') } } // Update cache for subsequent files manifestPathCache = manifestPath manifestCache = manifest manifestParseErrorCache = manifestParseError } return { // Track imports from the SDK ImportDeclaration(node) { if (node.source.value === SDK_PACKAGE) { // eslint-disable-next-line @shopify/prefer-early-return node.specifiers.forEach(spec => { if (spec.type === 'ImportSpecifier') { const importedName = spec.imported.name // Check if it's a hook that requires scopes if (hookScopesMap[importedName]) { usedHooks.add(importedName) } // Check if it's a hook that requires permissions if (hookPermissionsMap[importedName]) { hookPermissionsMap[importedName].forEach(permission => { requiredPermissions.add({ permission, reason: `Hook "${importedName}"`, node, }) }) } // Check if it's a component that requires scopes // We need to check all possible paths where the component might be defined const possibleComponentPaths = Object.keys( componentScopesMap ).filter(componentPath => { // Extract the component name from the path (e.g., "commerce/add-to-cart" -> "AddToCartButton") // We'll check if the import name matches common component naming patterns const pathParts = componentPath.split('/') const fileName = pathParts[pathParts.length - 1] // Convert kebab-case or snake_case to PascalCase const componentName = fileName .split(/[-_]/) .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join('') return ( componentName === importedName || fileName === importedName ) }) if (possibleComponentPaths.length > 0) { possibleComponentPaths.forEach(componentPath => { usedComponents.add({ path: componentPath, name: importedName, node, }) }) } } }) } }, // Detect browser API usage for permissions MemberExpression() { // This visitor is kept for potential future permission detection // getUserMedia detection has been moved to CallExpression for better constraint analysis }, // Detect network requests and event listeners CallExpression(node) { // Check for getUserMedia calls if ( node.callee.type === 'MemberExpression' && node.callee.property.name === 'getUserMedia' ) { // Validate it's actually navigator.getUserMedia or navigator.mediaDevices.getUserMedia const sourceCode = context.getSourceCode() const calleeText = sourceCode.getText(node.callee) if ( calleeText.includes('navigator.mediaDevices.getUserMedia') || calleeText.includes('navigator.getUserMedia') ) { // Get the constraints argument (first argument) const constraints = node.arguments[0] let needsCamera = true let needsMicrophone = true if (constraints && constraints.type === 'ObjectExpression') { // Check if video is explicitly set to false const videoProp = constraints.properties.find( prop => prop.type === 'Property' && prop.key && (prop.key.name === 'video' || (prop.key.type === 'Literal' && prop.key.value === 'video')) ) if ( videoProp && videoProp.value.type === 'Literal' && videoProp.value.value === false ) { needsCamera = false } // Check if audio is explicitly set to false const audioProp = constraints.properties.find( prop => prop.type === 'Property' && prop.key && (prop.key.name === 'audio' || (prop.key.type === 'Literal' && prop.key.value === 'audio')) ) if ( audioProp && audioProp.value.type === 'Literal' && audioProp.value.value === false ) { needsMicrophone = false } } if (needsCamera) { requiredPermissions.add({ permission: 'CAMERA', reason: 'getUserMedia API usage with video', node, }) } if (needsMicrophone) { requiredPermissions.add({ permission: 'MICROPHONE', reason: 'getUserMedia API usage with audio', node, }) } } } if (!node.arguments[0]) { return } const firstArg = node.arguments[0] // Check for fetch() calls if (node.callee.name === 'fetch' && firstArg.type === 'Literal') { const url = firstArg.value const urlInfo = extractDomainAndPath(url) if (urlInfo) { requiredDomains.add({ urlInfo, url, reason: 'fetch() call', node, }) } } // Check for XMLHttpRequest.open() if ( node.callee.type === 'MemberExpression' && node.callee.property.name === 'open' && node.arguments[1] && node.arguments[1].type === 'Literal' ) { const url = node.arguments[1].value const urlInfo = extractDomainAndPath(url) if (urlInfo) { requiredDomains.add({ urlInfo, url, reason: 'XMLHttpRequest.open() call', node, }) } } // Check for WebSocket if (node.callee.name === 'WebSocket' && firstArg.type === 'Literal') { const url = firstArg.value // WebSocket URLs use ws:// or wss:// const normalizedUrl = url.replace(/^wss?:\/\//, 'https://') const urlInfo = extractDomainAndPath(normalizedUrl) if (urlInfo) { requiredDomains.add({ urlInfo, url, reason: 'WebSocket connection', node, }) } } // Check for EventSource if (node.callee.name === 'EventSource' && firstArg.type === 'Literal') { const url = firstArg.value const urlInfo = extractDomainAndPath(url) if (urlInfo) { requiredDomains.add({ urlInfo, url, reason: 'EventSource connection', node, }) } } // Check for navigator.sendBeacon() if ( node.callee.type === 'MemberExpression' && node.callee.property.name === 'sendBeacon' && firstArg.type === 'Literal' ) { const url = firstArg.value const urlInfo = extractDomainAndPath(url) if (urlInfo) { requiredDomains.add({ urlInfo, url, reason: 'navigator.sendBeacon() call', node, }) } } // Check for window.open() if ( node.callee.type === 'MemberExpression' && node.callee.property.name === 'open' && node.callee.object.name === 'window' && firstArg.type === 'Literal' ) { const url = firstArg.value const urlInfo = extractDomainAndPath(url) if (urlInfo) { requiredDomains.add({ urlInfo, url, reason: 'window.open() call', node, }) } } // Check addEventListener for device motion/orientation if ( node.callee.type === 'MemberExpression' && node.callee.property.name === 'addEventListener' && firstArg.type === 'Literal' ) { const eventName = firstArg.value if ( eventName === 'deviceorientation' || eventName === 'devicemotion' ) { requiredPermissions.add({ permission: 'MOTION', reason: `${eventName} event listener`, node, }) } } }, // Check JSX attributes for external URLs JSXAttribute(node) { if (!node.value || node.value.type !== 'Literal') { return } const attrName = node.name.name const url = node.value.value const urlInfo = extractDomainAndPath(url) if (!urlInfo) { return } const elementName = node.parent.name.name // src attributes (img, video, audio, source, track, embed) // Exclude: script, iframe (not supported) if (attrName === 'src') { const supportedElements = [ 'img', 'video', 'audio', 'source', 'track', 'embed', 'Image', 'VideoPlayer', ] if (supportedElements.includes(elementName)) { requiredDomains.add({ urlInfo, url, reason: `<${elementName}> src attribute`, node, }) } } // poster attribute (video) else if (attrName === 'poster') { requiredDomains.add({ urlInfo, url, reason: `<${elementName}> poster attribute`, node, }) } // data attribute (object) else if (attrName === 'data') { requiredDomains.add({ urlInfo, url, reason: `<${elementName}> data attribute`, node, }) } // action attribute (form) else if (attrName === 'action') { requiredDomains.add({ urlInfo, url, reason: `<${elementName}> action attribute`, node, }) } }, // Detect DeviceOrientation/Motion events Identifier(node) { if ( node.name === 'DeviceOrientationEvent' || node.name === 'DeviceMotionEvent' ) { requiredPermissions.add({ permission: 'MOTION', reason: `${node.name} usage`, node, }) } }, // Check everything at the end 'Program:exit': function () { const issues = [] // If manifest exists but has parse error, report it once if ( manifestParseError && (usedHooks.size > 0 || usedComponents.size > 0 || requiredPermissions.size > 0 || requiredDomains.size > 0) ) { context.report({ loc: {line: 1, column: 0}, messageId: 'manifestInvalidJson', data: {error: manifestParseError}, }) return } // Check scopes for hooks usedHooks.forEach(hookName => { const requiredScopes = hookScopesMap[hookName] if (!requiredScopes || requiredScopes.length === 0) { return } if (!manifest) { issues.push({ messageId: 'manifestNotFound', data: {hookName, scopes: requiredScopes.join(', ')}, }) return } const manifestScopes = manifest.scopes || [] requiredScopes.forEach(requiredScope => { if (!manifestScopes.includes(requiredScope)) { issues.push({ type: 'scope', scope: requiredScope, hookName, }) } }) }) // Check scopes for components usedComponents.forEach( ({path: componentPath, name: componentName, node}) => { const componentData = componentScopesMap[componentPath] if ( !componentData || !componentData.scopes || componentData.scopes.length === 0 ) { return } const requiredScopes = componentData.scopes if (!manifest) { issues.push({ messageId: 'manifestNotFound', data: { hookName: componentName, scopes: requiredScopes.join(', '), }, }) return } const manifestScopes = manifest.scopes || [] requiredScopes.forEach(requiredScope => { if (!manifestScopes.includes(requiredScope)) { issues.push({ type: 'scope', scope: requiredScope, componentName, componentPath, node, }) } }) } ) // Check permissions requiredPermissions.forEach(({permission, reason, node}) => { if (!manifest) { issues.push({ messageId: 'manifestNotFound', data: {reason, permission}, }) return } const manifestPermissions = manifest.permissions || [] if (!manifestPermissions.includes(permission)) { issues.push({ type: 'permission', permission, reason, node, }) } }) // Check trusted domains requiredDomains.forEach(({urlInfo, url, reason, node}) => { if (!manifest) { issues.push({ messageId: 'manifestNotFound', data: {reason, domain: urlInfo.hostname}, }) return } const trustedDomains = manifest.trusted_domains || [] // Check if URL is trusted (supports wildcards and paths) const isTrusted = trustedDomains.some(pattern => urlMatchesPattern(urlInfo, pattern) ) if (!isTrusted) { issues.push({ type: 'domain', domain: urlInfo.hostname, reason, node, }) } }) // Report all issues issues.forEach(issue => { if (issue.messageId) { context.report({ loc: {line: 1, column: 0}, messageId: issue.messageId, data: issue.data, }) } else if (issue.type === 'scope') { const issueKey = `scopes:${issue.scope}` const fixer = createManifestFixer('scopes', issue.scope) // Call fixer immediately to check if we can fix fixer() // Only report if not fixed if (!fixedIssues.has(issueKey)) { // Determine if this is from a hook or component const sourceName = issue.hookName || issue.componentName const sourceType = issue.hookName ? 'Hook' : 'Component' const messageId = 'missingScope' context.report({ loc: {line: 1, column: 0}, messageId, data: { source: `${sourceType} "${sourceName}"`, scope: issue.scope, }, }) } } else if (issue.type === 'permission') { const issueKey = `permissions:${issue.permission}` const fixer = createManifestFixer('permissions', issue.permission) fixer() if (!fixedIssues.has(issueKey)) { context.report({ node: issue.node || {loc: {line: 1, column: 0}}, messageId: 'missingPermission', data: { reason: issue.reason, permission: issue.permission, }, }) } } else if (issue.type === 'domain') { const issueKey = `trusted_domains:${issue.domain}` const fixer = createManifestFixer('trusted_domains', issue.domain) fixer() if (!fixedIssues.has(issueKey)) { context.report({ node: issue.node || {loc: {line: 1, column: 0}}, messageId: 'missingTrustedDomain', data: { reason: issue.reason, domain: issue.domain, }, }) } } }) }, } function createManifestFixer(field, value) { return function () { // Explicit check: Only run if --fix flag was passed const hasFixFlag = process.argv.includes('--fix') if (!hasFixFlag) { return null } if (!manifestPath || manifestParseError) { return null } try { const currentManifest = JSON.parse( fs.readFileSync(manifestPath, 'utf8') ) if (!currentManifest[field]) { currentManifest[field] = [] } if (!currentManifest[field].includes(value)) { currentManifest[field].push(value) currentManifest[field].sort() const updatedContent = JSON.stringify(currentManifest, null, 2) fs.writeFileSync(manifestPath, `${updatedContent}\n`, 'utf8') // Update cache with the modified manifest and new mtime manifestCache = currentManifest manifest = currentManifest manifestMtimeCache = fs.statSync(manifestPath).mtimeMs // Mark this issue as fixed fixedIssues.add(`${field}:${value}`) } // Return empty array to signal we handled it return [] } catch (err) { return null } } } }, }