This is page 13 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 -------------------------------------------------------------------------------- /tests/integration/security/command-injection-prevention.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeAll } from 'vitest'; 2 | import { EnhancedDocumentationFetcher } from '../../../src/utils/enhanced-documentation-fetcher'; 3 | 4 | /** 5 | * Integration tests for command injection prevention 6 | * 7 | * SECURITY: These tests verify that malicious inputs cannot execute shell commands 8 | * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01) 9 | */ 10 | describe('Command Injection Prevention', () => { 11 | let fetcher: EnhancedDocumentationFetcher; 12 | 13 | beforeAll(() => { 14 | fetcher = new EnhancedDocumentationFetcher(); 15 | }); 16 | 17 | describe('Command Injection Attacks', () => { 18 | it('should sanitize all command injection attempts without executing commands', async () => { 19 | // SECURITY: The key is that special characters are sanitized, preventing command execution 20 | // After sanitization, the string may become a valid search term (e.g., 'test') 21 | // which is safe behavior - no commands are executed 22 | const attacks = [ 23 | 'test"; rm -rf / #', // Sanitizes to: test 24 | 'test && cat /etc/passwd',// Sanitizes to: test 25 | 'test | curl http://evil.com', // Sanitizes to: test 26 | 'test`whoami`', // Sanitizes to: test 27 | 'test$(cat /etc/passwd)', // Sanitizes to: test 28 | 'test\nrm -rf /', // Sanitizes to: test 29 | '"; rm -rf / #', // Sanitizes to: empty 30 | '&&& curl http://evil.com', // Sanitizes to: empty 31 | '|||', // Sanitizes to: empty 32 | '```', // Sanitizes to: empty 33 | '$()', // Sanitizes to: empty 34 | '\n\n\n', // Sanitizes to: empty 35 | ]; 36 | 37 | for (const attack of attacks) { 38 | // Should complete without throwing errors or executing commands 39 | // Result may be null or may find documentation - both are safe as long as no commands execute 40 | await expect(fetcher.getEnhancedNodeDocumentation(attack)).resolves.toBeDefined(); 41 | } 42 | }); 43 | }); 44 | 45 | describe('Directory Traversal Prevention', () => { 46 | it('should block parent directory traversal', async () => { 47 | const traversalAttacks = [ 48 | '../../../etc/passwd', 49 | '../../etc/passwd', 50 | '../etc/passwd', 51 | ]; 52 | 53 | for (const attack of traversalAttacks) { 54 | const result = await fetcher.getEnhancedNodeDocumentation(attack); 55 | expect(result).toBeNull(); 56 | } 57 | }); 58 | 59 | it('should block URL-encoded directory traversal', async () => { 60 | const traversalAttacks = [ 61 | '..%2f..%2fetc%2fpasswd', 62 | '..%2fetc%2fpasswd', 63 | ]; 64 | 65 | for (const attack of traversalAttacks) { 66 | const result = await fetcher.getEnhancedNodeDocumentation(attack); 67 | expect(result).toBeNull(); 68 | } 69 | }); 70 | 71 | it('should block relative path references', async () => { 72 | const pathAttacks = [ 73 | '..', 74 | '....', 75 | './test', 76 | '../test', 77 | ]; 78 | 79 | for (const attack of pathAttacks) { 80 | const result = await fetcher.getEnhancedNodeDocumentation(attack); 81 | expect(result).toBeNull(); 82 | } 83 | }); 84 | 85 | it('should block absolute paths', async () => { 86 | const pathAttacks = [ 87 | '/etc/passwd', 88 | '/usr/bin/whoami', 89 | '/var/log/auth.log', 90 | ]; 91 | 92 | for (const attack of pathAttacks) { 93 | const result = await fetcher.getEnhancedNodeDocumentation(attack); 94 | expect(result).toBeNull(); 95 | } 96 | }); 97 | }); 98 | 99 | describe('Special Character Handling', () => { 100 | it('should sanitize special characters', async () => { 101 | const specialChars = [ 102 | 'test;', 103 | 'test|', 104 | 'test&', 105 | 'test`', 106 | 'test$', 107 | 'test(', 108 | 'test)', 109 | 'test<', 110 | 'test>', 111 | ]; 112 | 113 | for (const input of specialChars) { 114 | const result = await fetcher.getEnhancedNodeDocumentation(input); 115 | // Should sanitize and search, not execute commands 116 | // Result should be null (not found) but no command execution 117 | expect(result).toBeNull(); 118 | } 119 | }); 120 | 121 | it('should sanitize null bytes', async () => { 122 | // Null bytes are sanitized, leaving 'test' as valid search term 123 | const nullByteAttacks = [ 124 | 'test\0.md', 125 | 'test\u0000', 126 | ]; 127 | 128 | for (const attack of nullByteAttacks) { 129 | // Should complete safely - null bytes are removed 130 | await expect(fetcher.getEnhancedNodeDocumentation(attack)).resolves.toBeDefined(); 131 | } 132 | }); 133 | }); 134 | 135 | describe('Legitimate Operations', () => { 136 | it('should still find valid node documentation with safe characters', async () => { 137 | // Test with a real node type that should exist 138 | const validNodeTypes = [ 139 | 'slack', 140 | 'gmail', 141 | 'httpRequest', 142 | ]; 143 | 144 | for (const nodeType of validNodeTypes) { 145 | const result = await fetcher.getEnhancedNodeDocumentation(nodeType); 146 | // May or may not find docs depending on setup, but should not throw or execute commands 147 | // The key is that it completes without error 148 | expect(result === null || typeof result === 'object').toBe(true); 149 | } 150 | }); 151 | 152 | it('should handle hyphens and underscores safely', async () => { 153 | const safeNames = [ 154 | 'http-request', 155 | 'google_sheets', 156 | 'n8n-nodes-base', 157 | ]; 158 | 159 | for (const name of safeNames) { 160 | const result = await fetcher.getEnhancedNodeDocumentation(name); 161 | // Should process safely without executing commands 162 | expect(result === null || typeof result === 'object').toBe(true); 163 | } 164 | }); 165 | }); 166 | 167 | describe('Git Command Injection Prevention (Issue #265 Part 2)', () => { 168 | it('should reject malicious paths in constructor with shell metacharacters', () => { 169 | const maliciousPaths = [ 170 | '/tmp/test; touch /tmp/PWNED #', 171 | '/tmp/test && curl http://evil.com', 172 | '/tmp/test | whoami', 173 | '/tmp/test`whoami`', 174 | '/tmp/test$(cat /etc/passwd)', 175 | '/tmp/test\nrm -rf /', 176 | '/tmp/test & rm -rf /', 177 | '/tmp/test || curl evil.com', 178 | ]; 179 | 180 | for (const maliciousPath of maliciousPaths) { 181 | expect(() => new EnhancedDocumentationFetcher(maliciousPath)).toThrow( 182 | /Invalid docsPath: path contains disallowed characters or patterns/ 183 | ); 184 | } 185 | }); 186 | 187 | it('should reject paths pointing to sensitive system directories', () => { 188 | const systemPaths = [ 189 | '/etc/passwd', 190 | '/sys/kernel', 191 | '/proc/self', 192 | '/var/log/auth.log', 193 | ]; 194 | 195 | for (const systemPath of systemPaths) { 196 | expect(() => new EnhancedDocumentationFetcher(systemPath)).toThrow( 197 | /Invalid docsPath: cannot use system directories/ 198 | ); 199 | } 200 | }); 201 | 202 | it('should reject directory traversal attempts in constructor', () => { 203 | const traversalPaths = [ 204 | '../../../etc/passwd', 205 | '../../sensitive', 206 | './relative/path', 207 | '.hidden/path', 208 | ]; 209 | 210 | for (const traversalPath of traversalPaths) { 211 | expect(() => new EnhancedDocumentationFetcher(traversalPath)).toThrow( 212 | /Invalid docsPath: path contains disallowed characters or patterns/ 213 | ); 214 | } 215 | }); 216 | 217 | it('should accept valid absolute paths in constructor', () => { 218 | // These should not throw 219 | expect(() => new EnhancedDocumentationFetcher('/tmp/valid-docs-path')).not.toThrow(); 220 | expect(() => new EnhancedDocumentationFetcher('/var/tmp/n8n-docs')).not.toThrow(); 221 | expect(() => new EnhancedDocumentationFetcher('/home/user/docs')).not.toThrow(); 222 | }); 223 | 224 | it('should use default path when no path provided', () => { 225 | // Should not throw with default path 226 | expect(() => new EnhancedDocumentationFetcher()).not.toThrow(); 227 | }); 228 | 229 | it('should reject paths with quote characters', () => { 230 | const quotePaths = [ 231 | '/tmp/test"malicious', 232 | "/tmp/test'malicious", 233 | '/tmp/test`command`', 234 | ]; 235 | 236 | for (const quotePath of quotePaths) { 237 | expect(() => new EnhancedDocumentationFetcher(quotePath)).toThrow( 238 | /Invalid docsPath: path contains disallowed characters or patterns/ 239 | ); 240 | } 241 | }); 242 | 243 | it('should reject paths with brackets and braces', () => { 244 | const bracketPaths = [ 245 | '/tmp/test[malicious]', 246 | '/tmp/test{a,b}', 247 | '/tmp/test<redirect>', 248 | '/tmp/test(subshell)', 249 | ]; 250 | 251 | for (const bracketPath of bracketPaths) { 252 | expect(() => new EnhancedDocumentationFetcher(bracketPath)).toThrow( 253 | /Invalid docsPath: path contains disallowed characters or patterns/ 254 | ); 255 | } 256 | }); 257 | }); 258 | }); 259 | ``` -------------------------------------------------------------------------------- /docs/local/integration-tests-phase1-summary.md: -------------------------------------------------------------------------------- ```markdown 1 | # Integration Tests Phase 1: Foundation - COMPLETED 2 | 3 | ## Overview 4 | Phase 1 establishes the foundation for n8n API integration testing. All core utilities, fixtures, and infrastructure are now in place. 5 | 6 | ## Branch 7 | `feat/integration-tests-foundation` 8 | 9 | ## Completed Tasks 10 | 11 | ### 1. Environment Configuration 12 | - ✅ Updated `.env.example` with integration testing configuration 13 | - ✅ Added environment variables for: 14 | - n8n API credentials (`N8N_API_URL`, `N8N_API_KEY`) 15 | - Webhook workflow IDs (4 workflows for GET/POST/PUT/DELETE) 16 | - Test configuration (cleanup, tags, naming) 17 | - ✅ Included detailed setup instructions in comments 18 | 19 | ### 2. Directory Structure 20 | ``` 21 | tests/integration/n8n-api/ 22 | ├── workflows/ (empty - for Phase 2+) 23 | ├── executions/ (empty - for Phase 2+) 24 | ├── system/ (empty - for Phase 2+) 25 | ├── scripts/ 26 | │ └── cleanup-orphans.ts 27 | └── utils/ 28 | ├── credentials.ts 29 | ├── n8n-client.ts 30 | ├── test-context.ts 31 | ├── cleanup-helpers.ts 32 | ├── fixtures.ts 33 | ├── factories.ts 34 | └── webhook-workflows.ts 35 | ``` 36 | 37 | ### 3. Core Utilities 38 | 39 | #### `credentials.ts` (200 lines) 40 | - Environment-aware credential loading 41 | - Detects CI vs local environment automatically 42 | - Validation functions with helpful error messages 43 | - Non-throwing credential check functions 44 | 45 | **Key Functions:** 46 | - `getN8nCredentials()` - Load credentials from .env or GitHub secrets 47 | - `validateCredentials()` - Ensure required credentials are present 48 | - `validateWebhookWorkflows()` - Check webhook workflow IDs with setup instructions 49 | - `hasCredentials()` - Non-throwing credential check 50 | - `hasWebhookWorkflows()` - Non-throwing webhook check 51 | 52 | #### `n8n-client.ts` (45 lines) 53 | - Singleton n8n API client wrapper 54 | - Pre-configured with test credentials 55 | - Health check functionality 56 | 57 | **Key Functions:** 58 | - `getTestN8nClient()` - Get/create configured API client 59 | - `resetTestN8nClient()` - Reset client instance 60 | - `isN8nApiAccessible()` - Check API connectivity 61 | 62 | #### `test-context.ts` (120 lines) 63 | - Resource tracking for automatic cleanup 64 | - Test workflow naming utilities 65 | - Tag management 66 | 67 | **Key Functions:** 68 | - `createTestContext()` - Create context for tracking resources 69 | - `TestContext.trackWorkflow()` - Track workflow for cleanup 70 | - `TestContext.trackExecution()` - Track execution for cleanup 71 | - `TestContext.cleanup()` - Delete all tracked resources 72 | - `createTestWorkflowName()` - Generate unique workflow names 73 | - `getTestTag()` - Get configured test tag 74 | 75 | #### `cleanup-helpers.ts` (275 lines) 76 | - Multi-level cleanup strategies 77 | - Orphaned resource detection 78 | - Age-based execution cleanup 79 | - Tag-based workflow cleanup 80 | 81 | **Key Functions:** 82 | - `cleanupOrphanedWorkflows()` - Find and delete test workflows 83 | - `cleanupOldExecutions()` - Delete executions older than X hours 84 | - `cleanupAllTestResources()` - Comprehensive cleanup 85 | - `cleanupWorkflowsByTag()` - Delete workflows by tag 86 | - `cleanupExecutionsByWorkflow()` - Delete workflow's executions 87 | 88 | #### `fixtures.ts` (310 lines) 89 | - Pre-built workflow templates 90 | - All using FULL node type format (n8n-nodes-base.*) 91 | 92 | **Available Fixtures:** 93 | - `SIMPLE_WEBHOOK_WORKFLOW` - Single webhook node 94 | - `SIMPLE_HTTP_WORKFLOW` - Webhook + HTTP Request 95 | - `MULTI_NODE_WORKFLOW` - Complex branching workflow 96 | - `ERROR_HANDLING_WORKFLOW` - Error output configuration 97 | - `AI_AGENT_WORKFLOW` - Langchain agent node 98 | - `EXPRESSION_WORKFLOW` - n8n expressions testing 99 | 100 | **Helper Functions:** 101 | - `getFixture()` - Get fixture by name (with deep clone) 102 | - `createCustomWorkflow()` - Build custom workflow from nodes 103 | 104 | #### `factories.ts` (315 lines) 105 | - Dynamic test data generation 106 | - Node builders with sensible defaults 107 | - Workflow composition helpers 108 | 109 | **Node Factories:** 110 | - `createWebhookNode()` - Webhook node with customization 111 | - `createHttpRequestNode()` - HTTP Request node 112 | - `createSetNode()` - Set node with assignments 113 | - `createManualTriggerNode()` - Manual trigger node 114 | 115 | **Connection Factories:** 116 | - `createConnection()` - Simple node connection 117 | - `createSequentialWorkflow()` - Auto-connected sequential nodes 118 | - `createParallelWorkflow()` - Trigger with parallel branches 119 | - `createErrorHandlingWorkflow()` - Workflow with error handling 120 | 121 | **Utilities:** 122 | - `randomString()` - Generate random test data 123 | - `uniqueId()` - Unique IDs for testing 124 | - `createTestTags()` - Test workflow tags 125 | - `createWorkflowSettings()` - Common settings 126 | 127 | #### `webhook-workflows.ts` (215 lines) 128 | - Webhook workflow configuration templates 129 | - Setup instructions generator 130 | - URL generation utilities 131 | 132 | **Key Features:** 133 | - `WEBHOOK_WORKFLOW_CONFIGS` - Configurations for all 4 HTTP methods 134 | - `printSetupInstructions()` - Print detailed setup guide 135 | - `generateWebhookWorkflowJson()` - Generate workflow JSON 136 | - `exportAllWebhookWorkflows()` - Export all 4 configs 137 | - `getWebhookUrl()` - Get webhook URL for testing 138 | - `isValidWebhookWorkflow()` - Validate workflow structure 139 | 140 | ### 4. Scripts 141 | 142 | #### `cleanup-orphans.ts` (40 lines) 143 | - Standalone cleanup script 144 | - Can be run manually or in CI 145 | - Comprehensive output logging 146 | 147 | **Usage:** 148 | ```bash 149 | npm run test:cleanup:orphans 150 | ``` 151 | 152 | ### 5. npm Scripts 153 | Added to `package.json`: 154 | ```json 155 | { 156 | "test:integration:n8n": "vitest run tests/integration/n8n-api", 157 | "test:cleanup:orphans": "tsx tests/integration/n8n-api/scripts/cleanup-orphans.ts" 158 | } 159 | ``` 160 | 161 | ## Code Quality 162 | 163 | ### TypeScript 164 | - ✅ All code passes `npm run typecheck` 165 | - ✅ All code compiles with `npm run build` 166 | - ✅ No TypeScript errors 167 | - ✅ Proper type annotations throughout 168 | 169 | ### Error Handling 170 | - ✅ Comprehensive error messages 171 | - ✅ Helpful setup instructions in error messages 172 | - ✅ Non-throwing validation functions where appropriate 173 | - ✅ Graceful handling of missing credentials 174 | 175 | ### Documentation 176 | - ✅ All functions have JSDoc comments 177 | - ✅ Usage examples in comments 178 | - ✅ Clear parameter descriptions 179 | - ✅ Return type documentation 180 | 181 | ## Files Created 182 | 183 | ### Documentation 184 | 1. `docs/local/integration-testing-plan.md` (550 lines) 185 | 2. `docs/local/integration-tests-phase1-summary.md` (this file) 186 | 187 | ### Code 188 | 1. `.env.example` - Updated with test configuration (32 new lines) 189 | 2. `package.json` - Added 2 npm scripts 190 | 3. `tests/integration/n8n-api/utils/credentials.ts` (200 lines) 191 | 4. `tests/integration/n8n-api/utils/n8n-client.ts` (45 lines) 192 | 5. `tests/integration/n8n-api/utils/test-context.ts` (120 lines) 193 | 6. `tests/integration/n8n-api/utils/cleanup-helpers.ts` (275 lines) 194 | 7. `tests/integration/n8n-api/utils/fixtures.ts` (310 lines) 195 | 8. `tests/integration/n8n-api/utils/factories.ts` (315 lines) 196 | 9. `tests/integration/n8n-api/utils/webhook-workflows.ts` (215 lines) 197 | 10. `tests/integration/n8n-api/scripts/cleanup-orphans.ts` (40 lines) 198 | 199 | **Total New Code:** ~1,520 lines of production-ready TypeScript 200 | 201 | ## Next Steps (Phase 2) 202 | 203 | Phase 2 will implement the first actual integration tests: 204 | - Create workflow creation tests (10+ scenarios) 205 | - Test P0 bug fix (SHORT vs FULL node types) 206 | - Test workflow retrieval 207 | - Test workflow deletion 208 | 209 | **Branch:** `feat/integration-tests-workflow-creation` 210 | 211 | ## Prerequisites for Running Tests 212 | 213 | Before running integration tests, you need to: 214 | 215 | 1. **Set up n8n instance:** 216 | - Local: `npx n8n start` 217 | - Or use cloud/self-hosted n8n 218 | 219 | 2. **Configure credentials in `.env`:** 220 | ```bash 221 | N8N_API_URL=http://localhost:5678 222 | N8N_API_KEY=<your-api-key> 223 | ``` 224 | 225 | 3. **Create 4 webhook workflows manually:** 226 | - One for each HTTP method (GET, POST, PUT, DELETE) 227 | - Activate each workflow in n8n UI 228 | - Set workflow IDs in `.env`: 229 | ```bash 230 | N8N_TEST_WEBHOOK_GET_ID=<workflow-id> 231 | N8N_TEST_WEBHOOK_POST_ID=<workflow-id> 232 | N8N_TEST_WEBHOOK_PUT_ID=<workflow-id> 233 | N8N_TEST_WEBHOOK_DELETE_ID=<workflow-id> 234 | ``` 235 | 236 | See `docs/local/integration-testing-plan.md` for detailed setup instructions. 237 | 238 | ## Success Metrics 239 | 240 | Phase 1 Success Criteria - ALL MET: 241 | - ✅ All utilities implemented and tested 242 | - ✅ TypeScript compiles without errors 243 | - ✅ Code follows project conventions 244 | - ✅ Comprehensive documentation 245 | - ✅ Environment configuration complete 246 | - ✅ Cleanup infrastructure in place 247 | - ✅ Ready for Phase 2 test implementation 248 | 249 | ## Lessons Learned 250 | 251 | 1. **N8nApiClient Constructor:** Uses config object, not separate parameters 252 | 2. **Cursor Handling:** n8n API returns `null` for no more pages, need to convert to `undefined` 253 | 3. **Workflow ID Validation:** Some workflows might have undefined IDs, need null checks 254 | 4. **Connection Types:** Error connections need explicit typing to avoid TypeScript errors 255 | 5. **Webhook Activation:** Cannot be done via API, must be manual - hence pre-activated workflow requirement 256 | 257 | ## Time Invested 258 | 259 | Phase 1 actual time: ~2 hours (estimated 2-3 days in plan) 260 | - Faster than expected due to clear architecture and reusable patterns 261 | ``` -------------------------------------------------------------------------------- /tests/unit/utils/node-type-utils.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from 'vitest'; 2 | import { 3 | normalizeNodeType, 4 | denormalizeNodeType, 5 | extractNodeName, 6 | getNodePackage, 7 | isBaseNode, 8 | isLangChainNode, 9 | isValidNodeTypeFormat, 10 | getNodeTypeVariations 11 | } from '@/utils/node-type-utils'; 12 | 13 | describe('node-type-utils', () => { 14 | describe('normalizeNodeType', () => { 15 | it('should normalize n8n-nodes-base to nodes-base', () => { 16 | expect(normalizeNodeType('n8n-nodes-base.httpRequest')).toBe('nodes-base.httpRequest'); 17 | expect(normalizeNodeType('n8n-nodes-base.webhook')).toBe('nodes-base.webhook'); 18 | }); 19 | 20 | it('should normalize @n8n/n8n-nodes-langchain to nodes-langchain', () => { 21 | expect(normalizeNodeType('@n8n/n8n-nodes-langchain.openAi')).toBe('nodes-langchain.openAi'); 22 | expect(normalizeNodeType('@n8n/n8n-nodes-langchain.chatOpenAi')).toBe('nodes-langchain.chatOpenAi'); 23 | }); 24 | 25 | it('should leave already normalized types unchanged', () => { 26 | expect(normalizeNodeType('nodes-base.httpRequest')).toBe('nodes-base.httpRequest'); 27 | expect(normalizeNodeType('nodes-langchain.openAi')).toBe('nodes-langchain.openAi'); 28 | }); 29 | 30 | it('should handle empty or null inputs', () => { 31 | expect(normalizeNodeType('')).toBe(''); 32 | expect(normalizeNodeType(null as any)).toBe(null); 33 | expect(normalizeNodeType(undefined as any)).toBe(undefined); 34 | }); 35 | }); 36 | 37 | describe('denormalizeNodeType', () => { 38 | it('should denormalize nodes-base to n8n-nodes-base', () => { 39 | expect(denormalizeNodeType('nodes-base.httpRequest', 'base')).toBe('n8n-nodes-base.httpRequest'); 40 | expect(denormalizeNodeType('nodes-base.webhook', 'base')).toBe('n8n-nodes-base.webhook'); 41 | }); 42 | 43 | it('should denormalize nodes-langchain to @n8n/n8n-nodes-langchain', () => { 44 | expect(denormalizeNodeType('nodes-langchain.openAi', 'langchain')).toBe('@n8n/n8n-nodes-langchain.openAi'); 45 | expect(denormalizeNodeType('nodes-langchain.chatOpenAi', 'langchain')).toBe('@n8n/n8n-nodes-langchain.chatOpenAi'); 46 | }); 47 | 48 | it('should handle already denormalized types', () => { 49 | expect(denormalizeNodeType('n8n-nodes-base.httpRequest', 'base')).toBe('n8n-nodes-base.httpRequest'); 50 | expect(denormalizeNodeType('@n8n/n8n-nodes-langchain.openAi', 'langchain')).toBe('@n8n/n8n-nodes-langchain.openAi'); 51 | }); 52 | 53 | it('should handle empty or null inputs', () => { 54 | expect(denormalizeNodeType('', 'base')).toBe(''); 55 | expect(denormalizeNodeType(null as any, 'base')).toBe(null); 56 | expect(denormalizeNodeType(undefined as any, 'base')).toBe(undefined); 57 | }); 58 | }); 59 | 60 | describe('extractNodeName', () => { 61 | it('should extract node name from normalized types', () => { 62 | expect(extractNodeName('nodes-base.httpRequest')).toBe('httpRequest'); 63 | expect(extractNodeName('nodes-langchain.openAi')).toBe('openAi'); 64 | }); 65 | 66 | it('should extract node name from denormalized types', () => { 67 | expect(extractNodeName('n8n-nodes-base.httpRequest')).toBe('httpRequest'); 68 | expect(extractNodeName('@n8n/n8n-nodes-langchain.openAi')).toBe('openAi'); 69 | }); 70 | 71 | it('should handle types without package prefix', () => { 72 | expect(extractNodeName('httpRequest')).toBe('httpRequest'); 73 | }); 74 | 75 | it('should handle empty or null inputs', () => { 76 | expect(extractNodeName('')).toBe(''); 77 | expect(extractNodeName(null as any)).toBe(''); 78 | expect(extractNodeName(undefined as any)).toBe(''); 79 | }); 80 | }); 81 | 82 | describe('getNodePackage', () => { 83 | it('should extract package from normalized types', () => { 84 | expect(getNodePackage('nodes-base.httpRequest')).toBe('nodes-base'); 85 | expect(getNodePackage('nodes-langchain.openAi')).toBe('nodes-langchain'); 86 | }); 87 | 88 | it('should extract package from denormalized types', () => { 89 | expect(getNodePackage('n8n-nodes-base.httpRequest')).toBe('nodes-base'); 90 | expect(getNodePackage('@n8n/n8n-nodes-langchain.openAi')).toBe('nodes-langchain'); 91 | }); 92 | 93 | it('should return null for types without package', () => { 94 | expect(getNodePackage('httpRequest')).toBeNull(); 95 | expect(getNodePackage('')).toBeNull(); 96 | }); 97 | 98 | it('should handle null inputs', () => { 99 | expect(getNodePackage(null as any)).toBeNull(); 100 | expect(getNodePackage(undefined as any)).toBeNull(); 101 | }); 102 | }); 103 | 104 | describe('isBaseNode', () => { 105 | it('should identify base nodes correctly', () => { 106 | expect(isBaseNode('nodes-base.httpRequest')).toBe(true); 107 | expect(isBaseNode('n8n-nodes-base.webhook')).toBe(true); 108 | expect(isBaseNode('nodes-base.slack')).toBe(true); 109 | }); 110 | 111 | it('should reject non-base nodes', () => { 112 | expect(isBaseNode('nodes-langchain.openAi')).toBe(false); 113 | expect(isBaseNode('@n8n/n8n-nodes-langchain.chatOpenAi')).toBe(false); 114 | expect(isBaseNode('httpRequest')).toBe(false); 115 | }); 116 | }); 117 | 118 | describe('isLangChainNode', () => { 119 | it('should identify langchain nodes correctly', () => { 120 | expect(isLangChainNode('nodes-langchain.openAi')).toBe(true); 121 | expect(isLangChainNode('@n8n/n8n-nodes-langchain.chatOpenAi')).toBe(true); 122 | expect(isLangChainNode('nodes-langchain.vectorStore')).toBe(true); 123 | }); 124 | 125 | it('should reject non-langchain nodes', () => { 126 | expect(isLangChainNode('nodes-base.httpRequest')).toBe(false); 127 | expect(isLangChainNode('n8n-nodes-base.webhook')).toBe(false); 128 | expect(isLangChainNode('openAi')).toBe(false); 129 | }); 130 | }); 131 | 132 | describe('isValidNodeTypeFormat', () => { 133 | it('should validate correct node type formats', () => { 134 | expect(isValidNodeTypeFormat('nodes-base.httpRequest')).toBe(true); 135 | expect(isValidNodeTypeFormat('n8n-nodes-base.webhook')).toBe(true); 136 | expect(isValidNodeTypeFormat('nodes-langchain.openAi')).toBe(true); 137 | // @n8n/n8n-nodes-langchain.chatOpenAi actually has a slash in the first part, so it appears as 2 parts when split by dot 138 | expect(isValidNodeTypeFormat('@n8n/n8n-nodes-langchain.chatOpenAi')).toBe(true); 139 | }); 140 | 141 | it('should reject invalid formats', () => { 142 | expect(isValidNodeTypeFormat('httpRequest')).toBe(false); // No package 143 | expect(isValidNodeTypeFormat('nodes-base.')).toBe(false); // No node name 144 | expect(isValidNodeTypeFormat('.httpRequest')).toBe(false); // No package 145 | expect(isValidNodeTypeFormat('nodes.base.httpRequest')).toBe(false); // Too many parts 146 | expect(isValidNodeTypeFormat('')).toBe(false); 147 | }); 148 | 149 | it('should handle invalid types', () => { 150 | expect(isValidNodeTypeFormat(null as any)).toBe(false); 151 | expect(isValidNodeTypeFormat(undefined as any)).toBe(false); 152 | expect(isValidNodeTypeFormat(123 as any)).toBe(false); 153 | }); 154 | }); 155 | 156 | describe('getNodeTypeVariations', () => { 157 | it('should generate variations for node name without package', () => { 158 | const variations = getNodeTypeVariations('httpRequest'); 159 | expect(variations).toContain('nodes-base.httpRequest'); 160 | expect(variations).toContain('n8n-nodes-base.httpRequest'); 161 | expect(variations).toContain('nodes-langchain.httpRequest'); 162 | expect(variations).toContain('@n8n/n8n-nodes-langchain.httpRequest'); 163 | }); 164 | 165 | it('should generate variations for normalized base node', () => { 166 | const variations = getNodeTypeVariations('nodes-base.httpRequest'); 167 | expect(variations).toContain('nodes-base.httpRequest'); 168 | expect(variations).toContain('n8n-nodes-base.httpRequest'); 169 | expect(variations.length).toBe(2); 170 | }); 171 | 172 | it('should generate variations for denormalized base node', () => { 173 | const variations = getNodeTypeVariations('n8n-nodes-base.webhook'); 174 | expect(variations).toContain('nodes-base.webhook'); 175 | expect(variations).toContain('n8n-nodes-base.webhook'); 176 | expect(variations.length).toBe(2); 177 | }); 178 | 179 | it('should generate variations for normalized langchain node', () => { 180 | const variations = getNodeTypeVariations('nodes-langchain.openAi'); 181 | expect(variations).toContain('nodes-langchain.openAi'); 182 | expect(variations).toContain('@n8n/n8n-nodes-langchain.openAi'); 183 | expect(variations.length).toBe(2); 184 | }); 185 | 186 | it('should generate variations for denormalized langchain node', () => { 187 | const variations = getNodeTypeVariations('@n8n/n8n-nodes-langchain.chatOpenAi'); 188 | expect(variations).toContain('nodes-langchain.chatOpenAi'); 189 | expect(variations).toContain('@n8n/n8n-nodes-langchain.chatOpenAi'); 190 | expect(variations.length).toBe(2); 191 | }); 192 | 193 | it('should remove duplicates from variations', () => { 194 | const variations = getNodeTypeVariations('nodes-base.httpRequest'); 195 | const uniqueVariations = [...new Set(variations)]; 196 | expect(variations.length).toBe(uniqueVariations.length); 197 | }); 198 | }); 199 | }); ``` -------------------------------------------------------------------------------- /tests/integration/telemetry/docker-user-id-stability.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { TelemetryConfigManager } from '../../../src/telemetry/config-manager'; 3 | import { existsSync, readFileSync, unlinkSync, rmSync } from 'fs'; 4 | import { join, resolve } from 'path'; 5 | import { homedir } from 'os'; 6 | 7 | /** 8 | * Integration tests for Docker user ID stability 9 | * Tests actual file system operations and environment detection 10 | */ 11 | describe('Docker User ID Stability - Integration Tests', () => { 12 | let manager: TelemetryConfigManager; 13 | const configPath = join(homedir(), '.n8n-mcp', 'telemetry.json'); 14 | const originalEnv = { ...process.env }; 15 | 16 | beforeEach(() => { 17 | // Clean up any existing config 18 | try { 19 | if (existsSync(configPath)) { 20 | unlinkSync(configPath); 21 | } 22 | } catch (error) { 23 | // Ignore cleanup errors 24 | } 25 | 26 | // Reset singleton 27 | (TelemetryConfigManager as any).instance = null; 28 | 29 | // Reset environment 30 | process.env = { ...originalEnv }; 31 | }); 32 | 33 | afterEach(() => { 34 | // Restore environment 35 | process.env = originalEnv; 36 | 37 | // Clean up test config 38 | try { 39 | if (existsSync(configPath)) { 40 | unlinkSync(configPath); 41 | } 42 | } catch (error) { 43 | // Ignore cleanup errors 44 | } 45 | }); 46 | 47 | describe('boot_id file reading', () => { 48 | it('should read boot_id from /proc/sys/kernel/random/boot_id if available', () => { 49 | const bootIdPath = '/proc/sys/kernel/random/boot_id'; 50 | 51 | // Skip test if not on Linux or boot_id not available 52 | if (!existsSync(bootIdPath)) { 53 | console.log('⚠️ Skipping boot_id test - not available on this system'); 54 | return; 55 | } 56 | 57 | try { 58 | const bootId = readFileSync(bootIdPath, 'utf-8').trim(); 59 | 60 | // Verify it's a valid UUID 61 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 62 | expect(bootId).toMatch(uuidRegex); 63 | expect(bootId).toHaveLength(36); // UUID with dashes 64 | } catch (error) { 65 | console.log('⚠️ boot_id exists but not readable:', error); 66 | } 67 | }); 68 | 69 | it('should generate stable user ID when boot_id is available in Docker', () => { 70 | const bootIdPath = '/proc/sys/kernel/random/boot_id'; 71 | 72 | // Skip if not in Docker environment or boot_id not available 73 | if (!existsSync(bootIdPath)) { 74 | console.log('⚠️ Skipping Docker boot_id test - not in Linux container'); 75 | return; 76 | } 77 | 78 | process.env.IS_DOCKER = 'true'; 79 | 80 | manager = TelemetryConfigManager.getInstance(); 81 | const userId1 = manager.getUserId(); 82 | 83 | // Reset singleton and get new instance 84 | (TelemetryConfigManager as any).instance = null; 85 | manager = TelemetryConfigManager.getInstance(); 86 | const userId2 = manager.getUserId(); 87 | 88 | // Should be identical across recreations (boot_id is stable) 89 | expect(userId1).toBe(userId2); 90 | expect(userId1).toMatch(/^[a-f0-9]{16}$/); 91 | }); 92 | }); 93 | 94 | describe('persistence across getInstance() calls', () => { 95 | it('should return same user ID across multiple getInstance() calls', () => { 96 | process.env.IS_DOCKER = 'true'; 97 | 98 | const manager1 = TelemetryConfigManager.getInstance(); 99 | const userId1 = manager1.getUserId(); 100 | 101 | const manager2 = TelemetryConfigManager.getInstance(); 102 | const userId2 = manager2.getUserId(); 103 | 104 | const manager3 = TelemetryConfigManager.getInstance(); 105 | const userId3 = manager3.getUserId(); 106 | 107 | expect(userId1).toBe(userId2); 108 | expect(userId2).toBe(userId3); 109 | expect(manager1).toBe(manager2); 110 | expect(manager2).toBe(manager3); 111 | }); 112 | 113 | it('should persist user ID to disk and reload correctly', () => { 114 | process.env.IS_DOCKER = 'true'; 115 | 116 | // First instance - creates config 117 | const manager1 = TelemetryConfigManager.getInstance(); 118 | const userId1 = manager1.getUserId(); 119 | 120 | // Load config to trigger save 121 | manager1.loadConfig(); 122 | 123 | // Wait a bit for file write 124 | expect(existsSync(configPath)).toBe(true); 125 | 126 | // Reset singleton 127 | (TelemetryConfigManager as any).instance = null; 128 | 129 | // Second instance - loads from disk 130 | const manager2 = TelemetryConfigManager.getInstance(); 131 | const userId2 = manager2.getUserId(); 132 | 133 | expect(userId1).toBe(userId2); 134 | }); 135 | }); 136 | 137 | describe('Docker vs non-Docker detection', () => { 138 | it('should detect Docker environment via IS_DOCKER=true', () => { 139 | process.env.IS_DOCKER = 'true'; 140 | 141 | manager = TelemetryConfigManager.getInstance(); 142 | const config = manager.loadConfig(); 143 | 144 | // In Docker, should use boot_id-based method 145 | expect(config.userId).toMatch(/^[a-f0-9]{16}$/); 146 | }); 147 | 148 | it('should use file-based method for non-Docker local installations', () => { 149 | // Ensure no Docker/cloud environment variables 150 | delete process.env.IS_DOCKER; 151 | delete process.env.RAILWAY_ENVIRONMENT; 152 | delete process.env.RENDER; 153 | delete process.env.FLY_APP_NAME; 154 | delete process.env.HEROKU_APP_NAME; 155 | delete process.env.AWS_EXECUTION_ENV; 156 | delete process.env.KUBERNETES_SERVICE_HOST; 157 | delete process.env.GOOGLE_CLOUD_PROJECT; 158 | delete process.env.AZURE_FUNCTIONS_ENVIRONMENT; 159 | 160 | manager = TelemetryConfigManager.getInstance(); 161 | const config = manager.loadConfig(); 162 | 163 | // Should generate valid user ID 164 | expect(config.userId).toMatch(/^[a-f0-9]{16}$/); 165 | 166 | // Should persist to file for local installations 167 | expect(existsSync(configPath)).toBe(true); 168 | }); 169 | }); 170 | 171 | describe('environment variable detection', () => { 172 | it('should detect Railway cloud environment', () => { 173 | process.env.RAILWAY_ENVIRONMENT = 'production'; 174 | 175 | manager = TelemetryConfigManager.getInstance(); 176 | const userId = manager.getUserId(); 177 | 178 | // Should use Docker/cloud method (boot_id-based) 179 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 180 | }); 181 | 182 | it('should detect Render cloud environment', () => { 183 | process.env.RENDER = 'true'; 184 | 185 | manager = TelemetryConfigManager.getInstance(); 186 | const userId = manager.getUserId(); 187 | 188 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 189 | }); 190 | 191 | it('should detect Fly.io cloud environment', () => { 192 | process.env.FLY_APP_NAME = 'n8n-mcp-app'; 193 | 194 | manager = TelemetryConfigManager.getInstance(); 195 | const userId = manager.getUserId(); 196 | 197 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 198 | }); 199 | 200 | it('should detect Heroku cloud environment', () => { 201 | process.env.HEROKU_APP_NAME = 'n8n-mcp-app'; 202 | 203 | manager = TelemetryConfigManager.getInstance(); 204 | const userId = manager.getUserId(); 205 | 206 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 207 | }); 208 | 209 | it('should detect AWS cloud environment', () => { 210 | process.env.AWS_EXECUTION_ENV = 'AWS_ECS_FARGATE'; 211 | 212 | manager = TelemetryConfigManager.getInstance(); 213 | const userId = manager.getUserId(); 214 | 215 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 216 | }); 217 | 218 | it('should detect Kubernetes environment', () => { 219 | process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1'; 220 | 221 | manager = TelemetryConfigManager.getInstance(); 222 | const userId = manager.getUserId(); 223 | 224 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 225 | }); 226 | 227 | it('should detect Google Cloud environment', () => { 228 | process.env.GOOGLE_CLOUD_PROJECT = 'n8n-mcp-project'; 229 | 230 | manager = TelemetryConfigManager.getInstance(); 231 | const userId = manager.getUserId(); 232 | 233 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 234 | }); 235 | 236 | it('should detect Azure cloud environment', () => { 237 | process.env.AZURE_FUNCTIONS_ENVIRONMENT = 'production'; 238 | 239 | manager = TelemetryConfigManager.getInstance(); 240 | const userId = manager.getUserId(); 241 | 242 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 243 | }); 244 | }); 245 | 246 | describe('fallback chain behavior', () => { 247 | it('should use combined fingerprint fallback when boot_id unavailable', () => { 248 | // Set Docker environment but boot_id won't be available on macOS 249 | process.env.IS_DOCKER = 'true'; 250 | 251 | manager = TelemetryConfigManager.getInstance(); 252 | const userId = manager.getUserId(); 253 | 254 | // Should still generate valid user ID via fallback 255 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 256 | expect(userId).toHaveLength(16); 257 | }); 258 | 259 | it('should generate consistent generic Docker ID when all else fails', () => { 260 | // Set Docker but no boot_id or /proc signals available (e.g., macOS) 261 | process.env.IS_DOCKER = 'true'; 262 | 263 | const manager1 = TelemetryConfigManager.getInstance(); 264 | const userId1 = manager1.getUserId(); 265 | 266 | // Reset singleton 267 | (TelemetryConfigManager as any).instance = null; 268 | 269 | const manager2 = TelemetryConfigManager.getInstance(); 270 | const userId2 = manager2.getUserId(); 271 | 272 | // Generic Docker ID should be consistent across calls 273 | expect(userId1).toBe(userId2); 274 | expect(userId1).toMatch(/^[a-f0-9]{16}$/); 275 | }); 276 | }); 277 | }); 278 | ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/utils/cleanup-helpers.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Cleanup Helpers for Integration Tests 3 | * 4 | * Provides multi-level cleanup strategies for test resources: 5 | * - Orphaned workflows (from failed test runs) 6 | * - Old executions (older than 24 hours) 7 | * - Bulk cleanup by tag or name prefix 8 | */ 9 | 10 | import { getTestN8nClient } from './n8n-client'; 11 | import { getN8nCredentials } from './credentials'; 12 | import { Logger } from '../../../../src/utils/logger'; 13 | 14 | const logger = new Logger({ prefix: '[Cleanup]' }); 15 | 16 | /** 17 | * Clean up orphaned test workflows 18 | * 19 | * Finds and deletes all workflows tagged with the test tag or 20 | * prefixed with the test name prefix. Run this periodically in CI 21 | * to clean up failed test runs. 22 | * 23 | * @returns Array of deleted workflow IDs 24 | */ 25 | export async function cleanupOrphanedWorkflows(): Promise<string[]> { 26 | const creds = getN8nCredentials(); 27 | const client = getTestN8nClient(); 28 | const deleted: string[] = []; 29 | 30 | logger.info('Searching for orphaned test workflows...'); 31 | 32 | let allWorkflows: any[] = []; 33 | let cursor: string | undefined; 34 | let pageCount = 0; 35 | const MAX_PAGES = 1000; // Safety limit to prevent infinite loops 36 | 37 | // Fetch all workflows with pagination 38 | try { 39 | do { 40 | pageCount++; 41 | 42 | if (pageCount > MAX_PAGES) { 43 | logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`); 44 | throw new Error('Pagination safety limit exceeded while fetching workflows'); 45 | } 46 | 47 | logger.debug(`Fetching workflows page ${pageCount}...`); 48 | 49 | const response = await client.listWorkflows({ 50 | cursor, 51 | limit: 100, 52 | excludePinnedData: true 53 | }); 54 | 55 | allWorkflows.push(...response.data); 56 | cursor = response.nextCursor || undefined; 57 | } while (cursor); 58 | 59 | logger.info(`Found ${allWorkflows.length} total workflows across ${pageCount} page(s)`); 60 | } catch (error) { 61 | logger.error('Failed to fetch workflows:', error); 62 | throw error; 63 | } 64 | 65 | // Pre-activated webhook workflow that should NOT be deleted 66 | // This is needed for webhook trigger integration tests 67 | // Note: Single webhook accepts all HTTP methods (GET, POST, PUT, DELETE) 68 | const preservedWorkflowNames = new Set([ 69 | '[MCP-TEST] Webhook All Methods' 70 | ]); 71 | 72 | // Find test workflows but exclude pre-activated webhook workflows 73 | const testWorkflows = allWorkflows.filter(w => { 74 | const isTestWorkflow = w.tags?.includes(creds.cleanup.tag) || w.name?.startsWith(creds.cleanup.namePrefix); 75 | const isPreserved = preservedWorkflowNames.has(w.name); 76 | 77 | return isTestWorkflow && !isPreserved; 78 | }); 79 | 80 | logger.info(`Found ${testWorkflows.length} orphaned test workflow(s) (excluding ${preservedWorkflowNames.size} preserved webhook workflow)`); 81 | 82 | if (testWorkflows.length === 0) { 83 | return deleted; 84 | } 85 | 86 | // Delete them 87 | for (const workflow of testWorkflows) { 88 | try { 89 | await client.deleteWorkflow(workflow.id); 90 | deleted.push(workflow.id); 91 | logger.debug(`Deleted orphaned workflow: ${workflow.name} (${workflow.id})`); 92 | } catch (error) { 93 | logger.warn(`Failed to delete workflow ${workflow.id}:`, error); 94 | } 95 | } 96 | 97 | logger.info(`Successfully deleted ${deleted.length} orphaned workflow(s)`); 98 | return deleted; 99 | } 100 | 101 | /** 102 | * Clean up old executions 103 | * 104 | * Deletes executions older than the specified age. 105 | * 106 | * @param maxAgeMs - Maximum age in milliseconds (default: 24 hours) 107 | * @returns Array of deleted execution IDs 108 | */ 109 | export async function cleanupOldExecutions( 110 | maxAgeMs: number = 24 * 60 * 60 * 1000 111 | ): Promise<string[]> { 112 | const client = getTestN8nClient(); 113 | const deleted: string[] = []; 114 | 115 | logger.info(`Searching for executions older than ${maxAgeMs}ms...`); 116 | 117 | let allExecutions: any[] = []; 118 | let cursor: string | undefined; 119 | let pageCount = 0; 120 | const MAX_PAGES = 1000; // Safety limit to prevent infinite loops 121 | 122 | // Fetch all executions 123 | try { 124 | do { 125 | pageCount++; 126 | 127 | if (pageCount > MAX_PAGES) { 128 | logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`); 129 | throw new Error('Pagination safety limit exceeded while fetching executions'); 130 | } 131 | 132 | logger.debug(`Fetching executions page ${pageCount}...`); 133 | 134 | const response = await client.listExecutions({ 135 | cursor, 136 | limit: 100, 137 | includeData: false 138 | }); 139 | 140 | allExecutions.push(...response.data); 141 | cursor = response.nextCursor || undefined; 142 | } while (cursor); 143 | 144 | logger.info(`Found ${allExecutions.length} total executions across ${pageCount} page(s)`); 145 | } catch (error) { 146 | logger.error('Failed to fetch executions:', error); 147 | throw error; 148 | } 149 | 150 | const cutoffTime = Date.now() - maxAgeMs; 151 | const oldExecutions = allExecutions.filter(e => { 152 | const executionTime = new Date(e.startedAt).getTime(); 153 | return executionTime < cutoffTime; 154 | }); 155 | 156 | logger.info(`Found ${oldExecutions.length} old execution(s)`); 157 | 158 | if (oldExecutions.length === 0) { 159 | return deleted; 160 | } 161 | 162 | for (const execution of oldExecutions) { 163 | try { 164 | await client.deleteExecution(execution.id); 165 | deleted.push(execution.id); 166 | logger.debug(`Deleted old execution: ${execution.id}`); 167 | } catch (error) { 168 | logger.warn(`Failed to delete execution ${execution.id}:`, error); 169 | } 170 | } 171 | 172 | logger.info(`Successfully deleted ${deleted.length} old execution(s)`); 173 | return deleted; 174 | } 175 | 176 | /** 177 | * Clean up all test resources 178 | * 179 | * Combines cleanupOrphanedWorkflows and cleanupOldExecutions. 180 | * Use this as a comprehensive cleanup in CI. 181 | * 182 | * @returns Object with counts of deleted resources 183 | */ 184 | export async function cleanupAllTestResources(): Promise<{ 185 | workflows: number; 186 | executions: number; 187 | }> { 188 | logger.info('Starting comprehensive test resource cleanup...'); 189 | 190 | const [workflowIds, executionIds] = await Promise.all([ 191 | cleanupOrphanedWorkflows(), 192 | cleanupOldExecutions() 193 | ]); 194 | 195 | logger.info( 196 | `Cleanup complete: ${workflowIds.length} workflows, ${executionIds.length} executions` 197 | ); 198 | 199 | return { 200 | workflows: workflowIds.length, 201 | executions: executionIds.length 202 | }; 203 | } 204 | 205 | /** 206 | * Delete workflows by tag 207 | * 208 | * Deletes all workflows with the specified tag. 209 | * 210 | * @param tag - Tag to match 211 | * @returns Array of deleted workflow IDs 212 | */ 213 | export async function cleanupWorkflowsByTag(tag: string): Promise<string[]> { 214 | const client = getTestN8nClient(); 215 | const deleted: string[] = []; 216 | 217 | logger.info(`Searching for workflows with tag: ${tag}`); 218 | 219 | try { 220 | const response = await client.listWorkflows({ 221 | tags: tag || undefined, 222 | limit: 100, 223 | excludePinnedData: true 224 | }); 225 | 226 | const workflows = response.data; 227 | logger.info(`Found ${workflows.length} workflow(s) with tag: ${tag}`); 228 | 229 | for (const workflow of workflows) { 230 | if (!workflow.id) continue; 231 | 232 | try { 233 | await client.deleteWorkflow(workflow.id); 234 | deleted.push(workflow.id); 235 | logger.debug(`Deleted workflow: ${workflow.name} (${workflow.id})`); 236 | } catch (error) { 237 | logger.warn(`Failed to delete workflow ${workflow.id}:`, error); 238 | } 239 | } 240 | 241 | logger.info(`Successfully deleted ${deleted.length} workflow(s)`); 242 | return deleted; 243 | } catch (error) { 244 | logger.error(`Failed to cleanup workflows by tag: ${tag}`, error); 245 | throw error; 246 | } 247 | } 248 | 249 | /** 250 | * Delete executions for a specific workflow 251 | * 252 | * @param workflowId - Workflow ID 253 | * @returns Array of deleted execution IDs 254 | */ 255 | export async function cleanupExecutionsByWorkflow( 256 | workflowId: string 257 | ): Promise<string[]> { 258 | const client = getTestN8nClient(); 259 | const deleted: string[] = []; 260 | 261 | logger.info(`Searching for executions of workflow: ${workflowId}`); 262 | 263 | let cursor: string | undefined; 264 | let totalCount = 0; 265 | let pageCount = 0; 266 | const MAX_PAGES = 1000; // Safety limit to prevent infinite loops 267 | 268 | try { 269 | do { 270 | pageCount++; 271 | 272 | if (pageCount > MAX_PAGES) { 273 | logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`); 274 | throw new Error(`Pagination safety limit exceeded while fetching executions for workflow ${workflowId}`); 275 | } 276 | 277 | const response = await client.listExecutions({ 278 | workflowId, 279 | cursor, 280 | limit: 100, 281 | includeData: false 282 | }); 283 | 284 | const executions = response.data; 285 | totalCount += executions.length; 286 | 287 | for (const execution of executions) { 288 | try { 289 | await client.deleteExecution(execution.id); 290 | deleted.push(execution.id); 291 | logger.debug(`Deleted execution: ${execution.id}`); 292 | } catch (error) { 293 | logger.warn(`Failed to delete execution ${execution.id}:`, error); 294 | } 295 | } 296 | 297 | cursor = response.nextCursor || undefined; 298 | } while (cursor); 299 | 300 | logger.info( 301 | `Successfully deleted ${deleted.length}/${totalCount} execution(s) for workflow ${workflowId}` 302 | ); 303 | return deleted; 304 | } catch (error) { 305 | logger.error(`Failed to cleanup executions for workflow: ${workflowId}`, error); 306 | throw error; 307 | } 308 | } 309 | ``` -------------------------------------------------------------------------------- /tests/unit/docker/serve-command.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { execSync } from 'child_process'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import os from 'os'; 6 | 7 | describe('n8n-mcp serve Command', () => { 8 | let tempDir: string; 9 | let mockEntrypointPath: string; 10 | 11 | // Clean environment for tests - only include essential variables 12 | const cleanEnv = { 13 | PATH: process.env.PATH, 14 | HOME: process.env.HOME, 15 | NODE_ENV: process.env.NODE_ENV 16 | }; 17 | 18 | beforeEach(() => { 19 | tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'serve-command-test-')); 20 | mockEntrypointPath = path.join(tempDir, 'mock-entrypoint.sh'); 21 | }); 22 | 23 | afterEach(() => { 24 | if (fs.existsSync(tempDir)) { 25 | fs.rmSync(tempDir, { recursive: true }); 26 | } 27 | }); 28 | 29 | /** 30 | * Create a mock entrypoint script that simulates the behavior 31 | * of the real docker-entrypoint.sh for testing purposes 32 | */ 33 | function createMockEntrypoint(content: string): void { 34 | fs.writeFileSync(mockEntrypointPath, content, { mode: 0o755 }); 35 | } 36 | 37 | describe('Command transformation', () => { 38 | it('should detect "n8n-mcp serve" and set MCP_MODE=http', () => { 39 | const mockScript = `#!/bin/sh 40 | # Simplified version of the entrypoint logic 41 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 42 | export MCP_MODE="http" 43 | shift 2 44 | echo "MCP_MODE=\$MCP_MODE" 45 | echo "Remaining args: \$@" 46 | else 47 | echo "Normal execution" 48 | fi 49 | `; 50 | createMockEntrypoint(mockScript); 51 | 52 | const output = execSync(`"${mockEntrypointPath}" n8n-mcp serve`, { encoding: 'utf8', env: cleanEnv }); 53 | 54 | expect(output).toContain('MCP_MODE=http'); 55 | expect(output).toContain('Remaining args:'); 56 | }); 57 | 58 | it('should preserve additional arguments after serve command', () => { 59 | const mockScript = `#!/bin/sh 60 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 61 | export MCP_MODE="http" 62 | shift 2 63 | echo "MCP_MODE=\$MCP_MODE" 64 | echo "Args: \$@" 65 | fi 66 | `; 67 | createMockEntrypoint(mockScript); 68 | 69 | const output = execSync( 70 | `"${mockEntrypointPath}" n8n-mcp serve --port 8080 --verbose --debug`, 71 | { encoding: 'utf8', env: cleanEnv } 72 | ); 73 | 74 | expect(output).toContain('MCP_MODE=http'); 75 | expect(output).toContain('Args: --port 8080 --verbose --debug'); 76 | }); 77 | 78 | it('should not affect other commands', () => { 79 | const mockScript = `#!/bin/sh 80 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 81 | export MCP_MODE="http" 82 | echo "Serve mode activated" 83 | else 84 | echo "Command: \$@" 85 | echo "MCP_MODE=\${MCP_MODE:-not-set}" 86 | fi 87 | `; 88 | createMockEntrypoint(mockScript); 89 | 90 | // Test with different command 91 | const output1 = execSync(`"${mockEntrypointPath}" node index.js`, { encoding: 'utf8', env: cleanEnv }); 92 | expect(output1).toContain('Command: node index.js'); 93 | expect(output1).toContain('MCP_MODE=not-set'); 94 | 95 | // Test with n8n-mcp but not serve 96 | const output2 = execSync(`"${mockEntrypointPath}" n8n-mcp validate`, { encoding: 'utf8', env: cleanEnv }); 97 | expect(output2).toContain('Command: n8n-mcp validate'); 98 | expect(output2).not.toContain('Serve mode activated'); 99 | }); 100 | }); 101 | 102 | describe('Integration with config loading', () => { 103 | it('should load config before processing serve command', () => { 104 | const configPath = path.join(tempDir, 'config.json'); 105 | const config = { 106 | custom_var: 'from-config', 107 | port: 9000 108 | }; 109 | fs.writeFileSync(configPath, JSON.stringify(config)); 110 | 111 | const mockScript = `#!/bin/sh 112 | # Simulate config loading 113 | if [ -f "${configPath}" ]; then 114 | export CUSTOM_VAR='from-config' 115 | export PORT='9000' 116 | fi 117 | 118 | # Process serve command 119 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 120 | export MCP_MODE="http" 121 | shift 2 122 | echo "MCP_MODE=\$MCP_MODE" 123 | echo "CUSTOM_VAR=\$CUSTOM_VAR" 124 | echo "PORT=\$PORT" 125 | fi 126 | `; 127 | createMockEntrypoint(mockScript); 128 | 129 | const output = execSync(`"${mockEntrypointPath}" n8n-mcp serve`, { encoding: 'utf8', env: cleanEnv }); 130 | 131 | expect(output).toContain('MCP_MODE=http'); 132 | expect(output).toContain('CUSTOM_VAR=from-config'); 133 | expect(output).toContain('PORT=9000'); 134 | }); 135 | }); 136 | 137 | describe('Command line variations', () => { 138 | it('should handle serve command with equals sign notation', () => { 139 | const mockScript = `#!/bin/sh 140 | # Handle both space and equals notation 141 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 142 | export MCP_MODE="http" 143 | shift 2 144 | echo "Standard notation worked" 145 | echo "Args: \$@" 146 | elif echo "\$@" | grep -q "n8n-mcp.*serve"; then 147 | echo "Alternative notation detected" 148 | fi 149 | `; 150 | createMockEntrypoint(mockScript); 151 | 152 | const output = execSync(`"${mockEntrypointPath}" n8n-mcp serve --port=8080`, { encoding: 'utf8', env: cleanEnv }); 153 | 154 | expect(output).toContain('Standard notation worked'); 155 | expect(output).toContain('Args: --port=8080'); 156 | }); 157 | 158 | it('should handle quoted arguments correctly', () => { 159 | const mockScript = `#!/bin/sh 160 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 161 | shift 2 162 | echo "Args received:" 163 | for arg in "\$@"; do 164 | echo " - '\$arg'" 165 | done 166 | fi 167 | `; 168 | createMockEntrypoint(mockScript); 169 | 170 | const output = execSync( 171 | `"${mockEntrypointPath}" n8n-mcp serve --message "Hello World" --path "/path with spaces"`, 172 | { encoding: 'utf8', env: cleanEnv } 173 | ); 174 | 175 | expect(output).toContain("- '--message'"); 176 | expect(output).toContain("- 'Hello World'"); 177 | expect(output).toContain("- '--path'"); 178 | expect(output).toContain("- '/path with spaces'"); 179 | }); 180 | }); 181 | 182 | describe('Error handling', () => { 183 | it('should handle serve command with missing AUTH_TOKEN in HTTP mode', () => { 184 | const mockScript = `#!/bin/sh 185 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 186 | export MCP_MODE="http" 187 | shift 2 188 | 189 | # Check for AUTH_TOKEN (simulate entrypoint validation) 190 | if [ -z "\$AUTH_TOKEN" ] && [ -z "\$AUTH_TOKEN_FILE" ]; then 191 | echo "ERROR: AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode" >&2 192 | exit 1 193 | fi 194 | fi 195 | `; 196 | createMockEntrypoint(mockScript); 197 | 198 | try { 199 | execSync(`"${mockEntrypointPath}" n8n-mcp serve`, { encoding: 'utf8', env: cleanEnv }); 200 | expect.fail('Should have thrown an error'); 201 | } catch (error: any) { 202 | expect(error.status).toBe(1); 203 | expect(error.stderr.toString()).toContain('AUTH_TOKEN or AUTH_TOKEN_FILE is required'); 204 | } 205 | }); 206 | 207 | it('should succeed with AUTH_TOKEN provided', () => { 208 | const mockScript = `#!/bin/sh 209 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 210 | export MCP_MODE="http" 211 | shift 2 212 | 213 | # Check for AUTH_TOKEN 214 | if [ -z "\$AUTH_TOKEN" ] && [ -z "\$AUTH_TOKEN_FILE" ]; then 215 | echo "ERROR: AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode" >&2 216 | exit 1 217 | fi 218 | 219 | echo "Server starting with AUTH_TOKEN" 220 | fi 221 | `; 222 | createMockEntrypoint(mockScript); 223 | 224 | const output = execSync( 225 | `"${mockEntrypointPath}" n8n-mcp serve`, 226 | { encoding: 'utf8', env: { ...cleanEnv, AUTH_TOKEN: 'test-token' } } 227 | ); 228 | 229 | expect(output).toContain('Server starting with AUTH_TOKEN'); 230 | }); 231 | }); 232 | 233 | describe('Backwards compatibility', () => { 234 | it('should maintain compatibility with direct HTTP mode setting', () => { 235 | const mockScript = `#!/bin/sh 236 | # Direct MCP_MODE setting should still work 237 | echo "Initial MCP_MODE=\${MCP_MODE:-not-set}" 238 | 239 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 240 | export MCP_MODE="http" 241 | echo "Serve command: MCP_MODE=\$MCP_MODE" 242 | else 243 | echo "Direct mode: MCP_MODE=\${MCP_MODE:-stdio}" 244 | fi 245 | `; 246 | createMockEntrypoint(mockScript); 247 | 248 | // Test with explicit MCP_MODE 249 | const output1 = execSync( 250 | `"${mockEntrypointPath}" node index.js`, 251 | { encoding: 'utf8', env: { ...cleanEnv, MCP_MODE: 'http' } } 252 | ); 253 | expect(output1).toContain('Initial MCP_MODE=http'); 254 | expect(output1).toContain('Direct mode: MCP_MODE=http'); 255 | 256 | // Test with serve command 257 | const output2 = execSync(`"${mockEntrypointPath}" n8n-mcp serve`, { encoding: 'utf8', env: cleanEnv }); 258 | expect(output2).toContain('Serve command: MCP_MODE=http'); 259 | }); 260 | }); 261 | 262 | describe('Command construction', () => { 263 | it('should properly construct the node command after transformation', () => { 264 | const mockScript = `#!/bin/sh 265 | if [ "\$1" = "n8n-mcp" ] && [ "\$2" = "serve" ]; then 266 | export MCP_MODE="http" 267 | shift 2 268 | # Simulate the actual command that would be executed 269 | echo "Would execute: node /app/dist/mcp/index.js \$@" 270 | fi 271 | `; 272 | createMockEntrypoint(mockScript); 273 | 274 | const output = execSync( 275 | `"${mockEntrypointPath}" n8n-mcp serve --port 8080 --host 0.0.0.0`, 276 | { encoding: 'utf8', env: cleanEnv } 277 | ); 278 | 279 | expect(output).toContain('Would execute: node /app/dist/mcp/index.js --port 8080 --host 0.0.0.0'); 280 | }); 281 | }); 282 | }); ``` -------------------------------------------------------------------------------- /docs/AUTOMATED_RELEASES.md: -------------------------------------------------------------------------------- ```markdown 1 | # Automated Release Process 2 | 3 | This document describes the automated release system for n8n-mcp, which handles version detection, changelog parsing, and multi-artifact publishing. 4 | 5 | ## Overview 6 | 7 | The automated release system is triggered when the version in `package.json` is updated and pushed to the main branch. It handles: 8 | 9 | - 🏷️ **GitHub Releases**: Creates releases with changelog content 10 | - 📦 **NPM Publishing**: Publishes optimized runtime package 11 | - 🐳 **Docker Images**: Builds and pushes multi-platform images 12 | - 📚 **Documentation**: Updates version badges automatically 13 | 14 | ## Quick Start 15 | 16 | ### For Maintainers 17 | 18 | Use the prepared release script for a guided experience: 19 | 20 | ```bash 21 | npm run prepare:release 22 | ``` 23 | 24 | This script will: 25 | 1. Prompt for the new version 26 | 2. Update `package.json` and `package.runtime.json` 27 | 3. Update the changelog 28 | 4. Run tests and build 29 | 5. Create a git commit 30 | 6. Optionally push to trigger the release 31 | 32 | ### Manual Process 33 | 34 | 1. **Update the version**: 35 | ```bash 36 | # Edit package.json version field 37 | vim package.json 38 | 39 | # Sync to runtime package 40 | npm run sync:runtime-version 41 | ``` 42 | 43 | 2. **Update the changelog**: 44 | ```bash 45 | # Edit docs/CHANGELOG.md 46 | vim docs/CHANGELOG.md 47 | ``` 48 | 49 | 3. **Test and commit**: 50 | ```bash 51 | # Ensure everything works 52 | npm test 53 | npm run build 54 | npm run rebuild 55 | 56 | # Commit changes 57 | git add package.json package.runtime.json docs/CHANGELOG.md 58 | git commit -m "chore: release vX.Y.Z" 59 | git push 60 | ``` 61 | 62 | ## Workflow Details 63 | 64 | ### Version Detection 65 | 66 | The workflow monitors pushes to the main branch and detects when `package.json` version changes: 67 | 68 | ```yaml 69 | paths: 70 | - 'package.json' 71 | - 'package.runtime.json' 72 | ``` 73 | 74 | ### Changelog Parsing 75 | 76 | Automatically extracts release notes from `docs/CHANGELOG.md` using the version header format: 77 | 78 | ```markdown 79 | ## [2.10.0] - 2025-08-02 80 | 81 | ### Added 82 | - New feature descriptions 83 | 84 | ### Changed 85 | - Changed feature descriptions 86 | 87 | ### Fixed 88 | - Bug fix descriptions 89 | ``` 90 | 91 | ### Release Artifacts 92 | 93 | #### GitHub Release 94 | - Created with extracted changelog content 95 | - Tagged with `vX.Y.Z` format 96 | - Includes installation instructions 97 | - Links to documentation 98 | 99 | #### NPM Package 100 | - Published as `n8n-mcp` on npmjs.com 101 | - Uses runtime-only dependencies (8 packages vs 50+ dev deps) 102 | - Optimized for `npx` usage 103 | - ~50MB vs 1GB+ with dev dependencies 104 | 105 | #### Docker Images 106 | - **Standard**: `ghcr.io/czlonkowski/n8n-mcp:vX.Y.Z` 107 | - **Railway**: `ghcr.io/czlonkowski/n8n-mcp-railway:vX.Y.Z` 108 | - Multi-platform: linux/amd64, linux/arm64 109 | - Semantic version tags: `vX.Y.Z`, `vX.Y`, `vX`, `latest` 110 | 111 | ## Configuration 112 | 113 | ### Required Secrets 114 | 115 | Set these in GitHub repository settings → Secrets: 116 | 117 | | Secret | Description | Required | 118 | |--------|-------------|----------| 119 | | `NPM_TOKEN` | NPM authentication token for publishing | ✅ Yes | 120 | | `GITHUB_TOKEN` | Automatically provided by GitHub Actions | ✅ Auto | 121 | 122 | ### NPM Token Setup 123 | 124 | 1. Login to [npmjs.com](https://www.npmjs.com) 125 | 2. Go to Account Settings → Access Tokens 126 | 3. Create a new **Automation** token 127 | 4. Add as `NPM_TOKEN` secret in GitHub 128 | 129 | ## Testing 130 | 131 | ### Test Release Automation 132 | 133 | Validate the release system without triggering a release: 134 | 135 | ```bash 136 | npm run test:release-automation 137 | ``` 138 | 139 | This checks: 140 | - ✅ File existence and structure 141 | - ✅ Version detection logic 142 | - ✅ Changelog parsing 143 | - ✅ Build process 144 | - ✅ NPM package preparation 145 | - ✅ Docker configuration 146 | - ✅ Workflow syntax 147 | - ✅ Environment setup 148 | 149 | ### Local Testing 150 | 151 | Test individual components: 152 | 153 | ```bash 154 | # Test version detection 155 | node -e "console.log(require('./package.json').version)" 156 | 157 | # Test changelog parsing 158 | node scripts/test-release-automation.js 159 | 160 | # Test npm package preparation 161 | npm run prepare:publish 162 | 163 | # Test Docker build 164 | docker build -t test-image . 165 | ``` 166 | 167 | ## Workflow Jobs 168 | 169 | ### 1. Version Detection 170 | - Compares current vs previous version in git history 171 | - Determines if it's a prerelease (alpha, beta, rc, dev) 172 | - Outputs version information for other jobs 173 | 174 | ### 2. Changelog Extraction 175 | - Parses `docs/CHANGELOG.md` for the current version 176 | - Extracts content between version headers 177 | - Provides formatted release notes 178 | 179 | ### 3. GitHub Release Creation 180 | - Creates annotated git tag 181 | - Creates GitHub release with changelog content 182 | - Handles prerelease flag for alpha/beta versions 183 | 184 | ### 4. Build and Test 185 | - Installs dependencies 186 | - Runs full test suite 187 | - Builds TypeScript 188 | - Rebuilds node database 189 | - Type checking 190 | 191 | ### 5. NPM Publishing 192 | - Prepares optimized package structure 193 | - Uses `package.runtime.json` for dependencies 194 | - Publishes to npmjs.com registry 195 | - Automatic cleanup 196 | 197 | ### 6. Docker Building 198 | - Multi-platform builds (amd64, arm64) 199 | - Two image variants (standard, railway) 200 | - Semantic versioning tags 201 | - GitHub Container Registry 202 | 203 | ### 7. Documentation Updates 204 | - Updates version badges in README 205 | - Commits documentation changes 206 | - Automatic push back to repository 207 | 208 | ## Monitoring 209 | 210 | ### GitHub Actions 211 | Monitor releases at: https://github.com/czlonkowski/n8n-mcp/actions 212 | 213 | ### Release Status 214 | - **GitHub Releases**: https://github.com/czlonkowski/n8n-mcp/releases 215 | - **NPM Package**: https://www.npmjs.com/package/n8n-mcp 216 | - **Docker Images**: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp 217 | 218 | ### Notifications 219 | 220 | The workflow provides comprehensive summaries: 221 | - ✅ Success notifications with links 222 | - ❌ Failure notifications with error details 223 | - 📊 Artifact information and installation commands 224 | 225 | ## Troubleshooting 226 | 227 | ### Common Issues 228 | 229 | #### NPM Publishing Fails 230 | ``` 231 | Error: 401 Unauthorized 232 | ``` 233 | **Solution**: Check NPM_TOKEN secret is valid and has publishing permissions. 234 | 235 | #### Docker Build Fails 236 | ``` 237 | Error: failed to solve: could not read from registry 238 | ``` 239 | **Solution**: Check GitHub Container Registry permissions and GITHUB_TOKEN. 240 | 241 | #### Changelog Parsing Fails 242 | ``` 243 | No changelog entries found for version X.Y.Z 244 | ``` 245 | **Solution**: Ensure changelog follows the correct format: 246 | ```markdown 247 | ## [X.Y.Z] - YYYY-MM-DD 248 | ``` 249 | 250 | #### Version Detection Fails 251 | ``` 252 | Version not incremented 253 | ``` 254 | **Solution**: Ensure new version is greater than the previous version. 255 | 256 | ### Recovery Steps 257 | 258 | #### Failed NPM Publish 259 | 1. Check if version was already published 260 | 2. If not, manually publish: 261 | ```bash 262 | npm run prepare:publish 263 | cd npm-publish-temp 264 | npm publish 265 | ``` 266 | 267 | #### Failed Docker Build 268 | 1. Build locally to test: 269 | ```bash 270 | docker build -t test-build . 271 | ``` 272 | 2. Re-trigger workflow or push a fix 273 | 274 | #### Incomplete Release 275 | 1. Delete the created tag if needed: 276 | ```bash 277 | git tag -d vX.Y.Z 278 | git push --delete origin vX.Y.Z 279 | ``` 280 | 2. Fix issues and push again 281 | 282 | ## Security 283 | 284 | ### Secrets Management 285 | - NPM_TOKEN has limited scope (publish only) 286 | - GITHUB_TOKEN has automatic scoping 287 | - No secrets are logged or exposed 288 | 289 | ### Package Security 290 | - Runtime package excludes development dependencies 291 | - No build tools or test frameworks in published package 292 | - Minimal attack surface (~50MB vs 1GB+) 293 | 294 | ### Docker Security 295 | - Multi-stage builds 296 | - Non-root user execution 297 | - Minimal base images 298 | - Security scanning enabled 299 | 300 | ## Changelog Format 301 | 302 | The automated system expects changelog entries in [Keep a Changelog](https://keepachangelog.com/) format: 303 | 304 | ```markdown 305 | # Changelog 306 | 307 | All notable changes to this project will be documented in this file. 308 | 309 | ## [Unreleased] 310 | 311 | ### Added 312 | - New features for next release 313 | 314 | ## [2.10.0] - 2025-08-02 315 | 316 | ### Added 317 | - Automated release system 318 | - Multi-platform Docker builds 319 | 320 | ### Changed 321 | - Improved version detection 322 | - Enhanced error handling 323 | 324 | ### Fixed 325 | - Fixed changelog parsing edge cases 326 | - Fixed Docker build optimization 327 | 328 | ## [2.9.1] - 2025-08-01 329 | 330 | ... 331 | ``` 332 | 333 | ## Version Strategy 334 | 335 | ### Semantic Versioning 336 | - **MAJOR** (X.0.0): Breaking changes 337 | - **MINOR** (X.Y.0): New features, backward compatible 338 | - **PATCH** (X.Y.Z): Bug fixes, backward compatible 339 | 340 | ### Prerelease Versions 341 | - **Alpha**: `X.Y.Z-alpha.N` - Early development 342 | - **Beta**: `X.Y.Z-beta.N` - Feature complete, testing 343 | - **RC**: `X.Y.Z-rc.N` - Release candidate 344 | 345 | Prerelease versions are automatically detected and marked appropriately. 346 | 347 | ## Best Practices 348 | 349 | ### Before Releasing 350 | 1. ✅ Run `npm run test:release-automation` 351 | 2. ✅ Update changelog with meaningful descriptions 352 | 3. ✅ Test locally with `npm test && npm run build` 353 | 4. ✅ Review breaking changes 354 | 5. ✅ Consider impact on users 355 | 356 | ### Version Bumping 357 | - Use `npm run prepare:release` for guided process 358 | - Follow semantic versioning strictly 359 | - Document breaking changes clearly 360 | - Consider backward compatibility 361 | 362 | ### Changelog Writing 363 | - Be specific about changes 364 | - Include migration notes for breaking changes 365 | - Credit contributors 366 | - Use consistent formatting 367 | 368 | ## Contributing 369 | 370 | ### For Maintainers 371 | 1. Use automated tools: `npm run prepare:release` 372 | 2. Follow semantic versioning 373 | 3. Update changelog thoroughly 374 | 4. Test before releasing 375 | 376 | ### For Contributors 377 | - Breaking changes require MAJOR version bump 378 | - New features require MINOR version bump 379 | - Bug fixes require PATCH version bump 380 | - Update changelog in PR descriptions 381 | 382 | --- 383 | 384 | 🤖 *This automated release system was designed with [Claude Code](https://claude.ai/code)* ``` -------------------------------------------------------------------------------- /tests/integration/workflow-creation-node-type-format.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration test for workflow creation with node type format validation 3 | * 4 | * This test validates that workflows are correctly validated with FULL form node types 5 | * (n8n-nodes-base.*) as required by the n8n API, without normalization to SHORT form. 6 | * 7 | * Background: Bug in handlers-n8n-manager.ts was normalizing node types to SHORT form 8 | * (nodes-base.*) before validation, causing validation to reject all workflows. 9 | */ 10 | 11 | import { describe, it, expect } from 'vitest'; 12 | import { validateWorkflowStructure } from '@/services/n8n-validation'; 13 | 14 | describe('Workflow Creation Node Type Format (Integration)', () => { 15 | describe('validateWorkflowStructure with FULL form node types', () => { 16 | it('should accept workflows with FULL form node types (n8n-nodes-base.*)', () => { 17 | const workflow = { 18 | name: 'Test Workflow', 19 | nodes: [ 20 | { 21 | id: 'manual-1', 22 | name: 'Manual Trigger', 23 | type: 'n8n-nodes-base.manualTrigger', // FULL form 24 | typeVersion: 1, 25 | position: [250, 300] as [number, number], 26 | parameters: {} 27 | }, 28 | { 29 | id: 'set-1', 30 | name: 'Set Data', 31 | type: 'n8n-nodes-base.set', // FULL form 32 | typeVersion: 3.4, 33 | position: [450, 300] as [number, number], 34 | parameters: { 35 | mode: 'manual', 36 | assignments: { 37 | assignments: [{ 38 | id: '1', 39 | name: 'test', 40 | value: 'hello', 41 | type: 'string' 42 | }] 43 | } 44 | } 45 | } 46 | ], 47 | connections: { 48 | 'Manual Trigger': { 49 | main: [[{ 50 | node: 'Set Data', 51 | type: 'main', 52 | index: 0 53 | }]] 54 | } 55 | } 56 | }; 57 | 58 | const errors = validateWorkflowStructure(workflow); 59 | 60 | expect(errors).toEqual([]); 61 | }); 62 | 63 | it('should reject workflows with SHORT form node types (nodes-base.*)', () => { 64 | const workflow = { 65 | name: 'Test Workflow', 66 | nodes: [ 67 | { 68 | id: 'manual-1', 69 | name: 'Manual Trigger', 70 | type: 'nodes-base.manualTrigger', // SHORT form - should be rejected 71 | typeVersion: 1, 72 | position: [250, 300] as [number, number], 73 | parameters: {} 74 | } 75 | ], 76 | connections: {} 77 | }; 78 | 79 | const errors = validateWorkflowStructure(workflow); 80 | 81 | expect(errors.length).toBeGreaterThan(0); 82 | expect(errors.some(e => 83 | e.includes('Invalid node type "nodes-base.manualTrigger"') && 84 | e.includes('Use "n8n-nodes-base.manualTrigger" instead') 85 | )).toBe(true); 86 | }); 87 | 88 | it('should accept workflows with LangChain nodes in FULL form', () => { 89 | const workflow = { 90 | name: 'AI Workflow', 91 | nodes: [ 92 | { 93 | id: 'manual-1', 94 | name: 'Manual Trigger', 95 | type: 'n8n-nodes-base.manualTrigger', 96 | typeVersion: 1, 97 | position: [250, 300] as [number, number], 98 | parameters: {} 99 | }, 100 | { 101 | id: 'agent-1', 102 | name: 'AI Agent', 103 | type: '@n8n/n8n-nodes-langchain.agent', // FULL form 104 | typeVersion: 1, 105 | position: [450, 300] as [number, number], 106 | parameters: {} 107 | } 108 | ], 109 | connections: { 110 | 'Manual Trigger': { 111 | main: [[{ 112 | node: 'AI Agent', 113 | type: 'main', 114 | index: 0 115 | }]] 116 | } 117 | } 118 | }; 119 | 120 | const errors = validateWorkflowStructure(workflow); 121 | 122 | // Should accept FULL form LangChain nodes 123 | // Note: May have other validation errors (missing parameters), but NOT node type errors 124 | const hasNodeTypeError = errors.some(e => 125 | e.includes('Invalid node type') && e.includes('@n8n/n8n-nodes-langchain.agent') 126 | ); 127 | expect(hasNodeTypeError).toBe(false); 128 | }); 129 | 130 | it('should reject node types without package prefix', () => { 131 | const workflow = { 132 | name: 'Invalid Workflow', 133 | nodes: [ 134 | { 135 | id: 'node-1', 136 | name: 'Invalid Node', 137 | type: 'webhook', // No package prefix 138 | typeVersion: 1, 139 | position: [250, 300] as [number, number], 140 | parameters: {} 141 | } 142 | ], 143 | connections: {} 144 | }; 145 | 146 | const errors = validateWorkflowStructure(workflow); 147 | 148 | expect(errors.length).toBeGreaterThan(0); 149 | expect(errors.some(e => 150 | e.includes('Invalid node type "webhook"') && 151 | e.includes('must include package prefix') 152 | )).toBe(true); 153 | }); 154 | }); 155 | 156 | describe('Real-world workflow examples', () => { 157 | it('should validate webhook workflow correctly', () => { 158 | const workflow = { 159 | name: 'Webhook to HTTP', 160 | nodes: [ 161 | { 162 | id: 'webhook-1', 163 | name: 'Webhook', 164 | type: 'n8n-nodes-base.webhook', 165 | typeVersion: 2, 166 | position: [250, 300] as [number, number], 167 | parameters: { 168 | path: 'test-webhook', 169 | httpMethod: 'POST', 170 | responseMode: 'onReceived' 171 | } 172 | }, 173 | { 174 | id: 'http-1', 175 | name: 'HTTP Request', 176 | type: 'n8n-nodes-base.httpRequest', 177 | typeVersion: 4.2, 178 | position: [450, 300] as [number, number], 179 | parameters: { 180 | method: 'POST', 181 | url: 'https://example.com/api', 182 | sendBody: true, 183 | bodyParameters: { 184 | parameters: [] 185 | } 186 | } 187 | } 188 | ], 189 | connections: { 190 | 'Webhook': { 191 | main: [[{ 192 | node: 'HTTP Request', 193 | type: 'main', 194 | index: 0 195 | }]] 196 | } 197 | } 198 | }; 199 | 200 | const errors = validateWorkflowStructure(workflow); 201 | 202 | expect(errors).toEqual([]); 203 | }); 204 | 205 | it('should validate schedule trigger workflow correctly', () => { 206 | const workflow = { 207 | name: 'Daily Report', 208 | nodes: [ 209 | { 210 | id: 'schedule-1', 211 | name: 'Schedule Trigger', 212 | type: 'n8n-nodes-base.scheduleTrigger', 213 | typeVersion: 1.2, 214 | position: [250, 300] as [number, number], 215 | parameters: { 216 | rule: { 217 | interval: [{ 218 | field: 'days', 219 | daysInterval: 1 220 | }] 221 | } 222 | } 223 | }, 224 | { 225 | id: 'set-1', 226 | name: 'Set', 227 | type: 'n8n-nodes-base.set', 228 | typeVersion: 3.4, 229 | position: [450, 300] as [number, number], 230 | parameters: { 231 | mode: 'manual', 232 | assignments: { 233 | assignments: [] 234 | } 235 | } 236 | } 237 | ], 238 | connections: { 239 | 'Schedule Trigger': { 240 | main: [[{ 241 | node: 'Set', 242 | type: 'main', 243 | index: 0 244 | }]] 245 | } 246 | } 247 | }; 248 | 249 | const errors = validateWorkflowStructure(workflow); 250 | 251 | expect(errors).toEqual([]); 252 | }); 253 | }); 254 | 255 | describe('Regression test for normalization bug', () => { 256 | it('should NOT normalize node types before validation', () => { 257 | // This test ensures that handleCreateWorkflow does NOT call 258 | // NodeTypeNormalizer.normalizeWorkflowNodeTypes() before validation 259 | 260 | const fullFormWorkflow = { 261 | name: 'Test', 262 | nodes: [ 263 | { 264 | id: '1', 265 | name: 'Manual Trigger', 266 | type: 'n8n-nodes-base.manualTrigger', 267 | typeVersion: 1, 268 | position: [0, 0] as [number, number], 269 | parameters: {} 270 | }, 271 | { 272 | id: '2', 273 | name: 'Set', 274 | type: 'n8n-nodes-base.set', 275 | typeVersion: 3.4, 276 | position: [200, 0] as [number, number], 277 | parameters: { 278 | mode: 'manual', 279 | assignments: { assignments: [] } 280 | } 281 | } 282 | ], 283 | connections: { 284 | 'Manual Trigger': { 285 | main: [[{ node: 'Set', type: 'main', index: 0 }]] 286 | } 287 | } 288 | }; 289 | 290 | const errors = validateWorkflowStructure(fullFormWorkflow); 291 | 292 | // FULL form should pass validation 293 | expect(errors).toEqual([]); 294 | 295 | // SHORT form (what normalizer produces) should FAIL validation 296 | const shortFormWorkflow = { 297 | ...fullFormWorkflow, 298 | nodes: fullFormWorkflow.nodes.map(node => ({ 299 | ...node, 300 | type: node.type.replace('n8n-nodes-base.', 'nodes-base.') // Convert to SHORT form 301 | })) 302 | }; 303 | 304 | const shortFormErrors = validateWorkflowStructure(shortFormWorkflow); 305 | 306 | expect(shortFormErrors.length).toBeGreaterThan(0); 307 | expect(shortFormErrors.some(e => 308 | e.includes('Invalid node type') && 309 | e.includes('nodes-base.') 310 | )).toBe(true); 311 | }); 312 | }); 313 | }); 314 | ``` -------------------------------------------------------------------------------- /src/scripts/rebuild.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) 2024 AiAdvisors Romuald Czlonkowski 4 | * Licensed under the Sustainable Use License v1.0 5 | */ 6 | import { createDatabaseAdapter } from '../database/database-adapter'; 7 | import { N8nNodeLoader } from '../loaders/node-loader'; 8 | import { NodeParser, ParsedNode } from '../parsers/node-parser'; 9 | import { DocsMapper } from '../mappers/docs-mapper'; 10 | import { NodeRepository } from '../database/node-repository'; 11 | import { TemplateSanitizer } from '../utils/template-sanitizer'; 12 | import * as fs from 'fs'; 13 | import * as path from 'path'; 14 | 15 | async function rebuild() { 16 | console.log('🔄 Rebuilding n8n node database...\n'); 17 | 18 | const dbPath = process.env.NODE_DB_PATH || './data/nodes.db'; 19 | const db = await createDatabaseAdapter(dbPath); 20 | const loader = new N8nNodeLoader(); 21 | const parser = new NodeParser(); 22 | const mapper = new DocsMapper(); 23 | const repository = new NodeRepository(db); 24 | 25 | // Initialize database 26 | const schema = fs.readFileSync(path.join(__dirname, '../../src/database/schema.sql'), 'utf8'); 27 | db.exec(schema); 28 | 29 | // Clear existing data 30 | db.exec('DELETE FROM nodes'); 31 | console.log('🗑️ Cleared existing data\n'); 32 | 33 | // Load all nodes 34 | const nodes = await loader.loadAllNodes(); 35 | console.log(`📦 Loaded ${nodes.length} nodes from packages\n`); 36 | 37 | // Statistics 38 | const stats = { 39 | successful: 0, 40 | failed: 0, 41 | aiTools: 0, 42 | triggers: 0, 43 | webhooks: 0, 44 | withProperties: 0, 45 | withOperations: 0, 46 | withDocs: 0 47 | }; 48 | 49 | // Process each node (documentation fetching must be outside transaction due to async) 50 | console.log('🔄 Processing nodes...'); 51 | const processedNodes: Array<{ parsed: ParsedNode; docs: string | undefined; nodeName: string }> = []; 52 | 53 | for (const { packageName, nodeName, NodeClass } of nodes) { 54 | try { 55 | // Parse node 56 | const parsed = parser.parse(NodeClass, packageName); 57 | 58 | // Validate parsed data 59 | if (!parsed.nodeType || !parsed.displayName) { 60 | throw new Error(`Missing required fields - nodeType: ${parsed.nodeType}, displayName: ${parsed.displayName}, packageName: ${parsed.packageName}`); 61 | } 62 | 63 | // Additional validation for required fields 64 | if (!parsed.packageName) { 65 | throw new Error(`Missing packageName for node ${nodeName}`); 66 | } 67 | 68 | // Get documentation 69 | const docs = await mapper.fetchDocumentation(parsed.nodeType); 70 | parsed.documentation = docs || undefined; 71 | 72 | processedNodes.push({ parsed, docs: docs || undefined, nodeName }); 73 | } catch (error) { 74 | stats.failed++; 75 | const errorMessage = (error as Error).message; 76 | console.error(`❌ Failed to process ${nodeName}: ${errorMessage}`); 77 | } 78 | } 79 | 80 | // Now save all processed nodes to database 81 | console.log(`\n💾 Saving ${processedNodes.length} processed nodes to database...`); 82 | 83 | let saved = 0; 84 | for (const { parsed, docs, nodeName } of processedNodes) { 85 | try { 86 | repository.saveNode(parsed); 87 | saved++; 88 | 89 | // Update statistics 90 | stats.successful++; 91 | if (parsed.isAITool) stats.aiTools++; 92 | if (parsed.isTrigger) stats.triggers++; 93 | if (parsed.isWebhook) stats.webhooks++; 94 | if (parsed.properties.length > 0) stats.withProperties++; 95 | if (parsed.operations.length > 0) stats.withOperations++; 96 | if (docs) stats.withDocs++; 97 | 98 | console.log(`✅ ${parsed.nodeType} [Props: ${parsed.properties.length}, Ops: ${parsed.operations.length}]`); 99 | } catch (error) { 100 | stats.failed++; 101 | const errorMessage = (error as Error).message; 102 | console.error(`❌ Failed to save ${nodeName}: ${errorMessage}`); 103 | } 104 | } 105 | 106 | console.log(`💾 Save completed: ${saved} nodes saved successfully`); 107 | 108 | // Validation check 109 | console.log('\n🔍 Running validation checks...'); 110 | try { 111 | const validationResults = validateDatabase(repository); 112 | 113 | if (!validationResults.passed) { 114 | console.log('⚠️ Validation Issues:'); 115 | validationResults.issues.forEach(issue => console.log(` - ${issue}`)); 116 | } else { 117 | console.log('✅ All validation checks passed'); 118 | } 119 | } catch (validationError) { 120 | console.error('❌ Validation failed:', (validationError as Error).message); 121 | console.log('⚠️ Skipping validation due to database compatibility issues'); 122 | } 123 | 124 | // Summary 125 | console.log('\n📊 Summary:'); 126 | console.log(` Total nodes: ${nodes.length}`); 127 | console.log(` Successful: ${stats.successful}`); 128 | console.log(` Failed: ${stats.failed}`); 129 | console.log(` AI Tools: ${stats.aiTools}`); 130 | console.log(` Triggers: ${stats.triggers}`); 131 | console.log(` Webhooks: ${stats.webhooks}`); 132 | console.log(` With Properties: ${stats.withProperties}`); 133 | console.log(` With Operations: ${stats.withOperations}`); 134 | console.log(` With Documentation: ${stats.withDocs}`); 135 | 136 | // Sanitize templates if they exist 137 | console.log('\n🧹 Checking for templates to sanitize...'); 138 | const templateCount = db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number }; 139 | 140 | if (templateCount && templateCount.count > 0) { 141 | console.log(` Found ${templateCount.count} templates, sanitizing...`); 142 | const sanitizer = new TemplateSanitizer(); 143 | let sanitizedCount = 0; 144 | 145 | const templates = db.prepare('SELECT id, name, workflow_json FROM templates').all() as any[]; 146 | for (const template of templates) { 147 | const originalWorkflow = JSON.parse(template.workflow_json); 148 | const { sanitized: sanitizedWorkflow, wasModified } = sanitizer.sanitizeWorkflow(originalWorkflow); 149 | 150 | if (wasModified) { 151 | const stmt = db.prepare('UPDATE templates SET workflow_json = ? WHERE id = ?'); 152 | stmt.run(JSON.stringify(sanitizedWorkflow), template.id); 153 | sanitizedCount++; 154 | console.log(` ✅ Sanitized template ${template.id}: ${template.name}`); 155 | } 156 | } 157 | 158 | console.log(` Sanitization complete: ${sanitizedCount} templates cleaned`); 159 | } else { 160 | console.log(' No templates found in database'); 161 | } 162 | 163 | console.log('\n✨ Rebuild complete!'); 164 | 165 | db.close(); 166 | } 167 | 168 | function validateDatabase(repository: NodeRepository): { passed: boolean; issues: string[] } { 169 | const issues = []; 170 | 171 | try { 172 | const db = (repository as any).db; 173 | 174 | // CRITICAL: Check if database has any nodes at all 175 | const nodeCount = db.prepare('SELECT COUNT(*) as count FROM nodes').get() as { count: number }; 176 | if (nodeCount.count === 0) { 177 | issues.push('CRITICAL: Database is empty - no nodes found! Rebuild failed or was interrupted.'); 178 | return { passed: false, issues }; 179 | } 180 | 181 | // Check minimum expected node count (should have at least 500 nodes from both packages) 182 | if (nodeCount.count < 500) { 183 | issues.push(`WARNING: Only ${nodeCount.count} nodes found - expected at least 500 (both n8n packages)`); 184 | } 185 | 186 | // Check critical nodes 187 | const criticalNodes = ['nodes-base.httpRequest', 'nodes-base.code', 'nodes-base.webhook', 'nodes-base.slack']; 188 | 189 | for (const nodeType of criticalNodes) { 190 | const node = repository.getNode(nodeType); 191 | 192 | if (!node) { 193 | issues.push(`Critical node ${nodeType} not found`); 194 | continue; 195 | } 196 | 197 | if (node.properties.length === 0) { 198 | issues.push(`Node ${nodeType} has no properties`); 199 | } 200 | } 201 | 202 | // Check AI tools 203 | const aiTools = repository.getAITools(); 204 | if (aiTools.length === 0) { 205 | issues.push('No AI tools found - check detection logic'); 206 | } 207 | 208 | // Check FTS5 table existence and population 209 | const ftsTableCheck = db.prepare(` 210 | SELECT name FROM sqlite_master 211 | WHERE type='table' AND name='nodes_fts' 212 | `).get(); 213 | 214 | if (!ftsTableCheck) { 215 | issues.push('CRITICAL: FTS5 table (nodes_fts) does not exist - searches will fail or be very slow'); 216 | } else { 217 | // Check if FTS5 table is properly populated 218 | const ftsCount = db.prepare('SELECT COUNT(*) as count FROM nodes_fts').get() as { count: number }; 219 | 220 | if (ftsCount.count === 0) { 221 | issues.push('CRITICAL: FTS5 index is empty - searches will return zero results'); 222 | } else if (nodeCount.count !== ftsCount.count) { 223 | issues.push(`FTS5 index out of sync: ${nodeCount.count} nodes but ${ftsCount.count} FTS5 entries`); 224 | } 225 | 226 | // Verify critical nodes are searchable via FTS5 227 | const searchableNodes = ['webhook', 'merge', 'split']; 228 | for (const searchTerm of searchableNodes) { 229 | const searchResult = db.prepare(` 230 | SELECT COUNT(*) as count FROM nodes_fts 231 | WHERE nodes_fts MATCH ? 232 | `).get(searchTerm); 233 | 234 | if (searchResult.count === 0) { 235 | issues.push(`CRITICAL: Search for "${searchTerm}" returns zero results in FTS5 index`); 236 | } 237 | } 238 | } 239 | } catch (error) { 240 | // Catch any validation errors 241 | const errorMessage = (error as Error).message; 242 | issues.push(`Validation error: ${errorMessage}`); 243 | } 244 | 245 | return { 246 | passed: issues.length === 0, 247 | issues 248 | }; 249 | } 250 | 251 | // Run if called directly 252 | if (require.main === module) { 253 | rebuild().catch(console.error); 254 | } ``` -------------------------------------------------------------------------------- /scripts/update-n8n-deps.js: -------------------------------------------------------------------------------- ```javascript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Update n8n dependencies to latest versions 5 | * Can be run manually or via GitHub Actions 6 | */ 7 | 8 | const { execSync } = require('child_process'); 9 | const fs = require('fs'); 10 | const path = require('path'); 11 | 12 | class N8nDependencyUpdater { 13 | constructor() { 14 | this.packageJsonPath = path.join(__dirname, '..', 'package.json'); 15 | // Only track the main n8n package - let it manage its own dependencies 16 | this.mainPackage = 'n8n'; 17 | } 18 | 19 | /** 20 | * Get latest version of a package from npm 21 | */ 22 | getLatestVersion(packageName) { 23 | try { 24 | const output = execSync(`npm view ${packageName} version`, { encoding: 'utf8' }); 25 | return output.trim(); 26 | } catch (error) { 27 | console.error(`Failed to get version for ${packageName}:`, error.message); 28 | return null; 29 | } 30 | } 31 | 32 | /** 33 | * Get dependencies of a specific n8n version 34 | */ 35 | getN8nDependencies(n8nVersion) { 36 | try { 37 | const output = execSync(`npm view n8n@${n8nVersion} dependencies --json`, { encoding: 'utf8' }); 38 | return JSON.parse(output); 39 | } catch (error) { 40 | console.error(`Failed to get dependencies for n8n@${n8nVersion}:`, error.message); 41 | return {}; 42 | } 43 | } 44 | 45 | /** 46 | * Get current version from package.json 47 | */ 48 | getCurrentVersion(packageName) { 49 | const packageJson = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8')); 50 | const version = packageJson.dependencies[packageName]; 51 | return version ? version.replace(/^[\^~]/, '') : null; 52 | } 53 | 54 | /** 55 | * Check which packages need updates 56 | */ 57 | async checkForUpdates() { 58 | console.log('🔍 Checking for n8n dependency updates...\n'); 59 | 60 | const updates = []; 61 | 62 | // First check the main n8n package 63 | const currentN8nVersion = this.getCurrentVersion('n8n'); 64 | const latestN8nVersion = this.getLatestVersion('n8n'); 65 | 66 | if (!currentN8nVersion || !latestN8nVersion) { 67 | console.error('Failed to check n8n version'); 68 | return updates; 69 | } 70 | 71 | if (currentN8nVersion !== latestN8nVersion) { 72 | console.log(`📦 n8n: ${currentN8nVersion} → ${latestN8nVersion} (update available)`); 73 | 74 | // Get the dependencies that n8n requires 75 | const n8nDeps = this.getN8nDependencies(latestN8nVersion); 76 | 77 | // Add main n8n update 78 | updates.push({ 79 | package: 'n8n', 80 | current: currentN8nVersion, 81 | latest: latestN8nVersion 82 | }); 83 | 84 | // Check our tracked dependencies that n8n uses 85 | const trackedDeps = ['n8n-core', 'n8n-workflow', '@n8n/n8n-nodes-langchain']; 86 | 87 | for (const dep of trackedDeps) { 88 | const currentVersion = this.getCurrentVersion(dep); 89 | const requiredVersion = n8nDeps[dep]; 90 | 91 | if (requiredVersion && currentVersion) { 92 | // Extract version from npm dependency format (e.g., "^1.2.3" -> "1.2.3") 93 | const cleanRequiredVersion = requiredVersion.replace(/^[\^~>=<]/, '').split(' ')[0]; 94 | 95 | if (currentVersion !== cleanRequiredVersion) { 96 | updates.push({ 97 | package: dep, 98 | current: currentVersion, 99 | latest: cleanRequiredVersion, 100 | reason: `Required by n8n@${latestN8nVersion}` 101 | }); 102 | console.log(`📦 ${dep}: ${currentVersion} → ${cleanRequiredVersion} (required by n8n)`); 103 | } else { 104 | console.log(`✅ ${dep}: ${currentVersion} (compatible with n8n@${latestN8nVersion})`); 105 | } 106 | } 107 | } 108 | } else { 109 | console.log(`✅ n8n: ${currentN8nVersion} (up to date)`); 110 | 111 | // Even if n8n is up to date, check if our dependencies match what n8n expects 112 | const n8nDeps = this.getN8nDependencies(currentN8nVersion); 113 | const trackedDeps = ['n8n-core', 'n8n-workflow', '@n8n/n8n-nodes-langchain']; 114 | 115 | for (const dep of trackedDeps) { 116 | const currentVersion = this.getCurrentVersion(dep); 117 | const requiredVersion = n8nDeps[dep]; 118 | 119 | if (requiredVersion && currentVersion) { 120 | const cleanRequiredVersion = requiredVersion.replace(/^[\^~>=<]/, '').split(' ')[0]; 121 | 122 | if (currentVersion !== cleanRequiredVersion) { 123 | updates.push({ 124 | package: dep, 125 | current: currentVersion, 126 | latest: cleanRequiredVersion, 127 | reason: `Required by n8n@${currentN8nVersion}` 128 | }); 129 | console.log(`📦 ${dep}: ${currentVersion} → ${cleanRequiredVersion} (sync with n8n)`); 130 | } else { 131 | console.log(`✅ ${dep}: ${currentVersion} (in sync)`); 132 | } 133 | } 134 | } 135 | } 136 | 137 | return updates; 138 | } 139 | 140 | /** 141 | * Update package.json with new versions 142 | */ 143 | updatePackageJson(updates) { 144 | if (updates.length === 0) { 145 | console.log('\n✨ All n8n dependencies are up to date and in sync!'); 146 | return false; 147 | } 148 | 149 | console.log(`\n📝 Updating ${updates.length} packages in package.json...`); 150 | 151 | const packageJson = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8')); 152 | 153 | for (const update of updates) { 154 | packageJson.dependencies[update.package] = `^${update.latest}`; 155 | console.log(` Updated ${update.package} to ^${update.latest}${update.reason ? ` (${update.reason})` : ''}`); 156 | } 157 | 158 | fs.writeFileSync( 159 | this.packageJsonPath, 160 | JSON.stringify(packageJson, null, 2) + '\n', 161 | 'utf8' 162 | ); 163 | 164 | return true; 165 | } 166 | 167 | /** 168 | * Run npm install to update lock file 169 | */ 170 | runNpmInstall() { 171 | console.log('\n📥 Running npm install to update lock file...'); 172 | try { 173 | execSync('npm install', { 174 | cwd: path.join(__dirname, '..'), 175 | stdio: 'inherit' 176 | }); 177 | return true; 178 | } catch (error) { 179 | console.error('❌ npm install failed:', error.message); 180 | return false; 181 | } 182 | } 183 | 184 | /** 185 | * Rebuild the node database 186 | */ 187 | rebuildDatabase() { 188 | console.log('\n🔨 Rebuilding node database...'); 189 | try { 190 | execSync('npm run build && npm run rebuild', { 191 | cwd: path.join(__dirname, '..'), 192 | stdio: 'inherit' 193 | }); 194 | return true; 195 | } catch (error) { 196 | console.error('❌ Database rebuild failed:', error.message); 197 | return false; 198 | } 199 | } 200 | 201 | /** 202 | * Run validation tests 203 | */ 204 | runValidation() { 205 | console.log('\n🧪 Running validation tests...'); 206 | try { 207 | execSync('npm run validate && npm run test-nodes', { 208 | cwd: path.join(__dirname, '..'), 209 | stdio: 'inherit' 210 | }); 211 | console.log('✅ All tests passed!'); 212 | return true; 213 | } catch (error) { 214 | console.error('❌ Validation failed:', error.message); 215 | return false; 216 | } 217 | } 218 | 219 | /** 220 | * Generate update summary for PR/commit message 221 | */ 222 | generateUpdateSummary(updates) { 223 | if (updates.length === 0) return ''; 224 | 225 | const summary = ['Updated n8n dependencies:\n']; 226 | 227 | for (const update of updates) { 228 | summary.push(`- ${update.package}: ${update.current} → ${update.latest}`); 229 | } 230 | 231 | return summary.join('\n'); 232 | } 233 | 234 | /** 235 | * Main update process 236 | */ 237 | async run(options = {}) { 238 | const { dryRun = false, skipTests = false } = options; 239 | 240 | console.log('🚀 n8n Dependency Updater\n'); 241 | console.log('Mode:', dryRun ? 'DRY RUN' : 'LIVE UPDATE'); 242 | console.log('Skip tests:', skipTests ? 'YES' : 'NO'); 243 | console.log('Strategy: Update n8n and sync its required dependencies'); 244 | console.log(''); 245 | 246 | // Check for updates 247 | const updates = await this.checkForUpdates(); 248 | 249 | if (updates.length === 0) { 250 | process.exit(0); 251 | } 252 | 253 | if (dryRun) { 254 | console.log('\n🔍 DRY RUN: No changes made'); 255 | console.log('\nUpdate summary:'); 256 | console.log(this.generateUpdateSummary(updates)); 257 | process.exit(0); 258 | } 259 | 260 | // Apply updates 261 | if (!this.updatePackageJson(updates)) { 262 | process.exit(0); 263 | } 264 | 265 | // Install dependencies 266 | if (!this.runNpmInstall()) { 267 | console.error('\n❌ Update failed at npm install step'); 268 | process.exit(1); 269 | } 270 | 271 | // Rebuild database 272 | if (!this.rebuildDatabase()) { 273 | console.error('\n❌ Update failed at database rebuild step'); 274 | process.exit(1); 275 | } 276 | 277 | // Run tests 278 | if (!skipTests && !this.runValidation()) { 279 | console.error('\n❌ Update failed at validation step'); 280 | process.exit(1); 281 | } 282 | 283 | // Success! 284 | console.log('\n✅ Update completed successfully!'); 285 | console.log('\nUpdate summary:'); 286 | console.log(this.generateUpdateSummary(updates)); 287 | 288 | // Write summary to file for GitHub Actions 289 | if (process.env.GITHUB_ACTIONS) { 290 | fs.writeFileSync( 291 | path.join(__dirname, '..', 'update-summary.txt'), 292 | this.generateUpdateSummary(updates), 293 | 'utf8' 294 | ); 295 | } 296 | } 297 | } 298 | 299 | // CLI handling 300 | if (require.main === module) { 301 | const args = process.argv.slice(2); 302 | const options = { 303 | dryRun: args.includes('--dry-run') || args.includes('-d'), 304 | skipTests: args.includes('--skip-tests') || args.includes('-s') 305 | }; 306 | 307 | const updater = new N8nDependencyUpdater(); 308 | updater.run(options).catch(error => { 309 | console.error('Unexpected error:', error); 310 | process.exit(1); 311 | }); 312 | } 313 | 314 | module.exports = N8nDependencyUpdater; ``` -------------------------------------------------------------------------------- /tests/unit/mcp/handlers-n8n-manager-simple.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Simple, focused unit tests for handlers-n8n-manager.ts coverage gaps 3 | * 4 | * This test file focuses on specific uncovered lines to achieve >95% coverage 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 8 | import { createHash } from 'crypto'; 9 | 10 | describe('handlers-n8n-manager Simple Coverage Tests', () => { 11 | beforeEach(() => { 12 | vi.resetAllMocks(); 13 | vi.resetModules(); 14 | }); 15 | 16 | afterEach(() => { 17 | vi.clearAllMocks(); 18 | }); 19 | 20 | describe('Cache Key Generation', () => { 21 | it('should generate deterministic SHA-256 hashes', () => { 22 | const input1 = 'https://api.n8n.cloud:key123:instance1'; 23 | const input2 = 'https://api.n8n.cloud:key123:instance1'; 24 | const input3 = 'https://api.n8n.cloud:key456:instance2'; 25 | 26 | const hash1 = createHash('sha256').update(input1).digest('hex'); 27 | const hash2 = createHash('sha256').update(input2).digest('hex'); 28 | const hash3 = createHash('sha256').update(input3).digest('hex'); 29 | 30 | // Same input should produce same hash 31 | expect(hash1).toBe(hash2); 32 | // Different input should produce different hash 33 | expect(hash1).not.toBe(hash3); 34 | // Hash should be 64 characters (SHA-256) 35 | expect(hash1).toHaveLength(64); 36 | expect(hash1).toMatch(/^[a-f0-9]{64}$/); 37 | }); 38 | 39 | it('should handle empty instanceId in cache key generation', () => { 40 | const url = 'https://api.n8n.cloud'; 41 | const key = 'test-key'; 42 | const instanceId = ''; 43 | 44 | const cacheInput = `${url}:${key}:${instanceId}`; 45 | const hash = createHash('sha256').update(cacheInput).digest('hex'); 46 | 47 | expect(hash).toBeDefined(); 48 | expect(hash).toHaveLength(64); 49 | }); 50 | 51 | it('should handle undefined values in cache key generation', () => { 52 | const url = 'https://api.n8n.cloud'; 53 | const key = 'test-key'; 54 | const instanceId = undefined; 55 | 56 | // This simulates the actual cache key generation in the code 57 | const cacheInput = `${url}:${key}:${instanceId || ''}`; 58 | const hash = createHash('sha256').update(cacheInput).digest('hex'); 59 | 60 | expect(hash).toBeDefined(); 61 | expect(cacheInput).toBe('https://api.n8n.cloud:test-key:'); 62 | }); 63 | }); 64 | 65 | describe('URL Sanitization', () => { 66 | it('should sanitize URLs for logging', () => { 67 | const fullUrl = 'https://secret.example.com/api/v1/private'; 68 | 69 | // This simulates the URL sanitization in the logging code 70 | const sanitizedUrl = fullUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1'); 71 | 72 | expect(sanitizedUrl).toBe('https://secret.example.com'); 73 | expect(sanitizedUrl).not.toContain('/api/v1/private'); 74 | }); 75 | 76 | it('should handle various URL formats in sanitization', () => { 77 | const testUrls = [ 78 | 'https://api.n8n.cloud', 79 | 'https://api.n8n.cloud/', 80 | 'https://api.n8n.cloud/webhook/abc123', 81 | 'http://localhost:5678/api/v1', 82 | 'https://subdomain.domain.com/path/to/resource' 83 | ]; 84 | 85 | testUrls.forEach(url => { 86 | const sanitized = url.replace(/^(https?:\/\/[^\/]+).*/, '$1'); 87 | 88 | // Should contain protocol and domain only 89 | expect(sanitized).toMatch(/^https?:\/\/[^\/]+$/); 90 | // Should not contain paths (but domain names containing 'api' are OK) 91 | expect(sanitized).not.toContain('/webhook'); 92 | if (!sanitized.includes('api.n8n.cloud')) { 93 | expect(sanitized).not.toContain('/api'); 94 | } 95 | expect(sanitized).not.toContain('/path'); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('Cache Key Partial Logging', () => { 101 | it('should create partial cache key for logging', () => { 102 | const fullHash = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; 103 | 104 | // This simulates the partial key logging in the dispose callback 105 | const partialKey = fullHash.substring(0, 8) + '...'; 106 | 107 | expect(partialKey).toBe('abcdef12...'); 108 | expect(partialKey).toHaveLength(11); 109 | expect(partialKey).toMatch(/^[a-f0-9]{8}\.\.\.$/); 110 | }); 111 | 112 | it('should handle various hash lengths for partial logging', () => { 113 | const hashes = [ 114 | 'a'.repeat(64), 115 | 'b'.repeat(32), 116 | 'c'.repeat(16), 117 | 'd'.repeat(8) 118 | ]; 119 | 120 | hashes.forEach(hash => { 121 | const partial = hash.substring(0, 8) + '...'; 122 | expect(partial).toHaveLength(11); 123 | expect(partial.endsWith('...')).toBe(true); 124 | }); 125 | }); 126 | }); 127 | 128 | describe('Error Message Handling', () => { 129 | it('should handle different error types correctly', () => { 130 | // Test the error handling patterns used in the handlers 131 | const errorTypes = [ 132 | new Error('Standard error'), 133 | 'String error', 134 | { message: 'Object error' }, 135 | null, 136 | undefined 137 | ]; 138 | 139 | errorTypes.forEach(error => { 140 | // This simulates the error handling in handlers 141 | const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; 142 | 143 | if (error instanceof Error) { 144 | expect(errorMessage).toBe(error.message); 145 | } else { 146 | expect(errorMessage).toBe('Unknown error occurred'); 147 | } 148 | }); 149 | }); 150 | 151 | it('should handle error objects without message property', () => { 152 | const errorLikeObject = { code: 500, details: 'Some details' }; 153 | 154 | // This simulates error handling for non-Error objects 155 | const errorMessage = errorLikeObject instanceof Error ? 156 | errorLikeObject.message : 'Unknown error occurred'; 157 | 158 | expect(errorMessage).toBe('Unknown error occurred'); 159 | }); 160 | }); 161 | 162 | describe('Configuration Fallbacks', () => { 163 | it('should handle null config scenarios', () => { 164 | // Test configuration fallback logic 165 | const config = null; 166 | const apiConfigured = config !== null; 167 | 168 | expect(apiConfigured).toBe(false); 169 | }); 170 | 171 | it('should handle undefined config values', () => { 172 | const contextWithUndefined = { 173 | n8nApiUrl: 'https://api.n8n.cloud', 174 | n8nApiKey: 'test-key', 175 | n8nApiTimeout: undefined, 176 | n8nApiMaxRetries: undefined 177 | }; 178 | 179 | // Test default value assignment using nullish coalescing 180 | const timeout = contextWithUndefined.n8nApiTimeout ?? 30000; 181 | const maxRetries = contextWithUndefined.n8nApiMaxRetries ?? 3; 182 | 183 | expect(timeout).toBe(30000); 184 | expect(maxRetries).toBe(3); 185 | }); 186 | }); 187 | 188 | describe('Array and Object Handling', () => { 189 | it('should handle undefined array lengths', () => { 190 | const workflowData: { nodes?: any[] } = { 191 | nodes: undefined 192 | }; 193 | 194 | // This simulates the nodeCount calculation in list workflows 195 | const nodeCount = workflowData.nodes?.length || 0; 196 | 197 | expect(nodeCount).toBe(0); 198 | }); 199 | 200 | it('should handle empty arrays', () => { 201 | const workflowData = { 202 | nodes: [] 203 | }; 204 | 205 | const nodeCount = workflowData.nodes?.length || 0; 206 | 207 | expect(nodeCount).toBe(0); 208 | }); 209 | 210 | it('should handle arrays with elements', () => { 211 | const workflowData = { 212 | nodes: [{ id: 'node1' }, { id: 'node2' }] 213 | }; 214 | 215 | const nodeCount = workflowData.nodes?.length || 0; 216 | 217 | expect(nodeCount).toBe(2); 218 | }); 219 | }); 220 | 221 | describe('Conditional Logic Coverage', () => { 222 | it('should handle truthy cursor values', () => { 223 | const response = { 224 | nextCursor: 'abc123' 225 | }; 226 | 227 | // This simulates the cursor handling logic 228 | const hasMore = !!response.nextCursor; 229 | const noteCondition = response.nextCursor ? { 230 | _note: "More workflows available. Use cursor to get next page." 231 | } : {}; 232 | 233 | expect(hasMore).toBe(true); 234 | expect(noteCondition._note).toBeDefined(); 235 | }); 236 | 237 | it('should handle falsy cursor values', () => { 238 | const response = { 239 | nextCursor: null 240 | }; 241 | 242 | const hasMore = !!response.nextCursor; 243 | const noteCondition = response.nextCursor ? { 244 | _note: "More workflows available. Use cursor to get next page." 245 | } : {}; 246 | 247 | expect(hasMore).toBe(false); 248 | expect(noteCondition._note).toBeUndefined(); 249 | }); 250 | }); 251 | 252 | describe('String Manipulation', () => { 253 | it('should handle environment variable filtering', () => { 254 | const envKeys = [ 255 | 'N8N_API_URL', 256 | 'N8N_API_KEY', 257 | 'MCP_MODE', 258 | 'NODE_ENV', 259 | 'PATH', 260 | 'HOME', 261 | 'N8N_CUSTOM_VAR' 262 | ]; 263 | 264 | // This simulates the environment variable filtering in diagnostic 265 | const filtered = envKeys.filter(key => 266 | key.startsWith('N8N_') || key.startsWith('MCP_') 267 | ); 268 | 269 | expect(filtered).toEqual(['N8N_API_URL', 'N8N_API_KEY', 'MCP_MODE', 'N8N_CUSTOM_VAR']); 270 | }); 271 | 272 | it('should handle version string extraction', () => { 273 | const packageJson = { 274 | dependencies: { 275 | n8n: '^1.111.0' 276 | } 277 | }; 278 | 279 | // This simulates the version extraction logic 280 | const supportedVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, '') || ''; 281 | 282 | expect(supportedVersion).toBe('1.111.0'); 283 | }); 284 | 285 | it('should handle missing dependencies', () => { 286 | const packageJson: { dependencies?: { n8n?: string } } = {}; 287 | 288 | const supportedVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, '') || ''; 289 | 290 | expect(supportedVersion).toBe(''); 291 | }); 292 | }); 293 | }); ``` -------------------------------------------------------------------------------- /src/utils/template-node-resolver.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { logger } from './logger'; 2 | 3 | /** 4 | * Resolves various node type input formats to all possible template node type formats. 5 | * Templates store node types in full n8n format (e.g., "n8n-nodes-base.slack"). 6 | * This function handles various input formats and expands them to all possible matches. 7 | * 8 | * @param nodeTypes Array of node types in various formats 9 | * @returns Array of all possible template node type formats 10 | * 11 | * @example 12 | * resolveTemplateNodeTypes(['slack']) 13 | * // Returns: ['n8n-nodes-base.slack', 'n8n-nodes-base.slackTrigger'] 14 | * 15 | * resolveTemplateNodeTypes(['nodes-base.webhook']) 16 | * // Returns: ['n8n-nodes-base.webhook'] 17 | * 18 | * resolveTemplateNodeTypes(['httpRequest']) 19 | * // Returns: ['n8n-nodes-base.httpRequest'] 20 | */ 21 | export function resolveTemplateNodeTypes(nodeTypes: string[]): string[] { 22 | const resolvedTypes = new Set<string>(); 23 | 24 | for (const nodeType of nodeTypes) { 25 | // Add all variations for this node type 26 | const variations = generateTemplateNodeVariations(nodeType); 27 | variations.forEach(v => resolvedTypes.add(v)); 28 | } 29 | 30 | const result = Array.from(resolvedTypes); 31 | logger.debug(`Resolved ${nodeTypes.length} input types to ${result.length} template variations`, { 32 | input: nodeTypes, 33 | output: result 34 | }); 35 | 36 | return result; 37 | } 38 | 39 | /** 40 | * Generates all possible template node type variations for a single input. 41 | * 42 | * @param nodeType Single node type in any format 43 | * @returns Array of possible template formats 44 | */ 45 | function generateTemplateNodeVariations(nodeType: string): string[] { 46 | const variations = new Set<string>(); 47 | 48 | // If it's already in full n8n format, just return it 49 | if (nodeType.startsWith('n8n-nodes-base.') || nodeType.startsWith('@n8n/n8n-nodes-langchain.')) { 50 | variations.add(nodeType); 51 | return Array.from(variations); 52 | } 53 | 54 | // Handle partial prefix formats (e.g., "nodes-base.slack" -> "n8n-nodes-base.slack") 55 | if (nodeType.startsWith('nodes-base.')) { 56 | const nodeName = nodeType.replace('nodes-base.', ''); 57 | variations.add(`n8n-nodes-base.${nodeName}`); 58 | // Also try camelCase variations 59 | addCamelCaseVariations(variations, nodeName, 'n8n-nodes-base'); 60 | } else if (nodeType.startsWith('nodes-langchain.')) { 61 | const nodeName = nodeType.replace('nodes-langchain.', ''); 62 | variations.add(`@n8n/n8n-nodes-langchain.${nodeName}`); 63 | // Also try camelCase variations 64 | addCamelCaseVariations(variations, nodeName, '@n8n/n8n-nodes-langchain'); 65 | } else if (!nodeType.includes('.')) { 66 | // Bare node name (e.g., "slack", "webhook", "httpRequest") 67 | // Try both packages with various case combinations 68 | 69 | // For n8n-nodes-base 70 | variations.add(`n8n-nodes-base.${nodeType}`); 71 | addCamelCaseVariations(variations, nodeType, 'n8n-nodes-base'); 72 | 73 | // For langchain (less common for bare names, but include for completeness) 74 | variations.add(`@n8n/n8n-nodes-langchain.${nodeType}`); 75 | addCamelCaseVariations(variations, nodeType, '@n8n/n8n-nodes-langchain'); 76 | 77 | // Add common related node types (e.g., "slack" -> also include "slackTrigger") 78 | addRelatedNodeTypes(variations, nodeType); 79 | } 80 | 81 | return Array.from(variations); 82 | } 83 | 84 | /** 85 | * Adds camelCase variations for a node name. 86 | * 87 | * @param variations Set to add variations to 88 | * @param nodeName The node name to create variations for 89 | * @param packagePrefix The package prefix to use 90 | */ 91 | function addCamelCaseVariations(variations: Set<string>, nodeName: string, packagePrefix: string): void { 92 | const lowerName = nodeName.toLowerCase(); 93 | 94 | // Common patterns in n8n node names 95 | const patterns = [ 96 | // Pattern: somethingTrigger (e.g., slackTrigger, webhookTrigger) 97 | { suffix: 'trigger', capitalize: true }, 98 | { suffix: 'Trigger', capitalize: false }, 99 | // Pattern: somethingRequest (e.g., httpRequest) 100 | { suffix: 'request', capitalize: true }, 101 | { suffix: 'Request', capitalize: false }, 102 | // Pattern: somethingDatabase (e.g., mysqlDatabase, postgresDatabase) 103 | { suffix: 'database', capitalize: true }, 104 | { suffix: 'Database', capitalize: false }, 105 | // Pattern: somethingSheet/Sheets (e.g., googleSheets) 106 | { suffix: 'sheet', capitalize: true }, 107 | { suffix: 'Sheet', capitalize: false }, 108 | { suffix: 'sheets', capitalize: true }, 109 | { suffix: 'Sheets', capitalize: false }, 110 | ]; 111 | 112 | // Check if the lowercase name matches any pattern 113 | for (const pattern of patterns) { 114 | const lowerSuffix = pattern.suffix.toLowerCase(); 115 | 116 | if (lowerName.endsWith(lowerSuffix)) { 117 | // Name already has the suffix, try different capitalizations 118 | const baseName = lowerName.slice(0, -lowerSuffix.length); 119 | if (baseName) { 120 | if (pattern.capitalize) { 121 | // Capitalize the suffix 122 | const capitalizedSuffix = pattern.suffix.charAt(0).toUpperCase() + pattern.suffix.slice(1).toLowerCase(); 123 | variations.add(`${packagePrefix}.${baseName}${capitalizedSuffix}`); 124 | } else { 125 | // Use the suffix as-is 126 | variations.add(`${packagePrefix}.${baseName}${pattern.suffix}`); 127 | } 128 | } 129 | } else if (!lowerName.includes(lowerSuffix)) { 130 | // Name doesn't have the suffix, try adding it 131 | if (pattern.capitalize) { 132 | const capitalizedSuffix = pattern.suffix.charAt(0).toUpperCase() + pattern.suffix.slice(1).toLowerCase(); 133 | variations.add(`${packagePrefix}.${lowerName}${capitalizedSuffix}`); 134 | } 135 | } 136 | } 137 | 138 | // Handle specific known cases 139 | const specificCases: Record<string, string[]> = { 140 | 'http': ['httpRequest'], 141 | 'httprequest': ['httpRequest'], 142 | 'mysql': ['mysql', 'mysqlDatabase'], 143 | 'postgres': ['postgres', 'postgresDatabase'], 144 | 'postgresql': ['postgres', 'postgresDatabase'], 145 | 'mongo': ['mongoDb', 'mongodb'], 146 | 'mongodb': ['mongoDb', 'mongodb'], 147 | 'google': ['googleSheets', 'googleDrive', 'googleCalendar'], 148 | 'googlesheet': ['googleSheets'], 149 | 'googlesheets': ['googleSheets'], 150 | 'microsoft': ['microsoftTeams', 'microsoftExcel', 'microsoftOutlook'], 151 | 'slack': ['slack'], 152 | 'discord': ['discord'], 153 | 'telegram': ['telegram'], 154 | 'webhook': ['webhook'], 155 | 'schedule': ['scheduleTrigger'], 156 | 'cron': ['cron', 'scheduleTrigger'], 157 | 'email': ['emailSend', 'emailReadImap', 'gmail'], 158 | 'gmail': ['gmail', 'gmailTrigger'], 159 | 'code': ['code'], 160 | 'javascript': ['code'], 161 | 'python': ['code'], 162 | 'js': ['code'], 163 | 'set': ['set'], 164 | 'if': ['if'], 165 | 'switch': ['switch'], 166 | 'merge': ['merge'], 167 | 'loop': ['splitInBatches'], 168 | 'split': ['splitInBatches', 'splitOut'], 169 | 'ai': ['openAi'], 170 | 'openai': ['openAi'], 171 | 'chatgpt': ['openAi'], 172 | 'gpt': ['openAi'], 173 | 'api': ['httpRequest', 'graphql', 'webhook'], 174 | 'csv': ['spreadsheetFile', 'readBinaryFile'], 175 | 'excel': ['microsoftExcel', 'spreadsheetFile'], 176 | 'spreadsheet': ['spreadsheetFile', 'googleSheets', 'microsoftExcel'], 177 | }; 178 | 179 | const cases = specificCases[lowerName]; 180 | if (cases) { 181 | cases.forEach(c => variations.add(`${packagePrefix}.${c}`)); 182 | } 183 | } 184 | 185 | /** 186 | * Adds related node types for common patterns. 187 | * For example, "slack" should also include "slackTrigger". 188 | * 189 | * @param variations Set to add variations to 190 | * @param nodeName The base node name 191 | */ 192 | function addRelatedNodeTypes(variations: Set<string>, nodeName: string): void { 193 | const lowerName = nodeName.toLowerCase(); 194 | 195 | // Map of base names to their related node types 196 | const relatedTypes: Record<string, string[]> = { 197 | 'slack': ['slack', 'slackTrigger'], 198 | 'gmail': ['gmail', 'gmailTrigger'], 199 | 'telegram': ['telegram', 'telegramTrigger'], 200 | 'discord': ['discord', 'discordTrigger'], 201 | 'webhook': ['webhook', 'webhookTrigger'], 202 | 'http': ['httpRequest', 'webhook'], 203 | 'email': ['emailSend', 'emailReadImap', 'gmail', 'gmailTrigger'], 204 | 'google': ['googleSheets', 'googleDrive', 'googleCalendar', 'googleDocs'], 205 | 'microsoft': ['microsoftTeams', 'microsoftExcel', 'microsoftOutlook', 'microsoftOneDrive'], 206 | 'database': ['postgres', 'mysql', 'mongoDb', 'redis', 'postgresDatabase', 'mysqlDatabase'], 207 | 'db': ['postgres', 'mysql', 'mongoDb', 'redis'], 208 | 'sql': ['postgres', 'mysql', 'mssql'], 209 | 'nosql': ['mongoDb', 'redis', 'couchDb'], 210 | 'schedule': ['scheduleTrigger', 'cron'], 211 | 'time': ['scheduleTrigger', 'cron', 'wait'], 212 | 'file': ['readBinaryFile', 'writeBinaryFile', 'moveBinaryFile'], 213 | 'binary': ['readBinaryFile', 'writeBinaryFile', 'moveBinaryFile'], 214 | 'csv': ['spreadsheetFile', 'readBinaryFile'], 215 | 'excel': ['microsoftExcel', 'spreadsheetFile'], 216 | 'json': ['code', 'set'], 217 | 'transform': ['code', 'set', 'merge', 'splitInBatches'], 218 | 'ai': ['openAi', 'agent', 'lmChatOpenAi', 'lmChatAnthropic'], 219 | 'llm': ['openAi', 'agent', 'lmChatOpenAi', 'lmChatAnthropic', 'lmChatGoogleGemini'], 220 | 'agent': ['agent', 'toolAgent'], 221 | 'chat': ['chatTrigger', 'agent'], 222 | }; 223 | 224 | const related = relatedTypes[lowerName]; 225 | if (related) { 226 | related.forEach(r => { 227 | variations.add(`n8n-nodes-base.${r}`); 228 | // Also check if it might be a langchain node 229 | if (['agent', 'toolAgent', 'chatTrigger', 'lmChatOpenAi', 'lmChatAnthropic', 'lmChatGoogleGemini'].includes(r)) { 230 | variations.add(`@n8n/n8n-nodes-langchain.${r}`); 231 | } 232 | }); 233 | } 234 | } ``` -------------------------------------------------------------------------------- /src/telemetry/event-validator.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Event Validator for Telemetry 3 | * Validates and sanitizes telemetry events using Zod schemas 4 | */ 5 | 6 | import { z } from 'zod'; 7 | import { TelemetryEvent, WorkflowTelemetry } from './telemetry-types'; 8 | import { logger } from '../utils/logger'; 9 | 10 | // Base property schema that sanitizes strings 11 | const sanitizedString = z.string().transform(val => { 12 | // Remove URLs 13 | let sanitized = val.replace(/https?:\/\/[^\s]+/gi, '[URL]'); 14 | // Remove potential API keys 15 | sanitized = sanitized.replace(/[a-zA-Z0-9_-]{32,}/g, '[KEY]'); 16 | // Remove emails 17 | sanitized = sanitized.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]'); 18 | return sanitized; 19 | }); 20 | 21 | // Schema for generic event properties 22 | const eventPropertiesSchema = z.record(z.unknown()).transform(obj => { 23 | const sanitized: Record<string, any> = {}; 24 | 25 | for (const [key, value] of Object.entries(obj)) { 26 | // Skip sensitive keys 27 | if (isSensitiveKey(key)) { 28 | continue; 29 | } 30 | 31 | // Sanitize string values 32 | if (typeof value === 'string') { 33 | sanitized[key] = sanitizedString.parse(value); 34 | } else if (typeof value === 'number' || typeof value === 'boolean') { 35 | sanitized[key] = value; 36 | } else if (value === null || value === undefined) { 37 | sanitized[key] = null; 38 | } else if (typeof value === 'object') { 39 | // Recursively sanitize nested objects (limited depth) 40 | sanitized[key] = sanitizeNestedObject(value, 3); 41 | } 42 | } 43 | 44 | return sanitized; 45 | }); 46 | 47 | // Schema for telemetry events 48 | export const telemetryEventSchema = z.object({ 49 | user_id: z.string().min(1).max(64), 50 | event: z.string().min(1).max(100).regex(/^[a-zA-Z0-9_-]+$/), 51 | properties: eventPropertiesSchema, 52 | created_at: z.string().datetime().optional() 53 | }); 54 | 55 | // Schema for workflow telemetry 56 | export const workflowTelemetrySchema = z.object({ 57 | user_id: z.string().min(1).max(64), 58 | workflow_hash: z.string().min(1).max(64), 59 | node_count: z.number().int().min(0).max(1000), 60 | node_types: z.array(z.string()).max(100), 61 | has_trigger: z.boolean(), 62 | has_webhook: z.boolean(), 63 | complexity: z.enum(['simple', 'medium', 'complex']), 64 | sanitized_workflow: z.object({ 65 | nodes: z.array(z.any()).max(1000), 66 | connections: z.record(z.any()) 67 | }), 68 | created_at: z.string().datetime().optional() 69 | }); 70 | 71 | // Specific event property schemas for common events 72 | const toolUsagePropertiesSchema = z.object({ 73 | tool: z.string().max(100), 74 | success: z.boolean(), 75 | duration: z.number().min(0).max(3600000), // Max 1 hour 76 | }); 77 | 78 | const searchQueryPropertiesSchema = z.object({ 79 | query: z.string().max(100).transform(val => { 80 | // Apply same sanitization as sanitizedString 81 | let sanitized = val.replace(/https?:\/\/[^\s]+/gi, '[URL]'); 82 | sanitized = sanitized.replace(/[a-zA-Z0-9_-]{32,}/g, '[KEY]'); 83 | sanitized = sanitized.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]'); 84 | return sanitized; 85 | }), 86 | resultsFound: z.number().int().min(0), 87 | searchType: z.string().max(50), 88 | hasResults: z.boolean(), 89 | isZeroResults: z.boolean() 90 | }); 91 | 92 | const validationDetailsPropertiesSchema = z.object({ 93 | nodeType: z.string().max(100), 94 | errorType: z.string().max(100), 95 | errorCategory: z.string().max(50), 96 | details: z.record(z.any()).optional() 97 | }); 98 | 99 | const performanceMetricPropertiesSchema = z.object({ 100 | operation: z.string().max(100), 101 | duration: z.number().min(0).max(3600000), 102 | isSlow: z.boolean(), 103 | isVerySlow: z.boolean(), 104 | metadata: z.record(z.any()).optional() 105 | }); 106 | 107 | // Schema for startup_error event properties (v2.18.2) 108 | const startupErrorPropertiesSchema = z.object({ 109 | checkpoint: z.string().max(100), 110 | errorMessage: z.string().max(500), 111 | errorType: z.string().max(100), 112 | checkpointsPassed: z.array(z.string()).max(20), 113 | checkpointsPassedCount: z.number().int().min(0).max(20), 114 | startupDuration: z.number().min(0).max(300000), // Max 5 minutes 115 | platform: z.string().max(50), 116 | arch: z.string().max(50), 117 | nodeVersion: z.string().max(50), 118 | isDocker: z.boolean() 119 | }); 120 | 121 | // Schema for startup_completed event properties (v2.18.2) 122 | const startupCompletedPropertiesSchema = z.object({ 123 | version: z.string().max(50) 124 | }); 125 | 126 | // Map of event names to their specific schemas 127 | const EVENT_SCHEMAS: Record<string, z.ZodSchema<any>> = { 128 | 'tool_used': toolUsagePropertiesSchema, 129 | 'search_query': searchQueryPropertiesSchema, 130 | 'validation_details': validationDetailsPropertiesSchema, 131 | 'performance_metric': performanceMetricPropertiesSchema, 132 | 'startup_error': startupErrorPropertiesSchema, 133 | 'startup_completed': startupCompletedPropertiesSchema, 134 | }; 135 | 136 | /** 137 | * Check if a key is sensitive 138 | * Handles various naming conventions: camelCase, snake_case, kebab-case, and case variations 139 | */ 140 | function isSensitiveKey(key: string): boolean { 141 | const sensitivePatterns = [ 142 | // Core sensitive terms 143 | 'password', 'passwd', 'pwd', 144 | 'token', 'jwt', 'bearer', 145 | 'apikey', 'api_key', 'api-key', 146 | 'secret', 'private', 147 | 'credential', 'cred', 'auth', 148 | 149 | // Network/Connection sensitive 150 | 'url', 'uri', 'endpoint', 'host', 'hostname', 151 | 'database', 'db', 'connection', 'conn', 152 | 153 | // Service-specific 154 | 'slack', 'discord', 'telegram', 155 | 'oauth', 'client_secret', 'client-secret', 'clientsecret', 156 | 'access_token', 'access-token', 'accesstoken', 157 | 'refresh_token', 'refresh-token', 'refreshtoken' 158 | ]; 159 | 160 | const lowerKey = key.toLowerCase(); 161 | 162 | // Check for exact matches first (most efficient) 163 | if (sensitivePatterns.includes(lowerKey)) { 164 | return true; 165 | } 166 | 167 | // Check for compound key terms specifically 168 | if (lowerKey.includes('key') && lowerKey !== 'key') { 169 | // Check if it's a compound term like apikey, api_key, etc. 170 | const keyPatterns = ['apikey', 'api_key', 'api-key', 'secretkey', 'secret_key', 'privatekey', 'private_key']; 171 | if (keyPatterns.some(pattern => lowerKey.includes(pattern))) { 172 | return true; 173 | } 174 | } 175 | 176 | // Check for substring matches with word boundaries 177 | return sensitivePatterns.some(pattern => { 178 | // Match as whole words or with common separators 179 | const regex = new RegExp(`(?:^|[_-])${pattern}(?:[_-]|$)`, 'i'); 180 | return regex.test(key) || lowerKey.includes(pattern); 181 | }); 182 | } 183 | 184 | /** 185 | * Sanitize nested objects with depth limit 186 | */ 187 | function sanitizeNestedObject(obj: any, maxDepth: number): any { 188 | if (maxDepth <= 0 || !obj || typeof obj !== 'object') { 189 | return '[NESTED]'; 190 | } 191 | 192 | if (Array.isArray(obj)) { 193 | return obj.slice(0, 10).map(item => 194 | typeof item === 'object' ? sanitizeNestedObject(item, maxDepth - 1) : item 195 | ); 196 | } 197 | 198 | const sanitized: Record<string, any> = {}; 199 | let keyCount = 0; 200 | 201 | for (const [key, value] of Object.entries(obj)) { 202 | if (keyCount++ >= 20) { // Limit keys per object 203 | sanitized['...'] = 'truncated'; 204 | break; 205 | } 206 | 207 | if (isSensitiveKey(key)) { 208 | continue; 209 | } 210 | 211 | if (typeof value === 'string') { 212 | sanitized[key] = sanitizedString.parse(value); 213 | } else if (typeof value === 'object' && value !== null) { 214 | sanitized[key] = sanitizeNestedObject(value, maxDepth - 1); 215 | } else { 216 | sanitized[key] = value; 217 | } 218 | } 219 | 220 | return sanitized; 221 | } 222 | 223 | export class TelemetryEventValidator { 224 | private validationErrors: number = 0; 225 | private validationSuccesses: number = 0; 226 | 227 | /** 228 | * Validate and sanitize a telemetry event 229 | */ 230 | validateEvent(event: TelemetryEvent): TelemetryEvent | null { 231 | try { 232 | // Use specific schema if available for this event type 233 | const specificSchema = EVENT_SCHEMAS[event.event]; 234 | 235 | if (specificSchema) { 236 | // Validate properties with specific schema first 237 | const validatedProperties = specificSchema.safeParse(event.properties); 238 | if (!validatedProperties.success) { 239 | logger.debug(`Event validation failed for ${event.event}:`, validatedProperties.error.errors); 240 | this.validationErrors++; 241 | return null; 242 | } 243 | event.properties = validatedProperties.data; 244 | } 245 | 246 | // Validate the complete event 247 | const validated = telemetryEventSchema.parse(event); 248 | this.validationSuccesses++; 249 | return validated; 250 | } catch (error) { 251 | if (error instanceof z.ZodError) { 252 | logger.debug('Event validation error:', error.errors); 253 | } else { 254 | logger.debug('Unexpected validation error:', error); 255 | } 256 | this.validationErrors++; 257 | return null; 258 | } 259 | } 260 | 261 | /** 262 | * Validate workflow telemetry 263 | */ 264 | validateWorkflow(workflow: WorkflowTelemetry): WorkflowTelemetry | null { 265 | try { 266 | const validated = workflowTelemetrySchema.parse(workflow); 267 | this.validationSuccesses++; 268 | return validated; 269 | } catch (error) { 270 | if (error instanceof z.ZodError) { 271 | logger.debug('Workflow validation error:', error.errors); 272 | } else { 273 | logger.debug('Unexpected workflow validation error:', error); 274 | } 275 | this.validationErrors++; 276 | return null; 277 | } 278 | } 279 | 280 | /** 281 | * Get validation statistics 282 | */ 283 | getStats() { 284 | return { 285 | errors: this.validationErrors, 286 | successes: this.validationSuccesses, 287 | total: this.validationErrors + this.validationSuccesses, 288 | errorRate: this.validationErrors / (this.validationErrors + this.validationSuccesses) || 0 289 | }; 290 | } 291 | 292 | /** 293 | * Reset statistics 294 | */ 295 | resetStats(): void { 296 | this.validationErrors = 0; 297 | this.validationSuccesses = 0; 298 | } 299 | } ``` -------------------------------------------------------------------------------- /src/telemetry/early-error-logger.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Early Error Logger (v2.18.3) 3 | * Captures errors that occur BEFORE the main telemetry system is ready 4 | * Uses direct Supabase insert to bypass batching and ensure immediate persistence 5 | * 6 | * CRITICAL FIXES: 7 | * - Singleton pattern to prevent multiple instances 8 | * - Defensive initialization (safe defaults before any throwing operation) 9 | * - Timeout wrapper for Supabase operations (5s max) 10 | * - Shared sanitization utilities (DRY principle) 11 | */ 12 | 13 | import { createClient, SupabaseClient } from '@supabase/supabase-js'; 14 | import { TelemetryConfigManager } from './config-manager'; 15 | import { TELEMETRY_BACKEND } from './telemetry-types'; 16 | import { StartupCheckpoint, isValidCheckpoint, getCheckpointDescription } from './startup-checkpoints'; 17 | import { sanitizeErrorMessageCore } from './error-sanitization-utils'; 18 | import { logger } from '../utils/logger'; 19 | 20 | /** 21 | * Timeout wrapper for async operations 22 | * Prevents hanging if Supabase is unreachable 23 | */ 24 | async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, operation: string): Promise<T | null> { 25 | try { 26 | const timeoutPromise = new Promise<T>((_, reject) => { 27 | setTimeout(() => reject(new Error(`${operation} timeout after ${timeoutMs}ms`)), timeoutMs); 28 | }); 29 | 30 | return await Promise.race([promise, timeoutPromise]); 31 | } catch (error) { 32 | logger.debug(`${operation} failed or timed out:`, error); 33 | return null; 34 | } 35 | } 36 | 37 | export class EarlyErrorLogger { 38 | // Singleton instance 39 | private static instance: EarlyErrorLogger | null = null; 40 | 41 | // DEFENSIVE INITIALIZATION: Initialize all fields to safe defaults FIRST 42 | // This ensures the object is in a valid state even if initialization fails 43 | private enabled: boolean = false; // Safe default: disabled 44 | private supabase: SupabaseClient | null = null; // Safe default: null 45 | private userId: string | null = null; // Safe default: null 46 | private checkpoints: StartupCheckpoint[] = []; 47 | private startTime: number = Date.now(); 48 | private initPromise: Promise<void>; 49 | 50 | /** 51 | * Private constructor - use getInstance() instead 52 | * Ensures only one instance exists per process 53 | */ 54 | private constructor() { 55 | // Kick off async initialization without blocking 56 | this.initPromise = this.initialize(); 57 | } 58 | 59 | /** 60 | * Get singleton instance 61 | * Safe to call from anywhere - initialization errors won't crash caller 62 | */ 63 | static getInstance(): EarlyErrorLogger { 64 | if (!EarlyErrorLogger.instance) { 65 | EarlyErrorLogger.instance = new EarlyErrorLogger(); 66 | } 67 | return EarlyErrorLogger.instance; 68 | } 69 | 70 | /** 71 | * Async initialization logic 72 | * Separated from constructor to prevent throwing before safe defaults are set 73 | */ 74 | private async initialize(): Promise<void> { 75 | try { 76 | // Validate backend configuration before using 77 | if (!TELEMETRY_BACKEND.URL || !TELEMETRY_BACKEND.ANON_KEY) { 78 | logger.debug('Telemetry backend not configured, early error logger disabled'); 79 | this.enabled = false; 80 | return; 81 | } 82 | 83 | // Check if telemetry is disabled by user 84 | const configManager = TelemetryConfigManager.getInstance(); 85 | const isEnabled = configManager.isEnabled(); 86 | 87 | if (!isEnabled) { 88 | logger.debug('Telemetry disabled by user, early error logger will not send events'); 89 | this.enabled = false; 90 | return; 91 | } 92 | 93 | // Initialize Supabase client for direct inserts 94 | this.supabase = createClient( 95 | TELEMETRY_BACKEND.URL, 96 | TELEMETRY_BACKEND.ANON_KEY, 97 | { 98 | auth: { 99 | persistSession: false, 100 | autoRefreshToken: false, 101 | }, 102 | } 103 | ); 104 | 105 | // Get user ID from config manager 106 | this.userId = configManager.getUserId(); 107 | 108 | // Mark as enabled only after successful initialization 109 | this.enabled = true; 110 | 111 | logger.debug('Early error logger initialized successfully'); 112 | } catch (error) { 113 | // Initialization failed - ensure safe state 114 | logger.debug('Early error logger initialization failed:', error); 115 | this.enabled = false; 116 | this.supabase = null; 117 | this.userId = null; 118 | } 119 | } 120 | 121 | /** 122 | * Wait for initialization to complete (for testing) 123 | * Not needed in production - all methods handle uninitialized state gracefully 124 | */ 125 | async waitForInit(): Promise<void> { 126 | await this.initPromise; 127 | } 128 | 129 | /** 130 | * Log a checkpoint as the server progresses through startup 131 | * FIRE-AND-FORGET: Does not block caller (no await needed) 132 | */ 133 | logCheckpoint(checkpoint: StartupCheckpoint): void { 134 | if (!this.enabled) { 135 | return; 136 | } 137 | 138 | try { 139 | // Validate checkpoint 140 | if (!isValidCheckpoint(checkpoint)) { 141 | logger.warn(`Invalid checkpoint: ${checkpoint}`); 142 | return; 143 | } 144 | 145 | // Add to internal checkpoint list 146 | this.checkpoints.push(checkpoint); 147 | 148 | logger.debug(`Checkpoint passed: ${checkpoint} (${getCheckpointDescription(checkpoint)})`); 149 | } catch (error) { 150 | // Don't throw - we don't want checkpoint logging to crash the server 151 | logger.debug('Failed to log checkpoint:', error); 152 | } 153 | } 154 | 155 | /** 156 | * Log a startup error with checkpoint context 157 | * This is the main error capture mechanism 158 | * FIRE-AND-FORGET: Does not block caller 159 | */ 160 | logStartupError(checkpoint: StartupCheckpoint, error: unknown): void { 161 | if (!this.enabled || !this.supabase || !this.userId) { 162 | return; 163 | } 164 | 165 | // Run async operation without blocking caller 166 | this.logStartupErrorAsync(checkpoint, error).catch((logError) => { 167 | // Swallow errors - telemetry must never crash the server 168 | logger.debug('Failed to log startup error:', logError); 169 | }); 170 | } 171 | 172 | /** 173 | * Internal async implementation with timeout wrapper 174 | */ 175 | private async logStartupErrorAsync(checkpoint: StartupCheckpoint, error: unknown): Promise<void> { 176 | try { 177 | // Sanitize error message using shared utilities (v2.18.3) 178 | let errorMessage = 'Unknown error'; 179 | if (error instanceof Error) { 180 | errorMessage = error.message; 181 | if (error.stack) { 182 | errorMessage = error.stack; 183 | } 184 | } else if (typeof error === 'string') { 185 | errorMessage = error; 186 | } else { 187 | errorMessage = String(error); 188 | } 189 | 190 | const sanitizedError = sanitizeErrorMessageCore(errorMessage); 191 | 192 | // Extract error type if it's an Error object 193 | let errorType = 'unknown'; 194 | if (error instanceof Error) { 195 | errorType = error.name || 'Error'; 196 | } else if (typeof error === 'string') { 197 | errorType = 'string_error'; 198 | } 199 | 200 | // Create startup_error event 201 | const event = { 202 | user_id: this.userId!, 203 | event: 'startup_error', 204 | properties: { 205 | checkpoint, 206 | errorMessage: sanitizedError, 207 | errorType, 208 | checkpointsPassed: this.checkpoints, 209 | checkpointsPassedCount: this.checkpoints.length, 210 | startupDuration: Date.now() - this.startTime, 211 | platform: process.platform, 212 | arch: process.arch, 213 | nodeVersion: process.version, 214 | isDocker: process.env.IS_DOCKER === 'true', 215 | }, 216 | created_at: new Date().toISOString(), 217 | }; 218 | 219 | // Direct insert to Supabase with timeout (5s max) 220 | const insertOperation = async () => { 221 | return await this.supabase! 222 | .from('events') 223 | .insert(event) 224 | .select() 225 | .single(); 226 | }; 227 | 228 | const result = await withTimeout(insertOperation(), 5000, 'Startup error insert'); 229 | 230 | if (result && 'error' in result && result.error) { 231 | logger.debug('Failed to insert startup error event:', result.error); 232 | } else if (result) { 233 | logger.debug(`Startup error logged for checkpoint: ${checkpoint}`); 234 | } 235 | } catch (logError) { 236 | // Don't throw - telemetry failures should never crash the server 237 | logger.debug('Failed to log startup error:', logError); 238 | } 239 | } 240 | 241 | /** 242 | * Log successful startup completion 243 | * Called when all checkpoints have been passed 244 | * FIRE-AND-FORGET: Does not block caller 245 | */ 246 | logStartupSuccess(checkpoints: StartupCheckpoint[], durationMs: number): void { 247 | if (!this.enabled) { 248 | return; 249 | } 250 | 251 | try { 252 | // Store checkpoints for potential session_start enhancement 253 | this.checkpoints = checkpoints; 254 | 255 | logger.debug(`Startup successful: ${checkpoints.length} checkpoints passed in ${durationMs}ms`); 256 | 257 | // We don't send a separate event here - this data will be included 258 | // in the session_start event sent by the main telemetry system 259 | } catch (error) { 260 | logger.debug('Failed to log startup success:', error); 261 | } 262 | } 263 | 264 | /** 265 | * Get the list of checkpoints passed so far 266 | */ 267 | getCheckpoints(): StartupCheckpoint[] { 268 | return [...this.checkpoints]; 269 | } 270 | 271 | /** 272 | * Get startup duration in milliseconds 273 | */ 274 | getStartupDuration(): number { 275 | return Date.now() - this.startTime; 276 | } 277 | 278 | /** 279 | * Get startup data for inclusion in session_start event 280 | */ 281 | getStartupData(): { durationMs: number; checkpoints: StartupCheckpoint[] } | null { 282 | if (!this.enabled) { 283 | return null; 284 | } 285 | 286 | return { 287 | durationMs: this.getStartupDuration(), 288 | checkpoints: this.getCheckpoints(), 289 | }; 290 | } 291 | 292 | /** 293 | * Check if early logger is enabled 294 | */ 295 | isEnabled(): boolean { 296 | return this.enabled && this.supabase !== null && this.userId !== null; 297 | } 298 | } 299 | ```