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 | } ```