@shopify/shop-minis-react 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,607 @@
1
+ /**
2
+ * ESLint rule to validate manifest.json configuration
3
+ * @fileoverview Validates that manifest.json contains required scopes and permissions
4
+ */
5
+
6
+ const fs = require('fs')
7
+ const path = require('path')
8
+
9
+ // Load the hook-scopes map
10
+ const hookScopesMap = require('../../generated-hook-maps/hook-scopes-map.json')
11
+
12
+ // Module-level cache for manifest.json to avoid repeated file I/O
13
+ // ESLint reuses the same rule module across all files, so this cache
14
+ // persists for the entire lint run, dramatically improving performance
15
+ // Cache is invalidated if manifest.json modification time changes (for IDE support)
16
+ let manifestCache = null
17
+ let manifestPathCache = null
18
+ let manifestParseErrorCache = null
19
+ let manifestMtimeCache = null
20
+
21
+ // Hook to permission mappings
22
+ const hookPermissionsMap = {
23
+ useImagePicker: ['CAMERA'],
24
+ }
25
+
26
+ // Extract domain from URL (returns domain in CSP format)
27
+ function extractDomain(url) {
28
+ if (!url || typeof url !== 'string') return null
29
+
30
+ try {
31
+ // Handle relative URLs
32
+ if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) {
33
+ return null
34
+ }
35
+
36
+ // Handle data: and blob: URLs
37
+ if (url.startsWith('data:') || url.startsWith('blob:')) {
38
+ return null
39
+ }
40
+
41
+ // Parse URL
42
+ const urlObj = new URL(url)
43
+ return urlObj.hostname
44
+ } catch (err) {
45
+ // If URL parsing fails, might be a relative path
46
+ return null
47
+ }
48
+ }
49
+
50
+ // Check if domain matches a pattern (supports wildcards)
51
+ function domainMatchesPattern(domain, pattern) {
52
+ if (pattern === '*') return true
53
+ if (pattern === domain) return true
54
+
55
+ // Handle wildcard subdomains (*.example.com)
56
+ if (pattern.startsWith('*.')) {
57
+ const basePattern = pattern.slice(2)
58
+ return domain === basePattern || domain.endsWith(`.${basePattern}`)
59
+ }
60
+
61
+ return false
62
+ }
63
+
64
+ module.exports = {
65
+ meta: {
66
+ type: 'problem',
67
+ docs: {
68
+ description:
69
+ 'Ensure manifest.json includes required scopes and permissions for Shop Minis features',
70
+ category: 'Possible Errors',
71
+ recommended: true,
72
+ },
73
+ fixable: 'code',
74
+ messages: {
75
+ missingScope:
76
+ 'Hook "{{hookName}}" requires scope "{{scope}}" in src/manifest.json. Add "{{scope}}" to the "scopes" array.',
77
+ missingPermission:
78
+ '{{reason}} requires permission "{{permission}}" in src/manifest.json. Add "{{permission}}" to the "permissions" array.',
79
+ missingTrustedDomain:
80
+ '{{reason}} loads from "{{domain}}" which is not in trusted_domains. Add "{{domain}}" to the "trusted_domains" array in src/manifest.json.',
81
+ manifestNotFound:
82
+ 'Cannot find src/manifest.json. Create a manifest file with required scopes/permissions/trusted_domains.',
83
+ manifestInvalidJson:
84
+ 'Failed to parse src/manifest.json: {{error}}. Please fix the JSON syntax (common issues: trailing commas, missing quotes).',
85
+ },
86
+ schema: [],
87
+ },
88
+
89
+ create(context) {
90
+ // eslint-disable-next-line @shopify/prefer-module-scope-constants
91
+ const SDK_PACKAGE = '@shopify/shop-minis-react'
92
+ let manifestPath = null
93
+ let manifest = null
94
+ let manifestParseError = null
95
+ const usedHooks = new Set()
96
+ const requiredPermissions = new Set()
97
+ const requiredDomains = new Set()
98
+ const fixedIssues = new Set()
99
+
100
+ // Check module-level cache first to avoid repeated file I/O
101
+ if (manifestPathCache && fs.existsSync(manifestPathCache)) {
102
+ // Check if manifest.json has been modified (for IDE integration)
103
+ const currentMtime = fs.statSync(manifestPathCache).mtimeMs
104
+
105
+ if (manifestMtimeCache === currentMtime) {
106
+ // Cache is still valid - use it
107
+ manifestPath = manifestPathCache
108
+ manifest = manifestCache
109
+ manifestParseError = manifestParseErrorCache
110
+ } else {
111
+ // File was modified - invalidate cache and reload
112
+ manifestCache = null
113
+ manifestParseErrorCache = null
114
+ manifestMtimeCache = null
115
+ }
116
+ }
117
+
118
+ // Load manifest if not cached or cache was invalidated
119
+ if (!manifestPath) {
120
+ // Cache miss - find and load manifest.json
121
+ const filename = context.getFilename()
122
+ if (filename && filename !== '<input>') {
123
+ const dir = path.dirname(filename)
124
+ // Look for src/manifest.json
125
+ let currentDir = dir
126
+ for (let i = 0; i < 5; i++) {
127
+ const testPath = path.join(currentDir, 'src', 'manifest.json')
128
+ if (fs.existsSync(testPath)) {
129
+ manifestPath = testPath
130
+ try {
131
+ manifest = JSON.parse(fs.readFileSync(testPath, 'utf8'))
132
+ // Store modification time for cache invalidation
133
+ manifestMtimeCache = fs.statSync(testPath).mtimeMs
134
+ } catch (err) {
135
+ // Invalid JSON - save error for reporting
136
+ manifestParseError = err.message
137
+ }
138
+ break
139
+ }
140
+ currentDir = path.join(currentDir, '..')
141
+ }
142
+ }
143
+
144
+ // Update cache for subsequent files
145
+ manifestPathCache = manifestPath
146
+ manifestCache = manifest
147
+ manifestParseErrorCache = manifestParseError
148
+ }
149
+
150
+ return {
151
+ // Track imports from the SDK
152
+ ImportDeclaration(node) {
153
+ if (node.source.value === SDK_PACKAGE) {
154
+ // eslint-disable-next-line @shopify/prefer-early-return
155
+ node.specifiers.forEach(spec => {
156
+ if (spec.type === 'ImportSpecifier') {
157
+ const importedName = spec.imported.name
158
+
159
+ // Check if it's a hook that requires scopes
160
+ if (hookScopesMap[importedName]) {
161
+ usedHooks.add(importedName)
162
+ }
163
+
164
+ // Check if it's a hook that requires permissions
165
+ if (hookPermissionsMap[importedName]) {
166
+ hookPermissionsMap[importedName].forEach(permission => {
167
+ requiredPermissions.add({
168
+ permission,
169
+ reason: `Hook "${importedName}"`,
170
+ node,
171
+ })
172
+ })
173
+ }
174
+ }
175
+ })
176
+ }
177
+ },
178
+
179
+ // Detect browser API usage for permissions
180
+ MemberExpression(node) {
181
+ const sourceCode = context.getSourceCode()
182
+ const code = sourceCode.getText(node)
183
+
184
+ // Check for camera/microphone API
185
+ if (
186
+ code.includes('navigator.mediaDevices.getUserMedia') ||
187
+ code.includes('navigator.getUserMedia')
188
+ ) {
189
+ // Need to check the constraints to determine if it's camera or microphone
190
+ // For now, we'll flag both as potentially needed
191
+ requiredPermissions.add({
192
+ permission: 'CAMERA',
193
+ reason: 'getUserMedia API usage',
194
+ node,
195
+ })
196
+ requiredPermissions.add({
197
+ permission: 'MICROPHONE',
198
+ reason: 'getUserMedia API usage',
199
+ node,
200
+ })
201
+ }
202
+ },
203
+
204
+ // Detect network requests and event listeners
205
+ CallExpression(node) {
206
+ if (!node.arguments[0]) {
207
+ return
208
+ }
209
+
210
+ const firstArg = node.arguments[0]
211
+
212
+ // Check for fetch() calls
213
+ if (node.callee.name === 'fetch' && firstArg.type === 'Literal') {
214
+ const url = firstArg.value
215
+ const domain = extractDomain(url)
216
+ if (domain) {
217
+ requiredDomains.add({
218
+ domain,
219
+ reason: 'fetch() call',
220
+ node,
221
+ })
222
+ }
223
+ }
224
+
225
+ // Check for XMLHttpRequest.open()
226
+ if (
227
+ node.callee.type === 'MemberExpression' &&
228
+ node.callee.property.name === 'open' &&
229
+ node.arguments[1] &&
230
+ node.arguments[1].type === 'Literal'
231
+ ) {
232
+ const url = node.arguments[1].value
233
+ const domain = extractDomain(url)
234
+ if (domain) {
235
+ requiredDomains.add({
236
+ domain,
237
+ reason: 'XMLHttpRequest.open() call',
238
+ node,
239
+ })
240
+ }
241
+ }
242
+
243
+ // Check for WebSocket
244
+ if (node.callee.name === 'WebSocket' && firstArg.type === 'Literal') {
245
+ const url = firstArg.value
246
+ // WebSocket URLs use ws:// or wss://
247
+ const domain = url.replace(/^wss?:\/\//, 'https://')
248
+ const extracted = extractDomain(domain)
249
+ if (extracted) {
250
+ requiredDomains.add({
251
+ domain: extracted,
252
+ reason: 'WebSocket connection',
253
+ node,
254
+ })
255
+ }
256
+ }
257
+
258
+ // Check for EventSource
259
+ if (node.callee.name === 'EventSource' && firstArg.type === 'Literal') {
260
+ const url = firstArg.value
261
+ const domain = extractDomain(url)
262
+ if (domain) {
263
+ requiredDomains.add({
264
+ domain,
265
+ reason: 'EventSource connection',
266
+ node,
267
+ })
268
+ }
269
+ }
270
+
271
+ // Check for navigator.sendBeacon()
272
+ if (
273
+ node.callee.type === 'MemberExpression' &&
274
+ node.callee.property.name === 'sendBeacon' &&
275
+ firstArg.type === 'Literal'
276
+ ) {
277
+ const url = firstArg.value
278
+ const domain = extractDomain(url)
279
+ if (domain) {
280
+ requiredDomains.add({
281
+ domain,
282
+ reason: 'navigator.sendBeacon() call',
283
+ node,
284
+ })
285
+ }
286
+ }
287
+
288
+ // Check for window.open()
289
+ if (
290
+ node.callee.type === 'MemberExpression' &&
291
+ node.callee.property.name === 'open' &&
292
+ node.callee.object.name === 'window' &&
293
+ firstArg.type === 'Literal'
294
+ ) {
295
+ const url = firstArg.value
296
+ const domain = extractDomain(url)
297
+ if (domain) {
298
+ requiredDomains.add({
299
+ domain,
300
+ reason: 'window.open() call',
301
+ node,
302
+ })
303
+ }
304
+ }
305
+
306
+ // Check addEventListener for device motion/orientation
307
+ if (
308
+ node.callee.type === 'MemberExpression' &&
309
+ node.callee.property.name === 'addEventListener' &&
310
+ firstArg.type === 'Literal'
311
+ ) {
312
+ const eventName = firstArg.value
313
+ if (
314
+ eventName === 'deviceorientation' ||
315
+ eventName === 'devicemotion'
316
+ ) {
317
+ requiredPermissions.add({
318
+ permission: 'MOTION',
319
+ reason: `${eventName} event listener`,
320
+ node,
321
+ })
322
+ }
323
+ }
324
+ },
325
+
326
+ // Check JSX attributes for external URLs
327
+ JSXAttribute(node) {
328
+ if (!node.value || node.value.type !== 'Literal') {
329
+ return
330
+ }
331
+
332
+ const attrName = node.name.name
333
+ const url = node.value.value
334
+ const domain = extractDomain(url)
335
+
336
+ if (!domain) {
337
+ return
338
+ }
339
+
340
+ const elementName = node.parent.name.name
341
+
342
+ // src attributes (img, video, audio, source, track, embed)
343
+ // Exclude: script, iframe (not supported)
344
+ if (attrName === 'src') {
345
+ const supportedElements = [
346
+ 'img',
347
+ 'video',
348
+ 'audio',
349
+ 'source',
350
+ 'track',
351
+ 'embed',
352
+ 'Image',
353
+ 'VideoPlayer',
354
+ ]
355
+ if (supportedElements.includes(elementName)) {
356
+ requiredDomains.add({
357
+ domain,
358
+ reason: `<${elementName}> src attribute`,
359
+ node,
360
+ })
361
+ }
362
+ }
363
+ // poster attribute (video)
364
+ else if (attrName === 'poster') {
365
+ requiredDomains.add({
366
+ domain,
367
+ reason: `<${elementName}> poster attribute`,
368
+ node,
369
+ })
370
+ }
371
+ // data attribute (object)
372
+ else if (attrName === 'data') {
373
+ requiredDomains.add({
374
+ domain,
375
+ reason: `<${elementName}> data attribute`,
376
+ node,
377
+ })
378
+ }
379
+ // action attribute (form)
380
+ else if (attrName === 'action') {
381
+ requiredDomains.add({
382
+ domain,
383
+ reason: `<${elementName}> action attribute`,
384
+ node,
385
+ })
386
+ }
387
+ },
388
+
389
+ // Detect DeviceOrientation/Motion events
390
+ Identifier(node) {
391
+ if (
392
+ node.name === 'DeviceOrientationEvent' ||
393
+ node.name === 'DeviceMotionEvent'
394
+ ) {
395
+ requiredPermissions.add({
396
+ permission: 'MOTION',
397
+ reason: `${node.name} usage`,
398
+ node,
399
+ })
400
+ }
401
+ },
402
+
403
+ // Check everything at the end
404
+ 'Program:exit': function () {
405
+ const issues = []
406
+
407
+ // If manifest exists but has parse error, report it once
408
+ if (
409
+ manifestParseError &&
410
+ (usedHooks.size > 0 ||
411
+ requiredPermissions.size > 0 ||
412
+ requiredDomains.size > 0)
413
+ ) {
414
+ context.report({
415
+ loc: {line: 1, column: 0},
416
+ messageId: 'manifestInvalidJson',
417
+ data: {error: manifestParseError},
418
+ })
419
+ return
420
+ }
421
+
422
+ // Check scopes for hooks
423
+ usedHooks.forEach(hookName => {
424
+ const requiredScopes = hookScopesMap[hookName]
425
+ if (!requiredScopes || requiredScopes.length === 0) {
426
+ return
427
+ }
428
+
429
+ if (!manifest) {
430
+ issues.push({
431
+ messageId: 'manifestNotFound',
432
+ data: {hookName, scopes: requiredScopes.join(', ')},
433
+ })
434
+ return
435
+ }
436
+
437
+ const manifestScopes = manifest.scopes || []
438
+
439
+ requiredScopes.forEach(requiredScope => {
440
+ if (!manifestScopes.includes(requiredScope)) {
441
+ issues.push({
442
+ type: 'scope',
443
+ scope: requiredScope,
444
+ hookName,
445
+ })
446
+ }
447
+ })
448
+ })
449
+
450
+ // Check permissions
451
+ requiredPermissions.forEach(({permission, reason, node}) => {
452
+ if (!manifest) {
453
+ issues.push({
454
+ messageId: 'manifestNotFound',
455
+ data: {reason, permission},
456
+ })
457
+ return
458
+ }
459
+
460
+ const manifestPermissions = manifest.permissions || []
461
+
462
+ if (!manifestPermissions.includes(permission)) {
463
+ issues.push({
464
+ type: 'permission',
465
+ permission,
466
+ reason,
467
+ node,
468
+ })
469
+ }
470
+ })
471
+
472
+ // Check trusted domains
473
+ requiredDomains.forEach(({domain, reason, node}) => {
474
+ if (!manifest) {
475
+ issues.push({
476
+ messageId: 'manifestNotFound',
477
+ data: {reason, domain},
478
+ })
479
+ return
480
+ }
481
+
482
+ const trustedDomains = manifest.trusted_domains || []
483
+
484
+ // Check if domain is trusted (supports wildcards)
485
+ const isTrusted = trustedDomains.some(pattern =>
486
+ domainMatchesPattern(domain, pattern)
487
+ )
488
+
489
+ if (!isTrusted) {
490
+ issues.push({
491
+ type: 'domain',
492
+ domain,
493
+ reason,
494
+ node,
495
+ })
496
+ }
497
+ })
498
+
499
+ // Report all issues
500
+ issues.forEach(issue => {
501
+ if (issue.messageId) {
502
+ context.report({
503
+ loc: {line: 1, column: 0},
504
+ messageId: issue.messageId,
505
+ data: issue.data,
506
+ })
507
+ } else if (issue.type === 'scope') {
508
+ const issueKey = `scopes:${issue.scope}`
509
+ const fixer = createManifestFixer('scopes', issue.scope)
510
+
511
+ // Call fixer immediately to check if we can fix
512
+ fixer()
513
+
514
+ // Only report if not fixed
515
+ if (!fixedIssues.has(issueKey)) {
516
+ context.report({
517
+ loc: {line: 1, column: 0},
518
+ messageId: 'missingScope',
519
+ data: {
520
+ hookName: issue.hookName,
521
+ scope: issue.scope,
522
+ },
523
+ })
524
+ }
525
+ } else if (issue.type === 'permission') {
526
+ const issueKey = `permissions:${issue.permission}`
527
+ const fixer = createManifestFixer('permissions', issue.permission)
528
+
529
+ fixer()
530
+
531
+ if (!fixedIssues.has(issueKey)) {
532
+ context.report({
533
+ node: issue.node || {loc: {line: 1, column: 0}},
534
+ messageId: 'missingPermission',
535
+ data: {
536
+ reason: issue.reason,
537
+ permission: issue.permission,
538
+ },
539
+ })
540
+ }
541
+ } else if (issue.type === 'domain') {
542
+ const issueKey = `trusted_domains:${issue.domain}`
543
+ const fixer = createManifestFixer('trusted_domains', issue.domain)
544
+
545
+ fixer()
546
+
547
+ if (!fixedIssues.has(issueKey)) {
548
+ context.report({
549
+ node: issue.node || {loc: {line: 1, column: 0}},
550
+ messageId: 'missingTrustedDomain',
551
+ data: {
552
+ reason: issue.reason,
553
+ domain: issue.domain,
554
+ },
555
+ })
556
+ }
557
+ }
558
+ })
559
+ },
560
+ }
561
+
562
+ function createManifestFixer(field, value) {
563
+ return function () {
564
+ // Explicit check: Only run if --fix flag was passed
565
+ const hasFixFlag = process.argv.includes('--fix')
566
+ if (!hasFixFlag) {
567
+ return null
568
+ }
569
+
570
+ if (!manifestPath || manifestParseError) {
571
+ return null
572
+ }
573
+
574
+ try {
575
+ const currentManifest = JSON.parse(
576
+ fs.readFileSync(manifestPath, 'utf8')
577
+ )
578
+
579
+ if (!currentManifest[field]) {
580
+ currentManifest[field] = []
581
+ }
582
+
583
+ if (!currentManifest[field].includes(value)) {
584
+ currentManifest[field].push(value)
585
+ currentManifest[field].sort()
586
+
587
+ const updatedContent = JSON.stringify(currentManifest, null, 2)
588
+ fs.writeFileSync(manifestPath, `${updatedContent}\n`, 'utf8')
589
+
590
+ // Update cache with the modified manifest and new mtime
591
+ manifestCache = currentManifest
592
+ manifest = currentManifest
593
+ manifestMtimeCache = fs.statSync(manifestPath).mtimeMs
594
+
595
+ // Mark this issue as fixed
596
+ fixedIssues.add(`${field}:${value}`)
597
+ }
598
+
599
+ // Return empty array to signal we handled it
600
+ return []
601
+ } catch (err) {
602
+ return null
603
+ }
604
+ }
605
+ }
606
+ },
607
+ }