From 5960d2826eb23e87ed142b3a88cf5d8ac0eddc42 Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:33:16 +0200 Subject: [PATCH] feat: add anonymous telemetry system with Supabase integration - Implement telemetry manager for tracking tool usage and workflows - Add workflow sanitizer to remove sensitive data before storage - Create config manager with opt-in/opt-out mechanism - Integrate telemetry tracking into MCP server and workflow handlers - Add CLI commands for telemetry control (enable/disable/status) - Show first-run notice with clear privacy information - Add comprehensive unit tests for sanitization and config - Track tool usage metrics, workflow patterns, and errors - Ensure complete anonymity with deterministic user IDs - Never collect URLs, API keys, or sensitive information --- .gitignore | 3 + package-lock.json | 113 +++-- package.json | 1 + src/mcp/handlers-n8n-manager.ts | 11 +- src/mcp/index.ts | 37 +- src/mcp/server.ts | 21 +- src/telemetry/config-manager.ts | 207 ++++++++++ src/telemetry/index.ts | 9 + src/telemetry/telemetry-manager.ts | 387 ++++++++++++++++++ src/telemetry/workflow-sanitizer.ts | 299 ++++++++++++++ tests/unit/telemetry/config-manager.test.ts | 205 ++++++++++ .../unit/telemetry/workflow-sanitizer.test.ts | 306 ++++++++++++++ 12 files changed, 1569 insertions(+), 30 deletions(-) create mode 100644 src/telemetry/config-manager.ts create mode 100644 src/telemetry/index.ts create mode 100644 src/telemetry/telemetry-manager.ts create mode 100644 src/telemetry/workflow-sanitizer.ts create mode 100644 tests/unit/telemetry/config-manager.test.ts create mode 100644 tests/unit/telemetry/workflow-sanitizer.test.ts diff --git a/.gitignore b/.gitignore index 815590b..06d1697 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ n8n-mcp-wrapper.sh # MCP configuration files .mcp.json + +# Telemetry configuration (user-specific) +~/.n8n-mcp/ diff --git a/package-lock.json b/package-lock.json index acbd185..861b3ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "n8n-mcp", - "version": "2.12.1", + "version": "2.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-mcp", - "version": "2.12.1", + "version": "2.13.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.13.2", "@n8n/n8n-nodes-langchain": "^1.111.1", + "@supabase/supabase-js": "^2.57.4", "dotenv": "^16.5.0", "express": "^5.1.0", "lru-cache": "^11.2.1", @@ -12328,6 +12329,68 @@ "@opentelemetry/semantic-conventions": "^1.28.0" } }, + "node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/auth-js": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", + "integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/functions-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", + "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/postgrest-js": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", + "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/realtime-js": { + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.9.tgz", + "integrity": "sha512-fLseWq8tEPCO85x3TrV9Hqvk7H4SGOqnFQ223NPJSsxjSYn0EmzU1lvYO6wbA0fc8DE94beCAiiWvGvo4g33lQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/supabase-js": { + "version": "2.49.9", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.9.tgz", + "integrity": "sha512-lB2A2X8k1aWAqvlpO4uZOdfvSuZ2s0fCMwJ1Vq6tjWsi3F+au5lMbVVn92G0pG8gfmis33d64Plkm6eSDs6jRA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.69.1", + "@supabase/functions-js": "2.4.4", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.4", + "@supabase/realtime-js": "2.11.9", + "@supabase/storage-js": "2.7.1" + } + }, "node_modules/@n8n/n8n-nodes-langchain/node_modules/@types/connect": { "version": "3.4.36", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", @@ -15647,18 +15710,18 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.69.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", - "integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==", + "version": "2.71.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", "license": "MIT", "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "node_modules/@supabase/functions-js": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", - "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.6.tgz", + "integrity": "sha512-bhjZ7rmxAibjgmzTmQBxJU6ZIBCCJTc3Uwgvdi4FewueUTAGO5hxZT1Sj6tiD+0dSXf9XI87BDdJrg12z8Uaew==", "license": "MIT", "dependencies": { "@supabase/node-fetch": "^2.6.14" @@ -15677,18 +15740,18 @@ } }, "node_modules/@supabase/postgrest-js": { - "version": "1.19.4", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", - "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "version": "1.21.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.4.tgz", + "integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==", "license": "MIT", "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "node_modules/@supabase/realtime-js": { - "version": "2.11.9", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.9.tgz", - "integrity": "sha512-fLseWq8tEPCO85x3TrV9Hqvk7H4SGOqnFQ223NPJSsxjSYn0EmzU1lvYO6wbA0fc8DE94beCAiiWvGvo4g33lQ==", + "version": "2.15.5", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.5.tgz", + "integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==", "license": "MIT", "dependencies": { "@supabase/node-fetch": "^2.6.13", @@ -15698,26 +15761,26 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", - "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.12.1.tgz", + "integrity": "sha512-QWg3HV6Db2J81VQx0PqLq0JDBn4Q8B1FYn1kYcbla8+d5WDmTdwwMr+EJAxNOSs9W4mhKMv+EYCpCrTFlTj4VQ==", "license": "MIT", "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "node_modules/@supabase/supabase-js": { - "version": "2.49.9", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.9.tgz", - "integrity": "sha512-lB2A2X8k1aWAqvlpO4uZOdfvSuZ2s0fCMwJ1Vq6tjWsi3F+au5lMbVVn92G0pG8gfmis33d64Plkm6eSDs6jRA==", + "version": "2.57.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.4.tgz", + "integrity": "sha512-LcbTzFhHYdwfQ7TRPfol0z04rLEyHabpGYANME6wkQ/kLtKNmI+Vy+WEM8HxeOZAtByUFxoUTTLwhXmrh+CcVw==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.69.1", - "@supabase/functions-js": "2.4.4", + "@supabase/auth-js": "2.71.1", + "@supabase/functions-js": "2.4.6", "@supabase/node-fetch": "2.6.15", - "@supabase/postgrest-js": "1.19.4", - "@supabase/realtime-js": "2.11.9", - "@supabase/storage-js": "2.7.1" + "@supabase/postgrest-js": "1.21.4", + "@supabase/realtime-js": "2.15.5", + "@supabase/storage-js": "2.12.1" } }, "node_modules/@supercharge/promise-pool": { diff --git a/package.json b/package.json index 88e81d0..003668c 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.13.2", "@n8n/n8n-nodes-langchain": "^1.111.1", + "@supabase/supabase-js": "^2.57.4", "dotenv": "^16.5.0", "express": "^5.1.0", "lru-cache": "^11.2.1", diff --git a/src/mcp/handlers-n8n-manager.ts b/src/mcp/handlers-n8n-manager.ts index cb22f37..ad6c66d 100644 --- a/src/mcp/handlers-n8n-manager.ts +++ b/src/mcp/handlers-n8n-manager.ts @@ -27,6 +27,7 @@ import { InstanceContext, validateInstanceContext } from '../types/instance-cont import { WorkflowAutoFixer, AutoFixConfig } from '../services/workflow-auto-fixer'; import { ExpressionFormatValidator } from '../services/expression-format-validator'; import { handleUpdatePartialWorkflow } from './handlers-workflow-diff'; +import { telemetry } from '../telemetry'; import { createCacheKey, createInstanceCache, @@ -280,16 +281,22 @@ export async function handleCreateWorkflow(args: unknown, context?: InstanceCont // Validate workflow structure const errors = validateWorkflowStructure(input); if (errors.length > 0) { + // Track validation failure + telemetry.trackWorkflowCreation(input, false); + return { success: false, error: 'Workflow validation failed', details: { errors } }; } - + // Create workflow const workflow = await client.createWorkflow(input); - + + // Track successful workflow creation + telemetry.trackWorkflowCreation(workflow, true); + return { success: true, data: workflow, diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 91d4468..22596c9 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -2,6 +2,7 @@ import { N8NDocumentationMCPServer } from './server'; import { logger } from '../utils/logger'; +import { TelemetryConfigManager } from '../telemetry/config-manager'; // Add error details to stderr for Claude Desktop debugging process.on('uncaughtException', (error) => { @@ -21,8 +22,42 @@ process.on('unhandledRejection', (reason, promise) => { }); async function main() { + // Handle telemetry CLI commands + const args = process.argv.slice(2); + if (args.length > 0 && args[0] === 'telemetry') { + const telemetryConfig = TelemetryConfigManager.getInstance(); + const action = args[1]; + + switch (action) { + case 'enable': + telemetryConfig.enable(); + process.exit(0); + break; + case 'disable': + telemetryConfig.disable(); + process.exit(0); + break; + case 'status': + console.log(telemetryConfig.getStatus()); + process.exit(0); + break; + default: + console.log(` +Usage: n8n-mcp telemetry [command] + +Commands: + enable Enable anonymous telemetry + disable Disable anonymous telemetry + status Show current telemetry status + +Learn more: https://github.com/czlonkowski/n8n-mcp/privacy +`); + process.exit(args[1] ? 1 : 0); + } + } + const mode = process.env.MCP_MODE || 'stdio'; - + try { // Only show debug messages in HTTP mode to avoid corrupting stdio communication if (mode === 'http') { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a81920d..6727a49 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -35,6 +35,7 @@ import { STANDARD_PROTOCOL_VERSION } from '../utils/protocol-version'; import { InstanceContext } from '../types/instance-context'; +import { telemetry } from '../telemetry'; interface NodeRow { node_type: string; @@ -180,7 +181,10 @@ export class N8NDocumentationMCPServer { clientCapabilities, clientInfo }); - + + // Track session start + telemetry.trackSessionStart(); + // Store client info for later use this.clientInfo = clientInfo; @@ -322,8 +326,13 @@ export class N8NDocumentationMCPServer { try { logger.debug(`Executing tool: ${name}`, { args: processedArgs }); + const startTime = Date.now(); const result = await this.executeTool(name, processedArgs); + const duration = Date.now() - startTime; logger.debug(`Tool ${name} executed successfully`); + + // Track tool usage + telemetry.trackToolUsage(name, true, duration); // Ensure the result is properly formatted for MCP let responseText: string; @@ -370,7 +379,15 @@ export class N8NDocumentationMCPServer { } catch (error) { logger.error(`Error executing tool ${name}`, error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - + + // Track tool error + telemetry.trackToolUsage(name, false); + telemetry.trackError( + error instanceof Error ? error.constructor.name : 'UnknownError', + `tool_execution`, + name + ); + // Provide more helpful error messages for common n8n issues let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`; diff --git a/src/telemetry/config-manager.ts b/src/telemetry/config-manager.ts new file mode 100644 index 0000000..e7a2853 --- /dev/null +++ b/src/telemetry/config-manager.ts @@ -0,0 +1,207 @@ +/** + * Telemetry Configuration Manager + * Handles telemetry settings, opt-in/opt-out, and first-run detection + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { createHash } from 'crypto'; +import { hostname, platform, arch } from 'os'; + +export interface TelemetryConfig { + enabled: boolean; + userId: string; + firstRun?: string; + lastModified?: string; + version?: string; +} + +export class TelemetryConfigManager { + private static instance: TelemetryConfigManager; + private readonly configDir: string; + private readonly configPath: string; + private config: TelemetryConfig | null = null; + + private constructor() { + this.configDir = join(homedir(), '.n8n-mcp'); + this.configPath = join(this.configDir, 'telemetry.json'); + } + + static getInstance(): TelemetryConfigManager { + if (!TelemetryConfigManager.instance) { + TelemetryConfigManager.instance = new TelemetryConfigManager(); + } + return TelemetryConfigManager.instance; + } + + /** + * Generate a deterministic anonymous user ID based on machine characteristics + */ + private generateUserId(): string { + const machineId = `${hostname()}-${platform()}-${arch()}-${homedir()}`; + return createHash('sha256').update(machineId).digest('hex').substring(0, 16); + } + + /** + * Load configuration from disk or create default + */ + loadConfig(): TelemetryConfig { + if (this.config) { + return this.config; + } + + if (!existsSync(this.configPath)) { + // First run - create default config + this.config = { + enabled: true, + userId: this.generateUserId(), + firstRun: new Date().toISOString(), + version: require('../../package.json').version + }; + + this.saveConfig(); + this.showFirstRunNotice(); + + return this.config; + } + + try { + const rawConfig = readFileSync(this.configPath, 'utf-8'); + this.config = JSON.parse(rawConfig); + + // Ensure userId exists (for upgrades from older versions) + if (!this.config!.userId) { + this.config!.userId = this.generateUserId(); + this.saveConfig(); + } + + return this.config!; + } catch (error) { + console.error('Failed to load telemetry config, using defaults:', error); + this.config = { + enabled: false, + userId: this.generateUserId() + }; + return this.config; + } + } + + /** + * Save configuration to disk + */ + private saveConfig(): void { + if (!this.config) return; + + try { + if (!existsSync(this.configDir)) { + mkdirSync(this.configDir, { recursive: true }); + } + + this.config.lastModified = new Date().toISOString(); + writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); + } catch (error) { + console.error('Failed to save telemetry config:', error); + } + } + + /** + * Check if telemetry is enabled + */ + isEnabled(): boolean { + const config = this.loadConfig(); + return config.enabled; + } + + /** + * Get the anonymous user ID + */ + getUserId(): string { + const config = this.loadConfig(); + return config.userId; + } + + /** + * Check if this is the first run + */ + isFirstRun(): boolean { + return !existsSync(this.configPath); + } + + /** + * Enable telemetry + */ + enable(): void { + const config = this.loadConfig(); + config.enabled = true; + this.config = config; + this.saveConfig(); + console.log('✓ Anonymous telemetry enabled'); + } + + /** + * Disable telemetry + */ + disable(): void { + const config = this.loadConfig(); + config.enabled = false; + this.config = config; + this.saveConfig(); + console.log('✓ Anonymous telemetry disabled'); + } + + /** + * Get current status + */ + getStatus(): string { + const config = this.loadConfig(); + return ` +Telemetry Status: ${config.enabled ? 'ENABLED' : 'DISABLED'} +Anonymous ID: ${config.userId} +First Run: ${config.firstRun || 'Unknown'} +Config Path: ${this.configPath} + +To opt-out: npx n8n-mcp telemetry disable +To opt-in: npx n8n-mcp telemetry enable +`; + } + + /** + * Show first-run notice to user + */ + private showFirstRunNotice(): void { + console.log(` +╔════════════════════════════════════════════════════════════╗ +║ Anonymous Usage Statistics ║ +╠════════════════════════════════════════════════════════════╣ +║ ║ +║ n8n-mcp collects anonymous usage data to improve the ║ +║ tool and understand how it's being used. ║ +║ ║ +║ We track: ║ +║ • Which MCP tools are used (no parameters) ║ +║ • Workflow structures (sanitized, no sensitive data) ║ +║ • Error patterns (hashed, no details) ║ +║ • Performance metrics (timing, success rates) ║ +║ ║ +║ We NEVER collect: ║ +║ • URLs, API keys, or credentials ║ +║ • Workflow content or actual data ║ +║ • Personal or identifiable information ║ +║ • n8n instance details or locations ║ +║ ║ +║ Your anonymous ID: ${this.config?.userId || 'generating...'} ║ +║ ║ +║ This helps me understand usage patterns and improve ║ +║ n8n-mcp for everyone. Thank you for your support! ║ +║ ║ +║ To opt-out at any time: ║ +║ npx n8n-mcp telemetry disable ║ +║ ║ +║ Learn more: ║ +║ https://github.com/czlonkowski/n8n-mcp/privacy ║ +║ ║ +╚════════════════════════════════════════════════════════════╝ +`); + } +} \ No newline at end of file diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts new file mode 100644 index 0000000..d5bbd03 --- /dev/null +++ b/src/telemetry/index.ts @@ -0,0 +1,9 @@ +/** + * Telemetry Module + * Exports for anonymous usage statistics + */ + +export { TelemetryManager, telemetry } from './telemetry-manager'; +export { TelemetryConfigManager } from './config-manager'; +export { WorkflowSanitizer } from './workflow-sanitizer'; +export type { TelemetryConfig } from './config-manager'; \ No newline at end of file diff --git a/src/telemetry/telemetry-manager.ts b/src/telemetry/telemetry-manager.ts new file mode 100644 index 0000000..c5db22c --- /dev/null +++ b/src/telemetry/telemetry-manager.ts @@ -0,0 +1,387 @@ +/** + * Telemetry Manager + * Main telemetry class for anonymous usage statistics + */ + +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { TelemetryConfigManager } from './config-manager'; +import { WorkflowSanitizer } from './workflow-sanitizer'; +import { logger } from '../utils/logger'; + +interface TelemetryEvent { + user_id: string; + event: string; + properties: Record; + created_at?: string; +} + +interface WorkflowTelemetry { + user_id: string; + workflow_hash: string; + node_count: number; + node_types: string[]; + has_trigger: boolean; + has_webhook: boolean; + complexity: 'simple' | 'medium' | 'complex'; + sanitized_workflow: any; + created_at?: string; +} + +export class TelemetryManager { + private static instance: TelemetryManager; + private supabase: SupabaseClient | null = null; + private configManager: TelemetryConfigManager; + private eventQueue: TelemetryEvent[] = []; + private workflowQueue: WorkflowTelemetry[] = []; + private flushTimer?: NodeJS.Timeout; + private isInitialized: boolean = false; + + private constructor() { + this.configManager = TelemetryConfigManager.getInstance(); + this.initialize(); + } + + static getInstance(): TelemetryManager { + if (!TelemetryManager.instance) { + TelemetryManager.instance = new TelemetryManager(); + } + return TelemetryManager.instance; + } + + /** + * Initialize telemetry if enabled + */ + private initialize(): void { + if (!this.configManager.isEnabled()) { + logger.debug('Telemetry disabled by user preference'); + return; + } + + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseAnonKey = process.env.SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseAnonKey) { + logger.debug('Telemetry not configured: missing SUPABASE_URL or SUPABASE_ANON_KEY'); + return; + } + + try { + this.supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + persistSession: false, + autoRefreshToken: false, + }, + realtime: { + params: { + eventsPerSecond: 1, + }, + }, + }); + + this.isInitialized = true; + this.startBatchProcessor(); + + // Flush on exit + process.on('beforeExit', () => this.flush()); + process.on('SIGINT', () => { + this.flush(); + process.exit(0); + }); + process.on('SIGTERM', () => { + this.flush(); + process.exit(0); + }); + + logger.debug('Telemetry initialized successfully'); + } catch (error) { + logger.debug('Failed to initialize telemetry:', error); + this.isInitialized = false; + } + } + + /** + * Track a tool usage event + */ + trackToolUsage(toolName: string, success: boolean, duration?: number): void { + if (!this.isEnabled()) return; + + // Sanitize tool name (remove any potential sensitive data) + const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, '_'); + + this.trackEvent('tool_used', { + tool: sanitizedToolName, + success, + duration: duration || 0, + }); + } + + /** + * Track workflow creation + */ + async trackWorkflowCreation(workflow: any, validationPassed: boolean): Promise { + if (!this.isEnabled()) return; + + // Only store workflows that pass validation + if (!validationPassed) { + this.trackEvent('workflow_validation_failed', { + nodeCount: workflow.nodes?.length || 0, + }); + return; + } + + try { + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + const telemetryData: WorkflowTelemetry = { + user_id: this.configManager.getUserId(), + workflow_hash: sanitized.workflowHash, + node_count: sanitized.nodeCount, + node_types: sanitized.nodeTypes, + has_trigger: sanitized.hasTrigger, + has_webhook: sanitized.hasWebhook, + complexity: sanitized.complexity, + sanitized_workflow: { + nodes: sanitized.nodes, + connections: sanitized.connections, + }, + }; + + this.workflowQueue.push(telemetryData); + + // Also track as event + this.trackEvent('workflow_created', { + nodeCount: sanitized.nodeCount, + nodeTypes: sanitized.nodeTypes.length, + complexity: sanitized.complexity, + hasTrigger: sanitized.hasTrigger, + hasWebhook: sanitized.hasWebhook, + }); + + // Flush if queue is getting large + if (this.workflowQueue.length >= 5) { + await this.flush(); + } + } catch (error) { + logger.debug('Failed to track workflow creation:', error); + } + } + + /** + * Track an error event + */ + trackError(errorType: string, context: string, toolName?: string): void { + if (!this.isEnabled()) return; + + this.trackEvent('error_occurred', { + errorType: this.sanitizeErrorType(errorType), + context: this.sanitizeContext(context), + tool: toolName ? toolName.replace(/[^a-zA-Z0-9_-]/g, '_') : undefined, + }); + } + + /** + * Track a generic event + */ + trackEvent(eventName: string, properties: Record): void { + if (!this.isEnabled()) return; + + const event: TelemetryEvent = { + user_id: this.configManager.getUserId(), + event: eventName, + properties: this.sanitizeProperties(properties), + }; + + this.eventQueue.push(event); + + // Flush if queue is getting large + if (this.eventQueue.length >= 20) { + this.flush(); + } + } + + /** + * Track session start + */ + trackSessionStart(): void { + if (!this.isEnabled()) return; + + this.trackEvent('session_start', { + version: require('../../package.json').version, + platform: process.platform, + arch: process.arch, + nodeVersion: process.version, + }); + } + + /** + * Flush queued events to Supabase + */ + async flush(): Promise { + if (!this.isEnabled() || !this.supabase) return; + + // Flush events + if (this.eventQueue.length > 0) { + const events = [...this.eventQueue]; + this.eventQueue = []; + + try { + const { error } = await this.supabase + .from('telemetry_events') + .insert(events); + + if (error) { + logger.debug('Failed to flush telemetry events:', error.message); + } + } catch (error) { + logger.debug('Error flushing telemetry events:', error); + } + } + + // Flush workflows + if (this.workflowQueue.length > 0) { + const workflows = [...this.workflowQueue]; + this.workflowQueue = []; + + try { + // Use upsert to avoid duplicates based on workflow_hash + const { error } = await this.supabase + .from('telemetry_workflows') + .upsert(workflows, { + onConflict: 'workflow_hash', + ignoreDuplicates: true, + }); + + if (error) { + logger.debug('Failed to flush telemetry workflows:', error.message); + } + } catch (error) { + logger.debug('Error flushing telemetry workflows:', error); + } + } + } + + /** + * Start batch processor for periodic flushing + */ + private startBatchProcessor(): void { + // Flush every 30 seconds + this.flushTimer = setInterval(() => { + this.flush(); + }, 30000); + + // Prevent timer from keeping process alive + this.flushTimer.unref(); + } + + /** + * Check if telemetry is enabled + */ + private isEnabled(): boolean { + return this.isInitialized && this.configManager.isEnabled(); + } + + /** + * Sanitize properties to remove sensitive data + */ + private sanitizeProperties(properties: Record): Record { + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(properties)) { + // Skip sensitive keys + if (this.isSensitiveKey(key)) { + continue; + } + + // Sanitize values + if (typeof value === 'string') { + sanitized[key] = this.sanitizeString(value); + } else if (typeof value === 'object' && value !== null) { + sanitized[key] = this.sanitizeProperties(value); + } else { + sanitized[key] = value; + } + } + + return sanitized; + } + + /** + * Check if a key is sensitive + */ + private isSensitiveKey(key: string): boolean { + const sensitiveKeys = [ + 'password', 'token', 'key', 'secret', 'credential', + 'auth', 'url', 'endpoint', 'host', 'database', + ]; + + const lowerKey = key.toLowerCase(); + return sensitiveKeys.some(sensitive => lowerKey.includes(sensitive)); + } + + /** + * Sanitize string values + */ + private sanitizeString(value: string): string { + // Remove URLs + let sanitized = value.replace(/https?:\/\/[^\s]+/gi, '[URL]'); + + // Remove potential API keys (long alphanumeric strings) + sanitized = sanitized.replace(/[a-zA-Z0-9_-]{32,}/g, '[KEY]'); + + // Remove email addresses + sanitized = sanitized.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]'); + + return sanitized; + } + + /** + * Sanitize error type + */ + private sanitizeErrorType(errorType: string): string { + // Remove any potential sensitive data from error type + return errorType + .replace(/[^a-zA-Z0-9_-]/g, '_') + .substring(0, 50); + } + + /** + * Sanitize context + */ + private sanitizeContext(context: string): string { + // Remove any potential sensitive data from context + return context + .replace(/https?:\/\/[^\s]+/gi, '[URL]') + .replace(/[a-zA-Z0-9_-]{32,}/g, '[KEY]') + .substring(0, 100); + } + + /** + * Disable telemetry + */ + disable(): void { + this.configManager.disable(); + if (this.flushTimer) { + clearInterval(this.flushTimer); + } + this.isInitialized = false; + this.supabase = null; + } + + /** + * Enable telemetry + */ + enable(): void { + this.configManager.enable(); + this.initialize(); + } + + /** + * Get telemetry status + */ + getStatus(): string { + return this.configManager.getStatus(); + } +} + +// Export singleton instance +export const telemetry = TelemetryManager.getInstance(); \ No newline at end of file diff --git a/src/telemetry/workflow-sanitizer.ts b/src/telemetry/workflow-sanitizer.ts new file mode 100644 index 0000000..44b2e7d --- /dev/null +++ b/src/telemetry/workflow-sanitizer.ts @@ -0,0 +1,299 @@ +/** + * Workflow Sanitizer + * Removes sensitive data from workflows before telemetry storage + */ + +import { createHash } from 'crypto'; + +interface WorkflowNode { + id: string; + name: string; + type: string; + position: [number, number]; + parameters: any; + credentials?: any; + disabled?: boolean; + typeVersion?: number; +} + +interface SanitizedWorkflow { + nodes: WorkflowNode[]; + connections: any; + nodeCount: number; + nodeTypes: string[]; + hasTrigger: boolean; + hasWebhook: boolean; + complexity: 'simple' | 'medium' | 'complex'; + workflowHash: string; +} + +export class WorkflowSanitizer { + private static readonly SENSITIVE_PATTERNS = [ + // Webhook URLs (replace with placeholder but keep structure) - MUST BE FIRST + /https?:\/\/[^\s/]+\/webhook\/[^\s]+/g, + /https?:\/\/[^\s/]+\/hook\/[^\s]+/g, + + // API keys and tokens + /sk-[a-zA-Z0-9]{16,}/g, // OpenAI keys + /Bearer\s+[^\s]+/gi, // Bearer tokens + /[a-zA-Z0-9_-]{20,}/g, // Long alphanumeric strings (API keys) - reduced threshold + /token['":\s]+[^,}]+/gi, // Token fields + /apikey['":\s]+[^,}]+/gi, // API key fields + /api_key['":\s]+[^,}]+/gi, + /secret['":\s]+[^,}]+/gi, + /password['":\s]+[^,}]+/gi, + /credential['":\s]+[^,}]+/gi, + + // URLs with authentication + /https?:\/\/[^:]+:[^@]+@[^\s/]+/g, // URLs with auth + /wss?:\/\/[^:]+:[^@]+@[^\s/]+/g, + + // Email addresses (optional - uncomment if needed) + // /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + ]; + + private static readonly SENSITIVE_FIELDS = [ + 'apiKey', + 'api_key', + 'token', + 'secret', + 'password', + 'credential', + 'auth', + 'authorization', + 'webhook', + 'webhookUrl', + 'url', + 'endpoint', + 'host', + 'server', + 'database', + 'connectionString', + 'privateKey', + 'publicKey', + 'certificate', + ]; + + /** + * Sanitize a complete workflow + */ + static sanitizeWorkflow(workflow: any): SanitizedWorkflow { + // Create a deep copy to avoid modifying original + const sanitized = JSON.parse(JSON.stringify(workflow)); + + // Sanitize nodes + if (sanitized.nodes && Array.isArray(sanitized.nodes)) { + sanitized.nodes = sanitized.nodes.map((node: WorkflowNode) => + this.sanitizeNode(node) + ); + } + + // Sanitize connections (keep structure only) + if (sanitized.connections) { + sanitized.connections = this.sanitizeConnections(sanitized.connections); + } + + // Remove other potentially sensitive data + delete sanitized.settings?.errorWorkflow; + delete sanitized.staticData; + delete sanitized.pinData; + delete sanitized.credentials; + delete sanitized.sharedWorkflows; + delete sanitized.ownedBy; + delete sanitized.createdBy; + delete sanitized.updatedBy; + + // Calculate metrics + const nodeTypes = sanitized.nodes?.map((n: WorkflowNode) => n.type) || []; + const uniqueNodeTypes = [...new Set(nodeTypes)] as string[]; + + const hasTrigger = nodeTypes.some((type: string) => + type.includes('trigger') || type.includes('webhook') + ); + + const hasWebhook = nodeTypes.some((type: string) => + type.includes('webhook') + ); + + // Calculate complexity + const nodeCount = sanitized.nodes?.length || 0; + let complexity: 'simple' | 'medium' | 'complex' = 'simple'; + if (nodeCount > 20) { + complexity = 'complex'; + } else if (nodeCount > 10) { + complexity = 'medium'; + } + + // Generate workflow hash (for deduplication) + const workflowStructure = JSON.stringify({ + nodeTypes: uniqueNodeTypes.sort(), + connections: sanitized.connections + }); + const workflowHash = createHash('sha256') + .update(workflowStructure) + .digest('hex') + .substring(0, 16); + + return { + nodes: sanitized.nodes || [], + connections: sanitized.connections || {}, + nodeCount, + nodeTypes: uniqueNodeTypes, + hasTrigger, + hasWebhook, + complexity, + workflowHash + }; + } + + /** + * Sanitize a single node + */ + private static sanitizeNode(node: WorkflowNode): WorkflowNode { + const sanitized = { ...node }; + + // Remove credentials entirely + delete sanitized.credentials; + + // Sanitize parameters + if (sanitized.parameters) { + sanitized.parameters = this.sanitizeObject(sanitized.parameters); + } + + return sanitized; + } + + /** + * Recursively sanitize an object + */ + private static sanitizeObject(obj: any): any { + if (!obj || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => this.sanitizeObject(item)); + } + + const sanitized: any = {}; + + for (const [key, value] of Object.entries(obj)) { + // Check if key is sensitive + if (this.isSensitiveField(key)) { + sanitized[key] = '[REDACTED]'; + continue; + } + + // Recursively sanitize nested objects + if (typeof value === 'object' && value !== null) { + sanitized[key] = this.sanitizeObject(value); + } + // Sanitize string values + else if (typeof value === 'string') { + sanitized[key] = this.sanitizeString(value, key); + } + // Keep other types as-is + else { + sanitized[key] = value; + } + } + + return sanitized; + } + + /** + * Sanitize string values + */ + private static sanitizeString(value: string, fieldName: string): string { + // First check if this is a webhook URL + if (value.includes('/webhook/') || value.includes('/hook/')) { + return 'https://[webhook-url]'; + } + + let sanitized = value; + + // Apply all sensitive patterns + for (const pattern of this.SENSITIVE_PATTERNS) { + // Skip webhook patterns - already handled above + if (pattern.toString().includes('webhook')) { + continue; + } + sanitized = sanitized.replace(pattern, '[REDACTED]'); + } + + // Additional sanitization for specific field types + if (fieldName.toLowerCase().includes('url') || + fieldName.toLowerCase().includes('endpoint')) { + // Keep URL structure but remove domain details + if (sanitized.startsWith('http://') || sanitized.startsWith('https://')) { + // If value has been redacted, leave it as is + if (sanitized.includes('[REDACTED]')) { + return '[REDACTED]'; + } + const urlParts = sanitized.split('/'); + if (urlParts.length > 2) { + urlParts[2] = '[domain]'; + sanitized = urlParts.join('/'); + } + } + } + + return sanitized; + } + + /** + * Check if a field name is sensitive + */ + private static isSensitiveField(fieldName: string): boolean { + const lowerFieldName = fieldName.toLowerCase(); + return this.SENSITIVE_FIELDS.some(sensitive => + lowerFieldName.includes(sensitive.toLowerCase()) + ); + } + + /** + * Sanitize connections (keep structure only) + */ + private static sanitizeConnections(connections: any): any { + if (!connections || typeof connections !== 'object') { + return connections; + } + + const sanitized: any = {}; + + for (const [nodeId, nodeConnections] of Object.entries(connections)) { + if (typeof nodeConnections === 'object' && nodeConnections !== null) { + sanitized[nodeId] = {}; + + for (const [connType, connArray] of Object.entries(nodeConnections as any)) { + if (Array.isArray(connArray)) { + sanitized[nodeId][connType] = connArray.map((conns: any) => { + if (Array.isArray(conns)) { + return conns.map((conn: any) => ({ + node: conn.node, + type: conn.type, + index: conn.index + })); + } + return conns; + }); + } else { + sanitized[nodeId][connType] = connArray; + } + } + } else { + sanitized[nodeId] = nodeConnections; + } + } + + return sanitized; + } + + /** + * Generate a hash for workflow deduplication + */ + static generateWorkflowHash(workflow: any): string { + const sanitized = this.sanitizeWorkflow(workflow); + return sanitized.workflowHash; + } +} \ No newline at end of file diff --git a/tests/unit/telemetry/config-manager.test.ts b/tests/unit/telemetry/config-manager.test.ts new file mode 100644 index 0000000..a3d3582 --- /dev/null +++ b/tests/unit/telemetry/config-manager.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TelemetryConfigManager } from '../../../src/telemetry/config-manager'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +// Mock fs module +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn() + }; +}); + +describe('TelemetryConfigManager', () => { + let manager: TelemetryConfigManager; + + beforeEach(() => { + vi.clearAllMocks(); + // Clear singleton instance + (TelemetryConfigManager as any).instance = null; + + // Mock console.log to suppress first-run notice in tests + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getInstance', () => { + it('should return singleton instance', () => { + const instance1 = TelemetryConfigManager.getInstance(); + const instance2 = TelemetryConfigManager.getInstance(); + expect(instance1).toBe(instance2); + }); + }); + + describe('loadConfig', () => { + it('should create default config on first run', () => { + vi.mocked(existsSync).mockReturnValue(false); + + manager = TelemetryConfigManager.getInstance(); + const config = manager.loadConfig(); + + expect(config.enabled).toBe(true); + expect(config.userId).toMatch(/^[a-f0-9]{16}$/); + expect(config.firstRun).toBeDefined(); + expect(vi.mocked(mkdirSync)).toHaveBeenCalledWith( + join(homedir(), '.n8n-mcp'), + { recursive: true } + ); + expect(vi.mocked(writeFileSync)).toHaveBeenCalled(); + }); + + it('should load existing config from disk', () => { + const mockConfig = { + enabled: false, + userId: 'test-user-id', + firstRun: '2024-01-01T00:00:00Z' + }; + + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig)); + + manager = TelemetryConfigManager.getInstance(); + const config = manager.loadConfig(); + + expect(config).toEqual(mockConfig); + }); + + it('should handle corrupted config file gracefully', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('invalid json'); + + manager = TelemetryConfigManager.getInstance(); + const config = manager.loadConfig(); + + expect(config.enabled).toBe(false); + expect(config.userId).toMatch(/^[a-f0-9]{16}$/); + }); + + it('should add userId to config if missing', () => { + const mockConfig = { + enabled: true, + firstRun: '2024-01-01T00:00:00Z' + }; + + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig)); + + manager = TelemetryConfigManager.getInstance(); + const config = manager.loadConfig(); + + expect(config.userId).toMatch(/^[a-f0-9]{16}$/); + expect(vi.mocked(writeFileSync)).toHaveBeenCalled(); + }); + }); + + describe('isEnabled', () => { + it('should return true when telemetry is enabled', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, + userId: 'test-id' + })); + + manager = TelemetryConfigManager.getInstance(); + expect(manager.isEnabled()).toBe(true); + }); + + it('should return false when telemetry is disabled', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: false, + userId: 'test-id' + })); + + manager = TelemetryConfigManager.getInstance(); + expect(manager.isEnabled()).toBe(false); + }); + }); + + describe('getUserId', () => { + it('should return consistent user ID', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, + userId: 'test-user-id-123' + })); + + manager = TelemetryConfigManager.getInstance(); + expect(manager.getUserId()).toBe('test-user-id-123'); + }); + }); + + describe('isFirstRun', () => { + it('should return true if config file does not exist', () => { + vi.mocked(existsSync).mockReturnValue(false); + + manager = TelemetryConfigManager.getInstance(); + expect(manager.isFirstRun()).toBe(true); + }); + + it('should return false if config file exists', () => { + vi.mocked(existsSync).mockReturnValue(true); + + manager = TelemetryConfigManager.getInstance(); + expect(manager.isFirstRun()).toBe(false); + }); + }); + + describe('enable/disable', () => { + beforeEach(() => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: false, + userId: 'test-id' + })); + }); + + it('should enable telemetry', () => { + manager = TelemetryConfigManager.getInstance(); + manager.enable(); + + const calls = vi.mocked(writeFileSync).mock.calls; + expect(calls.length).toBeGreaterThan(0); + const lastCall = calls[calls.length - 1]; + expect(lastCall[1]).toContain('"enabled": true'); + }); + + it('should disable telemetry', () => { + manager = TelemetryConfigManager.getInstance(); + manager.disable(); + + const calls = vi.mocked(writeFileSync).mock.calls; + expect(calls.length).toBeGreaterThan(0); + const lastCall = calls[calls.length - 1]; + expect(lastCall[1]).toContain('"enabled": false'); + }); + }); + + describe('getStatus', () => { + it('should return formatted status string', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, + userId: 'test-id', + firstRun: '2024-01-01T00:00:00Z' + })); + + manager = TelemetryConfigManager.getInstance(); + const status = manager.getStatus(); + + expect(status).toContain('ENABLED'); + expect(status).toContain('test-id'); + expect(status).toContain('2024-01-01T00:00:00Z'); + expect(status).toContain('npx n8n-mcp telemetry'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/telemetry/workflow-sanitizer.test.ts b/tests/unit/telemetry/workflow-sanitizer.test.ts new file mode 100644 index 0000000..ce3f984 --- /dev/null +++ b/tests/unit/telemetry/workflow-sanitizer.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect } from 'vitest'; +import { WorkflowSanitizer } from '../../../src/telemetry/workflow-sanitizer'; + +describe('WorkflowSanitizer', () => { + describe('sanitizeWorkflow', () => { + it('should remove API keys from parameters', () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + position: [100, 100], + parameters: { + url: 'https://api.example.com', + apiKey: 'sk-1234567890abcdef1234567890abcdef', + headers: { + 'Authorization': 'Bearer sk-1234567890abcdef1234567890abcdef' + } + } + } + ], + connections: {} + }; + + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + expect(sanitized.nodes[0].parameters.apiKey).toBe('[REDACTED]'); + expect(sanitized.nodes[0].parameters.headers.Authorization).toBe('[REDACTED]'); + }); + + it('should sanitize webhook URLs but keep structure', () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + position: [100, 100], + parameters: { + path: 'my-webhook', + webhookUrl: 'https://n8n.example.com/webhook/abc-def-ghi', + method: 'POST' + } + } + ], + connections: {} + }; + + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + expect(sanitized.nodes[0].parameters.webhookUrl).toBe('https://[webhook-url]'); + expect(sanitized.nodes[0].parameters.method).toBe('POST'); // Method should remain + expect(sanitized.nodes[0].parameters.path).toBe('my-webhook'); // Path should remain + }); + + it('should remove credentials entirely', () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Slack', + type: 'n8n-nodes-base.slack', + position: [100, 100], + parameters: { + channel: 'general', + text: 'Hello World' + }, + credentials: { + slackApi: { + id: 'cred-123', + name: 'My Slack' + } + } + } + ], + connections: {} + }; + + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + expect(sanitized.nodes[0].credentials).toBeUndefined(); + expect(sanitized.nodes[0].parameters.channel).toBe('general'); // Channel should remain + expect(sanitized.nodes[0].parameters.text).toBe('Hello World'); // Text should remain + }); + + it('should sanitize URLs in parameters', () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + position: [100, 100], + parameters: { + url: 'https://api.example.com/endpoint', + endpoint: 'https://another.example.com/api', + baseUrl: 'https://base.example.com' + } + } + ], + connections: {} + }; + + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + expect(sanitized.nodes[0].parameters.url).toBe('https://[domain]/endpoint'); + expect(sanitized.nodes[0].parameters.endpoint).toBe('[REDACTED]'); + expect(sanitized.nodes[0].parameters.baseUrl).toBe('https://[domain]'); + }); + + it('should calculate workflow metrics correctly', () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + position: [100, 100], + parameters: {} + }, + { + id: '2', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + position: [200, 100], + parameters: {} + }, + { + id: '3', + name: 'Slack', + type: 'n8n-nodes-base.slack', + position: [300, 100], + parameters: {} + } + ], + connections: { + '1': { + main: [[{ node: '2', type: 'main', index: 0 }]] + }, + '2': { + main: [[{ node: '3', type: 'main', index: 0 }]] + } + } + }; + + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + expect(sanitized.nodeCount).toBe(3); + expect(sanitized.nodeTypes).toContain('n8n-nodes-base.webhook'); + expect(sanitized.nodeTypes).toContain('n8n-nodes-base.httpRequest'); + expect(sanitized.nodeTypes).toContain('n8n-nodes-base.slack'); + expect(sanitized.hasTrigger).toBe(true); + expect(sanitized.hasWebhook).toBe(true); + expect(sanitized.complexity).toBe('simple'); + }); + + it('should calculate complexity based on node count', () => { + const createWorkflow = (nodeCount: number) => ({ + nodes: Array.from({ length: nodeCount }, (_, i) => ({ + id: String(i), + name: `Node ${i}`, + type: 'n8n-nodes-base.function', + position: [i * 100, 100], + parameters: {} + })), + connections: {} + }); + + const simple = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(5)); + expect(simple.complexity).toBe('simple'); + + const medium = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(15)); + expect(medium.complexity).toBe('medium'); + + const complex = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(25)); + expect(complex.complexity).toBe('complex'); + }); + + it('should generate consistent workflow hash', () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + position: [100, 100], + parameters: { path: 'test' } + } + ], + connections: {} + }; + + const hash1 = WorkflowSanitizer.generateWorkflowHash(workflow); + const hash2 = WorkflowSanitizer.generateWorkflowHash(workflow); + + expect(hash1).toBe(hash2); + expect(hash1).toMatch(/^[a-f0-9]{16}$/); + }); + + it('should sanitize nested objects in parameters', () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Complex Node', + type: 'n8n-nodes-base.httpRequest', + position: [100, 100], + parameters: { + options: { + headers: { + 'X-API-Key': 'secret-key-1234567890abcdef', + 'Content-Type': 'application/json' + }, + body: { + data: 'some data', + token: 'another-secret-token-xyz123' + } + } + } + } + ], + connections: {} + }; + + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + expect(sanitized.nodes[0].parameters.options.headers['X-API-Key']).toBe('[REDACTED]'); + expect(sanitized.nodes[0].parameters.options.headers['Content-Type']).toBe('application/json'); + expect(sanitized.nodes[0].parameters.options.body.data).toBe('some data'); + expect(sanitized.nodes[0].parameters.options.body.token).toBe('[REDACTED]'); + }); + + it('should preserve connections structure', () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Node 1', + type: 'n8n-nodes-base.start', + position: [100, 100], + parameters: {} + }, + { + id: '2', + name: 'Node 2', + type: 'n8n-nodes-base.function', + position: [200, 100], + parameters: {} + } + ], + connections: { + '1': { + main: [[{ node: '2', type: 'main', index: 0 }]], + error: [[{ node: '2', type: 'error', index: 0 }]] + } + } + }; + + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + expect(sanitized.connections).toEqual({ + '1': { + main: [[{ node: '2', type: 'main', index: 0 }]], + error: [[{ node: '2', type: 'error', index: 0 }]] + } + }); + }); + + it('should remove sensitive workflow metadata', () => { + const workflow = { + id: 'workflow-123', + name: 'My Workflow', + nodes: [], + connections: {}, + settings: { + errorWorkflow: 'error-workflow-id', + timezone: 'America/New_York' + }, + staticData: { some: 'data' }, + pinData: { node1: 'pinned' }, + credentials: { slack: 'cred-123' }, + sharedWorkflows: ['user-456'], + ownedBy: 'user-123', + createdBy: 'user-123', + updatedBy: 'user-456' + }; + + const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow); + + // These should be removed + expect(sanitized.settings?.errorWorkflow).toBeUndefined(); + expect(sanitized.staticData).toBeUndefined(); + expect(sanitized.pinData).toBeUndefined(); + expect(sanitized.credentials).toBeUndefined(); + expect(sanitized.sharedWorkflows).toBeUndefined(); + expect(sanitized.ownedBy).toBeUndefined(); + expect(sanitized.createdBy).toBeUndefined(); + expect(sanitized.updatedBy).toBeUndefined(); + + // These should be preserved + expect(sanitized.nodes).toEqual([]); + expect(sanitized.connections).toEqual({}); + }); + }); +}); \ No newline at end of file