suparank 1.2.3 → 1.2.5

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.
package/bin/suparank.js CHANGED
@@ -23,6 +23,8 @@ const SUPARANK_DIR = path.join(os.homedir(), '.suparank')
23
23
  const CONFIG_FILE = path.join(SUPARANK_DIR, 'config.json')
24
24
  const CREDENTIALS_FILE = path.join(SUPARANK_DIR, 'credentials.json')
25
25
  const SESSION_FILE = path.join(SUPARANK_DIR, 'session.json')
26
+ const CONTENT_DIR = path.join(SUPARANK_DIR, 'content')
27
+ const STATS_FILE = path.join(SUPARANK_DIR, 'stats.json')
26
28
 
27
29
  // Production API URL
28
30
  const DEFAULT_API_URL = 'https://api.suparank.io'
@@ -514,6 +516,165 @@ function showVersion() {
514
516
  log('https://suparank.io', 'dim')
515
517
  }
516
518
 
519
+ function loadStats() {
520
+ try {
521
+ if (fs.existsSync(STATS_FILE)) {
522
+ return JSON.parse(fs.readFileSync(STATS_FILE, 'utf-8'))
523
+ }
524
+ } catch (e) {}
525
+ return { tool_calls: 0, images_generated: 0, articles_created: 0, words_written: 0 }
526
+ }
527
+
528
+ function loadSession() {
529
+ try {
530
+ if (fs.existsSync(SESSION_FILE)) {
531
+ return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'))
532
+ }
533
+ } catch (e) {}
534
+ return null
535
+ }
536
+
537
+ function countSavedContent() {
538
+ try {
539
+ if (fs.existsSync(CONTENT_DIR)) {
540
+ const folders = fs.readdirSync(CONTENT_DIR).filter(f => {
541
+ const stat = fs.statSync(path.join(CONTENT_DIR, f))
542
+ return stat.isDirectory()
543
+ })
544
+ return folders.length
545
+ }
546
+ } catch (e) {}
547
+ return 0
548
+ }
549
+
550
+ function getRecentContent(limit = 3) {
551
+ try {
552
+ if (fs.existsSync(CONTENT_DIR)) {
553
+ const folders = fs.readdirSync(CONTENT_DIR)
554
+ .filter(f => fs.statSync(path.join(CONTENT_DIR, f)).isDirectory())
555
+ .map(f => {
556
+ const metaPath = path.join(CONTENT_DIR, f, 'metadata.json')
557
+ let meta = { title: f }
558
+ try {
559
+ if (fs.existsSync(metaPath)) {
560
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
561
+ }
562
+ } catch (e) {}
563
+ return { folder: f, ...meta }
564
+ })
565
+ .sort((a, b) => (b.savedAt || '').localeCompare(a.savedAt || ''))
566
+ .slice(0, limit)
567
+ return folders
568
+ }
569
+ } catch (e) {}
570
+ return []
571
+ }
572
+
573
+ async function displayDashboard(config, project) {
574
+ const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
575
+ const credentials = loadCredentials()
576
+ const session = loadSession()
577
+ const stats = loadStats()
578
+ const savedCount = countSavedContent()
579
+ const recentContent = getRecentContent(3)
580
+ const projectConfig = project?.config || {}
581
+
582
+ // Header
583
+ console.log()
584
+ log('╔══════════════════════════════════════════════════════════════════════════════╗', 'cyan')
585
+ log('║ 🚀 SUPARANK MCP SERVER ║', 'cyan')
586
+ log('╚══════════════════════════════════════════════════════════════════════════════╝', 'cyan')
587
+ console.log()
588
+
589
+ // Version and project info
590
+ log(` Version: ${packageJson.version}`, 'dim')
591
+ log(` Project: ${colors.bright}${project?.name || config.project_slug}${colors.reset}`, 'reset')
592
+ log(` URL: ${projectConfig.site?.url || 'Not set'}`, 'dim')
593
+ console.log()
594
+
595
+ // Project Settings Box
596
+ log('┌─────────────────────────────────────────────────────────────────────────────┐', 'yellow')
597
+ log('│ 📋 PROJECT SETTINGS (from Supabase) │', 'yellow')
598
+ log('├─────────────────────────────────────────────────────────────────────────────┤', 'yellow')
599
+ log(`│ Word Count: ${String(projectConfig.content?.default_word_count || 'Not set').padEnd(15)} │ Brand Voice: ${String(projectConfig.brand?.voice || 'Not set').substring(0, 25).padEnd(25)}│`, 'reset')
600
+ log(`│ Reading Level: ${String(projectConfig.content?.reading_level ? `Grade ${projectConfig.content.reading_level}` : 'Not set').padEnd(15)} │ Target: ${String(projectConfig.brand?.target_audience || 'Not set').substring(0, 25).padEnd(25)}│`, 'reset')
601
+ log(`│ Include Images: ${String(projectConfig.content?.include_images ? 'Yes' : 'No').padEnd(15)} │ Niche: ${String(projectConfig.site?.niche || 'Not set').substring(0, 25).padEnd(25)}│`, 'reset')
602
+ log(`│ Keywords: ${String((projectConfig.seo?.primary_keywords || []).slice(0, 3).join(', ') || 'Not set').substring(0, 56).padEnd(56)}│`, 'reset')
603
+ log('└─────────────────────────────────────────────────────────────────────────────┘', 'yellow')
604
+ console.log()
605
+
606
+ // Integrations Status
607
+ log('┌─────────────────────────────────────────────────────────────────────────────┐', 'green')
608
+ log('│ 🔌 INTEGRATIONS │', 'green')
609
+ log('├─────────────────────────────────────────────────────────────────────────────┤', 'green')
610
+
611
+ const wpStatus = credentials.wordpress?.secret_key || credentials.wordpress?.app_password ? '✅ Enabled' : '❌ Not configured'
612
+ const ghostStatus = credentials.ghost?.admin_api_key ? '✅ Enabled' : '❌ Not configured'
613
+ const imageStatus = credentials[credentials.image_provider]?.api_key ? `✅ ${credentials.image_provider}` : '❌ Not configured'
614
+ const webhookStatus = credentials.webhooks && Object.values(credentials.webhooks).some(Boolean) ? '✅ Enabled' : '❌ Not configured'
615
+ const externalMcps = credentials.external_mcps?.length || 0
616
+
617
+ log(`│ WordPress: ${wpStatus.padEnd(20)} │ Ghost CMS: ${ghostStatus.padEnd(20)}│`, 'reset')
618
+ log(`│ Image Gen: ${imageStatus.padEnd(20)} │ Webhooks: ${webhookStatus.padEnd(20)}│`, 'reset')
619
+ log(`│ External MCPs: ${String(externalMcps > 0 ? `✅ ${externalMcps} configured` : '❌ None').padEnd(58)}│`, 'reset')
620
+ log('└─────────────────────────────────────────────────────────────────────────────┘', 'green')
621
+ console.log()
622
+
623
+ // Session Status
624
+ log('┌─────────────────────────────────────────────────────────────────────────────┐', 'magenta')
625
+ log('│ 📝 CURRENT SESSION │', 'magenta')
626
+ log('├─────────────────────────────────────────────────────────────────────────────┤', 'magenta')
627
+
628
+ if (session && session.articles?.length > 0) {
629
+ const totalWords = session.articles.reduce((sum, a) => sum + (a.wordCount || 0), 0)
630
+ const unpublished = session.articles.filter(a => !a.published).length
631
+ log(`│ Articles: ${String(session.articles.length).padEnd(5)} │ Words: ${String(totalWords).padEnd(8)} │ Unpublished: ${String(unpublished).padEnd(14)}│`, 'reset')
632
+
633
+ if (session.articles.length > 0) {
634
+ const latest = session.articles[session.articles.length - 1]
635
+ log(`│ Latest: ${String(`"${latest.title?.substring(0, 45) || 'Untitled'}..."`).padEnd(63)}│`, 'reset')
636
+ }
637
+ } else {
638
+ log(`│ No active session - Start with: "Create a blog post about [topic]" │`, 'dim')
639
+ }
640
+ log('└─────────────────────────────────────────────────────────────────────────────┘', 'magenta')
641
+ console.log()
642
+
643
+ // Recent Content
644
+ if (recentContent.length > 0) {
645
+ log('┌─────────────────────────────────────────────────────────────────────────────┐', 'blue')
646
+ log('│ 📚 RECENT CONTENT │', 'blue')
647
+ log('├─────────────────────────────────────────────────────────────────────────────┤', 'blue')
648
+ recentContent.forEach((content, i) => {
649
+ const title = (content.title || content.folder).substring(0, 50)
650
+ const words = content.wordCount || '?'
651
+ const date = content.savedAt ? new Date(content.savedAt).toLocaleDateString() : '?'
652
+ log(`│ ${i + 1}. ${title.padEnd(50)} ${String(words + ' words').padEnd(12)} ${date.padEnd(10)}│`, 'reset')
653
+ })
654
+ log(`│ Total saved: ${String(savedCount + ' articles').padEnd(60)}│`, 'dim')
655
+ log('└─────────────────────────────────────────────────────────────────────────────┘', 'blue')
656
+ console.log()
657
+ }
658
+
659
+ // Stats (if available)
660
+ if (stats.tool_calls > 0 || stats.articles_created > 0) {
661
+ log('┌─────────────────────────────────────────────────────────────────────────────┐', 'cyan')
662
+ log('│ 📊 USAGE STATS │', 'cyan')
663
+ log('├─────────────────────────────────────────────────────────────────────────────┤', 'cyan')
664
+ log(`│ Tool Calls: ${String(stats.tool_calls).padEnd(10)} │ Articles: ${String(stats.articles_created).padEnd(10)} │ Images: ${String(stats.images_generated).padEnd(10)}│`, 'reset')
665
+ log(`│ Words Written: ${String(stats.words_written?.toLocaleString() || 0).padEnd(58)}│`, 'reset')
666
+ log('└─────────────────────────────────────────────────────────────────────────────┘', 'cyan')
667
+ console.log()
668
+ }
669
+
670
+ // Ready message
671
+ log('─────────────────────────────────────────────────────────────────────────────', 'dim')
672
+ log(' MCP Server ready. Waiting for AI client connection...', 'green')
673
+ log(' Tip: Say "Create a blog post about [topic]" to start', 'dim')
674
+ log('─────────────────────────────────────────────────────────────────────────────', 'dim')
675
+ console.log()
676
+ }
677
+
517
678
  async function checkForUpdates(showCurrent = false) {
518
679
  const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
519
680
  const currentVersion = packageJson.version
@@ -590,16 +751,30 @@ async function runUpdate() {
590
751
  }
591
752
  }
