#
tokens: 48177/50000 4/615 files (page 39/59)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 39 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── _config.yml
├── .claude
│   └── agents
│       ├── code-reviewer.md
│       ├── context-manager.md
│       ├── debugger.md
│       ├── deployment-engineer.md
│       ├── mcp-backend-engineer.md
│       ├── n8n-mcp-tester.md
│       ├── technical-researcher.md
│       └── test-automator.md
├── .dockerignore
├── .env.docker
├── .env.example
├── .env.n8n.example
├── .env.test
├── .env.test.example
├── .github
│   ├── ABOUT.md
│   ├── BENCHMARK_THRESHOLDS.md
│   ├── FUNDING.yml
│   ├── gh-pages.yml
│   ├── secret_scanning.yml
│   └── workflows
│       ├── benchmark-pr.yml
│       ├── benchmark.yml
│       ├── docker-build-fast.yml
│       ├── docker-build-n8n.yml
│       ├── docker-build.yml
│       ├── release.yml
│       ├── test.yml
│       └── update-n8n-deps.yml
├── .gitignore
├── .npmignore
├── ATTRIBUTION.md
├── CHANGELOG.md
├── CLAUDE.md
├── codecov.yml
├── coverage.json
├── data
│   ├── .gitkeep
│   ├── nodes.db
│   ├── nodes.db-shm
│   ├── nodes.db-wal
│   └── templates.db
├── deploy
│   └── quick-deploy-n8n.sh
├── docker
│   ├── docker-entrypoint.sh
│   ├── n8n-mcp
│   ├── parse-config.js
│   └── README.md
├── docker-compose.buildkit.yml
├── docker-compose.extract.yml
├── docker-compose.n8n.yml
├── docker-compose.override.yml.example
├── docker-compose.test-n8n.yml
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.railway
├── Dockerfile.test
├── docs
│   ├── AUTOMATED_RELEASES.md
│   ├── BENCHMARKS.md
│   ├── CHANGELOG.md
│   ├── CLAUDE_CODE_SETUP.md
│   ├── CLAUDE_INTERVIEW.md
│   ├── CODECOV_SETUP.md
│   ├── CODEX_SETUP.md
│   ├── CURSOR_SETUP.md
│   ├── DEPENDENCY_UPDATES.md
│   ├── DOCKER_README.md
│   ├── DOCKER_TROUBLESHOOTING.md
│   ├── FINAL_AI_VALIDATION_SPEC.md
│   ├── FLEXIBLE_INSTANCE_CONFIGURATION.md
│   ├── HTTP_DEPLOYMENT.md
│   ├── img
│   │   ├── cc_command.png
│   │   ├── cc_connected.png
│   │   ├── codex_connected.png
│   │   ├── cursor_tut.png
│   │   ├── Railway_api.png
│   │   ├── Railway_server_address.png
│   │   ├── vsc_ghcp_chat_agent_mode.png
│   │   ├── vsc_ghcp_chat_instruction_files.png
│   │   ├── vsc_ghcp_chat_thinking_tool.png
│   │   └── windsurf_tut.png
│   ├── INSTALLATION.md
│   ├── LIBRARY_USAGE.md
│   ├── local
│   │   ├── DEEP_DIVE_ANALYSIS_2025-10-02.md
│   │   ├── DEEP_DIVE_ANALYSIS_README.md
│   │   ├── Deep_dive_p1_p2.md
│   │   ├── integration-testing-plan.md
│   │   ├── integration-tests-phase1-summary.md
│   │   ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
│   │   ├── P0_IMPLEMENTATION_PLAN.md
│   │   └── TEMPLATE_MINING_ANALYSIS.md
│   ├── MCP_ESSENTIALS_README.md
│   ├── MCP_QUICK_START_GUIDE.md
│   ├── N8N_DEPLOYMENT.md
│   ├── RAILWAY_DEPLOYMENT.md
│   ├── README_CLAUDE_SETUP.md
│   ├── README.md
│   ├── tools-documentation-usage.md
│   ├── VS_CODE_PROJECT_SETUP.md
│   ├── WINDSURF_SETUP.md
│   └── workflow-diff-examples.md
├── examples
│   └── enhanced-documentation-demo.js
├── fetch_log.txt
├── LICENSE
├── MEMORY_N8N_UPDATE.md
├── MEMORY_TEMPLATE_UPDATE.md
├── monitor_fetch.sh
├── N8N_HTTP_STREAMABLE_SETUP.md
├── n8n-nodes.db
├── P0-R3-TEST-PLAN.md
├── package-lock.json
├── package.json
├── package.runtime.json
├── PRIVACY.md
├── railway.json
├── README.md
├── renovate.json
├── scripts
│   ├── analyze-optimization.sh
│   ├── audit-schema-coverage.ts
│   ├── build-optimized.sh
│   ├── compare-benchmarks.js
│   ├── demo-optimization.sh
│   ├── deploy-http.sh
│   ├── deploy-to-vm.sh
│   ├── export-webhook-workflows.ts
│   ├── extract-changelog.js
│   ├── extract-from-docker.js
│   ├── extract-nodes-docker.sh
│   ├── extract-nodes-simple.sh
│   ├── format-benchmark-results.js
│   ├── generate-benchmark-stub.js
│   ├── generate-detailed-reports.js
│   ├── generate-test-summary.js
│   ├── http-bridge.js
│   ├── mcp-http-client.js
│   ├── migrate-nodes-fts.ts
│   ├── migrate-tool-docs.ts
│   ├── n8n-docs-mcp.service
│   ├── nginx-n8n-mcp.conf
│   ├── prebuild-fts5.ts
│   ├── prepare-release.js
│   ├── publish-npm-quick.sh
│   ├── publish-npm.sh
│   ├── quick-test.ts
│   ├── run-benchmarks-ci.js
│   ├── sync-runtime-version.js
│   ├── test-ai-validation-debug.ts
│   ├── test-code-node-enhancements.ts
│   ├── test-code-node-fixes.ts
│   ├── test-docker-config.sh
│   ├── test-docker-fingerprint.ts
│   ├── test-docker-optimization.sh
│   ├── test-docker.sh
│   ├── test-empty-connection-validation.ts
│   ├── test-error-message-tracking.ts
│   ├── test-error-output-validation.ts
│   ├── test-error-validation.js
│   ├── test-essentials.ts
│   ├── test-expression-code-validation.ts
│   ├── test-expression-format-validation.js
│   ├── test-fts5-search.ts
│   ├── test-fuzzy-fix.ts
│   ├── test-fuzzy-simple.ts
│   ├── test-helpers-validation.ts
│   ├── test-http-search.ts
│   ├── test-http.sh
│   ├── test-jmespath-validation.ts
│   ├── test-multi-tenant-simple.ts
│   ├── test-multi-tenant.ts
│   ├── test-n8n-integration.sh
│   ├── test-node-info.js
│   ├── test-node-type-validation.ts
│   ├── test-nodes-base-prefix.ts
│   ├── test-operation-validation.ts
│   ├── test-optimized-docker.sh
│   ├── test-release-automation.js
│   ├── test-search-improvements.ts
│   ├── test-security.ts
│   ├── test-single-session.sh
│   ├── test-sqljs-triggers.ts
│   ├── test-telemetry-debug.ts
│   ├── test-telemetry-direct.ts
│   ├── test-telemetry-env.ts
│   ├── test-telemetry-integration.ts
│   ├── test-telemetry-no-select.ts
│   ├── test-telemetry-security.ts
│   ├── test-telemetry-simple.ts
│   ├── test-typeversion-validation.ts
│   ├── test-url-configuration.ts
│   ├── test-user-id-persistence.ts
│   ├── test-webhook-validation.ts
│   ├── test-workflow-insert.ts
│   ├── test-workflow-sanitizer.ts
│   ├── test-workflow-tracking-debug.ts
│   ├── update-and-publish-prep.sh
│   ├── update-n8n-deps.js
│   ├── update-readme-version.js
│   ├── vitest-benchmark-json-reporter.js
│   └── vitest-benchmark-reporter.ts
├── SECURITY.md
├── src
│   ├── config
│   │   └── n8n-api.ts
│   ├── data
│   │   └── canonical-ai-tool-examples.json
│   ├── database
│   │   ├── database-adapter.ts
│   │   ├── migrations
│   │   │   └── add-template-node-configs.sql
│   │   ├── node-repository.ts
│   │   ├── nodes.db
│   │   ├── schema-optimized.sql
│   │   └── schema.sql
│   ├── errors
│   │   └── validation-service-error.ts
│   ├── http-server-single-session.ts
│   ├── http-server.ts
│   ├── index.ts
│   ├── loaders
│   │   └── node-loader.ts
│   ├── mappers
│   │   └── docs-mapper.ts
│   ├── mcp
│   │   ├── handlers-n8n-manager.ts
│   │   ├── handlers-workflow-diff.ts
│   │   ├── index.ts
│   │   ├── server.ts
│   │   ├── stdio-wrapper.ts
│   │   ├── tool-docs
│   │   │   ├── configuration
│   │   │   │   ├── get-node-as-tool-info.ts
│   │   │   │   ├── get-node-documentation.ts
│   │   │   │   ├── get-node-essentials.ts
│   │   │   │   ├── get-node-info.ts
│   │   │   │   ├── get-property-dependencies.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── search-node-properties.ts
│   │   │   ├── discovery
│   │   │   │   ├── get-database-statistics.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── list-ai-tools.ts
│   │   │   │   ├── list-nodes.ts
│   │   │   │   └── search-nodes.ts
│   │   │   ├── guides
│   │   │   │   ├── ai-agents-guide.ts
│   │   │   │   └── index.ts
│   │   │   ├── index.ts
│   │   │   ├── system
│   │   │   │   ├── index.ts
│   │   │   │   ├── n8n-diagnostic.ts
│   │   │   │   ├── n8n-health-check.ts
│   │   │   │   ├── n8n-list-available-tools.ts
│   │   │   │   └── tools-documentation.ts
│   │   │   ├── templates
│   │   │   │   ├── get-template.ts
│   │   │   │   ├── get-templates-for-task.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── list-node-templates.ts
│   │   │   │   ├── list-tasks.ts
│   │   │   │   ├── search-templates-by-metadata.ts
│   │   │   │   └── search-templates.ts
│   │   │   ├── types.ts
│   │   │   ├── validation
│   │   │   │   ├── index.ts
│   │   │   │   ├── validate-node-minimal.ts
│   │   │   │   ├── validate-node-operation.ts
│   │   │   │   ├── validate-workflow-connections.ts
│   │   │   │   ├── validate-workflow-expressions.ts
│   │   │   │   └── validate-workflow.ts
│   │   │   └── workflow_management
│   │   │       ├── index.ts
│   │   │       ├── n8n-autofix-workflow.ts
│   │   │       ├── n8n-create-workflow.ts
│   │   │       ├── n8n-delete-execution.ts
│   │   │       ├── n8n-delete-workflow.ts
│   │   │       ├── n8n-get-execution.ts
│   │   │       ├── n8n-get-workflow-details.ts
│   │   │       ├── n8n-get-workflow-minimal.ts
│   │   │       ├── n8n-get-workflow-structure.ts
│   │   │       ├── n8n-get-workflow.ts
│   │   │       ├── n8n-list-executions.ts
│   │   │       ├── n8n-list-workflows.ts
│   │   │       ├── n8n-trigger-webhook-workflow.ts
│   │   │       ├── n8n-update-full-workflow.ts
│   │   │       ├── n8n-update-partial-workflow.ts
│   │   │       └── n8n-validate-workflow.ts
│   │   ├── tools-documentation.ts
│   │   ├── tools-n8n-friendly.ts
│   │   ├── tools-n8n-manager.ts
│   │   ├── tools.ts
│   │   └── workflow-examples.ts
│   ├── mcp-engine.ts
│   ├── mcp-tools-engine.ts
│   ├── n8n
│   │   ├── MCPApi.credentials.ts
│   │   └── MCPNode.node.ts
│   ├── parsers
│   │   ├── node-parser.ts
│   │   ├── property-extractor.ts
│   │   └── simple-parser.ts
│   ├── scripts
│   │   ├── debug-http-search.ts
│   │   ├── extract-from-docker.ts
│   │   ├── fetch-templates-robust.ts
│   │   ├── fetch-templates.ts
│   │   ├── rebuild-database.ts
│   │   ├── rebuild-optimized.ts
│   │   ├── rebuild.ts
│   │   ├── sanitize-templates.ts
│   │   ├── seed-canonical-ai-examples.ts
│   │   ├── test-autofix-documentation.ts
│   │   ├── test-autofix-workflow.ts
│   │   ├── test-execution-filtering.ts
│   │   ├── test-node-suggestions.ts
│   │   ├── test-protocol-negotiation.ts
│   │   ├── test-summary.ts
│   │   ├── test-webhook-autofix.ts
│   │   ├── validate.ts
│   │   └── validation-summary.ts
│   ├── services
│   │   ├── ai-node-validator.ts
│   │   ├── ai-tool-validators.ts
│   │   ├── confidence-scorer.ts
│   │   ├── config-validator.ts
│   │   ├── enhanced-config-validator.ts
│   │   ├── example-generator.ts
│   │   ├── execution-processor.ts
│   │   ├── expression-format-validator.ts
│   │   ├── expression-validator.ts
│   │   ├── n8n-api-client.ts
│   │   ├── n8n-validation.ts
│   │   ├── node-documentation-service.ts
│   │   ├── node-similarity-service.ts
│   │   ├── node-specific-validators.ts
│   │   ├── operation-similarity-service.ts
│   │   ├── property-dependencies.ts
│   │   ├── property-filter.ts
│   │   ├── resource-similarity-service.ts
│   │   ├── sqlite-storage-service.ts
│   │   ├── task-templates.ts
│   │   ├── universal-expression-validator.ts
│   │   ├── workflow-auto-fixer.ts
│   │   ├── workflow-diff-engine.ts
│   │   └── workflow-validator.ts
│   ├── telemetry
│   │   ├── batch-processor.ts
│   │   ├── config-manager.ts
│   │   ├── early-error-logger.ts
│   │   ├── error-sanitization-utils.ts
│   │   ├── error-sanitizer.ts
│   │   ├── event-tracker.ts
│   │   ├── event-validator.ts
│   │   ├── index.ts
│   │   ├── performance-monitor.ts
│   │   ├── rate-limiter.ts
│   │   ├── startup-checkpoints.ts
│   │   ├── telemetry-error.ts
│   │   ├── telemetry-manager.ts
│   │   ├── telemetry-types.ts
│   │   └── workflow-sanitizer.ts
│   ├── templates
│   │   ├── batch-processor.ts
│   │   ├── metadata-generator.ts
│   │   ├── README.md
│   │   ├── template-fetcher.ts
│   │   ├── template-repository.ts
│   │   └── template-service.ts
│   ├── types
│   │   ├── index.ts
│   │   ├── instance-context.ts
│   │   ├── n8n-api.ts
│   │   ├── node-types.ts
│   │   └── workflow-diff.ts
│   └── utils
│       ├── auth.ts
│       ├── bridge.ts
│       ├── cache-utils.ts
│       ├── console-manager.ts
│       ├── documentation-fetcher.ts
│       ├── enhanced-documentation-fetcher.ts
│       ├── error-handler.ts
│       ├── example-generator.ts
│       ├── fixed-collection-validator.ts
│       ├── logger.ts
│       ├── mcp-client.ts
│       ├── n8n-errors.ts
│       ├── node-source-extractor.ts
│       ├── node-type-normalizer.ts
│       ├── node-type-utils.ts
│       ├── node-utils.ts
│       ├── npm-version-checker.ts
│       ├── protocol-version.ts
│       ├── simple-cache.ts
│       ├── ssrf-protection.ts
│       ├── template-node-resolver.ts
│       ├── template-sanitizer.ts
│       ├── url-detector.ts
│       ├── validation-schemas.ts
│       └── version.ts
├── test-output.txt
├── test-reinit-fix.sh
├── tests
│   ├── __snapshots__
│   │   └── .gitkeep
│   ├── auth.test.ts
│   ├── benchmarks
│   │   ├── database-queries.bench.ts
│   │   ├── index.ts
│   │   ├── mcp-tools.bench.ts
│   │   ├── mcp-tools.bench.ts.disabled
│   │   ├── mcp-tools.bench.ts.skip
│   │   ├── node-loading.bench.ts.disabled
│   │   ├── README.md
│   │   ├── search-operations.bench.ts.disabled
│   │   └── validation-performance.bench.ts.disabled
│   ├── bridge.test.ts
│   ├── comprehensive-extraction-test.js
│   ├── data
│   │   └── .gitkeep
│   ├── debug-slack-doc.js
│   ├── demo-enhanced-documentation.js
│   ├── docker-tests-README.md
│   ├── error-handler.test.ts
│   ├── examples
│   │   └── using-database-utils.test.ts
│   ├── extracted-nodes-db
│   │   ├── database-import.json
│   │   ├── extraction-report.json
│   │   ├── insert-nodes.sql
│   │   ├── n8n-nodes-base__Airtable.json
│   │   ├── n8n-nodes-base__Discord.json
│   │   ├── n8n-nodes-base__Function.json
│   │   ├── n8n-nodes-base__HttpRequest.json
│   │   ├── n8n-nodes-base__If.json
│   │   ├── n8n-nodes-base__Slack.json
│   │   ├── n8n-nodes-base__SplitInBatches.json
│   │   └── n8n-nodes-base__Webhook.json
│   ├── factories
│   │   ├── node-factory.ts
│   │   └── property-definition-factory.ts
│   ├── fixtures
│   │   ├── .gitkeep
│   │   ├── database
│   │   │   └── test-nodes.json
│   │   ├── factories
│   │   │   ├── node.factory.ts
│   │   │   └── parser-node.factory.ts
│   │   └── template-configs.ts
│   ├── helpers
│   │   └── env-helpers.ts
│   ├── http-server-auth.test.ts
│   ├── integration
│   │   ├── ai-validation
│   │   │   ├── ai-agent-validation.test.ts
│   │   │   ├── ai-tool-validation.test.ts
│   │   │   ├── chat-trigger-validation.test.ts
│   │   │   ├── e2e-validation.test.ts
│   │   │   ├── helpers.ts
│   │   │   ├── llm-chain-validation.test.ts
│   │   │   ├── README.md
│   │   │   └── TEST_REPORT.md
│   │   ├── ci
│   │   │   └── database-population.test.ts
│   │   ├── database
│   │   │   ├── connection-management.test.ts
│   │   │   ├── empty-database.test.ts
│   │   │   ├── fts5-search.test.ts
│   │   │   ├── node-fts5-search.test.ts
│   │   │   ├── node-repository.test.ts
│   │   │   ├── performance.test.ts
│   │   │   ├── sqljs-memory-leak.test.ts
│   │   │   ├── template-node-configs.test.ts
│   │   │   ├── template-repository.test.ts
│   │   │   ├── test-utils.ts
│   │   │   └── transactions.test.ts
│   │   ├── database-integration.test.ts
│   │   ├── docker
│   │   │   ├── docker-config.test.ts
│   │   │   ├── docker-entrypoint.test.ts
│   │   │   └── test-helpers.ts
│   │   ├── flexible-instance-config.test.ts
│   │   ├── mcp
│   │   │   └── template-examples-e2e.test.ts
│   │   ├── mcp-protocol
│   │   │   ├── basic-connection.test.ts
│   │   │   ├── error-handling.test.ts
│   │   │   ├── performance.test.ts
│   │   │   ├── protocol-compliance.test.ts
│   │   │   ├── README.md
│   │   │   ├── session-management.test.ts
│   │   │   ├── test-helpers.ts
│   │   │   ├── tool-invocation.test.ts
│   │   │   └── workflow-error-validation.test.ts
│   │   ├── msw-setup.test.ts
│   │   ├── n8n-api
│   │   │   ├── executions
│   │   │   │   ├── delete-execution.test.ts
│   │   │   │   ├── get-execution.test.ts
│   │   │   │   ├── list-executions.test.ts
│   │   │   │   └── trigger-webhook.test.ts
│   │   │   ├── scripts
│   │   │   │   └── cleanup-orphans.ts
│   │   │   ├── system
│   │   │   │   ├── diagnostic.test.ts
│   │   │   │   ├── health-check.test.ts
│   │   │   │   └── list-tools.test.ts
│   │   │   ├── test-connection.ts
│   │   │   ├── types
│   │   │   │   └── mcp-responses.ts
│   │   │   ├── utils
│   │   │   │   ├── cleanup-helpers.ts
│   │   │   │   ├── credentials.ts
│   │   │   │   ├── factories.ts
│   │   │   │   ├── fixtures.ts
│   │   │   │   ├── mcp-context.ts
│   │   │   │   ├── n8n-client.ts
│   │   │   │   ├── node-repository.ts
│   │   │   │   ├── response-types.ts
│   │   │   │   ├── test-context.ts
│   │   │   │   └── webhook-workflows.ts
│   │   │   └── workflows
│   │   │       ├── autofix-workflow.test.ts
│   │   │       ├── create-workflow.test.ts
│   │   │       ├── delete-workflow.test.ts
│   │   │       ├── get-workflow-details.test.ts
│   │   │       ├── get-workflow-minimal.test.ts
│   │   │       ├── get-workflow-structure.test.ts
│   │   │       ├── get-workflow.test.ts
│   │   │       ├── list-workflows.test.ts
│   │   │       ├── smart-parameters.test.ts
│   │   │       ├── update-partial-workflow.test.ts
│   │   │       ├── update-workflow.test.ts
│   │   │       └── validate-workflow.test.ts
│   │   ├── security
│   │   │   ├── command-injection-prevention.test.ts
│   │   │   └── rate-limiting.test.ts
│   │   ├── setup
│   │   │   ├── integration-setup.ts
│   │   │   └── msw-test-server.ts
│   │   ├── telemetry
│   │   │   ├── docker-user-id-stability.test.ts
│   │   │   └── mcp-telemetry.test.ts
│   │   ├── templates
│   │   │   └── metadata-operations.test.ts
│   │   └── workflow-creation-node-type-format.test.ts
│   ├── logger.test.ts
│   ├── MOCKING_STRATEGY.md
│   ├── mocks
│   │   ├── n8n-api
│   │   │   ├── data
│   │   │   │   ├── credentials.ts
│   │   │   │   ├── executions.ts
│   │   │   │   └── workflows.ts
│   │   │   ├── handlers.ts
│   │   │   └── index.ts
│   │   └── README.md
│   ├── node-storage-export.json
│   ├── setup
│   │   ├── global-setup.ts
│   │   ├── msw-setup.ts
│   │   ├── TEST_ENV_DOCUMENTATION.md
│   │   └── test-env.ts
│   ├── test-database-extraction.js
│   ├── test-direct-extraction.js
│   ├── test-enhanced-documentation.js
│   ├── test-enhanced-integration.js
│   ├── test-mcp-extraction.js
│   ├── test-mcp-server-extraction.js
│   ├── test-mcp-tools-integration.js
│   ├── test-node-documentation-service.js
│   ├── test-node-list.js
│   ├── test-package-info.js
│   ├── test-parsing-operations.js
│   ├── test-slack-node-complete.js
│   ├── test-small-rebuild.js
│   ├── test-sqlite-search.js
│   ├── test-storage-system.js
│   ├── unit
│   │   ├── __mocks__
│   │   │   ├── n8n-nodes-base.test.ts
│   │   │   ├── n8n-nodes-base.ts
│   │   │   └── README.md
│   │   ├── database
│   │   │   ├── __mocks__
│   │   │   │   └── better-sqlite3.ts
│   │   │   ├── database-adapter-unit.test.ts
│   │   │   ├── node-repository-core.test.ts
│   │   │   ├── node-repository-operations.test.ts
│   │   │   ├── node-repository-outputs.test.ts
│   │   │   ├── README.md
│   │   │   └── template-repository-core.test.ts
│   │   ├── docker
│   │   │   ├── config-security.test.ts
│   │   │   ├── edge-cases.test.ts
│   │   │   ├── parse-config.test.ts
│   │   │   └── serve-command.test.ts
│   │   ├── errors
│   │   │   └── validation-service-error.test.ts
│   │   ├── examples
│   │   │   └── using-n8n-nodes-base-mock.test.ts
│   │   ├── flexible-instance-security-advanced.test.ts
│   │   ├── flexible-instance-security.test.ts
│   │   ├── http-server
│   │   │   └── multi-tenant-support.test.ts
│   │   ├── http-server-n8n-mode.test.ts
│   │   ├── http-server-n8n-reinit.test.ts
│   │   ├── http-server-session-management.test.ts
│   │   ├── loaders
│   │   │   └── node-loader.test.ts
│   │   ├── mappers
│   │   │   └── docs-mapper.test.ts
│   │   ├── mcp
│   │   │   ├── get-node-essentials-examples.test.ts
│   │   │   ├── handlers-n8n-manager-simple.test.ts
│   │   │   ├── handlers-n8n-manager.test.ts
│   │   │   ├── handlers-workflow-diff.test.ts
│   │   │   ├── lru-cache-behavior.test.ts
│   │   │   ├── multi-tenant-tool-listing.test.ts.disabled
│   │   │   ├── parameter-validation.test.ts
│   │   │   ├── search-nodes-examples.test.ts
│   │   │   ├── tools-documentation.test.ts
│   │   │   └── tools.test.ts
│   │   ├── monitoring
│   │   │   └── cache-metrics.test.ts
│   │   ├── MULTI_TENANT_TEST_COVERAGE.md
│   │   ├── multi-tenant-integration.test.ts
│   │   ├── parsers
│   │   │   ├── node-parser-outputs.test.ts
│   │   │   ├── node-parser.test.ts
│   │   │   ├── property-extractor.test.ts
│   │   │   └── simple-parser.test.ts
│   │   ├── scripts
│   │   │   └── fetch-templates-extraction.test.ts
│   │   ├── services
│   │   │   ├── ai-node-validator.test.ts
│   │   │   ├── ai-tool-validators.test.ts
│   │   │   ├── confidence-scorer.test.ts
│   │   │   ├── config-validator-basic.test.ts
│   │   │   ├── config-validator-edge-cases.test.ts
│   │   │   ├── config-validator-node-specific.test.ts
│   │   │   ├── config-validator-security.test.ts
│   │   │   ├── debug-validator.test.ts
│   │   │   ├── enhanced-config-validator-integration.test.ts
│   │   │   ├── enhanced-config-validator-operations.test.ts
│   │   │   ├── enhanced-config-validator.test.ts
│   │   │   ├── example-generator.test.ts
│   │   │   ├── execution-processor.test.ts
│   │   │   ├── expression-format-validator.test.ts
│   │   │   ├── expression-validator-edge-cases.test.ts
│   │   │   ├── expression-validator.test.ts
│   │   │   ├── fixed-collection-validation.test.ts
│   │   │   ├── loop-output-edge-cases.test.ts
│   │   │   ├── n8n-api-client.test.ts
│   │   │   ├── n8n-validation.test.ts
│   │   │   ├── node-similarity-service.test.ts
│   │   │   ├── node-specific-validators.test.ts
│   │   │   ├── operation-similarity-service-comprehensive.test.ts
│   │   │   ├── operation-similarity-service.test.ts
│   │   │   ├── property-dependencies.test.ts
│   │   │   ├── property-filter-edge-cases.test.ts
│   │   │   ├── property-filter.test.ts
│   │   │   ├── resource-similarity-service-comprehensive.test.ts
│   │   │   ├── resource-similarity-service.test.ts
│   │   │   ├── task-templates.test.ts
│   │   │   ├── template-service.test.ts
│   │   │   ├── universal-expression-validator.test.ts
│   │   │   ├── validation-fixes.test.ts
│   │   │   ├── workflow-auto-fixer.test.ts
│   │   │   ├── workflow-diff-engine.test.ts
│   │   │   ├── workflow-fixed-collection-validation.test.ts
│   │   │   ├── workflow-validator-comprehensive.test.ts
│   │   │   ├── workflow-validator-edge-cases.test.ts
│   │   │   ├── workflow-validator-error-outputs.test.ts
│   │   │   ├── workflow-validator-expression-format.test.ts
│   │   │   ├── workflow-validator-loops-simple.test.ts
│   │   │   ├── workflow-validator-loops.test.ts
│   │   │   ├── workflow-validator-mocks.test.ts
│   │   │   ├── workflow-validator-performance.test.ts
│   │   │   ├── workflow-validator-with-mocks.test.ts
│   │   │   └── workflow-validator.test.ts
│   │   ├── telemetry
│   │   │   ├── batch-processor.test.ts
│   │   │   ├── config-manager.test.ts
│   │   │   ├── event-tracker.test.ts
│   │   │   ├── event-validator.test.ts
│   │   │   ├── rate-limiter.test.ts
│   │   │   ├── telemetry-error.test.ts
│   │   │   ├── telemetry-manager.test.ts
│   │   │   ├── v2.18.3-fixes-verification.test.ts
│   │   │   └── workflow-sanitizer.test.ts
│   │   ├── templates
│   │   │   ├── batch-processor.test.ts
│   │   │   ├── metadata-generator.test.ts
│   │   │   ├── template-repository-metadata.test.ts
│   │   │   └── template-repository-security.test.ts
│   │   ├── test-env-example.test.ts
│   │   ├── test-infrastructure.test.ts
│   │   ├── types
│   │   │   ├── instance-context-coverage.test.ts
│   │   │   └── instance-context-multi-tenant.test.ts
│   │   ├── utils
│   │   │   ├── auth-timing-safe.test.ts
│   │   │   ├── cache-utils.test.ts
│   │   │   ├── console-manager.test.ts
│   │   │   ├── database-utils.test.ts
│   │   │   ├── fixed-collection-validator.test.ts
│   │   │   ├── n8n-errors.test.ts
│   │   │   ├── node-type-normalizer.test.ts
│   │   │   ├── node-type-utils.test.ts
│   │   │   ├── node-utils.test.ts
│   │   │   ├── simple-cache-memory-leak-fix.test.ts
│   │   │   ├── ssrf-protection.test.ts
│   │   │   └── template-node-resolver.test.ts
│   │   └── validation-fixes.test.ts
│   └── utils
│       ├── assertions.ts
│       ├── builders
│       │   └── workflow.builder.ts
│       ├── data-generators.ts
│       ├── database-utils.ts
│       ├── README.md
│       └── test-helpers.ts
├── thumbnail.png
├── tsconfig.build.json
├── tsconfig.json
├── types
│   ├── mcp.d.ts
│   └── test-env.d.ts
├── verify-telemetry-fix.js
├── versioned-nodes.md
├── vitest.config.benchmark.ts
├── vitest.config.integration.ts
└── vitest.config.ts
```

# Files

--------------------------------------------------------------------------------
/src/templates/template-repository.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { DatabaseAdapter } from '../database/database-adapter';
  2 | import { TemplateWorkflow, TemplateDetail } from './template-fetcher';
  3 | import { logger } from '../utils/logger';
  4 | import { TemplateSanitizer } from '../utils/template-sanitizer';
  5 | import * as zlib from 'zlib';
  6 | import { resolveTemplateNodeTypes } from '../utils/template-node-resolver';
  7 | 
  8 | export interface StoredTemplate {
  9 |   id: number;
 10 |   workflow_id: number;
 11 |   name: string;
 12 |   description: string;
 13 |   author_name: string;
 14 |   author_username: string;
 15 |   author_verified: number;
 16 |   nodes_used: string; // JSON string
 17 |   workflow_json?: string; // JSON string (deprecated)
 18 |   workflow_json_compressed?: string; // Base64 encoded gzip
 19 |   categories: string; // JSON string
 20 |   views: number;
 21 |   created_at: string;
 22 |   updated_at: string;
 23 |   url: string;
 24 |   scraped_at: string;
 25 |   metadata_json?: string; // Structured metadata from OpenAI (JSON string)
 26 |   metadata_generated_at?: string; // When metadata was generated
 27 | }
 28 | 
 29 | export class TemplateRepository {
 30 |   private sanitizer: TemplateSanitizer;
 31 |   private hasFTS5Support: boolean = false;
 32 |   
 33 |   constructor(private db: DatabaseAdapter) {
 34 |     this.sanitizer = new TemplateSanitizer();
 35 |     this.initializeFTS5();
 36 |   }
 37 |   
 38 |   /**
 39 |    * Initialize FTS5 tables if supported
 40 |    */
 41 |   private initializeFTS5(): void {
 42 |     this.hasFTS5Support = this.db.checkFTS5Support();
 43 |     
 44 |     if (this.hasFTS5Support) {
 45 |       try {
 46 |         // Check if FTS5 table already exists
 47 |         const ftsExists = this.db.prepare(`
 48 |           SELECT name FROM sqlite_master 
 49 |           WHERE type='table' AND name='templates_fts'
 50 |         `).get() as { name: string } | undefined;
 51 |         
 52 |         if (ftsExists) {
 53 |           logger.info('FTS5 table already exists for templates');
 54 |           
 55 |           // Verify FTS5 is working by doing a test query
 56 |           try {
 57 |             const testCount = this.db.prepare('SELECT COUNT(*) as count FROM templates_fts').get() as { count: number };
 58 |             logger.info(`FTS5 enabled with ${testCount.count} indexed entries`);
 59 |           } catch (testError) {
 60 |             logger.warn('FTS5 table exists but query failed:', testError);
 61 |             this.hasFTS5Support = false;
 62 |             return;
 63 |           }
 64 |         } else {
 65 |           // Create FTS5 virtual table
 66 |           logger.info('Creating FTS5 virtual table for templates...');
 67 |           this.db.exec(`
 68 |             CREATE VIRTUAL TABLE IF NOT EXISTS templates_fts USING fts5(
 69 |               name, description, content=templates
 70 |             );
 71 |           `);
 72 |           
 73 |           // Create triggers to keep FTS5 in sync
 74 |           this.db.exec(`
 75 |             CREATE TRIGGER IF NOT EXISTS templates_ai AFTER INSERT ON templates BEGIN
 76 |               INSERT INTO templates_fts(rowid, name, description)
 77 |               VALUES (new.id, new.name, new.description);
 78 |             END;
 79 |           `);
 80 |           
 81 |           this.db.exec(`
 82 |             CREATE TRIGGER IF NOT EXISTS templates_au AFTER UPDATE ON templates BEGIN
 83 |               UPDATE templates_fts SET name = new.name, description = new.description
 84 |               WHERE rowid = new.id;
 85 |             END;
 86 |           `);
 87 |           
 88 |           this.db.exec(`
 89 |             CREATE TRIGGER IF NOT EXISTS templates_ad AFTER DELETE ON templates BEGIN
 90 |               DELETE FROM templates_fts WHERE rowid = old.id;
 91 |             END;
 92 |           `);
 93 |           
 94 |           logger.info('FTS5 support enabled for template search');
 95 |         }
 96 |       } catch (error: any) {
 97 |         logger.warn('Failed to initialize FTS5 for templates:', {
 98 |           message: error.message,
 99 |           code: error.code,
100 |           stack: error.stack
101 |         });
102 |         this.hasFTS5Support = false;
103 |       }
104 |     } else {
105 |       logger.info('FTS5 not available, using LIKE search for templates');
106 |     }
107 |   }
108 |   
109 |   /**
110 |    * Save a template to the database
111 |    */
112 |   saveTemplate(workflow: TemplateWorkflow, detail: TemplateDetail, categories: string[] = []): void {
113 |     // Filter out templates with 10 or fewer views
114 |     if ((workflow.totalViews || 0) <= 10) {
115 |       logger.debug(`Skipping template ${workflow.id}: ${workflow.name} (only ${workflow.totalViews} views)`);
116 |       return;
117 |     }
118 |     
119 |     const stmt = this.db.prepare(`
120 |       INSERT OR REPLACE INTO templates (
121 |         id, workflow_id, name, description, author_name, author_username,
122 |         author_verified, nodes_used, workflow_json_compressed, categories, views,
123 |         created_at, updated_at, url
124 |       ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
125 |     `);
126 |     
127 |     // Extract node types from workflow detail
128 |     const nodeTypes = detail.workflow.nodes.map(n => n.type);
129 |     
130 |     // Build URL
131 |     const url = `https://n8n.io/workflows/${workflow.id}`;
132 |     
133 |     // Sanitize the workflow to remove API tokens
134 |     const { sanitized: sanitizedWorkflow, wasModified } = this.sanitizer.sanitizeWorkflow(detail.workflow);
135 |     
136 |     // Log if we sanitized any tokens
137 |     if (wasModified) {
138 |       const detectedTokens = this.sanitizer.detectTokens(detail.workflow);
139 |       logger.warn(`Sanitized API tokens in template ${workflow.id}: ${workflow.name}`, {
140 |         templateId: workflow.id,
141 |         templateName: workflow.name,
142 |         tokensFound: detectedTokens.length,
143 |         tokenPreviews: detectedTokens.map(t => t.substring(0, 20) + '...')
144 |       });
145 |     }
146 |     
147 |     // Compress the workflow JSON
148 |     const workflowJsonStr = JSON.stringify(sanitizedWorkflow);
149 |     const compressed = zlib.gzipSync(workflowJsonStr);
150 |     const compressedBase64 = compressed.toString('base64');
151 |     
152 |     // Log compression ratio
153 |     const originalSize = Buffer.byteLength(workflowJsonStr);
154 |     const compressedSize = compressed.length;
155 |     const ratio = Math.round((1 - compressedSize / originalSize) * 100);
156 |     logger.debug(`Template ${workflow.id} compression: ${originalSize} → ${compressedSize} bytes (${ratio}% reduction)`);
157 |     
158 |     stmt.run(
159 |       workflow.id,
160 |       workflow.id,
161 |       workflow.name,
162 |       workflow.description || '',
163 |       workflow.user.name,
164 |       workflow.user.username,
165 |       workflow.user.verified ? 1 : 0,
166 |       JSON.stringify(nodeTypes),
167 |       compressedBase64,
168 |       JSON.stringify(categories),
169 |       workflow.totalViews || 0,
170 |       workflow.createdAt,
171 |       workflow.createdAt, // Using createdAt as updatedAt since API doesn't provide updatedAt
172 |       url
173 |     );
174 |   }
175 |   
176 |   /**
177 |    * Get templates that use specific node types
178 |    */
179 |   getTemplatesByNodes(nodeTypes: string[], limit: number = 10, offset: number = 0): StoredTemplate[] {
180 |     // Resolve input node types to all possible template formats
181 |     const resolvedTypes = resolveTemplateNodeTypes(nodeTypes);
182 |     
183 |     if (resolvedTypes.length === 0) {
184 |       logger.debug('No resolved types for template search', { input: nodeTypes });
185 |       return [];
186 |     }
187 |     
188 |     // Build query for multiple node types
189 |     const conditions = resolvedTypes.map(() => "nodes_used LIKE ?").join(" OR ");
190 |     const query = `
191 |       SELECT * FROM templates 
192 |       WHERE ${conditions}
193 |       ORDER BY views DESC, created_at DESC
194 |       LIMIT ? OFFSET ?
195 |     `;
196 |     
197 |     const params = [...resolvedTypes.map(n => `%"${n}"%`), limit, offset];
198 |     const results = this.db.prepare(query).all(...params) as StoredTemplate[];
199 |     
200 |     logger.debug(`Template search found ${results.length} results`, {
201 |       input: nodeTypes,
202 |       resolved: resolvedTypes,
203 |       found: results.length
204 |     });
205 |     
206 |     return results.map(t => this.decompressWorkflow(t));
207 |   }
208 |   
209 |   /**
210 |    * Get a specific template by ID
211 |    */
212 |   getTemplate(templateId: number): StoredTemplate | null {
213 |     const row = this.db.prepare(`
214 |       SELECT * FROM templates WHERE id = ?
215 |     `).get(templateId) as StoredTemplate | undefined;
216 |     
217 |     if (!row) return null;
218 |     
219 |     // Decompress workflow JSON if compressed
220 |     if (row.workflow_json_compressed && !row.workflow_json) {
221 |       try {
222 |         const compressed = Buffer.from(row.workflow_json_compressed, 'base64');
223 |         const decompressed = zlib.gunzipSync(compressed);
224 |         row.workflow_json = decompressed.toString();
225 |       } catch (error) {
226 |         logger.error(`Failed to decompress workflow for template ${templateId}:`, error);
227 |         return null;
228 |       }
229 |     }
230 |     
231 |     return row;
232 |   }
233 |   
234 |   /**
235 |    * Decompress workflow JSON for a template
236 |    */
237 |   private decompressWorkflow(template: StoredTemplate): StoredTemplate {
238 |     if (template.workflow_json_compressed && !template.workflow_json) {
239 |       try {
240 |         const compressed = Buffer.from(template.workflow_json_compressed, 'base64');
241 |         const decompressed = zlib.gunzipSync(compressed);
242 |         template.workflow_json = decompressed.toString();
243 |       } catch (error) {
244 |         logger.error(`Failed to decompress workflow for template ${template.id}:`, error);
245 |       }
246 |     }
247 |     return template;
248 |   }
249 |   
250 |   /**
251 |    * Search templates by name or description
252 |    */
253 |   searchTemplates(query: string, limit: number = 20, offset: number = 0): StoredTemplate[] {
254 |     logger.debug(`Searching templates for: "${query}" (FTS5: ${this.hasFTS5Support})`);
255 |     
256 |     // If FTS5 is not supported, go straight to LIKE search
257 |     if (!this.hasFTS5Support) {
258 |       logger.debug('Using LIKE search (FTS5 not available)');
259 |       return this.searchTemplatesLIKE(query, limit, offset);
260 |     }
261 |     
262 |     try {
263 |       // Use FTS for search - escape quotes in terms
264 |       const ftsQuery = query.split(' ').map(term => {
265 |         // Escape double quotes by replacing with two double quotes
266 |         const escaped = term.replace(/"/g, '""');
267 |         return `"${escaped}"`;
268 |       }).join(' OR ');
269 |       logger.debug(`FTS5 query: ${ftsQuery}`);
270 |       
271 |       const results = this.db.prepare(`
272 |         SELECT t.* FROM templates t
273 |         JOIN templates_fts ON t.id = templates_fts.rowid
274 |         WHERE templates_fts MATCH ?
275 |         ORDER BY rank, t.views DESC
276 |         LIMIT ? OFFSET ?
277 |       `).all(ftsQuery, limit, offset) as StoredTemplate[];
278 |       
279 |       logger.debug(`FTS5 search returned ${results.length} results`);
280 |       return results.map(t => this.decompressWorkflow(t));
281 |     } catch (error: any) {
282 |       // If FTS5 query fails, fallback to LIKE search
283 |       logger.warn('FTS5 template search failed, using LIKE fallback:', {
284 |         message: error.message,
285 |         query: query,
286 |         ftsQuery: query.split(' ').map(term => `"${term}"`).join(' OR ')
287 |       });
288 |       return this.searchTemplatesLIKE(query, limit, offset);
289 |     }
290 |   }
291 |   
292 |   /**
293 |    * Fallback search using LIKE when FTS5 is not available
294 |    */
295 |   private searchTemplatesLIKE(query: string, limit: number = 20, offset: number = 0): StoredTemplate[] {
296 |     const likeQuery = `%${query}%`;
297 |     logger.debug(`Using LIKE search with pattern: ${likeQuery}`);
298 |     
299 |     const results = this.db.prepare(`
300 |       SELECT * FROM templates 
301 |       WHERE name LIKE ? OR description LIKE ?
302 |       ORDER BY views DESC, created_at DESC
303 |       LIMIT ? OFFSET ?
304 |     `).all(likeQuery, likeQuery, limit, offset) as StoredTemplate[];
305 |     
306 |     logger.debug(`LIKE search returned ${results.length} results`);
307 |     return results.map(t => this.decompressWorkflow(t));
308 |   }
309 |   
310 |   /**
311 |    * Get templates for a specific task/use case
312 |    */
313 |   getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): StoredTemplate[] {
314 |     // Map tasks to relevant node combinations
315 |     const taskNodeMap: Record<string, string[]> = {
316 |       'ai_automation': ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.openAi'],
317 |       'data_sync': ['n8n-nodes-base.googleSheets', 'n8n-nodes-base.postgres', 'n8n-nodes-base.mysql'],
318 |       'webhook_processing': ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'],
319 |       'email_automation': ['n8n-nodes-base.gmail', 'n8n-nodes-base.emailSend', 'n8n-nodes-base.emailReadImap'],
320 |       'slack_integration': ['n8n-nodes-base.slack', 'n8n-nodes-base.slackTrigger'],
321 |       'data_transformation': ['n8n-nodes-base.code', 'n8n-nodes-base.set', 'n8n-nodes-base.merge'],
322 |       'file_processing': ['n8n-nodes-base.readBinaryFile', 'n8n-nodes-base.writeBinaryFile', 'n8n-nodes-base.googleDrive'],
323 |       'scheduling': ['n8n-nodes-base.scheduleTrigger', 'n8n-nodes-base.cron'],
324 |       'api_integration': ['n8n-nodes-base.httpRequest', 'n8n-nodes-base.graphql'],
325 |       'database_operations': ['n8n-nodes-base.postgres', 'n8n-nodes-base.mysql', 'n8n-nodes-base.mongodb']
326 |     };
327 |     
328 |     const nodes = taskNodeMap[task];
329 |     if (!nodes) {
330 |       return [];
331 |     }
332 |     
333 |     return this.getTemplatesByNodes(nodes, limit, offset);
334 |   }
335 |   
336 |   /**
337 |    * Get all templates with limit
338 |    */
339 |   getAllTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views'): StoredTemplate[] {
340 |     const orderClause = sortBy === 'name' ? 'name ASC' : 
341 |                         sortBy === 'created_at' ? 'created_at DESC' : 
342 |                         'views DESC, created_at DESC';
343 |     const results = this.db.prepare(`
344 |       SELECT * FROM templates 
345 |       ORDER BY ${orderClause}
346 |       LIMIT ? OFFSET ?
347 |     `).all(limit, offset) as StoredTemplate[];
348 |     return results.map(t => this.decompressWorkflow(t));
349 |   }
350 |   
351 |   /**
352 |    * Get total template count
353 |    */
354 |   getTemplateCount(): number {
355 |     const result = this.db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number };
356 |     return result.count;
357 |   }
358 |   
359 |   /**
360 |    * Get count for search results
361 |    */
362 |   getSearchCount(query: string): number {
363 |     if (!this.hasFTS5Support) {
364 |       const likeQuery = `%${query}%`;
365 |       const result = this.db.prepare(`
366 |         SELECT COUNT(*) as count FROM templates 
367 |         WHERE name LIKE ? OR description LIKE ?
368 |       `).get(likeQuery, likeQuery) as { count: number };
369 |       return result.count;
370 |     }
371 |     
372 |     try {
373 |       const ftsQuery = query.split(' ').map(term => {
374 |         const escaped = term.replace(/"/g, '""');
375 |         return `"${escaped}"`;
376 |       }).join(' OR ');
377 |       
378 |       const result = this.db.prepare(`
379 |         SELECT COUNT(*) as count FROM templates t
380 |         JOIN templates_fts ON t.id = templates_fts.rowid
381 |         WHERE templates_fts MATCH ?
382 |       `).get(ftsQuery) as { count: number };
383 |       return result.count;
384 |     } catch {
385 |       const likeQuery = `%${query}%`;
386 |       const result = this.db.prepare(`
387 |         SELECT COUNT(*) as count FROM templates 
388 |         WHERE name LIKE ? OR description LIKE ?
389 |       `).get(likeQuery, likeQuery) as { count: number };
390 |       return result.count;
391 |     }
392 |   }
393 |   
394 |   /**
395 |    * Get count for node templates
396 |    */
397 |   getNodeTemplatesCount(nodeTypes: string[]): number {
398 |     // Resolve input node types to all possible template formats
399 |     const resolvedTypes = resolveTemplateNodeTypes(nodeTypes);
400 |     
401 |     if (resolvedTypes.length === 0) {
402 |       return 0;
403 |     }
404 |     
405 |     const conditions = resolvedTypes.map(() => "nodes_used LIKE ?").join(" OR ");
406 |     const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions}`;
407 |     const params = resolvedTypes.map(n => `%"${n}"%`);
408 |     const result = this.db.prepare(query).get(...params) as { count: number };
409 |     return result.count;
410 |   }
411 |   
412 |   /**
413 |    * Get count for task templates
414 |    */
415 |   getTaskTemplatesCount(task: string): number {
416 |     const taskNodeMap: Record<string, string[]> = {
417 |       'ai_automation': ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.openAi'],
418 |       'data_sync': ['n8n-nodes-base.googleSheets', 'n8n-nodes-base.postgres', 'n8n-nodes-base.mysql'],
419 |       'webhook_processing': ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'],
420 |       'email_automation': ['n8n-nodes-base.gmail', 'n8n-nodes-base.emailSend', 'n8n-nodes-base.emailReadImap'],
421 |       'slack_integration': ['n8n-nodes-base.slack', 'n8n-nodes-base.slackTrigger'],
422 |       'data_transformation': ['n8n-nodes-base.code', 'n8n-nodes-base.set', 'n8n-nodes-base.merge'],
423 |       'file_processing': ['n8n-nodes-base.readBinaryFile', 'n8n-nodes-base.writeBinaryFile', 'n8n-nodes-base.googleDrive'],
424 |       'scheduling': ['n8n-nodes-base.scheduleTrigger', 'n8n-nodes-base.cron'],
425 |       'api_integration': ['n8n-nodes-base.httpRequest', 'n8n-nodes-base.graphql'],
426 |       'database_operations': ['n8n-nodes-base.postgres', 'n8n-nodes-base.mysql', 'n8n-nodes-base.mongodb']
427 |     };
428 |     
429 |     const nodes = taskNodeMap[task];
430 |     if (!nodes) {
431 |       return 0;
432 |     }
433 |     
434 |     return this.getNodeTemplatesCount(nodes);
435 |   }
436 |   
437 |   /**
438 |    * Get all existing template IDs for comparison
439 |    * Used in update mode to skip already fetched templates
440 |    */
441 |   getExistingTemplateIds(): Set<number> {
442 |     const rows = this.db.prepare('SELECT id FROM templates').all() as { id: number }[];
443 |     return new Set(rows.map(r => r.id));
444 |   }
445 | 
446 |   /**
447 |    * Get the most recent template creation date
448 |    * Used in update mode to fetch only newer templates
449 |    */
450 |   getMostRecentTemplateDate(): Date | null {
451 |     const result = this.db.prepare('SELECT MAX(created_at) as max_date FROM templates').get() as { max_date: string | null } | undefined;
452 |     if (!result || !result.max_date) {
453 |       return null;
454 |     }
455 |     return new Date(result.max_date);
456 |   }
457 | 
458 |   /**
459 |    * Check if a template exists in the database
460 |    */
461 |   hasTemplate(templateId: number): boolean {
462 |     const result = this.db.prepare('SELECT 1 FROM templates WHERE id = ?').get(templateId) as { 1: number } | undefined;
463 |     return result !== undefined;
464 |   }
465 |   
466 |   /**
467 |    * Get template metadata (id, name, updated_at) for all templates
468 |    * Used for comparison in update scenarios
469 |    */
470 |   getTemplateMetadata(): Map<number, { name: string; updated_at: string }> {
471 |     const rows = this.db.prepare('SELECT id, name, updated_at FROM templates').all() as {
472 |       id: number;
473 |       name: string;
474 |       updated_at: string;
475 |     }[];
476 |     
477 |     const metadata = new Map<number, { name: string; updated_at: string }>();
478 |     for (const row of rows) {
479 |       metadata.set(row.id, { name: row.name, updated_at: row.updated_at });
480 |     }
481 |     return metadata;
482 |   }
483 |   
484 |   /**
485 |    * Get template statistics
486 |    */
487 |   getTemplateStats(): Record<string, any> {
488 |     const count = this.getTemplateCount();
489 |     const avgViews = this.db.prepare('SELECT AVG(views) as avg FROM templates').get() as { avg: number };
490 |     const topNodes = this.db.prepare(`
491 |       SELECT nodes_used FROM templates 
492 |       ORDER BY views DESC 
493 |       LIMIT 100
494 |     `).all() as { nodes_used: string }[];
495 |     
496 |     // Count node usage
497 |     const nodeCount: Record<string, number> = {};
498 |     topNodes.forEach(t => {
499 |       const nodes = JSON.parse(t.nodes_used);
500 |       nodes.forEach((n: string) => {
501 |         nodeCount[n] = (nodeCount[n] || 0) + 1;
502 |       });
503 |     });
504 |     
505 |     // Get top 10 most used nodes
506 |     const topUsedNodes = Object.entries(nodeCount)
507 |       .sort(([, a], [, b]) => b - a)
508 |       .slice(0, 10)
509 |       .map(([node, count]) => ({ node, count }));
510 |     
511 |     return {
512 |       totalTemplates: count,
513 |       averageViews: Math.round(avgViews.avg || 0),
514 |       topUsedNodes
515 |     };
516 |   }
517 |   
518 |   /**
519 |    * Clear all templates (for testing or refresh)
520 |    */
521 |   clearTemplates(): void {
522 |     this.db.exec('DELETE FROM templates');
523 |     logger.info('Cleared all templates from database');
524 |   }
525 |   
526 |   /**
527 |    * Rebuild the FTS5 index for all templates
528 |    * This is needed when templates are bulk imported or when FTS5 gets out of sync
529 |    */
530 |   rebuildTemplateFTS(): void {
531 |     // Skip if FTS5 is not supported
532 |     if (!this.hasFTS5Support) {
533 |       return;
534 |     }
535 |     
536 |     try {
537 |       // Clear existing FTS data
538 |       this.db.exec('DELETE FROM templates_fts');
539 |       
540 |       // Repopulate from templates table
541 |       this.db.exec(`
542 |         INSERT INTO templates_fts(rowid, name, description)
543 |         SELECT id, name, description FROM templates
544 |       `);
545 |       
546 |       const count = this.getTemplateCount();
547 |       logger.info(`Rebuilt FTS5 index for ${count} templates`);
548 |     } catch (error) {
549 |       logger.warn('Failed to rebuild template FTS5 index:', error);
550 |       // Non-critical error - search will fallback to LIKE
551 |     }
552 |   }
553 |   
554 |   /**
555 |    * Update metadata for a template
556 |    */
557 |   updateTemplateMetadata(templateId: number, metadata: any): void {
558 |     const stmt = this.db.prepare(`
559 |       UPDATE templates 
560 |       SET metadata_json = ?, metadata_generated_at = CURRENT_TIMESTAMP
561 |       WHERE id = ?
562 |     `);
563 |     
564 |     stmt.run(JSON.stringify(metadata), templateId);
565 |     logger.debug(`Updated metadata for template ${templateId}`);
566 |   }
567 |   
568 |   /**
569 |    * Batch update metadata for multiple templates
570 |    */
571 |   batchUpdateMetadata(metadataMap: Map<number, any>): void {
572 |     const stmt = this.db.prepare(`
573 |       UPDATE templates 
574 |       SET metadata_json = ?, metadata_generated_at = CURRENT_TIMESTAMP
575 |       WHERE id = ?
576 |     `);
577 |     
578 |     // Simple approach - just run the updates
579 |     // Most operations are fast enough without explicit transactions
580 |     for (const [templateId, metadata] of metadataMap.entries()) {
581 |       stmt.run(JSON.stringify(metadata), templateId);
582 |     }
583 |     
584 |     logger.info(`Updated metadata for ${metadataMap.size} templates`);
585 |   }
586 |   
587 |   /**
588 |    * Get templates without metadata
589 |    */
590 |   getTemplatesWithoutMetadata(limit: number = 100): StoredTemplate[] {
591 |     const stmt = this.db.prepare(`
592 |       SELECT * FROM templates 
593 |       WHERE metadata_json IS NULL OR metadata_generated_at IS NULL
594 |       ORDER BY views DESC
595 |       LIMIT ?
596 |     `);
597 |     
598 |     return stmt.all(limit) as StoredTemplate[];
599 |   }
600 |   
601 |   /**
602 |    * Get templates with outdated metadata (older than days specified)
603 |    */
604 |   getTemplatesWithOutdatedMetadata(daysOld: number = 30, limit: number = 100): StoredTemplate[] {
605 |     const stmt = this.db.prepare(`
606 |       SELECT * FROM templates 
607 |       WHERE metadata_generated_at < datetime('now', '-' || ? || ' days')
608 |       ORDER BY views DESC
609 |       LIMIT ?
610 |     `);
611 |     
612 |     return stmt.all(daysOld, limit) as StoredTemplate[];
613 |   }
614 |   
615 |   /**
616 |    * Get template metadata stats
617 |    */
618 |   getMetadataStats(): { 
619 |     total: number; 
620 |     withMetadata: number; 
621 |     withoutMetadata: number;
622 |     outdated: number;
623 |   } {
624 |     const total = this.getTemplateCount();
625 |     
626 |     const withMetadata = (this.db.prepare(`
627 |       SELECT COUNT(*) as count FROM templates 
628 |       WHERE metadata_json IS NOT NULL
629 |     `).get() as { count: number }).count;
630 |     
631 |     const withoutMetadata = total - withMetadata;
632 |     
633 |     const outdated = (this.db.prepare(`
634 |       SELECT COUNT(*) as count FROM templates 
635 |       WHERE metadata_generated_at < datetime('now', '-30 days')
636 |     `).get() as { count: number }).count;
637 |     
638 |     return { total, withMetadata, withoutMetadata, outdated };
639 |   }
640 | 
641 |   /**
642 |    * Build WHERE conditions for metadata filtering
643 |    * @private
644 |    * @returns Object containing SQL conditions array and parameter values array
645 |    */
646 |   private buildMetadataFilterConditions(filters: {
647 |     category?: string;
648 |     complexity?: 'simple' | 'medium' | 'complex';
649 |     maxSetupMinutes?: number;
650 |     minSetupMinutes?: number;
651 |     requiredService?: string;
652 |     targetAudience?: string;
653 |   }): { conditions: string[], params: any[] } {
654 |     const conditions: string[] = ['metadata_json IS NOT NULL'];
655 |     const params: any[] = [];
656 | 
657 |     if (filters.category !== undefined) {
658 |       // Use parameterized LIKE with JSON array search - safe from injection
659 |       conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
660 |       // Escape special characters and quotes for JSON string matching
661 |       const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
662 |       params.push(sanitizedCategory);
663 |     }
664 | 
665 |     if (filters.complexity) {
666 |       conditions.push("json_extract(metadata_json, '$.complexity') = ?");
667 |       params.push(filters.complexity);
668 |     }
669 | 
670 |     if (filters.maxSetupMinutes !== undefined) {
671 |       conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
672 |       params.push(filters.maxSetupMinutes);
673 |     }
674 | 
675 |     if (filters.minSetupMinutes !== undefined) {
676 |       conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
677 |       params.push(filters.minSetupMinutes);
678 |     }
679 | 
680 |     if (filters.requiredService !== undefined) {
681 |       // Use parameterized LIKE with JSON array search - safe from injection
682 |       conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
683 |       // Escape special characters and quotes for JSON string matching
684 |       const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
685 |       params.push(sanitizedService);
686 |     }
687 | 
688 |     if (filters.targetAudience !== undefined) {
689 |       // Use parameterized LIKE with JSON array search - safe from injection
690 |       conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
691 |       // Escape special characters and quotes for JSON string matching
692 |       const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
693 |       params.push(sanitizedAudience);
694 |     }
695 | 
696 |     return { conditions, params };
697 |   }
698 | 
699 |   /**
700 |    * Search templates by metadata fields
701 |    */
702 |   searchTemplatesByMetadata(filters: {
703 |     category?: string;
704 |     complexity?: 'simple' | 'medium' | 'complex';
705 |     maxSetupMinutes?: number;
706 |     minSetupMinutes?: number;
707 |     requiredService?: string;
708 |     targetAudience?: string;
709 |   }, limit: number = 20, offset: number = 0): StoredTemplate[] {
710 |     const startTime = Date.now();
711 | 
712 |     // Build WHERE conditions using shared helper
713 |     const { conditions, params } = this.buildMetadataFilterConditions(filters);
714 | 
715 |     // Performance optimization: Use two-phase query to avoid loading large compressed workflows
716 |     // during metadata filtering. This prevents timeout when no filters are provided.
717 |     // Phase 1: Get IDs only with metadata filtering (fast - no workflow data)
718 |     // Add id to ORDER BY to ensure stable ordering
719 |     const idsQuery = `
720 |       SELECT id FROM templates
721 |       WHERE ${conditions.join(' AND ')}
722 |       ORDER BY views DESC, created_at DESC, id ASC
723 |       LIMIT ? OFFSET ?
724 |     `;
725 | 
726 |     params.push(limit, offset);
727 |     const ids = this.db.prepare(idsQuery).all(...params) as { id: number }[];
728 | 
729 |     const phase1Time = Date.now() - startTime;
730 | 
731 |     if (ids.length === 0) {
732 |       logger.debug('Metadata search found 0 results', { filters, phase1Ms: phase1Time });
733 |       return [];
734 |     }
735 | 
736 |     // Defensive validation: ensure all IDs are valid positive integers
737 |     const idValues = ids.map(r => r.id).filter(id => typeof id === 'number' && id > 0 && Number.isInteger(id));
738 | 
739 |     if (idValues.length === 0) {
740 |       logger.warn('No valid IDs after filtering', { filters, originalCount: ids.length });
741 |       return [];
742 |     }
743 | 
744 |     if (idValues.length !== ids.length) {
745 |       logger.warn('Some IDs were filtered out as invalid', {
746 |         original: ids.length,
747 |         valid: idValues.length,
748 |         filtered: ids.length - idValues.length
749 |       });
750 |     }
751 | 
752 |     // Phase 2: Fetch full records preserving exact order from Phase 1
753 |     // Use CTE with VALUES to maintain ordering without depending on SQLite's IN clause behavior
754 |     const phase2Start = Date.now();
755 |     const orderedQuery = `
756 |       WITH ordered_ids(id, sort_order) AS (
757 |         VALUES ${idValues.map((id, idx) => `(${id}, ${idx})`).join(', ')}
758 |       )
759 |       SELECT t.* FROM templates t
760 |       INNER JOIN ordered_ids o ON t.id = o.id
761 |       ORDER BY o.sort_order
762 |     `;
763 | 
764 |     const results = this.db.prepare(orderedQuery).all() as StoredTemplate[];
765 |     const phase2Time = Date.now() - phase2Start;
766 | 
767 |     logger.debug(`Metadata search found ${results.length} results`, {
768 |       filters,
769 |       count: results.length,
770 |       phase1Ms: phase1Time,
771 |       phase2Ms: phase2Time,
772 |       totalMs: Date.now() - startTime,
773 |       optimization: 'two-phase-with-ordering'
774 |     });
775 | 
776 |     return results.map(t => this.decompressWorkflow(t));
777 |   }
778 |   
779 |   /**
780 |    * Get count for metadata search results
781 |    */
782 |   getMetadataSearchCount(filters: {
783 |     category?: string;
784 |     complexity?: 'simple' | 'medium' | 'complex';
785 |     maxSetupMinutes?: number;
786 |     minSetupMinutes?: number;
787 |     requiredService?: string;
788 |     targetAudience?: string;
789 |   }): number {
790 |     // Build WHERE conditions using shared helper
791 |     const { conditions, params } = this.buildMetadataFilterConditions(filters);
792 | 
793 |     const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions.join(' AND ')}`;
794 |     const result = this.db.prepare(query).get(...params) as { count: number };
795 | 
796 |     return result.count;
797 |   }
798 |   
799 |   /**
800 |    * Get unique categories from metadata
801 |    */
802 |   getAvailableCategories(): string[] {
803 |     const results = this.db.prepare(`
804 |       SELECT DISTINCT json_extract(value, '$') as category
805 |       FROM templates, json_each(json_extract(metadata_json, '$.categories'))
806 |       WHERE metadata_json IS NOT NULL
807 |       ORDER BY category
808 |     `).all() as { category: string }[];
809 |     
810 |     return results.map(r => r.category);
811 |   }
812 |   
813 |   /**
814 |    * Get unique target audiences from metadata
815 |    */
816 |   getAvailableTargetAudiences(): string[] {
817 |     const results = this.db.prepare(`
818 |       SELECT DISTINCT json_extract(value, '$') as audience
819 |       FROM templates, json_each(json_extract(metadata_json, '$.target_audience'))
820 |       WHERE metadata_json IS NOT NULL
821 |       ORDER BY audience
822 |     `).all() as { audience: string }[];
823 |     
824 |     return results.map(r => r.audience);
825 |   }
826 |   
827 |   /**
828 |    * Get templates by category with metadata
829 |    */
830 |   getTemplatesByCategory(category: string, limit: number = 10, offset: number = 0): StoredTemplate[] {
831 |     const query = `
832 |       SELECT * FROM templates 
833 |       WHERE metadata_json IS NOT NULL 
834 |         AND json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'
835 |       ORDER BY views DESC, created_at DESC
836 |       LIMIT ? OFFSET ?
837 |     `;
838 |     
839 |     // Use same sanitization as searchTemplatesByMetadata for consistency
840 |     const sanitizedCategory = JSON.stringify(category).slice(1, -1);
841 |     const results = this.db.prepare(query).all(sanitizedCategory, limit, offset) as StoredTemplate[];
842 |     return results.map(t => this.decompressWorkflow(t));
843 |   }
844 |   
845 |   /**
846 |    * Get templates by complexity level
847 |    */
848 |   getTemplatesByComplexity(complexity: 'simple' | 'medium' | 'complex', limit: number = 10, offset: number = 0): StoredTemplate[] {
849 |     const query = `
850 |       SELECT * FROM templates 
851 |       WHERE metadata_json IS NOT NULL 
852 |         AND json_extract(metadata_json, '$.complexity') = ?
853 |       ORDER BY views DESC, created_at DESC
854 |       LIMIT ? OFFSET ?
855 |     `;
856 |     
857 |     const results = this.db.prepare(query).all(complexity, limit, offset) as StoredTemplate[];
858 |     return results.map(t => this.decompressWorkflow(t));
859 |   }
860 | 
861 |   /**
862 |    * Get count of templates matching metadata search
863 |    */
864 |   getSearchTemplatesByMetadataCount(filters: {
865 |     category?: string;
866 |     complexity?: 'simple' | 'medium' | 'complex';
867 |     maxSetupMinutes?: number;
868 |     minSetupMinutes?: number;
869 |     requiredService?: string;
870 |     targetAudience?: string;
871 |   }): number {
872 |     let sql = `
873 |       SELECT COUNT(*) as count FROM templates 
874 |       WHERE metadata_json IS NOT NULL
875 |     `;
876 |     const params: any[] = [];
877 | 
878 |     if (filters.category) {
879 |       sql += ` AND json_extract(metadata_json, '$.categories') LIKE ?`;
880 |       params.push(`%"${filters.category}"%`);
881 |     }
882 | 
883 |     if (filters.complexity) {
884 |       sql += ` AND json_extract(metadata_json, '$.complexity') = ?`;
885 |       params.push(filters.complexity);
886 |     }
887 | 
888 |     if (filters.maxSetupMinutes !== undefined) {
889 |       sql += ` AND CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?`;
890 |       params.push(filters.maxSetupMinutes);
891 |     }
892 | 
893 |     if (filters.minSetupMinutes !== undefined) {
894 |       sql += ` AND CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?`;
895 |       params.push(filters.minSetupMinutes);
896 |     }
897 | 
898 |     if (filters.requiredService) {
899 |       sql += ` AND json_extract(metadata_json, '$.required_services') LIKE ?`;
900 |       params.push(`%"${filters.requiredService}"%`);
901 |     }
902 | 
903 |     if (filters.targetAudience) {
904 |       sql += ` AND json_extract(metadata_json, '$.target_audience') LIKE ?`;
905 |       params.push(`%"${filters.targetAudience}"%`);
906 |     }
907 | 
908 |     const result = this.db.prepare(sql).get(...params) as { count: number };
909 |     return result?.count || 0;
910 |   }
911 | 
912 |   /**
913 |    * Get unique categories from metadata
914 |    */
915 |   getUniqueCategories(): string[] {
916 |     const sql = `
917 |       SELECT DISTINCT value as category
918 |       FROM templates, json_each(metadata_json, '$.categories')
919 |       WHERE metadata_json IS NOT NULL
920 |       ORDER BY category
921 |     `;
922 |     
923 |     const results = this.db.prepare(sql).all() as { category: string }[];
924 |     return results.map(r => r.category);
925 |   }
926 | 
927 |   /**
928 |    * Get unique target audiences from metadata
929 |    */
930 |   getUniqueTargetAudiences(): string[] {
931 |     const sql = `
932 |       SELECT DISTINCT value as audience
933 |       FROM templates, json_each(metadata_json, '$.target_audience')
934 |       WHERE metadata_json IS NOT NULL
935 |       ORDER BY audience
936 |     `;
937 |     
938 |     const results = this.db.prepare(sql).all() as { audience: string }[];
939 |     return results.map(r => r.audience);
940 |   }
941 | }
```

--------------------------------------------------------------------------------
/src/services/config-validator.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Configuration Validator Service
  3 |  * 
  4 |  * Validates node configurations to catch errors before execution.
  5 |  * Provides helpful suggestions and identifies missing or misconfigured properties.
  6 |  */
  7 | 
  8 | export interface ValidationResult {
  9 |   valid: boolean;
 10 |   errors: ValidationError[];
 11 |   warnings: ValidationWarning[];
 12 |   suggestions: string[];
 13 |   visibleProperties: string[];
 14 |   hiddenProperties: string[];
 15 |   autofix?: Record<string, any>;
 16 | }
 17 | 
 18 | export interface ValidationError {
 19 |   type: 'missing_required' | 'invalid_type' | 'invalid_value' | 'incompatible' | 'invalid_configuration' | 'syntax_error';
 20 |   property: string;
 21 |   message: string;
 22 |   fix?: string;
 23 |   suggestion?: string;
 24 | }
 25 | 
 26 | export interface ValidationWarning {
 27 |   type: 'missing_common' | 'deprecated' | 'inefficient' | 'security' | 'best_practice' | 'invalid_value';
 28 |   property?: string;
 29 |   message: string;
 30 |   suggestion?: string;
 31 | }
 32 | 
 33 | export class ConfigValidator {
 34 |   /**
 35 |    * UI-only property types that should not be validated as configuration
 36 |    */
 37 |   private static readonly UI_ONLY_TYPES = ['notice', 'callout', 'infoBox', 'info'];
 38 | 
 39 |   /**
 40 |    * Validate a node configuration
 41 |    */
 42 |   static validate(
 43 |     nodeType: string,
 44 |     config: Record<string, any>,
 45 |     properties: any[],
 46 |     userProvidedKeys?: Set<string> // NEW: Track user-provided properties to avoid warning about defaults
 47 |   ): ValidationResult {
 48 |     // Input validation
 49 |     if (!config || typeof config !== 'object') {
 50 |       throw new TypeError('Config must be a non-null object');
 51 |     }
 52 |     if (!properties || !Array.isArray(properties)) {
 53 |       throw new TypeError('Properties must be a non-null array');
 54 |     }
 55 | 
 56 |     const errors: ValidationError[] = [];
 57 |     const warnings: ValidationWarning[] = [];
 58 |     const suggestions: string[] = [];
 59 |     const visibleProperties: string[] = [];
 60 |     const hiddenProperties: string[] = [];
 61 |     const autofix: Record<string, any> = {};
 62 |     
 63 |     // Check required properties
 64 |     this.checkRequiredProperties(properties, config, errors);
 65 |     
 66 |     // Check property visibility
 67 |     const { visible, hidden } = this.getPropertyVisibility(properties, config);
 68 |     visibleProperties.push(...visible);
 69 |     hiddenProperties.push(...hidden);
 70 |     
 71 |     // Validate property types and values
 72 |     this.validatePropertyTypes(properties, config, errors);
 73 |     
 74 |     // Node-specific validations
 75 |     this.performNodeSpecificValidation(nodeType, config, errors, warnings, suggestions, autofix);
 76 |     
 77 |     // Check for common issues
 78 |     this.checkCommonIssues(nodeType, config, properties, warnings, suggestions, userProvidedKeys);
 79 | 
 80 |     // Security checks
 81 |     this.performSecurityChecks(nodeType, config, warnings);
 82 |     
 83 |     return {
 84 |       valid: errors.length === 0,
 85 |       errors,
 86 |       warnings,
 87 |       suggestions,
 88 |       visibleProperties,
 89 |       hiddenProperties,
 90 |       autofix: Object.keys(autofix).length > 0 ? autofix : undefined
 91 |     };
 92 |   }
 93 | 
 94 |   /**
 95 |    * Validate multiple node configurations in batch
 96 |    * Useful for validating entire workflows or multiple nodes at once
 97 |    * 
 98 |    * @param configs - Array of configurations to validate
 99 |    * @returns Array of validation results in the same order as input
100 |    */
101 |   static validateBatch(
102 |     configs: Array<{
103 |       nodeType: string;
104 |       config: Record<string, any>;
105 |       properties: any[];
106 |     }>
107 |   ): ValidationResult[] {
108 |     return configs.map(({ nodeType, config, properties }) => 
109 |       this.validate(nodeType, config, properties)
110 |     );
111 |   }
112 |   
113 |   /**
114 |    * Check for missing required properties
115 |    */
116 |   private static checkRequiredProperties(
117 |     properties: any[],
118 |     config: Record<string, any>,
119 |     errors: ValidationError[]
120 |   ): void {
121 |     for (const prop of properties) {
122 |       if (!prop || !prop.name) continue; // Skip invalid properties
123 | 
124 |       if (prop.required) {
125 |         const value = config[prop.name];
126 | 
127 |         // Check if property is missing or has null/undefined value
128 |         if (!(prop.name in config)) {
129 |           errors.push({
130 |             type: 'missing_required',
131 |             property: prop.name,
132 |             message: `Required property '${prop.displayName || prop.name}' is missing`,
133 |             fix: `Add ${prop.name} to your configuration`
134 |           });
135 |         } else if (value === null || value === undefined) {
136 |           errors.push({
137 |             type: 'invalid_type',
138 |             property: prop.name,
139 |             message: `Required property '${prop.displayName || prop.name}' cannot be null or undefined`,
140 |             fix: `Provide a valid value for ${prop.name}`
141 |           });
142 |         } else if (typeof value === 'string' && value.trim() === '') {
143 |           // Check for empty strings which are invalid for required string properties
144 |           errors.push({
145 |             type: 'missing_required',
146 |             property: prop.name,
147 |             message: `Required property '${prop.displayName || prop.name}' cannot be empty`,
148 |             fix: `Provide a valid value for ${prop.name}`
149 |           });
150 |         }
151 |       }
152 |     }
153 |   }
154 |   
155 |   /**
156 |    * Get visible and hidden properties based on displayOptions
157 |    */
158 |   private static getPropertyVisibility(
159 |     properties: any[], 
160 |     config: Record<string, any>
161 |   ): { visible: string[]; hidden: string[] } {
162 |     const visible: string[] = [];
163 |     const hidden: string[] = [];
164 |     
165 |     for (const prop of properties) {
166 |       if (this.isPropertyVisible(prop, config)) {
167 |         visible.push(prop.name);
168 |       } else {
169 |         hidden.push(prop.name);
170 |       }
171 |     }
172 |     
173 |     return { visible, hidden };
174 |   }
175 |   
176 |   /**
177 |    * Check if a property is visible given current config
178 |    */
179 |   protected static isPropertyVisible(prop: any, config: Record<string, any>): boolean {
180 |     if (!prop.displayOptions) return true;
181 |     
182 |     // Check show conditions
183 |     if (prop.displayOptions.show) {
184 |       for (const [key, values] of Object.entries(prop.displayOptions.show)) {
185 |         const configValue = config[key];
186 |         const expectedValues = Array.isArray(values) ? values : [values];
187 |         
188 |         if (!expectedValues.includes(configValue)) {
189 |           return false;
190 |         }
191 |       }
192 |     }
193 |     
194 |     // Check hide conditions
195 |     if (prop.displayOptions.hide) {
196 |       for (const [key, values] of Object.entries(prop.displayOptions.hide)) {
197 |         const configValue = config[key];
198 |         const expectedValues = Array.isArray(values) ? values : [values];
199 |         
200 |         if (expectedValues.includes(configValue)) {
201 |           return false;
202 |         }
203 |       }
204 |     }
205 |     
206 |     return true;
207 |   }
208 |   
209 |   /**
210 |    * Validate property types and values
211 |    */
212 |   private static validatePropertyTypes(
213 |     properties: any[], 
214 |     config: Record<string, any>, 
215 |     errors: ValidationError[]
216 |   ): void {
217 |     for (const [key, value] of Object.entries(config)) {
218 |       const prop = properties.find(p => p.name === key);
219 |       if (!prop) continue;
220 |       
221 |       // Type validation
222 |       if (prop.type === 'string' && typeof value !== 'string') {
223 |         errors.push({
224 |           type: 'invalid_type',
225 |           property: key,
226 |           message: `Property '${key}' must be a string, got ${typeof value}`,
227 |           fix: `Change ${key} to a string value`
228 |         });
229 |       } else if (prop.type === 'number' && typeof value !== 'number') {
230 |         errors.push({
231 |           type: 'invalid_type',
232 |           property: key,
233 |           message: `Property '${key}' must be a number, got ${typeof value}`,
234 |           fix: `Change ${key} to a number`
235 |         });
236 |       } else if (prop.type === 'boolean' && typeof value !== 'boolean') {
237 |         errors.push({
238 |           type: 'invalid_type',
239 |           property: key,
240 |           message: `Property '${key}' must be a boolean, got ${typeof value}`,
241 |           fix: `Change ${key} to true or false`
242 |         });
243 |       } else if (prop.type === 'resourceLocator') {
244 |         // resourceLocator validation: Used by AI model nodes (OpenAI, Anthropic, etc.)
245 |         // Must be an object with required properties:
246 |         //   - mode: string ('list' | 'id' | 'url')
247 |         //   - value: any (the actual model/resource identifier)
248 |         // Common mistake: passing string directly instead of object structure
249 |         if (typeof value !== 'object' || value === null || Array.isArray(value)) {
250 |           const fixValue = typeof value === 'string' ? value : JSON.stringify(value);
251 |           errors.push({
252 |             type: 'invalid_type',
253 |             property: key,
254 |             message: `Property '${key}' is a resourceLocator and must be an object with 'mode' and 'value' properties, got ${typeof value}`,
255 |             fix: `Change ${key} to { mode: "list", value: ${JSON.stringify(fixValue)} } or { mode: "id", value: ${JSON.stringify(fixValue)} }`
256 |           });
257 |         } else {
258 |           // Check required properties
259 |           if (!value.mode) {
260 |             errors.push({
261 |               type: 'missing_required',
262 |               property: `${key}.mode`,
263 |               message: `resourceLocator '${key}' is missing required property 'mode'`,
264 |               fix: `Add mode property: { mode: "list", value: ${JSON.stringify(value.value || '')} }`
265 |             });
266 |           } else if (typeof value.mode !== 'string') {
267 |             errors.push({
268 |               type: 'invalid_type',
269 |               property: `${key}.mode`,
270 |               message: `resourceLocator '${key}.mode' must be a string, got ${typeof value.mode}`,
271 |               fix: `Set mode to a valid string value`
272 |             });
273 |           } else if (prop.modes) {
274 |             // Schema-based validation: Check if mode exists in the modes definition
275 |             // In n8n, modes are defined at the top level of resourceLocator properties
276 |             // Modes can be defined in different ways:
277 |             // 1. Array of mode objects: [{name: 'list', ...}, {name: 'id', ...}, {name: 'name', ...}]
278 |             // 2. Object with mode keys: { list: {...}, id: {...}, url: {...}, name: {...} }
279 |             const modes = prop.modes;
280 | 
281 |             // Validate modes structure before processing to prevent crashes
282 |             if (!modes || typeof modes !== 'object') {
283 |               // Invalid schema structure - skip validation to prevent false positives
284 |               continue;
285 |             }
286 | 
287 |             let allowedModes: string[] = [];
288 | 
289 |             if (Array.isArray(modes)) {
290 |               // Array format (most common in n8n): extract name property from each mode object
291 |               allowedModes = modes
292 |                 .map(m => (typeof m === 'object' && m !== null) ? m.name : m)
293 |                 .filter(m => typeof m === 'string' && m.length > 0);
294 |             } else {
295 |               // Object format: extract keys as mode names
296 |               allowedModes = Object.keys(modes).filter(k => k.length > 0);
297 |             }
298 | 
299 |             // Only validate if we successfully extracted modes
300 |             if (allowedModes.length > 0 && !allowedModes.includes(value.mode)) {
301 |               errors.push({
302 |                 type: 'invalid_value',
303 |                 property: `${key}.mode`,
304 |                 message: `resourceLocator '${key}.mode' must be one of [${allowedModes.join(', ')}], got '${value.mode}'`,
305 |                 fix: `Change mode to one of: ${allowedModes.join(', ')}`
306 |               });
307 |             }
308 |           }
309 |           // If no modes defined at property level, skip mode validation
310 |           // This prevents false positives for nodes with dynamic/runtime-determined modes
311 | 
312 |           if (value.value === undefined) {
313 |             errors.push({
314 |               type: 'missing_required',
315 |               property: `${key}.value`,
316 |               message: `resourceLocator '${key}' is missing required property 'value'`,
317 |               fix: `Add value property to specify the ${prop.displayName || key}`
318 |             });
319 |           }
320 |         }
321 |       }
322 | 
323 |       // Options validation
324 |       if (prop.type === 'options' && prop.options) {
325 |         const validValues = prop.options.map((opt: any) => 
326 |           typeof opt === 'string' ? opt : opt.value
327 |         );
328 |         
329 |         if (!validValues.includes(value)) {
330 |           errors.push({
331 |             type: 'invalid_value',
332 |             property: key,
333 |             message: `Invalid value for '${key}'. Must be one of: ${validValues.join(', ')}`,
334 |             fix: `Change ${key} to one of the valid options`
335 |           });
336 |         }
337 |       }
338 |     }
339 |   }
340 |   
341 |   /**
342 |    * Perform node-specific validation
343 |    */
344 |   private static performNodeSpecificValidation(
345 |     nodeType: string,
346 |     config: Record<string, any>,
347 |     errors: ValidationError[],
348 |     warnings: ValidationWarning[],
349 |     suggestions: string[],
350 |     autofix: Record<string, any>
351 |   ): void {
352 |     switch (nodeType) {
353 |       case 'nodes-base.httpRequest':
354 |         this.validateHttpRequest(config, errors, warnings, suggestions, autofix);
355 |         break;
356 |       
357 |       case 'nodes-base.webhook':
358 |         this.validateWebhook(config, warnings, suggestions);
359 |         break;
360 |         
361 |       case 'nodes-base.postgres':
362 |       case 'nodes-base.mysql':
363 |         this.validateDatabase(config, warnings, suggestions);
364 |         break;
365 |         
366 |       case 'nodes-base.code':
367 |         this.validateCode(config, errors, warnings);
368 |         break;
369 |     }
370 |   }
371 |   
372 |   /**
373 |    * Validate HTTP Request configuration
374 |    */
375 |   private static validateHttpRequest(
376 |     config: Record<string, any>,
377 |     errors: ValidationError[],
378 |     warnings: ValidationWarning[],
379 |     suggestions: string[],
380 |     autofix: Record<string, any>
381 |   ): void {
382 |     // URL validation
383 |     if (config.url && typeof config.url === 'string') {
384 |       if (!config.url.startsWith('http://') && !config.url.startsWith('https://')) {
385 |         errors.push({
386 |           type: 'invalid_value',
387 |           property: 'url',
388 |           message: 'URL must start with http:// or https://',
389 |           fix: 'Add https:// to the beginning of your URL'
390 |         });
391 |       }
392 |     }
393 |     
394 |     // POST/PUT/PATCH without body
395 |     if (['POST', 'PUT', 'PATCH'].includes(config.method) && !config.sendBody) {
396 |       warnings.push({
397 |         type: 'missing_common',
398 |         property: 'sendBody',
399 |         message: `${config.method} requests typically send a body`,
400 |         suggestion: 'Set sendBody=true and configure the body content'
401 |       });
402 |       
403 |       autofix.sendBody = true;
404 |       autofix.contentType = 'json';
405 |     }
406 |     
407 |     // Authentication warnings
408 |     if (!config.authentication || config.authentication === 'none') {
409 |       if (config.url?.includes('api.') || config.url?.includes('/api/')) {
410 |         warnings.push({
411 |           type: 'security',
412 |           message: 'API endpoints typically require authentication',
413 |           suggestion: 'Consider setting authentication if the API requires it'
414 |         });
415 |       }
416 |     }
417 |     
418 |     // JSON body validation
419 |     if (config.sendBody && config.contentType === 'json' && config.jsonBody) {
420 |       try {
421 |         JSON.parse(config.jsonBody);
422 |       } catch (e) {
423 |         errors.push({
424 |           type: 'invalid_value',
425 |           property: 'jsonBody',
426 |           message: 'jsonBody contains invalid JSON',
427 |           fix: 'Ensure jsonBody contains valid JSON syntax'
428 |         });
429 |       }
430 |     }
431 |   }
432 |   
433 |   /**
434 |    * Validate Webhook configuration
435 |    */
436 |   private static validateWebhook(
437 |     config: Record<string, any>,
438 |     warnings: ValidationWarning[],
439 |     suggestions: string[]
440 |   ): void {
441 |     // Basic webhook validation - moved detailed validation to NodeSpecificValidators
442 |     if (config.responseMode === 'responseNode' && !config.responseData) {
443 |       suggestions.push('When using responseMode=responseNode, add a "Respond to Webhook" node to send custom responses');
444 |     }
445 |   }
446 |   
447 |   /**
448 |    * Validate database queries
449 |    */
450 |   private static validateDatabase(
451 |     config: Record<string, any>,
452 |     warnings: ValidationWarning[],
453 |     suggestions: string[]
454 |   ): void {
455 |     if (config.query) {
456 |       const query = config.query.toLowerCase();
457 |       
458 |       // SQL injection warning
459 |       if (query.includes('${') || query.includes('{{')) {
460 |         warnings.push({
461 |           type: 'security',
462 |           message: 'Query contains template expressions that might be vulnerable to SQL injection',
463 |           suggestion: 'Use parameterized queries with additionalFields.queryParams instead'
464 |         });
465 |       }
466 |       
467 |       // DELETE without WHERE
468 |       if (query.includes('delete') && !query.includes('where')) {
469 |         warnings.push({
470 |           type: 'security',
471 |           message: 'DELETE query without WHERE clause will delete all records',
472 |           suggestion: 'Add a WHERE clause to limit the deletion'
473 |         });
474 |       }
475 |       
476 |       // SELECT * warning
477 |       if (query.includes('select *')) {
478 |         suggestions.push('Consider selecting specific columns instead of * for better performance');
479 |       }
480 |     }
481 |   }
482 |   
483 |   /**
484 |    * Validate Code node
485 |    */
486 |   private static validateCode(
487 |     config: Record<string, any>,
488 |     errors: ValidationError[],
489 |     warnings: ValidationWarning[]
490 |   ): void {
491 |     const codeField = config.language === 'python' ? 'pythonCode' : 'jsCode';
492 |     const code = config[codeField];
493 |     
494 |     if (!code || code.trim() === '') {
495 |       errors.push({
496 |         type: 'missing_required',
497 |         property: codeField,
498 |         message: 'Code cannot be empty',
499 |         fix: 'Add your code logic'
500 |       });
501 |       return;
502 |     }
503 |     
504 |     // Security checks
505 |     if (code?.includes('eval(') || code?.includes('exec(')) {
506 |       warnings.push({
507 |         type: 'security',
508 |         message: 'Code contains eval/exec which can be a security risk',
509 |         suggestion: 'Avoid using eval/exec with untrusted input'
510 |       });
511 |     }
512 |     
513 |     // Basic syntax validation
514 |     if (config.language === 'python') {
515 |       this.validatePythonSyntax(code, errors, warnings);
516 |     } else {
517 |       this.validateJavaScriptSyntax(code, errors, warnings);
518 |     }
519 |     
520 |     // n8n-specific patterns
521 |     this.validateN8nCodePatterns(code, config.language || 'javascript', errors, warnings);
522 |   }
523 |   
524 |   /**
525 |    * Check for common configuration issues
526 |    */
527 |   private static checkCommonIssues(
528 |     nodeType: string,
529 |     config: Record<string, any>,
530 |     properties: any[],
531 |     warnings: ValidationWarning[],
532 |     suggestions: string[],
533 |     userProvidedKeys?: Set<string> // NEW: Only warn about user-provided properties
534 |   ): void {
535 |     // Skip visibility checks for Code nodes as they have simple property structure
536 |     if (nodeType === 'nodes-base.code') {
537 |       // Code nodes don't have complex displayOptions, so skip visibility warnings
538 |       return;
539 |     }
540 | 
541 |     // Check for properties that won't be used
542 |     const visibleProps = properties.filter(p => this.isPropertyVisible(p, config));
543 |     const configuredKeys = Object.keys(config);
544 | 
545 |     for (const key of configuredKeys) {
546 |       // Skip internal properties that are always present
547 |       if (key === '@version' || key.startsWith('_')) {
548 |         continue;
549 |       }
550 | 
551 |       // CRITICAL FIX: Only warn about properties the user actually provided, not defaults
552 |       if (userProvidedKeys && !userProvidedKeys.has(key)) {
553 |         continue; // Skip properties that were added as defaults
554 |       }
555 | 
556 |       // Find the property definition
557 |       const prop = properties.find(p => p.name === key);
558 | 
559 |       // Skip UI-only properties (notice, callout, etc.) - they're not configuration
560 |       if (prop && this.UI_ONLY_TYPES.includes(prop.type)) {
561 |         continue;
562 |       }
563 | 
564 |       // Check if property is visible with current settings
565 |       if (!visibleProps.find(p => p.name === key)) {
566 |         // Get visibility requirements for better error message
567 |         const visibilityReq = this.getVisibilityRequirement(prop, config);
568 | 
569 |         warnings.push({
570 |           type: 'inefficient',
571 |           property: key,
572 |           message: `Property '${prop?.displayName || key}' won't be used - not visible with current settings`,
573 |           suggestion: visibilityReq || 'Remove this property or adjust other settings to make it visible'
574 |         });
575 |       }
576 |     }
577 |     
578 |     // Suggest commonly used properties
579 |     const commonProps = ['authentication', 'errorHandling', 'timeout'];
580 |     for (const prop of commonProps) {
581 |       const propDef = properties.find(p => p.name === prop);
582 |       if (propDef && this.isPropertyVisible(propDef, config) && !(prop in config)) {
583 |         suggestions.push(`Consider setting '${prop}' for better control`);
584 |       }
585 |     }
586 |   }
587 |   
588 |   /**
589 |    * Perform security checks
590 |    */
591 |   private static performSecurityChecks(
592 |     nodeType: string,
593 |     config: Record<string, any>,
594 |     warnings: ValidationWarning[]
595 |   ): void {
596 |     // Check for hardcoded credentials
597 |     const sensitivePatterns = [
598 |       /api[_-]?key/i,
599 |       /password/i,
600 |       /secret/i,
601 |       /token/i,
602 |       /credential/i
603 |     ];
604 |     
605 |     for (const [key, value] of Object.entries(config)) {
606 |       if (typeof value === 'string') {
607 |         for (const pattern of sensitivePatterns) {
608 |           if (pattern.test(key) && value.length > 0 && !value.includes('{{')) {
609 |             warnings.push({
610 |               type: 'security',
611 |               property: key,
612 |               message: `Hardcoded ${key} detected`,
613 |               suggestion: 'Use n8n credentials or expressions instead of hardcoding sensitive values'
614 |             });
615 |             break;
616 |           }
617 |         }
618 |       }
619 |     }
620 |   }
621 |   
622 |   /**
623 |    * Get visibility requirement for a property
624 |    * Explains what needs to be set for the property to be visible
625 |    */
626 |   private static getVisibilityRequirement(prop: any, config: Record<string, any>): string | undefined {
627 |     if (!prop || !prop.displayOptions?.show) {
628 |       return undefined;
629 |     }
630 | 
631 |     const requirements: string[] = [];
632 |     for (const [field, values] of Object.entries(prop.displayOptions.show)) {
633 |       const expectedValues = Array.isArray(values) ? values : [values];
634 |       const currentValue = config[field];
635 | 
636 |       // Only include if the current value doesn't match
637 |       if (!expectedValues.includes(currentValue)) {
638 |         const valueStr = expectedValues.length === 1
639 |           ? `"${expectedValues[0]}"`
640 |           : expectedValues.map(v => `"${v}"`).join(' or ');
641 |         requirements.push(`${field}=${valueStr}`);
642 |       }
643 |     }
644 | 
645 |     if (requirements.length === 0) {
646 |       return undefined;
647 |     }
648 | 
649 |     return `Requires: ${requirements.join(', ')}`;
650 |   }
651 | 
652 |   /**
653 |    * Basic JavaScript syntax validation
654 |    */
655 |   private static validateJavaScriptSyntax(
656 |     code: string,
657 |     errors: ValidationError[],
658 |     warnings: ValidationWarning[]
659 |   ): void {
660 |     // Check for common syntax errors
661 |     const openBraces = (code.match(/\{/g) || []).length;
662 |     const closeBraces = (code.match(/\}/g) || []).length;
663 |     if (openBraces !== closeBraces) {
664 |       errors.push({
665 |         type: 'invalid_value',
666 |         property: 'jsCode',
667 |         message: 'Unbalanced braces detected',
668 |         fix: 'Check that all { have matching }'
669 |       });
670 |     }
671 |     
672 |     const openParens = (code.match(/\(/g) || []).length;
673 |     const closeParens = (code.match(/\)/g) || []).length;
674 |     if (openParens !== closeParens) {
675 |       errors.push({
676 |         type: 'invalid_value',
677 |         property: 'jsCode',
678 |         message: 'Unbalanced parentheses detected',
679 |         fix: 'Check that all ( have matching )'
680 |       });
681 |     }
682 |     
683 |     // Check for unterminated strings
684 |     const stringMatches = code.match(/(["'`])(?:(?=(\\?))\2.)*?\1/g) || [];
685 |     const quotesInStrings = stringMatches.join('').match(/["'`]/g)?.length || 0;
686 |     const totalQuotes = (code.match(/["'`]/g) || []).length;
687 |     if ((totalQuotes - quotesInStrings) % 2 !== 0) {
688 |       warnings.push({
689 |         type: 'inefficient',
690 |         message: 'Possible unterminated string detected',
691 |         suggestion: 'Check that all strings are properly closed'
692 |       });
693 |     }
694 |   }
695 |   
696 |   /**
697 |    * Basic Python syntax validation
698 |    */
699 |   private static validatePythonSyntax(
700 |     code: string,
701 |     errors: ValidationError[],
702 |     warnings: ValidationWarning[]
703 |   ): void {
704 |     // Check indentation consistency
705 |     const lines = code.split('\n');
706 |     const indentTypes = new Set<string>();
707 |     
708 |     lines.forEach(line => {
709 |       const indent = line.match(/^(\s+)/);
710 |       if (indent) {
711 |         if (indent[1].includes('\t')) indentTypes.add('tabs');
712 |         if (indent[1].includes(' ')) indentTypes.add('spaces');
713 |       }
714 |     });
715 |     
716 |     if (indentTypes.size > 1) {
717 |       errors.push({
718 |         type: 'syntax_error',
719 |         property: 'pythonCode',
720 |         message: 'Mixed indentation (tabs and spaces)',
721 |         fix: 'Use either tabs or spaces consistently, not both'
722 |       });
723 |     }
724 |     
725 |     // Check for unmatched brackets in Python
726 |     const openSquare = (code.match(/\[/g) || []).length;
727 |     const closeSquare = (code.match(/\]/g) || []).length;
728 |     if (openSquare !== closeSquare) {
729 |       errors.push({
730 |         type: 'syntax_error',
731 |         property: 'pythonCode',
732 |         message: 'Unmatched bracket - missing ] or extra [',
733 |         fix: 'Check that all [ have matching ]'
734 |       });
735 |     }
736 |     
737 |     // Check for unmatched curly braces
738 |     const openCurly = (code.match(/\{/g) || []).length;
739 |     const closeCurly = (code.match(/\}/g) || []).length;
740 |     if (openCurly !== closeCurly) {
741 |       errors.push({
742 |         type: 'syntax_error',
743 |         property: 'pythonCode',
744 |         message: 'Unmatched bracket - missing } or extra {',
745 |         fix: 'Check that all { have matching }'
746 |       });
747 |     }
748 |     
749 |     // Check for colons after control structures
750 |     const controlStructures = /^\s*(if|elif|else|for|while|def|class|try|except|finally|with)\s+.*[^:]\s*$/gm;
751 |     if (controlStructures.test(code)) {
752 |       warnings.push({
753 |         type: 'inefficient',
754 |         message: 'Missing colon after control structure',
755 |         suggestion: 'Add : at the end of if/for/def/class statements'
756 |       });
757 |     }
758 |   }
759 |   
760 |   /**
761 |    * Validate n8n-specific code patterns
762 |    */
763 |   private static validateN8nCodePatterns(
764 |     code: string,
765 |     language: string,
766 |     errors: ValidationError[],
767 |     warnings: ValidationWarning[]
768 |   ): void {
769 |     // Check for return statement
770 |     const hasReturn = language === 'python' 
771 |       ? /return\s+/.test(code)
772 |       : /return\s+/.test(code);
773 |     
774 |     if (!hasReturn) {
775 |       warnings.push({
776 |         type: 'missing_common',
777 |         message: 'No return statement found',
778 |         suggestion: 'Code node must return data. Example: return [{json: {result: "success"}}]'
779 |       });
780 |     }
781 |     
782 |     // Check return format for JavaScript
783 |     if (language === 'javascript' && hasReturn) {
784 |       // Check for common incorrect return patterns
785 |       if (/return\s+items\s*;/.test(code) && !code.includes('.map') && !code.includes('json:')) {
786 |         warnings.push({
787 |           type: 'best_practice',
788 |           message: 'Returning items directly - ensure each item has {json: ...} structure',
789 |           suggestion: 'If modifying items, use: return items.map(item => ({json: {...item.json, newField: "value"}}))'
790 |         });
791 |       }
792 |       
793 |       // Check for return without array
794 |       if (/return\s+{[^}]+}\s*;/.test(code) && !code.includes('[') && !code.includes(']')) {
795 |         warnings.push({
796 |           type: 'invalid_value',
797 |           message: 'Return value must be an array',
798 |           suggestion: 'Wrap your return object in an array: return [{json: {your: "data"}}]'
799 |         });
800 |       }
801 |       
802 |       // Check for direct data return without json wrapper
803 |       if (/return\s+\[['"`]/.test(code) || /return\s+\[\d/.test(code)) {
804 |         warnings.push({
805 |           type: 'invalid_value',
806 |           message: 'Items must be objects with json property',
807 |           suggestion: 'Use format: return [{json: {value: "data"}}] not return ["data"]'
808 |         });
809 |       }
810 |     }
811 |     
812 |     // Check return format for Python
813 |     if (language === 'python' && hasReturn) {
814 |       // DEBUG: Log to see if we're entering this block
815 |       if (code.includes('result = {"data": "value"}')) {
816 |         console.log('DEBUG: Processing Python code with result variable');
817 |         console.log('DEBUG: Language:', language);
818 |         console.log('DEBUG: Has return:', hasReturn);
819 |       }
820 |       // Check for common incorrect patterns
821 |       if (/return\s+items\s*$/.test(code) && !code.includes('json') && !code.includes('dict')) {
822 |         warnings.push({
823 |           type: 'best_practice',
824 |           message: 'Returning items directly - ensure each item is a dict with "json" key',
825 |           suggestion: 'Use: return [{"json": item.json} for item in items]'
826 |         });
827 |       }
828 |       
829 |       // Check for dict return without list
830 |       if (/return\s+{['"]/.test(code) && !code.includes('[') && !code.includes(']')) {
831 |         warnings.push({
832 |           type: 'invalid_value',
833 |           message: 'Return value must be a list',
834 |           suggestion: 'Wrap your return dict in a list: return [{"json": {"your": "data"}}]'
835 |         });
836 |       }
837 |       
838 |       // Check for returning objects without json key
839 |       if (/return\s+(?!.*\[).*{(?!.*["']json["'])/.test(code)) {
840 |         warnings.push({
841 |           type: 'invalid_value',
842 |           message: 'Must return array of objects with json key',
843 |           suggestion: 'Use format: return [{"json": {"data": "value"}}]'
844 |         });
845 |       }
846 |       
847 |       // Check for returning variable that might contain invalid format
848 |       const returnMatch = code.match(/return\s+(\w+)\s*(?:#|$)/m);
849 |       if (returnMatch) {
850 |         const varName = returnMatch[1];
851 |         // Check if this variable is assigned a dict without being in a list
852 |         const assignmentRegex = new RegExp(`${varName}\\s*=\\s*{[^}]+}`, 'm');
853 |         if (assignmentRegex.test(code) && !new RegExp(`${varName}\\s*=\\s*\\[`).test(code)) {
854 |           warnings.push({
855 |             type: 'invalid_value',
856 |             message: 'Must return array of objects with json key',
857 |             suggestion: `Wrap ${varName} in a list with json key: return [{"json": ${varName}}]`
858 |           });
859 |         }
860 |       }
861 |     }
862 |     
863 |     // Check for common n8n variables and patterns
864 |     if (language === 'javascript') {
865 |       // Check if accessing items/input
866 |       if (!code.includes('items') && !code.includes('$input') && !code.includes('$json')) {
867 |         warnings.push({
868 |           type: 'missing_common',
869 |           message: 'Code doesn\'t reference input data',
870 |           suggestion: 'Access input with: items, $input.all(), or $json (in single-item mode)'
871 |         });
872 |       }
873 |       
874 |       // Check for common mistakes with $json
875 |       if (code.includes('$json') && !code.includes('mode')) {
876 |         warnings.push({
877 |           type: 'best_practice',
878 |           message: '$json only works in "Run Once for Each Item" mode',
879 |           suggestion: 'For all items mode, use: items[0].json or loop through items'
880 |         });
881 |       }
882 |       
883 |       // Check for undefined variable usage
884 |       const commonVars = ['$node', '$workflow', '$execution', '$prevNode', 'DateTime', 'jmespath'];
885 |       const usedVars = commonVars.filter(v => code.includes(v));
886 |       
887 |       // Check for incorrect $helpers usage patterns
888 |       if (code.includes('$helpers.getWorkflowStaticData')) {
889 |         // Check if it's missing parentheses
890 |         if (/\$helpers\.getWorkflowStaticData(?!\s*\()/.test(code)) {
891 |           errors.push({
892 |             type: 'invalid_value',
893 |             property: 'jsCode',
894 |             message: 'getWorkflowStaticData requires parentheses: $helpers.getWorkflowStaticData()',
895 |             fix: 'Add parentheses: $helpers.getWorkflowStaticData()'
896 |           });
897 |         } else {
898 |           warnings.push({
899 |             type: 'invalid_value',
900 |             message: '$helpers.getWorkflowStaticData() is incorrect - causes "$helpers is not defined" error',
901 |             suggestion: 'Use $getWorkflowStaticData() as a standalone function (no $helpers prefix)'
902 |           });
903 |         }
904 |       }
905 |       
906 |       // Check for $helpers usage without checking availability
907 |       if (code.includes('$helpers') && !code.includes('typeof $helpers')) {
908 |         warnings.push({
909 |           type: 'best_practice',
910 |           message: '$helpers is only available in Code nodes with mode="runOnceForEachItem"',
911 |           suggestion: 'Check availability first: if (typeof $helpers !== "undefined" && $helpers.httpRequest) { ... }'
912 |         });
913 |       }
914 |       
915 |       // Check for async without await
916 |       if ((code.includes('fetch(') || code.includes('Promise') || code.includes('.then(')) && !code.includes('await')) {
917 |         warnings.push({
918 |           type: 'best_practice',
919 |           message: 'Async operation without await - will return a Promise instead of actual data',
920 |           suggestion: 'Use await with async operations: const result = await fetch(...);'
921 |         });
922 |       }
923 |       
924 |       // Check for crypto usage without require
925 |       if ((code.includes('crypto.') || code.includes('randomBytes') || code.includes('randomUUID')) && !code.includes('require')) {
926 |         warnings.push({
927 |           type: 'invalid_value',
928 |           message: 'Using crypto without require statement',
929 |           suggestion: 'Add: const crypto = require("crypto"); at the beginning (ignore editor warnings)'
930 |         });
931 |       }
932 |       
933 |       // Check for console.log (informational)
934 |       if (code.includes('console.log')) {
935 |         warnings.push({
936 |           type: 'best_practice',
937 |           message: 'console.log output appears in n8n execution logs',
938 |           suggestion: 'Remove console.log statements in production or use them sparingly'
939 |         });
940 |       }
941 |     } else if (language === 'python') {
942 |       // Python-specific checks
943 |       if (!code.includes('items') && !code.includes('_input')) {
944 |         warnings.push({
945 |           type: 'missing_common',
946 |           message: 'Code doesn\'t reference input items',
947 |           suggestion: 'Access input data with: items variable'
948 |         });
949 |       }
950 |       
951 |       // Check for print statements
952 |       if (code.includes('print(')) {
953 |         warnings.push({
954 |           type: 'best_practice',
955 |           message: 'print() output appears in n8n execution logs',
956 |           suggestion: 'Remove print statements in production or use them sparingly'
957 |         });
958 |       }
959 |       
960 |       // Check for common Python mistakes
961 |       if (code.includes('import requests') || code.includes('import pandas')) {
962 |         warnings.push({
963 |           type: 'invalid_value',
964 |           message: 'External libraries not available in Code node',
965 |           suggestion: 'Only Python standard library is available. For HTTP requests, use JavaScript with $helpers.httpRequest'
966 |         });
967 |       }
968 |     }
969 |     
970 |     // Check for infinite loops
971 |     if (/while\s*\(\s*true\s*\)|while\s+True:/.test(code)) {
972 |       warnings.push({
973 |         type: 'security',
974 |         message: 'Infinite loop detected',
975 |         suggestion: 'Add a break condition or use a for loop with limits'
976 |       });
977 |     }
978 |     
979 |     // Check for error handling
980 |     if (!code.includes('try') && !code.includes('catch') && !code.includes('except')) {
981 |       if (code.length > 200) { // Only suggest for non-trivial code
982 |         warnings.push({
983 |           type: 'best_practice',
984 |           message: 'No error handling found',
985 |           suggestion: 'Consider adding try/catch (JavaScript) or try/except (Python) for robust error handling'
986 |         });
987 |       }
988 |     }
989 |   }
990 | }
```

--------------------------------------------------------------------------------
/tests/unit/telemetry/event-tracker.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
  2 | import { TelemetryEventTracker } from '../../../src/telemetry/event-tracker';
  3 | import { TelemetryEvent, WorkflowTelemetry } from '../../../src/telemetry/telemetry-types';
  4 | import { TelemetryError, TelemetryErrorType } from '../../../src/telemetry/telemetry-error';
  5 | import { WorkflowSanitizer } from '../../../src/telemetry/workflow-sanitizer';
  6 | import { existsSync } from 'fs';
  7 | 
  8 | // Mock dependencies
  9 | vi.mock('../../../src/utils/logger', () => ({
 10 |   logger: {
 11 |     debug: vi.fn(),
 12 |     info: vi.fn(),
 13 |     warn: vi.fn(),
 14 |     error: vi.fn(),
 15 |   }
 16 | }));
 17 | 
 18 | vi.mock('../../../src/telemetry/workflow-sanitizer');
 19 | vi.mock('fs');
 20 | vi.mock('path');
 21 | 
 22 | describe('TelemetryEventTracker', () => {
 23 |   let eventTracker: TelemetryEventTracker;
 24 |   let mockGetUserId: ReturnType<typeof vi.fn>;
 25 |   let mockIsEnabled: ReturnType<typeof vi.fn>;
 26 | 
 27 |   beforeEach(() => {
 28 |     mockGetUserId = vi.fn().mockReturnValue('test-user-123');
 29 |     mockIsEnabled = vi.fn().mockReturnValue(true);
 30 |     eventTracker = new TelemetryEventTracker(mockGetUserId, mockIsEnabled);
 31 |     vi.clearAllMocks();
 32 |   });
 33 | 
 34 |   afterEach(() => {
 35 |     vi.useRealTimers();
 36 |   });
 37 | 
 38 |   describe('trackToolUsage()', () => {
 39 |     it('should track successful tool usage', () => {
 40 |       eventTracker.trackToolUsage('httpRequest', true, 500);
 41 | 
 42 |       const events = eventTracker.getEventQueue();
 43 |       expect(events).toHaveLength(1);
 44 |       expect(events[0]).toMatchObject({
 45 |         user_id: 'test-user-123',
 46 |         event: 'tool_used',
 47 |         properties: {
 48 |           tool: 'httpRequest',
 49 |           success: true,
 50 |           duration: 500
 51 |         }
 52 |       });
 53 |     });
 54 | 
 55 |     it('should track failed tool usage', () => {
 56 |       eventTracker.trackToolUsage('invalidNode', false);
 57 | 
 58 |       const events = eventTracker.getEventQueue();
 59 |       expect(events).toHaveLength(1);
 60 |       expect(events[0]).toMatchObject({
 61 |         user_id: 'test-user-123',
 62 |         event: 'tool_used',
 63 |         properties: {
 64 |           tool: 'invalidNode',
 65 |           success: false,
 66 |           duration: 0
 67 |         }
 68 |       });
 69 |     });
 70 | 
 71 |     it('should sanitize tool names', () => {
 72 |       eventTracker.trackToolUsage('tool-with-special!@#chars', true);
 73 | 
 74 |       const events = eventTracker.getEventQueue();
 75 |       expect(events[0].properties.tool).toBe('tool-with-special___chars');
 76 |     });
 77 | 
 78 |     it('should not track when disabled', () => {
 79 |       mockIsEnabled.mockReturnValue(false);
 80 |       eventTracker.trackToolUsage('httpRequest', true);
 81 | 
 82 |       const events = eventTracker.getEventQueue();
 83 |       expect(events).toHaveLength(0);
 84 |     });
 85 | 
 86 |     it('should respect rate limiting', () => {
 87 |       // Mock rate limiter to deny requests
 88 |       vi.spyOn(eventTracker['rateLimiter'], 'allow').mockReturnValue(false);
 89 | 
 90 |       eventTracker.trackToolUsage('httpRequest', true);
 91 | 
 92 |       const events = eventTracker.getEventQueue();
 93 |       expect(events).toHaveLength(0);
 94 |     });
 95 | 
 96 |     it('should record performance metrics internally', () => {
 97 |       eventTracker.trackToolUsage('slowTool', true, 2000);
 98 |       eventTracker.trackToolUsage('slowTool', true, 3000);
 99 | 
100 |       const stats = eventTracker.getStats();
101 |       expect(stats.performanceMetrics.slowTool).toBeDefined();
102 |       expect(stats.performanceMetrics.slowTool.count).toBe(2);
103 |       expect(stats.performanceMetrics.slowTool.avg).toBeGreaterThan(2000);
104 |     });
105 |   });
106 | 
107 |   describe('trackWorkflowCreation()', () => {
108 |     const mockWorkflow = {
109 |       nodes: [
110 |         { id: '1', type: 'webhook', name: 'Webhook', position: [0, 0] as [number, number], parameters: {} },
111 |         { id: '2', type: 'httpRequest', name: 'HTTP Request', position: [100, 0] as [number, number], parameters: {} },
112 |         { id: '3', type: 'set', name: 'Set', position: [200, 0] as [number, number], parameters: {} }
113 |       ],
114 |       connections: {
115 |         '1': { main: [[{ node: '2', type: 'main', index: 0 }]] }
116 |       }
117 |     };
118 | 
119 |     beforeEach(() => {
120 |       const mockSanitized = {
121 |         workflowHash: 'hash123',
122 |         nodeCount: 3,
123 |         nodeTypes: ['webhook', 'httpRequest', 'set'],
124 |         hasTrigger: true,
125 |         hasWebhook: true,
126 |         complexity: 'medium' as const,
127 |         nodes: mockWorkflow.nodes,
128 |         connections: mockWorkflow.connections
129 |       };
130 | 
131 |       vi.mocked(WorkflowSanitizer.sanitizeWorkflow).mockReturnValue(mockSanitized);
132 |     });
133 | 
134 |     it('should track valid workflow creation', async () => {
135 |       await eventTracker.trackWorkflowCreation(mockWorkflow, true);
136 | 
137 |       const workflows = eventTracker.getWorkflowQueue();
138 |       const events = eventTracker.getEventQueue();
139 | 
140 |       expect(workflows).toHaveLength(1);
141 |       expect(workflows[0]).toMatchObject({
142 |         user_id: 'test-user-123',
143 |         workflow_hash: 'hash123',
144 |         node_count: 3,
145 |         node_types: ['webhook', 'httpRequest', 'set'],
146 |         has_trigger: true,
147 |         has_webhook: true,
148 |         complexity: 'medium'
149 |       });
150 | 
151 |       expect(events).toHaveLength(1);
152 |       expect(events[0].event).toBe('workflow_created');
153 |     });
154 | 
155 |     it('should track failed validation without storing workflow', async () => {
156 |       await eventTracker.trackWorkflowCreation(mockWorkflow, false);
157 | 
158 |       const workflows = eventTracker.getWorkflowQueue();
159 |       const events = eventTracker.getEventQueue();
160 | 
161 |       expect(workflows).toHaveLength(0);
162 |       expect(events).toHaveLength(1);
163 |       expect(events[0].event).toBe('workflow_validation_failed');
164 |     });
165 | 
166 |     it('should not track when disabled', async () => {
167 |       mockIsEnabled.mockReturnValue(false);
168 |       await eventTracker.trackWorkflowCreation(mockWorkflow, true);
169 | 
170 |       expect(eventTracker.getWorkflowQueue()).toHaveLength(0);
171 |       expect(eventTracker.getEventQueue()).toHaveLength(0);
172 |     });
173 | 
174 |     it('should handle sanitization errors', async () => {
175 |       vi.mocked(WorkflowSanitizer.sanitizeWorkflow).mockImplementation(() => {
176 |         throw new Error('Sanitization failed');
177 |       });
178 | 
179 |       await expect(eventTracker.trackWorkflowCreation(mockWorkflow, true))
180 |         .rejects.toThrow(TelemetryError);
181 |     });
182 | 
183 |     it('should respect rate limiting', async () => {
184 |       vi.spyOn(eventTracker['rateLimiter'], 'allow').mockReturnValue(false);
185 | 
186 |       await eventTracker.trackWorkflowCreation(mockWorkflow, true);
187 | 
188 |       expect(eventTracker.getWorkflowQueue()).toHaveLength(0);
189 |       expect(eventTracker.getEventQueue()).toHaveLength(0);
190 |     });
191 |   });
192 | 
193 |   describe('trackError()', () => {
194 |     it('should track error events without rate limiting', () => {
195 |       eventTracker.trackError('ValidationError', 'Node configuration invalid', 'httpRequest', 'Required field "url" is missing');
196 | 
197 |       const events = eventTracker.getEventQueue();
198 |       expect(events).toHaveLength(1);
199 |       expect(events[0]).toMatchObject({
200 |         user_id: 'test-user-123',
201 |         event: 'error_occurred',
202 |         properties: {
203 |           errorType: 'ValidationError',
204 |           context: 'Node configuration invalid',
205 |           tool: 'httpRequest',
206 |           error: 'Required field "url" is missing'
207 |         }
208 |       });
209 |     });
210 | 
211 |     it('should sanitize error context', () => {
212 |       const context = 'Failed to connect to https://api.example.com with key abc123def456ghi789jklmno0123456789';
213 |       eventTracker.trackError('NetworkError', context, undefined, 'Connection timeout after 30s');
214 | 
215 |       const events = eventTracker.getEventQueue();
216 |       expect(events[0].properties.context).toBe('Failed to connect to [URL] with key [KEY]');
217 |     });
218 | 
219 |     it('should sanitize error type', () => {
220 |       eventTracker.trackError('Invalid$Error!Type', 'test context', undefined, 'Test error message');
221 | 
222 |       const events = eventTracker.getEventQueue();
223 |       expect(events[0].properties.errorType).toBe('Invalid_Error_Type');
224 |     });
225 | 
226 |     it('should handle missing tool name', () => {
227 |       eventTracker.trackError('TestError', 'test context', undefined, 'No tool specified');
228 | 
229 |       const events = eventTracker.getEventQueue();
230 |       expect(events[0].properties.tool).toBeNull();  // Validator converts undefined to null
231 |     });
232 |   });
233 | 
234 |   describe('trackError() with error messages', () => {
235 |     it('should capture error messages in properties', () => {
236 |       eventTracker.trackError('ValidationError', 'test', 'tool', 'Field "url" is required');
237 | 
238 |       const events = eventTracker.getEventQueue();
239 |       expect(events[0].properties.error).toBe('Field "url" is required');
240 |     });
241 | 
242 |     it('should handle undefined error message', () => {
243 |       eventTracker.trackError('Error', 'test', 'tool', undefined);
244 | 
245 |       const events = eventTracker.getEventQueue();
246 |       expect(events[0].properties.error).toBeNull();  // Validator converts undefined to null
247 |     });
248 | 
249 |     it('should sanitize API keys in error messages', () => {
250 |       eventTracker.trackError('AuthError', 'test', 'tool', 'Failed with api_key=sk_live_abc123def456');
251 | 
252 |       const events = eventTracker.getEventQueue();
253 |       expect(events[0].properties.error).toContain('api_key=[REDACTED]');
254 |       expect(events[0].properties.error).not.toContain('sk_live_abc123def456');
255 |     });
256 | 
257 |     it('should sanitize passwords in error messages', () => {
258 |       eventTracker.trackError('AuthError', 'test', 'tool', 'Login failed: password=secret123');
259 | 
260 |       const events = eventTracker.getEventQueue();
261 |       expect(events[0].properties.error).toContain('password=[REDACTED]');
262 |     });
263 | 
264 |     it('should sanitize long keys (32+ chars)', () => {
265 |       eventTracker.trackError('Error', 'test', 'tool', 'Key: abc123def456ghi789jkl012mno345pqr678');
266 | 
267 |       const events = eventTracker.getEventQueue();
268 |       expect(events[0].properties.error).toContain('[KEY]');
269 |     });
270 | 
271 |     it('should sanitize URLs in error messages', () => {
272 |       eventTracker.trackError('NetworkError', 'test', 'tool', 'Failed to fetch https://api.example.com/v1/users');
273 | 
274 |       const events = eventTracker.getEventQueue();
275 |       expect(events[0].properties.error).toBe('Failed to fetch [URL]');
276 |       expect(events[0].properties.error).not.toContain('api.example.com');
277 |       expect(events[0].properties.error).not.toContain('/v1/users');
278 |     });
279 | 
280 |     it('should truncate very long error messages to 500 chars', () => {
281 |       const longError = 'Error occurred while processing the request. ' + 'Additional context details. '.repeat(50);
282 |       eventTracker.trackError('Error', 'test', 'tool', longError);
283 | 
284 |       const events = eventTracker.getEventQueue();
285 |       expect(events[0].properties.error.length).toBeLessThanOrEqual(503); // 500 + '...'
286 |       expect(events[0].properties.error).toMatch(/\.\.\.$/);
287 |     });
288 | 
289 |     it('should handle stack traces by keeping first 3 lines', () => {
290 |       const errorMsg = 'Error: Something failed\n  at foo (/path/file.js:10:5)\n  at bar (/path/file.js:20:10)\n  at baz (/path/file.js:30:15)\n  at qux (/path/file.js:40:20)';
291 |       eventTracker.trackError('Error', 'test', 'tool', errorMsg);
292 | 
293 |       const events = eventTracker.getEventQueue();
294 |       const lines = events[0].properties.error.split('\n');
295 |       expect(lines.length).toBeLessThanOrEqual(3);
296 |     });
297 | 
298 |     it('should sanitize emails in error messages', () => {
299 |       eventTracker.trackError('Error', 'test', 'tool', 'Failed for user [email protected]');
300 | 
301 |       const events = eventTracker.getEventQueue();
302 |       expect(events[0].properties.error).toContain('[EMAIL]');
303 |       expect(events[0].properties.error).not.toContain('[email protected]');
304 |     });
305 | 
306 |     it('should sanitize quoted tokens', () => {
307 |       eventTracker.trackError('Error', 'test', 'tool', 'Auth failed: "abc123def456ghi789"');
308 | 
309 |       const events = eventTracker.getEventQueue();
310 |       expect(events[0].properties.error).toContain('"[TOKEN]"');
311 |     });
312 | 
313 |     it('should sanitize token= patterns in error messages', () => {
314 |       eventTracker.trackError('AuthError', 'test', 'tool', 'Failed with token=abc123def456');
315 | 
316 |       const events = eventTracker.getEventQueue();
317 |       expect(events[0].properties.error).toContain('token=[REDACTED]');
318 |     });
319 | 
320 |     it('should sanitize AWS access keys', () => {
321 |       eventTracker.trackError('Error', 'test', 'tool', 'Failed with AWS key AKIAIOSFODNN7EXAMPLE');
322 | 
323 |       const events = eventTracker.getEventQueue();
324 |       expect(events[0].properties.error).toContain('[AWS_KEY]');
325 |       expect(events[0].properties.error).not.toContain('AKIAIOSFODNN7EXAMPLE');
326 |     });
327 | 
328 |     it('should sanitize GitHub tokens', () => {
329 |       eventTracker.trackError('Error', 'test', 'tool', 'Auth failed: ghp_1234567890abcdefghijklmnopqrstuvwxyz');
330 | 
331 |       const events = eventTracker.getEventQueue();
332 |       expect(events[0].properties.error).toContain('[GITHUB_TOKEN]');
333 |       expect(events[0].properties.error).not.toContain('ghp_1234567890abcdefghijklmnopqrstuvwxyz');
334 |     });
335 | 
336 |     it('should sanitize JWT tokens', () => {
337 |       eventTracker.trackError('Error', 'test', 'tool', 'Invalid JWT eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.signature provided');
338 | 
339 |       const events = eventTracker.getEventQueue();
340 |       expect(events[0].properties.error).toContain('[JWT]');
341 |       expect(events[0].properties.error).not.toContain('eyJhbGciOiJIUzI1NiJ9');
342 |     });
343 | 
344 |     it('should sanitize Bearer tokens', () => {
345 |       eventTracker.trackError('Error', 'test', 'tool', 'Authorization failed: Bearer abc123def456ghi789');
346 | 
347 |       const events = eventTracker.getEventQueue();
348 |       expect(events[0].properties.error).toContain('Bearer [TOKEN]');
349 |       expect(events[0].properties.error).not.toContain('abc123def456ghi789');
350 |     });
351 | 
352 |     it('should prevent email leakage in URLs by sanitizing URLs first', () => {
353 |       eventTracker.trackError('Error', 'test', 'tool', 'Failed: https://api.example.com/users/[email protected]/profile');
354 | 
355 |       const events = eventTracker.getEventQueue();
356 |       // URL should be fully redacted, preventing any email leakage
357 |       expect(events[0].properties.error).toBe('Failed: [URL]');
358 |       expect(events[0].properties.error).not.toContain('[email protected]');
359 |       expect(events[0].properties.error).not.toContain('/users/');
360 |     });
361 | 
362 |     it('should handle extremely long error messages efficiently', () => {
363 |       const hugeError = 'Error: ' + 'x'.repeat(10000);
364 |       eventTracker.trackError('Error', 'test', 'tool', hugeError);
365 | 
366 |       const events = eventTracker.getEventQueue();
367 |       // Should be truncated at 500 chars max
368 |       expect(events[0].properties.error.length).toBeLessThanOrEqual(503); // 500 + '...'
369 |     });
370 |   });
371 | 
372 |   describe('trackEvent()', () => {
373 |     it('should track generic events', () => {
374 |       const properties = { key: 'value', count: 42 };
375 |       eventTracker.trackEvent('custom_event', properties);
376 | 
377 |       const events = eventTracker.getEventQueue();
378 |       expect(events).toHaveLength(1);
379 |       expect(events[0].user_id).toBe('test-user-123');
380 |       expect(events[0].event).toBe('custom_event');
381 |       expect(events[0].properties).toEqual(properties);
382 |     });
383 | 
384 |     it('should respect rate limiting by default', () => {
385 |       vi.spyOn(eventTracker['rateLimiter'], 'allow').mockReturnValue(false);
386 | 
387 |       eventTracker.trackEvent('rate_limited_event', {});
388 | 
389 |       expect(eventTracker.getEventQueue()).toHaveLength(0);
390 |     });
391 | 
392 |     it('should skip rate limiting when requested', () => {
393 |       vi.spyOn(eventTracker['rateLimiter'], 'allow').mockReturnValue(false);
394 | 
395 |       eventTracker.trackEvent('critical_event', {}, false);
396 | 
397 |       const events = eventTracker.getEventQueue();
398 |       expect(events).toHaveLength(1);
399 |       expect(events[0].event).toBe('critical_event');
400 |     });
401 |   });
402 | 
403 |   describe('trackSessionStart()', () => {
404 |     beforeEach(() => {
405 |       // Mock existsSync and readFileSync for package.json reading
406 |       vi.mocked(existsSync).mockReturnValue(true);
407 |       const mockReadFileSync = vi.fn().mockReturnValue(JSON.stringify({ version: '1.2.3' }));
408 |       vi.doMock('fs', () => ({ existsSync: vi.mocked(existsSync), readFileSync: mockReadFileSync }));
409 |     });
410 | 
411 |     it('should track session start with system info', () => {
412 |       eventTracker.trackSessionStart();
413 | 
414 |       const events = eventTracker.getEventQueue();
415 |       expect(events).toHaveLength(1);
416 |       expect(events[0]).toMatchObject({
417 |         event: 'session_start',
418 |         properties: {
419 |           platform: process.platform,
420 |           arch: process.arch,
421 |           nodeVersion: process.version
422 |         }
423 |       });
424 |     });
425 |   });
426 | 
427 |   describe('trackSearchQuery()', () => {
428 |     it('should track search queries with results', () => {
429 |       eventTracker.trackSearchQuery('httpRequest nodes', 5, 'nodes');
430 | 
431 |       const events = eventTracker.getEventQueue();
432 |       expect(events).toHaveLength(1);
433 |       expect(events[0]).toMatchObject({
434 |         event: 'search_query',
435 |         properties: {
436 |           query: 'httpRequest nodes',
437 |           resultsFound: 5,
438 |           searchType: 'nodes',
439 |           hasResults: true,
440 |           isZeroResults: false
441 |         }
442 |       });
443 |     });
444 | 
445 |     it('should track zero result queries', () => {
446 |       eventTracker.trackSearchQuery('nonexistent node', 0, 'nodes');
447 | 
448 |       const events = eventTracker.getEventQueue();
449 |       expect(events[0].properties.hasResults).toBe(false);
450 |       expect(events[0].properties.isZeroResults).toBe(true);
451 |     });
452 | 
453 |     it('should truncate long queries', () => {
454 |       const longQuery = 'a'.repeat(150);
455 |       eventTracker.trackSearchQuery(longQuery, 1, 'nodes');
456 | 
457 |       const events = eventTracker.getEventQueue();
458 |       // The validator will sanitize this as [KEY] since it's a long string of alphanumeric chars
459 |       expect(events[0].properties.query).toBe('[KEY]');
460 |     });
461 |   });
462 | 
463 |   describe('trackValidationDetails()', () => {
464 |     it('should track validation error details', () => {
465 |       const details = { field: 'url', value: 'invalid' };
466 |       eventTracker.trackValidationDetails('nodes-base.httpRequest', 'required_field_missing', details);
467 | 
468 |       const events = eventTracker.getEventQueue();
469 |       expect(events).toHaveLength(1);
470 |       expect(events[0]).toMatchObject({
471 |         event: 'validation_details',
472 |         properties: {
473 |           nodeType: 'nodes-base.httpRequest',
474 |           errorType: 'required_field_missing',
475 |           errorCategory: 'required_field_error',
476 |           details
477 |         }
478 |       });
479 |     });
480 | 
481 |     it('should categorize different error types', () => {
482 |       const testCases = [
483 |         { errorType: 'type_mismatch', expectedCategory: 'type_error' },
484 |         { errorType: 'validation_failed', expectedCategory: 'validation_error' },
485 |         { errorType: 'connection_lost', expectedCategory: 'connection_error' },
486 |         { errorType: 'expression_syntax_error', expectedCategory: 'expression_error' },
487 |         { errorType: 'unknown_error', expectedCategory: 'other_error' }
488 |       ];
489 | 
490 |       testCases.forEach(({ errorType, expectedCategory }, index) => {
491 |         eventTracker.trackValidationDetails(`node${index}`, errorType, {});
492 |       });
493 | 
494 |       const events = eventTracker.getEventQueue();
495 |       testCases.forEach((testCase, index) => {
496 |         expect(events[index].properties.errorCategory).toBe(testCase.expectedCategory);
497 |       });
498 |     });
499 | 
500 |     it('should sanitize node type names', () => {
501 |       eventTracker.trackValidationDetails('invalid$node@type!', 'test_error', {});
502 | 
503 |       const events = eventTracker.getEventQueue();
504 |       expect(events[0].properties.nodeType).toBe('invalid_node_type_');
505 |     });
506 |   });
507 | 
508 |   describe('trackToolSequence()', () => {
509 |     it('should track tool usage sequences', () => {
510 |       eventTracker.trackToolSequence('httpRequest', 'webhook', 5000);
511 | 
512 |       const events = eventTracker.getEventQueue();
513 |       expect(events).toHaveLength(1);
514 |       expect(events[0]).toMatchObject({
515 |         event: 'tool_sequence',
516 |         properties: {
517 |           previousTool: 'httpRequest',
518 |           currentTool: 'webhook',
519 |           timeDelta: 5000,
520 |           isSlowTransition: false,
521 |           sequence: 'httpRequest->webhook'
522 |         }
523 |       });
524 |     });
525 | 
526 |     it('should identify slow transitions', () => {
527 |       eventTracker.trackToolSequence('search', 'validate', 15000);
528 | 
529 |       const events = eventTracker.getEventQueue();
530 |       expect(events[0].properties.isSlowTransition).toBe(true);
531 |     });
532 | 
533 |     it('should cap time delta', () => {
534 |       eventTracker.trackToolSequence('tool1', 'tool2', 500000);
535 | 
536 |       const events = eventTracker.getEventQueue();
537 |       expect(events[0].properties.timeDelta).toBe(300000); // Capped at 5 minutes
538 |     });
539 |   });
540 | 
541 |   describe('trackNodeConfiguration()', () => {
542 |     it('should track node configuration patterns', () => {
543 |       eventTracker.trackNodeConfiguration('nodes-base.httpRequest', 5, false);
544 | 
545 |       const events = eventTracker.getEventQueue();
546 |       expect(events).toHaveLength(1);
547 |       expect(events[0].event).toBe('node_configuration');
548 |       expect(events[0].properties.nodeType).toBe('nodes-base.httpRequest');
549 |       expect(events[0].properties.propertiesSet).toBe(5);
550 |       expect(events[0].properties.usedDefaults).toBe(false);
551 |       expect(events[0].properties.complexity).toBe('moderate'); // 5 properties is moderate (4-10)
552 |     });
553 | 
554 |     it('should categorize configuration complexity', () => {
555 |       const testCases = [
556 |         { properties: 0, expectedComplexity: 'defaults_only' },
557 |         { properties: 2, expectedComplexity: 'simple' },
558 |         { properties: 7, expectedComplexity: 'moderate' },
559 |         { properties: 15, expectedComplexity: 'complex' }
560 |       ];
561 | 
562 |       testCases.forEach(({ properties, expectedComplexity }, index) => {
563 |         eventTracker.trackNodeConfiguration(`node${index}`, properties, false);
564 |       });
565 | 
566 |       const events = eventTracker.getEventQueue();
567 |       testCases.forEach((testCase, index) => {
568 |         expect(events[index].properties.complexity).toBe(testCase.expectedComplexity);
569 |       });
570 |     });
571 |   });
572 | 
573 |   describe('trackPerformanceMetric()', () => {
574 |     it('should track performance metrics', () => {
575 |       const metadata = { operation: 'database_query', table: 'nodes' };
576 |       eventTracker.trackPerformanceMetric('search_nodes', 1500, metadata);
577 | 
578 |       const events = eventTracker.getEventQueue();
579 |       expect(events).toHaveLength(1);
580 |       expect(events[0]).toMatchObject({
581 |         event: 'performance_metric',
582 |         properties: {
583 |           operation: 'search_nodes',
584 |           duration: 1500,
585 |           isSlow: true,
586 |           isVerySlow: false,
587 |           metadata
588 |         }
589 |       });
590 |     });
591 | 
592 |     it('should identify very slow operations', () => {
593 |       eventTracker.trackPerformanceMetric('slow_operation', 6000);
594 | 
595 |       const events = eventTracker.getEventQueue();
596 |       expect(events[0].properties.isSlow).toBe(true);
597 |       expect(events[0].properties.isVerySlow).toBe(true);
598 |     });
599 | 
600 |     it('should record internal performance metrics', () => {
601 |       eventTracker.trackPerformanceMetric('test_op', 500);
602 |       eventTracker.trackPerformanceMetric('test_op', 1000);
603 | 
604 |       const stats = eventTracker.getStats();
605 |       expect(stats.performanceMetrics.test_op).toBeDefined();
606 |       expect(stats.performanceMetrics.test_op.count).toBe(2);
607 |     });
608 |   });
609 | 
610 |   describe('updateToolSequence()', () => {
611 |     it('should track first tool without previous', () => {
612 |       eventTracker.updateToolSequence('firstTool');
613 | 
614 |       expect(eventTracker.getEventQueue()).toHaveLength(0);
615 |     });
616 | 
617 |     it('should track sequence after first tool', () => {
618 |       eventTracker.updateToolSequence('firstTool');
619 | 
620 |       // Advance time slightly
621 |       vi.useFakeTimers();
622 |       vi.advanceTimersByTime(2000);
623 | 
624 |       eventTracker.updateToolSequence('secondTool');
625 | 
626 |       const events = eventTracker.getEventQueue();
627 |       expect(events).toHaveLength(1);
628 |       expect(events[0].event).toBe('tool_sequence');
629 |       expect(events[0].properties.previousTool).toBe('firstTool');
630 |       expect(events[0].properties.currentTool).toBe('secondTool');
631 |     });
632 |   });
633 | 
634 |   describe('queue management', () => {
635 |     it('should provide access to event queue', () => {
636 |       eventTracker.trackEvent('test1', {});
637 |       eventTracker.trackEvent('test2', {});
638 | 
639 |       const queue = eventTracker.getEventQueue();
640 |       expect(queue).toHaveLength(2);
641 |       expect(queue[0].event).toBe('test1');
642 |       expect(queue[1].event).toBe('test2');
643 |     });
644 | 
645 |     it('should provide access to workflow queue', async () => {
646 |       const workflow = { nodes: [], connections: {} };
647 |       vi.mocked(WorkflowSanitizer.sanitizeWorkflow).mockReturnValue({
648 |         workflowHash: 'hash1',
649 |         nodeCount: 0,
650 |         nodeTypes: [],
651 |         hasTrigger: false,
652 |         hasWebhook: false,
653 |         complexity: 'simple',
654 |         nodes: [],
655 |         connections: {}
656 |       });
657 | 
658 |       await eventTracker.trackWorkflowCreation(workflow, true);
659 | 
660 |       const queue = eventTracker.getWorkflowQueue();
661 |       expect(queue).toHaveLength(1);
662 |       expect(queue[0].workflow_hash).toBe('hash1');
663 |     });
664 | 
665 |     it('should clear event queue', () => {
666 |       eventTracker.trackEvent('test', {});
667 |       expect(eventTracker.getEventQueue()).toHaveLength(1);
668 | 
669 |       eventTracker.clearEventQueue();
670 |       expect(eventTracker.getEventQueue()).toHaveLength(0);
671 |     });
672 | 
673 |     it('should clear workflow queue', async () => {
674 |       const workflow = { nodes: [], connections: {} };
675 |       vi.mocked(WorkflowSanitizer.sanitizeWorkflow).mockReturnValue({
676 |         workflowHash: 'hash1',
677 |         nodeCount: 0,
678 |         nodeTypes: [],
679 |         hasTrigger: false,
680 |         hasWebhook: false,
681 |         complexity: 'simple',
682 |         nodes: [],
683 |         connections: {}
684 |       });
685 | 
686 |       await eventTracker.trackWorkflowCreation(workflow, true);
687 |       expect(eventTracker.getWorkflowQueue()).toHaveLength(1);
688 | 
689 |       eventTracker.clearWorkflowQueue();
690 |       expect(eventTracker.getWorkflowQueue()).toHaveLength(0);
691 |     });
692 |   });
693 | 
694 |   describe('getStats()', () => {
695 |     it('should return comprehensive statistics', () => {
696 |       eventTracker.trackEvent('test', {});
697 |       eventTracker.trackPerformanceMetric('op1', 500);
698 | 
699 |       const stats = eventTracker.getStats();
700 |       expect(stats).toHaveProperty('rateLimiter');
701 |       expect(stats).toHaveProperty('validator');
702 |       expect(stats).toHaveProperty('eventQueueSize');
703 |       expect(stats).toHaveProperty('workflowQueueSize');
704 |       expect(stats).toHaveProperty('performanceMetrics');
705 |       expect(stats.eventQueueSize).toBe(2); // test event + performance metric event
706 |     });
707 | 
708 |     it('should include performance metrics statistics', () => {
709 |       eventTracker.trackPerformanceMetric('test_operation', 100);
710 |       eventTracker.trackPerformanceMetric('test_operation', 200);
711 |       eventTracker.trackPerformanceMetric('test_operation', 300);
712 | 
713 |       const stats = eventTracker.getStats();
714 |       const perfStats = stats.performanceMetrics.test_operation;
715 | 
716 |       expect(perfStats).toBeDefined();
717 |       expect(perfStats.count).toBe(3);
718 |       expect(perfStats.min).toBe(100);
719 |       expect(perfStats.max).toBe(300);
720 |       expect(perfStats.avg).toBe(200);
721 |     });
722 |   });
723 | 
724 |   describe('performance metrics collection', () => {
725 |     it('should maintain limited history per operation', () => {
726 |       // Add more than the limit (100) to test truncation
727 |       for (let i = 0; i < 105; i++) {
728 |         eventTracker.trackPerformanceMetric('bulk_operation', i);
729 |       }
730 | 
731 |       const stats = eventTracker.getStats();
732 |       const perfStats = stats.performanceMetrics.bulk_operation;
733 | 
734 |       expect(perfStats.count).toBe(100); // Should be capped at 100
735 |       expect(perfStats.min).toBe(5); // First 5 should be truncated
736 |       expect(perfStats.max).toBe(104);
737 |     });
738 | 
739 |     it('should calculate percentiles correctly', () => {
740 |       // Add known values for percentile calculation
741 |       const values = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
742 |       values.forEach(val => {
743 |         eventTracker.trackPerformanceMetric('percentile_test', val);
744 |       });
745 | 
746 |       const stats = eventTracker.getStats();
747 |       const perfStats = stats.performanceMetrics.percentile_test;
748 | 
749 |       // With 10 values, the 50th percentile (median) is between 50 and 60
750 |       expect(perfStats.p50).toBeGreaterThanOrEqual(50);
751 |       expect(perfStats.p50).toBeLessThanOrEqual(60);
752 |       expect(perfStats.p95).toBeGreaterThanOrEqual(90);
753 |       expect(perfStats.p99).toBeGreaterThanOrEqual(90);
754 |     });
755 |   });
756 | 
757 |   describe('sanitization helpers', () => {
758 |     it('should sanitize context strings properly', () => {
759 |       const context = 'Error at https://api.example.com/v1/users/[email protected]?key=secret123456789012345678901234567890';
760 |       eventTracker.trackError('TestError', context, undefined, 'Test error with special chars');
761 | 
762 |       const events = eventTracker.getEventQueue();
763 |       // After sanitization: emails first, then keys, then URL (keeping path)
764 |       expect(events[0].properties.context).toBe('Error at [URL]/v1/users/[EMAIL]?key=[KEY]');
765 |     });
766 | 
767 |     it('should handle context truncation', () => {
768 |       // Use a more realistic long context that won't trigger key sanitization
769 |       const longContext = 'Error occurred while processing the request: ' + 'details '.repeat(20);
770 |       eventTracker.trackError('TestError', longContext, undefined, 'Long error message for truncation test');
771 | 
772 |       const events = eventTracker.getEventQueue();
773 |       // Should be truncated to 100 chars
774 |       expect(events[0].properties.context).toHaveLength(100);
775 |     });
776 |   });
777 | 
778 |   describe('trackSessionStart()', () => {
779 |     // Store original env vars
780 |     const originalEnv = { ...process.env };
781 | 
782 |     afterEach(() => {
783 |       // Restore original env vars after each test
784 |       process.env = { ...originalEnv };
785 |       eventTracker.clearEventQueue();
786 |     });
787 | 
788 |     it('should track session start with basic environment info', () => {
789 |       eventTracker.trackSessionStart();
790 | 
791 |       const events = eventTracker.getEventQueue();
792 |       expect(events).toHaveLength(1);
793 |       expect(events[0]).toMatchObject({
794 |         user_id: 'test-user-123',
795 |         event: 'session_start',
796 |       });
797 | 
798 |       const props = events[0].properties;
799 |       expect(props.version).toBeDefined();
800 |       expect(typeof props.version).toBe('string');
801 |       expect(props.platform).toBeDefined();
802 |       expect(props.arch).toBeDefined();
803 |       expect(props.nodeVersion).toBeDefined();
804 |       expect(props.isDocker).toBe(false);
805 |       expect(props.cloudPlatform).toBeNull();
806 |     });
807 | 
808 |     it('should detect Docker environment', () => {
809 |       process.env.IS_DOCKER = 'true';
810 |       eventTracker.trackSessionStart();
811 | 
812 |       const events = eventTracker.getEventQueue();
813 |       expect(events[0].properties.isDocker).toBe(true);
814 |       expect(events[0].properties.cloudPlatform).toBeNull();
815 |     });
816 | 
817 |     it('should detect Railway cloud platform', () => {
818 |       process.env.RAILWAY_ENVIRONMENT = 'production';
819 |       eventTracker.trackSessionStart();
820 | 
821 |       const events = eventTracker.getEventQueue();
822 |       expect(events[0].properties.isDocker).toBe(false);
823 |       expect(events[0].properties.cloudPlatform).toBe('railway');
824 |     });
825 | 
826 |     it('should detect Render cloud platform', () => {
827 |       process.env.RENDER = 'true';
828 |       eventTracker.trackSessionStart();
829 | 
830 |       const events = eventTracker.getEventQueue();
831 |       expect(events[0].properties.isDocker).toBe(false);
832 |       expect(events[0].properties.cloudPlatform).toBe('render');
833 |     });
834 | 
835 |     it('should detect Fly.io cloud platform', () => {
836 |       process.env.FLY_APP_NAME = 'my-app';
837 |       eventTracker.trackSessionStart();
838 | 
839 |       const events = eventTracker.getEventQueue();
840 |       expect(events[0].properties.isDocker).toBe(false);
841 |       expect(events[0].properties.cloudPlatform).toBe('fly');
842 |     });
843 | 
844 |     it('should detect Heroku cloud platform', () => {
845 |       process.env.HEROKU_APP_NAME = 'my-app';
846 |       eventTracker.trackSessionStart();
847 | 
848 |       const events = eventTracker.getEventQueue();
849 |       expect(events[0].properties.isDocker).toBe(false);
850 |       expect(events[0].properties.cloudPlatform).toBe('heroku');
851 |     });
852 | 
853 |     it('should detect AWS cloud platform', () => {
854 |       process.env.AWS_EXECUTION_ENV = 'AWS_ECS_FARGATE';
855 |       eventTracker.trackSessionStart();
856 | 
857 |       const events = eventTracker.getEventQueue();
858 |       expect(events[0].properties.isDocker).toBe(false);
859 |       expect(events[0].properties.cloudPlatform).toBe('aws');
860 |     });
861 | 
862 |     it('should detect Kubernetes cloud platform', () => {
863 |       process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1';
864 |       eventTracker.trackSessionStart();
865 | 
866 |       const events = eventTracker.getEventQueue();
867 |       expect(events[0].properties.isDocker).toBe(false);
868 |       expect(events[0].properties.cloudPlatform).toBe('kubernetes');
869 |     });
870 | 
871 |     it('should detect GCP cloud platform', () => {
872 |       process.env.GOOGLE_CLOUD_PROJECT = 'my-project';
873 |       eventTracker.trackSessionStart();
874 | 
875 |       const events = eventTracker.getEventQueue();
876 |       expect(events[0].properties.isDocker).toBe(false);
877 |       expect(events[0].properties.cloudPlatform).toBe('gcp');
878 |     });
879 | 
880 |     it('should detect Azure cloud platform', () => {
881 |       process.env.AZURE_FUNCTIONS_ENVIRONMENT = 'Production';
882 |       eventTracker.trackSessionStart();
883 | 
884 |       const events = eventTracker.getEventQueue();
885 |       expect(events[0].properties.isDocker).toBe(false);
886 |       expect(events[0].properties.cloudPlatform).toBe('azure');
887 |     });
888 | 
889 |     it('should detect Docker + cloud platform combination', () => {
890 |       process.env.IS_DOCKER = 'true';
891 |       process.env.RAILWAY_ENVIRONMENT = 'production';
892 |       eventTracker.trackSessionStart();
893 | 
894 |       const events = eventTracker.getEventQueue();
895 |       expect(events[0].properties.isDocker).toBe(true);
896 |       expect(events[0].properties.cloudPlatform).toBe('railway');
897 |     });
898 | 
899 |     it('should handle local environment (no Docker, no cloud)', () => {
900 |       // Ensure no Docker or cloud env vars are set
901 |       delete process.env.IS_DOCKER;
902 |       delete process.env.RAILWAY_ENVIRONMENT;
903 |       delete process.env.RENDER;
904 |       delete process.env.FLY_APP_NAME;
905 |       delete process.env.HEROKU_APP_NAME;
906 |       delete process.env.AWS_EXECUTION_ENV;
907 |       delete process.env.KUBERNETES_SERVICE_HOST;
908 |       delete process.env.GOOGLE_CLOUD_PROJECT;
909 |       delete process.env.AZURE_FUNCTIONS_ENVIRONMENT;
910 | 
911 |       eventTracker.trackSessionStart();
912 | 
913 |       const events = eventTracker.getEventQueue();
914 |       expect(events[0].properties.isDocker).toBe(false);
915 |       expect(events[0].properties.cloudPlatform).toBeNull();
916 |     });
917 | 
918 |     it('should prioritize Railway over other cloud platforms', () => {
919 |       // Set multiple cloud env vars - Railway should win (first in detection chain)
920 |       process.env.RAILWAY_ENVIRONMENT = 'production';
921 |       process.env.RENDER = 'true';
922 |       process.env.FLY_APP_NAME = 'my-app';
923 | 
924 |       eventTracker.trackSessionStart();
925 | 
926 |       const events = eventTracker.getEventQueue();
927 |       expect(events[0].properties.cloudPlatform).toBe('railway');
928 |     });
929 | 
930 |     it('should not track when disabled', () => {
931 |       mockIsEnabled.mockReturnValue(false);
932 |       process.env.IS_DOCKER = 'true';
933 |       eventTracker.trackSessionStart();
934 | 
935 |       const events = eventTracker.getEventQueue();
936 |       expect(events).toHaveLength(0);
937 |     });
938 | 
939 |     it('should treat IS_DOCKER=false as not Docker', () => {
940 |       process.env.IS_DOCKER = 'false';
941 |       eventTracker.trackSessionStart();
942 | 
943 |       const events = eventTracker.getEventQueue();
944 |       expect(events[0].properties.isDocker).toBe(false);
945 |     });
946 | 
947 |     it('should include version, platform, arch, and nodeVersion', () => {
948 |       eventTracker.trackSessionStart();
949 | 
950 |       const events = eventTracker.getEventQueue();
951 |       const props = events[0].properties;
952 | 
953 |       // Check all expected fields are present
954 |       expect(props).toHaveProperty('version');
955 |       expect(props).toHaveProperty('platform');
956 |       expect(props).toHaveProperty('arch');
957 |       expect(props).toHaveProperty('nodeVersion');
958 |       expect(props).toHaveProperty('isDocker');
959 |       expect(props).toHaveProperty('cloudPlatform');
960 | 
961 |       // Verify types
962 |       expect(typeof props.version).toBe('string');
963 |       expect(typeof props.platform).toBe('string');
964 |       expect(typeof props.arch).toBe('string');
965 |       expect(typeof props.nodeVersion).toBe('string');
966 |       expect(typeof props.isDocker).toBe('boolean');
967 |       expect(props.cloudPlatform === null || typeof props.cloudPlatform === 'string').toBe(true);
968 |     });
969 |   });
970 | });
```

--------------------------------------------------------------------------------
/src/services/workflow-diff-engine.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Workflow Diff Engine
  3 |  * Applies diff operations to n8n workflows
  4 |  */
  5 | 
  6 | import { v4 as uuidv4 } from 'uuid';
  7 | import {
  8 |   WorkflowDiffOperation,
  9 |   WorkflowDiffRequest,
 10 |   WorkflowDiffResult,
 11 |   WorkflowDiffValidationError,
 12 |   isNodeOperation,
 13 |   isConnectionOperation,
 14 |   isMetadataOperation,
 15 |   AddNodeOperation,
 16 |   RemoveNodeOperation,
 17 |   UpdateNodeOperation,
 18 |   MoveNodeOperation,
 19 |   EnableNodeOperation,
 20 |   DisableNodeOperation,
 21 |   AddConnectionOperation,
 22 |   RemoveConnectionOperation,
 23 |   RewireConnectionOperation,
 24 |   UpdateSettingsOperation,
 25 |   UpdateNameOperation,
 26 |   AddTagOperation,
 27 |   RemoveTagOperation,
 28 |   CleanStaleConnectionsOperation,
 29 |   ReplaceConnectionsOperation
 30 | } from '../types/workflow-diff';
 31 | import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api';
 32 | import { Logger } from '../utils/logger';
 33 | import { validateWorkflowNode, validateWorkflowConnections } from './n8n-validation';
 34 | 
 35 | const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
 36 | 
 37 | export class WorkflowDiffEngine {
 38 |   /**
 39 |    * Apply diff operations to a workflow
 40 |    */
 41 |   async applyDiff(
 42 |     workflow: Workflow,
 43 |     request: WorkflowDiffRequest
 44 |   ): Promise<WorkflowDiffResult> {
 45 |     try {
 46 |       // Clone workflow to avoid modifying original
 47 |       const workflowCopy = JSON.parse(JSON.stringify(workflow));
 48 | 
 49 |       // Group operations by type for two-pass processing
 50 |       const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode'];
 51 |       const nodeOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = [];
 52 |       const otherOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = [];
 53 | 
 54 |       request.operations.forEach((operation, index) => {
 55 |         if (nodeOperationTypes.includes(operation.type)) {
 56 |           nodeOperations.push({ operation, index });
 57 |         } else {
 58 |           otherOperations.push({ operation, index });
 59 |         }
 60 |       });
 61 | 
 62 |       const allOperations = [...nodeOperations, ...otherOperations];
 63 |       const errors: WorkflowDiffValidationError[] = [];
 64 |       const appliedIndices: number[] = [];
 65 |       const failedIndices: number[] = [];
 66 | 
 67 |       // Process based on mode
 68 |       if (request.continueOnError) {
 69 |         // Best-effort mode: continue even if some operations fail
 70 |         for (const { operation, index } of allOperations) {
 71 |           const error = this.validateOperation(workflowCopy, operation);
 72 |           if (error) {
 73 |             errors.push({
 74 |               operation: index,
 75 |               message: error,
 76 |               details: operation
 77 |             });
 78 |             failedIndices.push(index);
 79 |             continue;
 80 |           }
 81 | 
 82 |           try {
 83 |             this.applyOperation(workflowCopy, operation);
 84 |             appliedIndices.push(index);
 85 |           } catch (error) {
 86 |             const errorMsg = `Failed to apply operation: ${error instanceof Error ? error.message : 'Unknown error'}`;
 87 |             errors.push({
 88 |               operation: index,
 89 |               message: errorMsg,
 90 |               details: operation
 91 |             });
 92 |             failedIndices.push(index);
 93 |           }
 94 |         }
 95 | 
 96 |         // If validateOnly flag is set, return success without applying
 97 |         if (request.validateOnly) {
 98 |           return {
 99 |             success: errors.length === 0,
100 |             message: errors.length === 0
101 |               ? 'Validation successful. All operations are valid.'
102 |               : `Validation completed with ${errors.length} errors.`,
103 |             errors: errors.length > 0 ? errors : undefined,
104 |             applied: appliedIndices,
105 |             failed: failedIndices
106 |           };
107 |         }
108 | 
109 |         const success = appliedIndices.length > 0;
110 |         return {
111 |           success,
112 |           workflow: workflowCopy,
113 |           operationsApplied: appliedIndices.length,
114 |           message: `Applied ${appliedIndices.length} operations, ${failedIndices.length} failed (continueOnError mode)`,
115 |           errors: errors.length > 0 ? errors : undefined,
116 |           applied: appliedIndices,
117 |           failed: failedIndices
118 |         };
119 |       } else {
120 |         // Atomic mode: all operations must succeed
121 |         // Pass 1: Validate and apply node operations first
122 |         for (const { operation, index } of nodeOperations) {
123 |           const error = this.validateOperation(workflowCopy, operation);
124 |           if (error) {
125 |             return {
126 |               success: false,
127 |               errors: [{
128 |                 operation: index,
129 |                 message: error,
130 |                 details: operation
131 |               }]
132 |             };
133 |           }
134 | 
135 |           try {
136 |             this.applyOperation(workflowCopy, operation);
137 |           } catch (error) {
138 |             return {
139 |               success: false,
140 |               errors: [{
141 |                 operation: index,
142 |                 message: `Failed to apply operation: ${error instanceof Error ? error.message : 'Unknown error'}`,
143 |                 details: operation
144 |               }]
145 |             };
146 |           }
147 |         }
148 | 
149 |         // Pass 2: Validate and apply other operations (connections, metadata)
150 |         for (const { operation, index } of otherOperations) {
151 |           const error = this.validateOperation(workflowCopy, operation);
152 |           if (error) {
153 |             return {
154 |               success: false,
155 |               errors: [{
156 |                 operation: index,
157 |                 message: error,
158 |                 details: operation
159 |               }]
160 |             };
161 |           }
162 | 
163 |           try {
164 |             this.applyOperation(workflowCopy, operation);
165 |           } catch (error) {
166 |             return {
167 |               success: false,
168 |               errors: [{
169 |                 operation: index,
170 |                 message: `Failed to apply operation: ${error instanceof Error ? error.message : 'Unknown error'}`,
171 |                 details: operation
172 |               }]
173 |             };
174 |           }
175 |         }
176 | 
177 |         // If validateOnly flag is set, return success without applying
178 |         if (request.validateOnly) {
179 |           return {
180 |             success: true,
181 |             message: 'Validation successful. Operations are valid but not applied.'
182 |           };
183 |         }
184 | 
185 |         const operationsApplied = request.operations.length;
186 |         return {
187 |           success: true,
188 |           workflow: workflowCopy,
189 |           operationsApplied,
190 |           message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`
191 |         };
192 |       }
193 |     } catch (error) {
194 |       logger.error('Failed to apply diff', error);
195 |       return {
196 |         success: false,
197 |         errors: [{
198 |           operation: -1,
199 |           message: `Diff engine error: ${error instanceof Error ? error.message : 'Unknown error'}`
200 |         }]
201 |       };
202 |     }
203 |   }
204 | 
205 | 
206 |   /**
207 |    * Validate a single operation
208 |    */
209 |   private validateOperation(workflow: Workflow, operation: WorkflowDiffOperation): string | null {
210 |     switch (operation.type) {
211 |       case 'addNode':
212 |         return this.validateAddNode(workflow, operation);
213 |       case 'removeNode':
214 |         return this.validateRemoveNode(workflow, operation);
215 |       case 'updateNode':
216 |         return this.validateUpdateNode(workflow, operation);
217 |       case 'moveNode':
218 |         return this.validateMoveNode(workflow, operation);
219 |       case 'enableNode':
220 |       case 'disableNode':
221 |         return this.validateToggleNode(workflow, operation);
222 |       case 'addConnection':
223 |         return this.validateAddConnection(workflow, operation);
224 |       case 'removeConnection':
225 |         return this.validateRemoveConnection(workflow, operation);
226 |       case 'rewireConnection':
227 |         return this.validateRewireConnection(workflow, operation as RewireConnectionOperation);
228 |       case 'updateSettings':
229 |       case 'updateName':
230 |       case 'addTag':
231 |       case 'removeTag':
232 |         return null; // These are always valid
233 |       case 'cleanStaleConnections':
234 |         return this.validateCleanStaleConnections(workflow, operation);
235 |       case 'replaceConnections':
236 |         return this.validateReplaceConnections(workflow, operation);
237 |       default:
238 |         return `Unknown operation type: ${(operation as any).type}`;
239 |     }
240 |   }
241 | 
242 |   /**
243 |    * Apply a single operation to the workflow
244 |    */
245 |   private applyOperation(workflow: Workflow, operation: WorkflowDiffOperation): void {
246 |     switch (operation.type) {
247 |       case 'addNode':
248 |         this.applyAddNode(workflow, operation);
249 |         break;
250 |       case 'removeNode':
251 |         this.applyRemoveNode(workflow, operation);
252 |         break;
253 |       case 'updateNode':
254 |         this.applyUpdateNode(workflow, operation);
255 |         break;
256 |       case 'moveNode':
257 |         this.applyMoveNode(workflow, operation);
258 |         break;
259 |       case 'enableNode':
260 |         this.applyEnableNode(workflow, operation);
261 |         break;
262 |       case 'disableNode':
263 |         this.applyDisableNode(workflow, operation);
264 |         break;
265 |       case 'addConnection':
266 |         this.applyAddConnection(workflow, operation);
267 |         break;
268 |       case 'removeConnection':
269 |         this.applyRemoveConnection(workflow, operation);
270 |         break;
271 |       case 'rewireConnection':
272 |         this.applyRewireConnection(workflow, operation as RewireConnectionOperation);
273 |         break;
274 |       case 'updateSettings':
275 |         this.applyUpdateSettings(workflow, operation);
276 |         break;
277 |       case 'updateName':
278 |         this.applyUpdateName(workflow, operation);
279 |         break;
280 |       case 'addTag':
281 |         this.applyAddTag(workflow, operation);
282 |         break;
283 |       case 'removeTag':
284 |         this.applyRemoveTag(workflow, operation);
285 |         break;
286 |       case 'cleanStaleConnections':
287 |         this.applyCleanStaleConnections(workflow, operation);
288 |         break;
289 |       case 'replaceConnections':
290 |         this.applyReplaceConnections(workflow, operation);
291 |         break;
292 |     }
293 |   }
294 | 
295 |   // Node operation validators
296 |   private validateAddNode(workflow: Workflow, operation: AddNodeOperation): string | null {
297 |     const { node } = operation;
298 | 
299 |     // Check if node with same name already exists (use normalization to prevent collisions)
300 |     const normalizedNewName = this.normalizeNodeName(node.name);
301 |     const duplicate = workflow.nodes.find(n =>
302 |       this.normalizeNodeName(n.name) === normalizedNewName
303 |     );
304 |     if (duplicate) {
305 |       return `Node with name "${node.name}" already exists (normalized name matches existing node "${duplicate.name}")`;
306 |     }
307 |     
308 |     // Validate node type format
309 |     if (!node.type.includes('.')) {
310 |       return `Invalid node type "${node.type}". Must include package prefix (e.g., "n8n-nodes-base.webhook")`;
311 |     }
312 |     
313 |     if (node.type.startsWith('nodes-base.')) {
314 |       return `Invalid node type "${node.type}". Use "n8n-nodes-base.${node.type.substring(11)}" instead`;
315 |     }
316 |     
317 |     return null;
318 |   }
319 | 
320 |   private validateRemoveNode(workflow: Workflow, operation: RemoveNodeOperation): string | null {
321 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
322 |     if (!node) {
323 |       return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'removeNode');
324 |     }
325 |     
326 |     // Check if node has connections that would be broken
327 |     const hasConnections = Object.values(workflow.connections).some(conn => {
328 |       return Object.values(conn).some(outputs => 
329 |         outputs.some(connections => 
330 |           connections.some(c => c.node === node.name)
331 |         )
332 |       );
333 |     });
334 |     
335 |     if (hasConnections || workflow.connections[node.name]) {
336 |       // This is a warning, not an error - connections will be cleaned up
337 |       logger.warn(`Removing node "${node.name}" will break existing connections`);
338 |     }
339 |     
340 |     return null;
341 |   }
342 | 
343 |   private validateUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): string | null {
344 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
345 |     if (!node) {
346 |       return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'updateNode');
347 |     }
348 |     return null;
349 |   }
350 | 
351 |   private validateMoveNode(workflow: Workflow, operation: MoveNodeOperation): string | null {
352 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
353 |     if (!node) {
354 |       return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'moveNode');
355 |     }
356 |     return null;
357 |   }
358 | 
359 |   private validateToggleNode(workflow: Workflow, operation: EnableNodeOperation | DisableNodeOperation): string | null {
360 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
361 |     if (!node) {
362 |       const operationType = operation.type === 'enableNode' ? 'enableNode' : 'disableNode';
363 |       return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', operationType);
364 |     }
365 |     return null;
366 |   }
367 | 
368 |   // Connection operation validators
369 |   private validateAddConnection(workflow: Workflow, operation: AddConnectionOperation): string | null {
370 |     // Check for common parameter mistakes (Issue #249)
371 |     const operationAny = operation as any;
372 |     if (operationAny.sourceNodeId || operationAny.targetNodeId) {
373 |       const wrongParams: string[] = [];
374 |       if (operationAny.sourceNodeId) wrongParams.push('sourceNodeId');
375 |       if (operationAny.targetNodeId) wrongParams.push('targetNodeId');
376 | 
377 |       return `Invalid parameter(s): ${wrongParams.join(', ')}. Use 'source' and 'target' instead. Example: {type: "addConnection", source: "Node Name", target: "Target Name"}`;
378 |     }
379 | 
380 |     // Check for missing required parameters
381 |     if (!operation.source) {
382 |       return `Missing required parameter 'source'. The addConnection operation requires both 'source' and 'target' parameters. Check that you're using 'source' (not 'sourceNodeId').`;
383 |     }
384 |     if (!operation.target) {
385 |       return `Missing required parameter 'target'. The addConnection operation requires both 'source' and 'target' parameters. Check that you're using 'target' (not 'targetNodeId').`;
386 |     }
387 | 
388 |     const sourceNode = this.findNode(workflow, operation.source, operation.source);
389 |     const targetNode = this.findNode(workflow, operation.target, operation.target);
390 | 
391 |     if (!sourceNode) {
392 |       const availableNodes = workflow.nodes
393 |         .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
394 |         .join(', ');
395 |       return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
396 |     }
397 |     if (!targetNode) {
398 |       const availableNodes = workflow.nodes
399 |         .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
400 |         .join(', ');
401 |       return `Target node not found: "${operation.target}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
402 |     }
403 | 
404 |     // Check if connection already exists
405 |     const sourceOutput = operation.sourceOutput || 'main';
406 |     const existing = workflow.connections[sourceNode.name]?.[sourceOutput];
407 |     if (existing) {
408 |       const hasConnection = existing.some(connections =>
409 |         connections.some(c => c.node === targetNode.name)
410 |       );
411 |       if (hasConnection) {
412 |         return `Connection already exists from "${sourceNode.name}" to "${targetNode.name}"`;
413 |       }
414 |     }
415 | 
416 |     return null;
417 |   }
418 | 
419 |   private validateRemoveConnection(workflow: Workflow, operation: RemoveConnectionOperation): string | null {
420 |     // If ignoreErrors is true, don't validate - operation will silently succeed even if connection doesn't exist
421 |     if (operation.ignoreErrors) {
422 |       return null;
423 |     }
424 | 
425 |     const sourceNode = this.findNode(workflow, operation.source, operation.source);
426 |     const targetNode = this.findNode(workflow, operation.target, operation.target);
427 | 
428 |     if (!sourceNode) {
429 |       const availableNodes = workflow.nodes
430 |         .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
431 |         .join(', ');
432 |       return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
433 |     }
434 |     if (!targetNode) {
435 |       const availableNodes = workflow.nodes
436 |         .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
437 |         .join(', ');
438 |       return `Target node not found: "${operation.target}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
439 |     }
440 | 
441 |     const sourceOutput = operation.sourceOutput || 'main';
442 |     const connections = workflow.connections[sourceNode.name]?.[sourceOutput];
443 |     if (!connections) {
444 |       return `No connections found from "${sourceNode.name}"`;
445 |     }
446 | 
447 |     const hasConnection = connections.some(conns =>
448 |       conns.some(c => c.node === targetNode.name)
449 |     );
450 | 
451 |     if (!hasConnection) {
452 |       return `No connection exists from "${sourceNode.name}" to "${targetNode.name}"`;
453 |     }
454 | 
455 |     return null;
456 |   }
457 | 
458 |   private validateRewireConnection(workflow: Workflow, operation: RewireConnectionOperation): string | null {
459 |     // Validate source node exists
460 |     const sourceNode = this.findNode(workflow, operation.source, operation.source);
461 |     if (!sourceNode) {
462 |       const availableNodes = workflow.nodes
463 |         .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
464 |         .join(', ');
465 |       return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
466 |     }
467 | 
468 |     // Validate "from" node exists (current target)
469 |     const fromNode = this.findNode(workflow, operation.from, operation.from);
470 |     if (!fromNode) {
471 |       const availableNodes = workflow.nodes
472 |         .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
473 |         .join(', ');
474 |       return `"From" node not found: "${operation.from}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
475 |     }
476 | 
477 |     // Validate "to" node exists (new target)
478 |     const toNode = this.findNode(workflow, operation.to, operation.to);
479 |     if (!toNode) {
480 |       const availableNodes = workflow.nodes
481 |         .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
482 |         .join(', ');
483 |       return `"To" node not found: "${operation.to}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
484 |     }
485 | 
486 |     // Resolve smart parameters (branch, case) before validating connections
487 |     const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation);
488 | 
489 |     // Validate that connection from source to "from" exists at the specific index
490 |     const connections = workflow.connections[sourceNode.name]?.[sourceOutput];
491 |     if (!connections) {
492 |       return `No connections found from "${sourceNode.name}" on output "${sourceOutput}"`;
493 |     }
494 | 
495 |     if (!connections[sourceIndex]) {
496 |       return `No connections found from "${sourceNode.name}" on output "${sourceOutput}" at index ${sourceIndex}`;
497 |     }
498 | 
499 |     const hasConnection = connections[sourceIndex].some(c => c.node === fromNode.name);
500 | 
501 |     if (!hasConnection) {
502 |       return `No connection exists from "${sourceNode.name}" to "${fromNode.name}" on output "${sourceOutput}" at index ${sourceIndex}"`;
503 |     }
504 | 
505 |     return null;
506 |   }
507 | 
508 |   // Node operation appliers
509 |   private applyAddNode(workflow: Workflow, operation: AddNodeOperation): void {
510 |     const newNode: WorkflowNode = {
511 |       id: operation.node.id || uuidv4(),
512 |       name: operation.node.name,
513 |       type: operation.node.type,
514 |       typeVersion: operation.node.typeVersion || 1,
515 |       position: operation.node.position,
516 |       parameters: operation.node.parameters || {},
517 |       credentials: operation.node.credentials,
518 |       disabled: operation.node.disabled,
519 |       notes: operation.node.notes,
520 |       notesInFlow: operation.node.notesInFlow,
521 |       continueOnFail: operation.node.continueOnFail,
522 |       onError: operation.node.onError,
523 |       retryOnFail: operation.node.retryOnFail,
524 |       maxTries: operation.node.maxTries,
525 |       waitBetweenTries: operation.node.waitBetweenTries,
526 |       alwaysOutputData: operation.node.alwaysOutputData,
527 |       executeOnce: operation.node.executeOnce
528 |     };
529 |     
530 |     workflow.nodes.push(newNode);
531 |   }
532 | 
533 |   private applyRemoveNode(workflow: Workflow, operation: RemoveNodeOperation): void {
534 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
535 |     if (!node) return;
536 |     
537 |     // Remove node from array
538 |     const index = workflow.nodes.findIndex(n => n.id === node.id);
539 |     if (index !== -1) {
540 |       workflow.nodes.splice(index, 1);
541 |     }
542 |     
543 |     // Remove all connections from this node
544 |     delete workflow.connections[node.name];
545 |     
546 |     // Remove all connections to this node
547 |     Object.keys(workflow.connections).forEach(sourceName => {
548 |       const sourceConnections = workflow.connections[sourceName];
549 |       Object.keys(sourceConnections).forEach(outputName => {
550 |         sourceConnections[outputName] = sourceConnections[outputName].map(connections =>
551 |           connections.filter(conn => conn.node !== node.name)
552 |         ).filter(connections => connections.length > 0);
553 |         
554 |         // Clean up empty arrays
555 |         if (sourceConnections[outputName].length === 0) {
556 |           delete sourceConnections[outputName];
557 |         }
558 |       });
559 |       
560 |       // Clean up empty connection objects
561 |       if (Object.keys(sourceConnections).length === 0) {
562 |         delete workflow.connections[sourceName];
563 |       }
564 |     });
565 |   }
566 | 
567 |   private applyUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): void {
568 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
569 |     if (!node) return;
570 |     
571 |     // Apply updates using dot notation
572 |     Object.entries(operation.updates).forEach(([path, value]) => {
573 |       this.setNestedProperty(node, path, value);
574 |     });
575 |   }
576 | 
577 |   private applyMoveNode(workflow: Workflow, operation: MoveNodeOperation): void {
578 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
579 |     if (!node) return;
580 |     
581 |     node.position = operation.position;
582 |   }
583 | 
584 |   private applyEnableNode(workflow: Workflow, operation: EnableNodeOperation): void {
585 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
586 |     if (!node) return;
587 |     
588 |     node.disabled = false;
589 |   }
590 | 
591 |   private applyDisableNode(workflow: Workflow, operation: DisableNodeOperation): void {
592 |     const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
593 |     if (!node) return;
594 |     
595 |     node.disabled = true;
596 |   }
597 | 
598 |   /**
599 |    * Resolve smart parameters (branch, case) to technical parameters
600 |    * Phase 1 UX improvement: Semantic parameters for multi-output nodes
601 |    */
602 |   private resolveSmartParameters(
603 |     workflow: Workflow,
604 |     operation: AddConnectionOperation | RewireConnectionOperation
605 |   ): { sourceOutput: string; sourceIndex: number } {
606 |     const sourceNode = this.findNode(workflow, operation.source, operation.source);
607 | 
608 |     // Start with explicit values or defaults
609 |     let sourceOutput = operation.sourceOutput ?? 'main';
610 |     let sourceIndex = operation.sourceIndex ?? 0;
611 | 
612 |     // Smart parameter: branch (for IF nodes)
613 |     // IF nodes use 'main' output with index 0 (true) or 1 (false)
614 |     if (operation.branch !== undefined && operation.sourceIndex === undefined) {
615 |       // Only apply if sourceIndex not explicitly set
616 |       if (sourceNode?.type === 'n8n-nodes-base.if') {
617 |         sourceIndex = operation.branch === 'true' ? 0 : 1;
618 |         // sourceOutput remains 'main' (do not change it)
619 |       }
620 |     }
621 | 
622 |     // Smart parameter: case (for Switch nodes)
623 |     if (operation.case !== undefined && operation.sourceIndex === undefined) {
624 |       // Only apply if sourceIndex not explicitly set
625 |       sourceIndex = operation.case;
626 |     }
627 | 
628 |     return { sourceOutput, sourceIndex };
629 |   }
630 | 
631 |   // Connection operation appliers
632 |   private applyAddConnection(workflow: Workflow, operation: AddConnectionOperation): void {
633 |     const sourceNode = this.findNode(workflow, operation.source, operation.source);
634 |     const targetNode = this.findNode(workflow, operation.target, operation.target);
635 |     if (!sourceNode || !targetNode) return;
636 | 
637 |     // Resolve smart parameters (branch, case) to technical parameters
638 |     const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation);
639 | 
640 |     // Use nullish coalescing to properly handle explicit 0 values
641 |     const targetInput = operation.targetInput ?? 'main';
642 |     const targetIndex = operation.targetIndex ?? 0;
643 | 
644 |     // Initialize source node connections object
645 |     if (!workflow.connections[sourceNode.name]) {
646 |       workflow.connections[sourceNode.name] = {};
647 |     }
648 | 
649 |     // Initialize output type array
650 |     if (!workflow.connections[sourceNode.name][sourceOutput]) {
651 |       workflow.connections[sourceNode.name][sourceOutput] = [];
652 |     }
653 | 
654 |     // Get reference to output array for clarity
655 |     const outputArray = workflow.connections[sourceNode.name][sourceOutput];
656 | 
657 |     // Ensure we have connection arrays up to and including the target sourceIndex
658 |     while (outputArray.length <= sourceIndex) {
659 |       outputArray.push([]);
660 |     }
661 | 
662 |     // Defensive: Verify the slot is an array (should always be true after while loop)
663 |     if (!Array.isArray(outputArray[sourceIndex])) {
664 |       outputArray[sourceIndex] = [];
665 |     }
666 | 
667 |     // Add connection to the correct sourceIndex
668 |     outputArray[sourceIndex].push({
669 |       node: targetNode.name,
670 |       type: targetInput,
671 |       index: targetIndex
672 |     });
673 |   }
674 | 
675 |   private applyRemoveConnection(workflow: Workflow, operation: RemoveConnectionOperation): void {
676 |     const sourceNode = this.findNode(workflow, operation.source, operation.source);
677 |     const targetNode = this.findNode(workflow, operation.target, operation.target);
678 |     // If ignoreErrors is true, silently succeed even if nodes don't exist
679 |     if (!sourceNode || !targetNode) {
680 |       if (operation.ignoreErrors) {
681 |         return; // Gracefully handle missing nodes
682 |       }
683 |       return; // Should never reach here if validation passed, but safety check
684 |     }
685 |     
686 |     const sourceOutput = operation.sourceOutput || 'main';
687 |     const connections = workflow.connections[sourceNode.name]?.[sourceOutput];
688 |     if (!connections) return;
689 |     
690 |     // Remove connection from all indices
691 |     workflow.connections[sourceNode.name][sourceOutput] = connections.map(conns =>
692 |       conns.filter(conn => conn.node !== targetNode.name)
693 |     );
694 | 
695 |     // Remove trailing empty arrays only (preserve intermediate empty arrays to maintain indices)
696 |     const outputConnections = workflow.connections[sourceNode.name][sourceOutput];
697 |     while (outputConnections.length > 0 && outputConnections[outputConnections.length - 1].length === 0) {
698 |       outputConnections.pop();
699 |     }
700 | 
701 |     if (outputConnections.length === 0) {
702 |       delete workflow.connections[sourceNode.name][sourceOutput];
703 |     }
704 |     
705 |     if (Object.keys(workflow.connections[sourceNode.name]).length === 0) {
706 |       delete workflow.connections[sourceNode.name];
707 |     }
708 |   }
709 | 
710 |   /**
711 |    * Rewire a connection from one target to another
712 |    * This is a semantic wrapper around removeConnection + addConnection
713 |    * that provides clear intent: "rewire connection from X to Y"
714 |    *
715 |    * @param workflow - Workflow to modify
716 |    * @param operation - Rewire operation specifying source, from, and to
717 |    */
718 |   private applyRewireConnection(workflow: Workflow, operation: RewireConnectionOperation): void {
719 |     // Resolve smart parameters (branch, case) to technical parameters
720 |     const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation);
721 | 
722 |     // First, remove the old connection (source → from)
723 |     this.applyRemoveConnection(workflow, {
724 |       type: 'removeConnection',
725 |       source: operation.source,
726 |       target: operation.from,
727 |       sourceOutput: sourceOutput,
728 |       targetInput: operation.targetInput
729 |     });
730 | 
731 |     // Then, add the new connection (source → to)
732 |     this.applyAddConnection(workflow, {
733 |       type: 'addConnection',
734 |       source: operation.source,
735 |       target: operation.to,
736 |       sourceOutput: sourceOutput,
737 |       targetInput: operation.targetInput,
738 |       sourceIndex: sourceIndex,
739 |       targetIndex: 0 // Default target index for new connection
740 |     });
741 |   }
742 | 
743 |   // Metadata operation appliers
744 |   private applyUpdateSettings(workflow: Workflow, operation: UpdateSettingsOperation): void {
745 |     if (!workflow.settings) {
746 |       workflow.settings = {};
747 |     }
748 |     Object.assign(workflow.settings, operation.settings);
749 |   }
750 | 
751 |   private applyUpdateName(workflow: Workflow, operation: UpdateNameOperation): void {
752 |     workflow.name = operation.name;
753 |   }
754 | 
755 |   private applyAddTag(workflow: Workflow, operation: AddTagOperation): void {
756 |     if (!workflow.tags) {
757 |       workflow.tags = [];
758 |     }
759 |     if (!workflow.tags.includes(operation.tag)) {
760 |       workflow.tags.push(operation.tag);
761 |     }
762 |   }
763 | 
764 |   private applyRemoveTag(workflow: Workflow, operation: RemoveTagOperation): void {
765 |     if (!workflow.tags) return;
766 |     
767 |     const index = workflow.tags.indexOf(operation.tag);
768 |     if (index !== -1) {
769 |       workflow.tags.splice(index, 1);
770 |     }
771 |   }
772 | 
773 |   // Connection cleanup operation validators
774 |   private validateCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): string | null {
775 |     // This operation is always valid - it just cleans up what it finds
776 |     return null;
777 |   }
778 | 
779 |   private validateReplaceConnections(workflow: Workflow, operation: ReplaceConnectionsOperation): string | null {
780 |     // Validate that all referenced nodes exist
781 |     const nodeNames = new Set(workflow.nodes.map(n => n.name));
782 | 
783 |     for (const [sourceName, outputs] of Object.entries(operation.connections)) {
784 |       if (!nodeNames.has(sourceName)) {
785 |         return `Source node not found in connections: ${sourceName}`;
786 |       }
787 | 
788 |       // outputs is the value from Object.entries, need to iterate its keys
789 |       for (const outputName of Object.keys(outputs)) {
790 |         const connections = outputs[outputName];
791 |         for (const conns of connections) {
792 |           for (const conn of conns) {
793 |             if (!nodeNames.has(conn.node)) {
794 |               return `Target node not found in connections: ${conn.node}`;
795 |             }
796 |           }
797 |         }
798 |       }
799 |     }
800 | 
801 |     return null;
802 |   }
803 | 
804 |   // Connection cleanup operation appliers
805 |   private applyCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): void {
806 |     const nodeNames = new Set(workflow.nodes.map(n => n.name));
807 |     const staleConnections: Array<{ from: string; to: string }> = [];
808 | 
809 |     // If dryRun, only identify stale connections without removing them
810 |     if (operation.dryRun) {
811 |       for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
812 |         if (!nodeNames.has(sourceName)) {
813 |           for (const [outputName, connections] of Object.entries(outputs)) {
814 |             for (const conns of connections) {
815 |               for (const conn of conns) {
816 |                 staleConnections.push({ from: sourceName, to: conn.node });
817 |               }
818 |             }
819 |           }
820 |         } else {
821 |           for (const [outputName, connections] of Object.entries(outputs)) {
822 |             for (const conns of connections) {
823 |               for (const conn of conns) {
824 |                 if (!nodeNames.has(conn.node)) {
825 |                   staleConnections.push({ from: sourceName, to: conn.node });
826 |                 }
827 |               }
828 |             }
829 |           }
830 |         }
831 |       }
832 |       logger.info(`[DryRun] Would remove ${staleConnections.length} stale connections:`, staleConnections);
833 |       return;
834 |     }
835 | 
836 |     // Actually remove stale connections
837 |     for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
838 |       // If source node doesn't exist, mark all connections as stale
839 |       if (!nodeNames.has(sourceName)) {
840 |         for (const [outputName, connections] of Object.entries(outputs)) {
841 |           for (const conns of connections) {
842 |             for (const conn of conns) {
843 |               staleConnections.push({ from: sourceName, to: conn.node });
844 |             }
845 |           }
846 |         }
847 |         delete workflow.connections[sourceName];
848 |         continue;
849 |       }
850 | 
851 |       // Check each connection
852 |       for (const [outputName, connections] of Object.entries(outputs)) {
853 |         const filteredConnections = connections.map(conns =>
854 |           conns.filter(conn => {
855 |             if (!nodeNames.has(conn.node)) {
856 |               staleConnections.push({ from: sourceName, to: conn.node });
857 |               return false;
858 |             }
859 |             return true;
860 |           })
861 |         ).filter(conns => conns.length > 0);
862 | 
863 |         if (filteredConnections.length === 0) {
864 |           delete outputs[outputName];
865 |         } else {
866 |           outputs[outputName] = filteredConnections;
867 |         }
868 |       }
869 | 
870 |       // Clean up empty output objects
871 |       if (Object.keys(outputs).length === 0) {
872 |         delete workflow.connections[sourceName];
873 |       }
874 |     }
875 | 
876 |     logger.info(`Removed ${staleConnections.length} stale connections`);
877 |   }
878 | 
879 |   private applyReplaceConnections(workflow: Workflow, operation: ReplaceConnectionsOperation): void {
880 |     workflow.connections = operation.connections;
881 |   }
882 | 
883 |   // Helper methods
884 | 
885 |   /**
886 |    * Normalize node names to handle special characters and escaping differences.
887 |    * Fixes issue #270: apostrophes and other special characters in node names.
888 |    *
889 |    * ⚠️ WARNING: Normalization can cause collisions between names that differ only in:
890 |    * - Leading/trailing whitespace
891 |    * - Multiple consecutive spaces vs single spaces
892 |    * - Escaped vs unescaped quotes/backslashes
893 |    * - Different types of whitespace (tabs, newlines, spaces)
894 |    *
895 |    * Examples of names that normalize to the SAME value:
896 |    * - "Node 'test'" === "Node  'test'" (multiple spaces)
897 |    * - "Node 'test'" === "Node\t'test'" (tab vs space)
898 |    * - "Node 'test'" === "Node \\'test\\'" (escaped quotes)
899 |    * - "Path\\to\\file" === "Path\\\\to\\\\file" (escaped backslashes)
900 |    *
901 |    * Best Practice: For node names with special characters, prefer using node IDs
902 |    * to avoid ambiguity. Use n8n_get_workflow_structure() to get node IDs.
903 |    *
904 |    * @param name - The node name to normalize
905 |    * @returns Normalized node name for safe comparison
906 |    */
907 |   private normalizeNodeName(name: string): string {
908 |     return name
909 |       .trim()                    // Remove leading/trailing whitespace
910 |       .replace(/\\\\/g, '\\')    // FIRST: Unescape backslashes: \\ -> \ (must be first to handle multiply-escaped chars)
911 |       .replace(/\\'/g, "'")      // THEN: Unescape single quotes: \' -> '
912 |       .replace(/\\"/g, '"')      // THEN: Unescape double quotes: \" -> "
913 |       .replace(/\s+/g, ' ');     // FINALLY: Normalize all whitespace (spaces, tabs, newlines) to single space
914 |   }
915 | 
916 |   /**
917 |    * Find a node by ID or name in the workflow.
918 |    * Uses string normalization to handle special characters (Issue #270).
919 |    *
920 |    * @param workflow - The workflow to search in
921 |    * @param nodeId - Optional node ID to search for
922 |    * @param nodeName - Optional node name to search for
923 |    * @returns The found node or null
924 |    */
925 |   private findNode(workflow: Workflow, nodeId?: string, nodeName?: string): WorkflowNode | null {
926 |     // Try to find by ID first (exact match, no normalization needed for UUIDs)
927 |     if (nodeId) {
928 |       const nodeById = workflow.nodes.find(n => n.id === nodeId);
929 |       if (nodeById) return nodeById;
930 |     }
931 | 
932 |     // Try to find by name with normalization (handles special characters)
933 |     if (nodeName) {
934 |       const normalizedSearch = this.normalizeNodeName(nodeName);
935 |       const nodeByName = workflow.nodes.find(n =>
936 |         this.normalizeNodeName(n.name) === normalizedSearch
937 |       );
938 |       if (nodeByName) return nodeByName;
939 |     }
940 | 
941 |     // Fallback: If nodeId provided but not found, try treating it as a name
942 |     // This allows operations to work with either IDs or names flexibly
943 |     if (nodeId && !nodeName) {
944 |       const normalizedSearch = this.normalizeNodeName(nodeId);
945 |       const nodeByName = workflow.nodes.find(n =>
946 |         this.normalizeNodeName(n.name) === normalizedSearch
947 |       );
948 |       if (nodeByName) return nodeByName;
949 |     }
950 | 
951 |     return null;
952 |   }
953 | 
954 |   /**
955 |    * Format a consistent "node not found" error message with helpful context.
956 |    * Shows available nodes with IDs and tips about using node IDs for special characters.
957 |    *
958 |    * @param workflow - The workflow being validated
959 |    * @param nodeIdentifier - The node ID or name that wasn't found
960 |    * @param operationType - The operation being performed (e.g., "removeNode", "updateNode")
961 |    * @returns Formatted error message with available nodes and helpful tips
962 |    */
963 |   private formatNodeNotFoundError(
964 |     workflow: Workflow,
965 |     nodeIdentifier: string,
966 |     operationType: string
967 |   ): string {
968 |     const availableNodes = workflow.nodes
969 |       .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
970 |       .join(', ');
971 |     return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
972 |   }
973 | 
974 |   private setNestedProperty(obj: any, path: string, value: any): void {
975 |     const keys = path.split('.');
976 |     let current = obj;
977 |     
978 |     for (let i = 0; i < keys.length - 1; i++) {
979 |       const key = keys[i];
980 |       if (!(key in current) || typeof current[key] !== 'object') {
981 |         current[key] = {};
982 |       }
983 |       current = current[key];
984 |     }
985 |     
986 |     current[keys[keys.length - 1]] = value;
987 |   }
988 | }
```
Page 39/59FirstPrevNextLast