@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
JavaScript
/**
* 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
}
}
}
},
}