592
753
 
593
- function runMCP() {
754
+ async function runMCP() {
594
755
  const config = loadConfig()
595
756
 
596
757
  if (!config) {
597
758
  log('No configuration found. Running setup...', 'yellow')
598
759
  console.log()
599
- runSetup()
760
+ await runSetup()
600
761
  return
601
762
  }
602
763
 
764
+ // Fetch project data for dashboard
765
+ let project = null
766
+ try {
767
+ const result = await testConnection(config.api_key, config.project_slug, config.api_url)
768
+ if (result.success) {
769
+ project = result.project
770
+ }
771
+ } catch (e) {
772
+ // Continue without project data
773
+ }
774
+
775
+ // Display dashboard
776
+ await displayDashboard(config, project)
777
+
603
778
  // Find the MCP client script
604
779
  const mcpClientPaths = [
605
780
  path.join(import.meta.dirname, '..', 'mcp-client.js'),
package/mcp-client.js CHANGED
@@ -35,6 +35,17 @@ const projectSlug = process.argv[2]
35
35
  const apiKey = process.argv[3]
36
36
  const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
37
37
 
38
+ // ============================================================================
39
+ // EXTERNAL API ENDPOINTS - Configurable via environment variables
40
+ // ============================================================================
41
+ const API_ENDPOINTS = {
42
+ // Image generation providers
43
+ fal: process.env.FAL_API_URL || 'https://fal.run/fal-ai/nano-banana-pro',
44
+ gemini: process.env.GEMINI_API_URL || 'https://generativelanguage.googleapis.com/v1beta/models',
45
+ wiro: process.env.WIRO_API_URL || 'https://api.wiro.ai/v1',
46
+ wiroTaskDetail: process.env.WIRO_TASK_URL || 'https://api.wiro.ai/v1/Task/Detail'
47
+ }
48
+
38
49
  if (!projectSlug) {
39
50
  console.error('Error: Project slug is required')
40
51
  console.error('Usage: node mcp-client.js <project-slug> <api-key>')
@@ -144,6 +155,49 @@ function ensureContentDir() {
144
155
  return dir
145
156
  }
146
157
 
158
+ /**
159
+ * Get the path to the stats file (~/.suparank/stats.json)
160
+ */
161
+ function getStatsFile() {
162
+ return path.join(getSuparankDir(), 'stats.json')
163
+ }
164
+
165
+ /**
166
+ * Load usage stats
167
+ */
168
+ function loadStats() {
169
+ try {
170
+ const file = getStatsFile()
171
+ if (fs.existsSync(file)) {
172
+ return JSON.parse(fs.readFileSync(file, 'utf-8'))
173
+ }
174
+ } catch (e) {
175
+ log(`Warning: Could not load stats: ${e.message}`)
176
+ }
177
+ return { tool_calls: 0, images_generated: 0, articles_created: 0, words_written: 0 }
178
+ }
179
+
180
+ /**
181
+ * Save usage stats
182
+ */
183
+ function saveStats(stats) {
184
+ try {
185
+ ensureSuparankDir()
186
+ fs.writeFileSync(getStatsFile(), JSON.stringify(stats, null, 2))
187
+ } catch (e) {
188
+ log(`Error saving stats: ${e.message}`)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Increment a stat counter
194
+ */
195
+ function incrementStat(key, amount = 1) {
196
+ const stats = loadStats()
197
+ stats[key] = (stats[key] || 0) + amount
198
+ saveStats(stats)
199
+ }
200
+
147
201
  /**
148
202
  * Generate a slug from title for folder naming
149
203
  */
@@ -155,6 +209,46 @@ function slugify(text) {
155
209
  .substring(0, 50)
156
210
  }
157
211
 
212
+ // ============================================================================
213
+ // PATH SANITIZATION - Prevents path traversal attacks
214
+ // ============================================================================
215
+
216
+ /**
217
+ * Sanitize and validate a path to prevent traversal attacks
218
+ * @param {string} userPath - User-provided path segment
219
+ * @param {string} allowedBase - Base directory that paths must stay within
220
+ * @returns {string} - Resolved safe path
221
+ * @throws {Error} - If path would escape the allowed base
222
+ */
223
+ function sanitizePath(userPath, allowedBase) {
224
+ // Remove any null bytes (common attack vector)
225
+ const cleanPath = userPath.replace(/\0/g, '')
226
+
227
+ // Resolve to absolute path
228
+ const resolved = path.resolve(allowedBase, cleanPath)
229
+
230
+ // Ensure the resolved path starts with the allowed base
231
+ // Adding path.sep ensures we don't match partial directory names
232
+ const normalizedBase = path.normalize(allowedBase + path.sep)
233
+ const normalizedResolved = path.normalize(resolved + path.sep)
234
+
235
+ if (!normalizedResolved.startsWith(normalizedBase)) {
236
+ throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
237
+ }
238
+
239
+ return resolved
240
+ }
241
+
242
+ /**
243
+ * Get content folder path safely
244
+ * @param {string} folderName - Folder name (article ID or title slug)
245
+ * @returns {string} - Safe path to content folder
246
+ */
247
+ function getContentFolderSafe(folderName) {
248
+ const contentDir = path.join(getSuparankDir(), 'content')
249
+ return sanitizePath(folderName, contentDir)
250
+ }
251
+
158
252
  /**
159
253
  * Atomic file write - prevents corruption on concurrent writes
160
254
  */
@@ -315,9 +409,56 @@ function loadSession() {
315
409
  return false
316
410
  }
317
411
 
412
+ // ============================================================================
413
+ // SESSION MUTEX - Prevents race conditions on concurrent session writes
414
+ // ============================================================================
415
+
416
+ let sessionLock = false
417
+ const sessionLockQueue = []
418
+
419
+ /**
420
+ * Acquire session lock for safe concurrent access
421
+ * @returns {Promise<void>}
422
+ */
423
+ async function acquireSessionLock() {
424
+ if (!sessionLock) {
425
+ sessionLock = true
426
+ return
427
+ }
428
+ return new Promise(resolve => {
429
+ sessionLockQueue.push(resolve)
430
+ })
431
+ }
432
+
433
+ /**
434
+ * Release session lock, allowing next queued operation
435
+ */
436
+ function releaseSessionLock() {
437
+ if (sessionLockQueue.length > 0) {
438
+ const next = sessionLockQueue.shift()
439
+ next()
440
+ } else {
441
+ sessionLock = false
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Safe session save with mutex - use this for concurrent operations
447
+ * @returns {Promise<void>}
448
+ */
449
+ async function saveSessionSafe() {
450
+ await acquireSessionLock()
451
+ try {
452
+ saveSession()
453
+ } finally {
454
+ releaseSessionLock()
455
+ }
456
+ }
457
+
318
458
  /**
319
459
  * Save session state to file (persists across MCP restarts)
320
460
  * Uses atomic write to prevent corruption
461
+ * NOTE: For concurrent operations, use saveSessionSafe() instead
321
462
  */
322
463
  function saveSession() {
323
464
  try {
@@ -416,11 +557,13 @@ function saveContentToFolder() {
416
557
  try {
417
558
  ensureContentDir()
418
559
 
419
- // Create folder name: YYYY-MM-DD-slug
560
+ // Create folder name: YYYY-MM-DD-slug (slugify removes dangerous characters)
420
561
  const date = new Date().toISOString().split('T')[0]
421
562
  const slug = slugify(sessionState.title)
422
563
  const folderName = `${date}-${slug}`
423
- const folderPath = path.join(getContentDir(), folderName)
564
+
565
+ // Use safe path function to prevent any path traversal
566
+ const folderPath = getContentFolderSafe(folderName)
424
567
 
425
568
  // Create folder if doesn't exist
426
569
  if (!fs.existsSync(folderPath)) {
@@ -1922,6 +2065,10 @@ ${plan.steps[0].instruction}
1922
2065
  // Add to articles array (not overwriting previous articles!)
1923
2066
  sessionState.articles.push(newArticle)
1924
2067
 
2068
+ // Track stats
2069
+ incrementStat('articles_created')
2070
+ incrementStat('words_written', wordCount)
2071
+
1925
2072
  // Also keep in current working fields for backwards compatibility
1926
2073
  sessionState.title = title
1927
2074
  sessionState.article = content
@@ -2513,8 +2660,18 @@ Once loaded, you can run optimization tools:
2513
2660
  }
2514
2661
  }
2515
2662
 
2516
- const contentDir = getContentDir()
2517
- const folderPath = path.join(contentDir, folder_name)
2663
+ // Sanitize folder_name to prevent path traversal attacks
2664
+ let folderPath
2665
+ try {
2666
+ folderPath = getContentFolderSafe(folder_name)
2667
+ } catch (error) {
2668
+ return {
2669
+ content: [{
2670
+ type: 'text',
2671
+ text: `❌ Invalid folder name: ${error.message}`
2672
+ }]
2673
+ }
2674
+ }
2518
2675
 
2519
2676
  if (!fs.existsSync(folderPath)) {
2520
2677
  return {
@@ -2660,7 +2817,7 @@ async function executeImageGeneration(args) {
2660
2817
  switch (provider) {
2661
2818
  case 'fal': {
2662
2819
  // fal.ai Nano Banana Pro (gemini-3-pro-image)
2663
- const response = await fetchWithRetry('https://fal.run/fal-ai/nano-banana-pro', {
2820
+ const response = await fetchWithRetry(API_ENDPOINTS.fal, {
2664
2821
  method: 'POST',
2665
2822
  headers: {
2666
2823
  'Authorization': `Key ${config.api_key}`,
@@ -2694,6 +2851,9 @@ async function executeImageGeneration(args) {
2694
2851
  // Persist session to file
2695
2852
  saveSession()
2696
2853
 
2854
+ // Track stats
2855
+ incrementStat('images_generated')
2856
+
2697
2857
  const imageNumber = 1 + sessionState.inlineImages.length
2698
2858
  const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
2699
2859
  const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
@@ -2718,7 +2878,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2718
2878
  // Google Gemini 3 Pro Image (Nano Banana Pro) - generateContent API
2719
2879
  const model = config.model || 'gemini-3-pro-image-preview'
2720
2880
  const response = await fetch(
2721
- `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
2881
+ `${API_ENDPOINTS.gemini}/${model}:generateContent`,
2722
2882
  {
2723
2883
  method: 'POST',
2724
2884
  headers: {
@@ -2757,6 +2917,9 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2757
2917
  // Return base64 data URI
2758
2918
  const dataUri = `data:${mimeType};base64,${imageData}`
2759
2919
 
2920
+ // Track stats
2921
+ incrementStat('images_generated')
2922
+
2760
2923
  return {
2761
2924
  content: [{
2762
2925
  type: 'text',
@@ -2786,7 +2949,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2786
2949
 
2787
2950
  // Submit task
2788
2951
  log(`Submitting wiro.ai task for model: ${model}`)
2789
- const submitResponse = await fetch(`https://api.wiro.ai/v1/Run/${model}`, {
2952
+ const submitResponse = await fetch(`${API_ENDPOINTS.wiro}/Run/${model}`, {
2790
2953
  method: 'POST',
2791
2954
  headers: {
2792
2955
  'Content-Type': 'application/json',
@@ -2829,7 +2992,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2829
2992
  .update(pollSignatureData)
2830
2993
  .digest('hex')
2831
2994
 
2832
- const pollResponse = await fetch('https://api.wiro.ai/v1/Task/Detail', {
2995
+ const pollResponse = await fetch(API_ENDPOINTS.wiroTaskDetail, {
2833
2996
  method: 'POST',
2834
2997
  headers: {
2835
2998
  'Content-Type': 'application/json',
@@ -2871,6 +3034,9 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
2871
3034
  // Persist session to file
2872
3035
  saveSession()
2873
3036
 
3037
+ // Track stats
3038
+ incrementStat('images_generated')
3039
+
2874
3040
  const imageNumber = 1 + sessionState.inlineImages.length
2875
3041
  const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
2876
3042
  const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
@@ -3430,6 +3596,9 @@ async function main() {
3430
3596
  progress('Tool', `Executing ${name}`)
3431
3597
  log(`Executing tool: ${name}`)
3432
3598
 
3599
+ // Track tool call stats
3600
+ incrementStat('tool_calls')
3601
+
3433
3602
  // Check if this is an orchestrator tool
3434
3603
  const orchestratorTool = ORCHESTRATOR_TOOLS.find(t => t.name === name)
3435
3604
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparank",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "AI-powered SEO content creation MCP - generate and publish optimized blog posts with Claude, ChatGPT, or Cursor",
5
5
  "main": "mcp-client.js",
6
6
  "type": "module",